diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 7489bc142..c1cfec904 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, 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", 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() diff --git a/internal/repository/pg_segment.go b/internal/repository/pg_segment.go index 8b5a0f52c..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 { @@ -666,6 +674,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 }