Merge pull request #818 from warmbly/claude/api-contract-2026-10-04

feat: describe the stored send-plan snapshot in the OpenAPI document (stale flag on CampaignSendPlan)
This commit is contained in:
Matthew Meszaros
2026-10-04 05:31:35 +00:00
committed by GitHub
3 changed files with 32 additions and 5 deletions
+9 -3
View File
@@ -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",
+7 -1
View File
@@ -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()
+16 -1
View File
@@ -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
}