From e39ff8318deddfbc12604b099c7ca52e34821974 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:01:51 +0000 Subject: [PATCH 1/5] feat: describe the stored send-plan snapshot in docs/public/openapi.json by adding the required stale flag and computed_at meaning to CampaignSendPlan and noting the background snapshot on GET /campaigns/{id}/send-plan --- docs/public/openapi.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 7489bc142..56be0fb7d 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -6215,7 +6215,7 @@ "get": { "operationId": "campaigns_send_plan", "summary": "Today's sending plan", - "description": "What the campaign sends today and every limit that decided it, worked out through the scheduler's own gates. Nothing is stored or written. Scope READ_CAMPAIGNS, org permission view_campaigns.", + "description": "What the campaign sends today and every limit that decided it, worked out through the scheduler's own gates. An active campaign is served from a snapshot computed in the background (`computed_at` says when, `stale` says a refresh is pending); a campaign with no snapshot yet is computed on the request. Scope READ_CAMPAIGNS, org permission view_campaigns.", "tags": [ "campaigns" ], @@ -30540,13 +30540,14 @@ }, "CampaignSendPlan": { "type": "object", - "description": "Today's sending plan for a campaign, derived through the scheduler's gates on every read.", + "description": "Today's sending plan for a campaign, derived through the scheduler's gates and served from a background snapshot for an active campaign.", "required": [ "campaign_id", "status", "day", "timezone", "computed_at", + "stale", "configured_ceiling", "projected_today", "sent_today", @@ -30574,7 +30575,12 @@ }, "computed_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "When this plan was computed. For a snapshot, the moment it was last walked." + }, + "stale": { + "type": "boolean", + "description": "True when the snapshot has been outrun by an edit, a start or stop, or a new budget day and a refreshed plan is already being computed. Always false for a plan computed on the request." }, "configured_ceiling": { "type": "integer", From e2911fb01a01c6ac7b5ae1fb3bba967674834884 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:04:41 +0000 Subject: [PATCH 2/5] feat: say in the docs/public/openapi.json CampaignSendPlan stale description that a snapshot older than its maximum age is also served as stale when the campaign has not changed --- docs/public/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 56be0fb7d..c1cfec904 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -30580,7 +30580,7 @@ }, "stale": { "type": "boolean", - "description": "True when the snapshot has been outrun by an edit, a start or stop, or a new budget day and a refreshed plan is already being computed. Always false for a plan computed on the request." + "description": "True when the snapshot has been outrun by an edit, a start or stop, or a new budget day, or has grown older than the snapshot's maximum age even though nothing changed, and a refreshed plan is already being computed. Always false for a plan computed on the request." }, "configured_ceiling": { "type": "integer", From 51482769f5e622346e08425702c8b6b74e008b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:48:44 +0000 Subject: [PATCH 3/5] feat: advance campaigns.updated_at when a campaign's senders are replaced and when a tag or folder-only update runs so the send-plan snapshot keyed on it is invalidated and stops being served with stale false --- internal/repository/pg_campaign.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index b1a9e914d..e73c90b1c 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -1338,7 +1338,7 @@ func (r *campaignRepository) Update(ctx context.Context, orgID, campaignID strin }() var query string - if argPos > 3 { + if argPos >= 3 { query = fmt.Sprintf(` UPDATE campaigns SET %s @@ -2183,6 +2183,12 @@ func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaig return nil, xerr } + // The send-plan snapshot is keyed on updated_at, so a sender edit must move it. + if _, err := tx.Exec(ctx, `UPDATE campaigns SET updated_at = now() WHERE id = $1`, campaignID); err != nil { + db.CaptureError(err, "campaign updated_at", []any{campaignID}, "exec") + return nil, errx.InternalError() + } + if err := tx.Commit(ctx); err != nil { db.CaptureError(err, "", nil, "commit") return nil, errx.InternalError() From de230af27dbd627f94b46fd69bbedd4bd820c638 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:02:11 +0000 Subject: [PATCH 4/5] feat: advance campaigns.updated_at when a segment set change detaches linked segments in setForCampaignTx, including clearing the set, so the send-plan snapshot is invalidated after leads are withdrawn --- internal/repository/pg_segment.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/repository/pg_segment.go b/internal/repository/pg_segment.go index 8b5a0f52c..c792b3aee 100644 --- a/internal/repository/pg_segment.go +++ b/internal/repository/pg_segment.go @@ -666,6 +666,13 @@ func setForCampaignTx(ctx context.Context, tx pgx.Tx, orgID, campaignID uuid.UUI return "", change, errx.InternalError() } } + // Detaching withdraws leads, which changes the send plan the snapshot is keyed on. + if len(detached) > 0 { + if _, err := tx.Exec(ctx, `UPDATE campaigns SET updated_at = NOW() WHERE id = $1`, campaignID); err != nil { + db.CaptureError(err, "campaign updated_at", nil, "exec") + return "", change, errx.InternalError() + } + } return status, change, nil } From 71f69f185667e1c40509714e3773ef3b47907ad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:14:34 +0000 Subject: [PATCH 5/5] feat: advance campaigns.updated_at in setForCampaignTx when linking segments inserts new campaign_segments rows so the send-plan snapshot is invalidated when an audience grows --- internal/repository/pg_segment.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/repository/pg_segment.go b/internal/repository/pg_segment.go index c792b3aee..51ed90639 100644 --- a/internal/repository/pg_segment.go +++ b/internal/repository/pg_segment.go @@ -655,10 +655,18 @@ func setForCampaignTx(ctx context.Context, tx pgx.Tx, orgID, campaignID uuid.UUI return "", change, xerr } if len(segmentIDs) > 0 { - if _, err := tx.Exec(ctx, `INSERT INTO campaign_segments (campaign_id, segment_id) SELECT $1, unnest($2::uuid[]) ON CONFLICT DO NOTHING`, campaignID, segmentIDs); err != nil { + tag, err := tx.Exec(ctx, `INSERT INTO campaign_segments (campaign_id, segment_id) SELECT $1, unnest($2::uuid[]) ON CONFLICT DO NOTHING`, campaignID, segmentIDs) + if err != nil { db.CaptureError(err, "campaign segments insert", nil, "exec") return "", change, errx.InternalError() } + // New links change the send plan the snapshot is keyed on. + if tag.RowsAffected() > 0 { + if _, err := tx.Exec(ctx, `UPDATE campaigns SET updated_at = NOW() WHERE id = $1`, campaignID); err != nil { + db.CaptureError(err, "campaign updated_at", nil, "exec") + return "", change, errx.InternalError() + } + } // A live audience is the reason to keep running: linking turns the // setting on, and the owner can turn it off again in preferences. if _, err := tx.Exec(ctx, `UPDATE campaigns SET continuous = true, updated_at = NOW() WHERE id = $1 AND NOT continuous`, campaignID); err != nil {