diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebc3463..201e551f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -884,3 +884,16 @@ - Aligned call signaling and media behavior with the `voip-v2` implementation. This is a Git development channel. No npm package has been published. + +## Campaign contact frequency caps (beta) + +`client.campaigns.getFrequencyCap()` reads team limits and their revision. +`client.campaigns.updateFrequencyCap({ maxPer24Hours: 2, maxPer7Days: 5, revision })` +replaces both windows. Send both limits as null to remove them. A stale revision +raises `PolymorfaConflictError`; read and reconcile before trying again. A new +limit requires beta access and can return `403 feature_unavailable`. +Campaign create/update accepts `frequencyCap`; null removes a campaign cap. +Recipient reads expose `failureReason` and accept `reason: "frequency_cap"`. +Analytics adds `frequencyCap.skippedRecipientCount`. Raising limits never retries +settled skips. `contracts/campaign-frequency-cap.json` pins the merged API source; +package publication and API availability remain separate release gates. diff --git a/contracts/README.md b/contracts/README.md index 26a9529c..21f1296c 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -619,3 +619,16 @@ create, update, recipient and analytics schemas from API `dev` merge commit `87b25fd9e2494d90f01f7511fb47be6a28845fa1` (polymorfa/polymorfa#503), with both source paths and file hashes. `campaign-follow-ups-contract.test.ts` checks the SDK types against it. The full snapshots stay pinned to `contracts/source.json` until the next repin. + +## Campaign contact frequency caps (beta) + +`client.campaigns.getFrequencyCap()` reads team limits and their revision. +`client.campaigns.updateFrequencyCap({ maxPer24Hours: 2, maxPer7Days: 5, revision })` +replaces both windows. Send both limits as null to remove them. A stale revision +raises `PolymorfaConflictError`; read and reconcile before trying again. A new +limit requires beta access and can return `403 feature_unavailable`. +Campaign create/update accepts `frequencyCap`; null removes a campaign cap. +Recipient reads expose `failureReason` and accept `reason: "frequency_cap"`. +Analytics adds `frequencyCap.skippedRecipientCount`. Raising limits never retries +settled skips. `contracts/campaign-frequency-cap.json` pins the merged API source; +package publication and API availability remain separate release gates. diff --git a/contracts/campaign-frequency-cap.json b/contracts/campaign-frequency-cap.json new file mode 100644 index 00000000..be5be2aa --- /dev/null +++ b/contracts/campaign-frequency-cap.json @@ -0,0 +1,1257 @@ +{ + "repository": "polymorfa/polymorfa", + "commit": "e404a727ddb4abf42a8df768a5336fad922cc99a", + "note": "Contact frequency cap beta, merged backend #601 and Console #609. Full snapshots retain their independent pins.", + "sources": { + "messaging": { + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "9d8faf13be970bee800ad15e308c130bc4ae8703865ae99278b9ae315cc27638", + "schemas": {}, + "fields": { + "VoiceAssetFailedPayload.failureReason": { + "type": "string", + "enum": [ + "unsupported_format", + "too_large", + "too_long", + "decode_failed", + "silent", + "tts_failed", + "processing_failed" + ], + "description": "Treat unknown values as `processing_failed`." + }, + "Campaign.frequencyCap": { + "$ref": "#/components/schemas/CampaignFrequencyCap" + }, + "CampaignAnalytics.frequencyCap": { + "$ref": "#/components/schemas/CampaignFrequencyCapAnalytics" + }, + "CreateCampaignRequest.frequencyCap": { + "allOf": [ + { + "$ref": "#/components/schemas/CampaignFrequencyCap" + }, + { + "description": "This campaign's own contact frequency cap. It can only make the team cap stricter. Contact frequency caps are a beta; a team that is not enrolled gets `403 feature_unavailable`. Omit, or send null, to use the team cap alone." + } + ] + }, + "UpdateCampaignRequest.frequencyCap": { + "allOf": [ + { + "$ref": "#/components/schemas/CampaignFrequencyCap" + }, + { + "description": "Replaces this campaign's own contact frequency cap; null removes it and leaves the team cap. Only a draft or paused campaign can change it. Setting a cap requires the contact frequency caps beta; removing one does not." + } + ] + }, + "CampaignRecipient.failureReason": { + "type": "string", + "nullable": true, + "enum": [ + "opted_out", + "cold_held", + "campaign_cancelled", + "invalid_recipient", + "session_not_connected", + "ack_timeout", + "blocked_by_safety", + "send_failed", + "missing_variable", + "frequency_cap", + "other", + null + ], + "description": "Why the recipient was not sent, or why its last attempt did not succeed. Null when nothing went wrong. `opted_out`: the number is on the team's opt-out list. `cold_held`: the number never wrote to the sending number and the campaign holds cold sends. `campaign_cancelled`: the campaign was stopped before this recipient was sent. `invalid_recipient`: the number is not a valid WhatsApp address. `session_not_connected`: the sending number was not connected. `ack_timeout`: WhatsApp did not acknowledge the send in time. `blocked_by_safety`: a safety check refused the send before it was attempted; the recipient waits for the campaign to resume. `send_failed`: WhatsApp refused the send, or its outcome could not be confirmed. `missing_variable`: the campaign was launched with `skipMissingVariables` and the recipient had no value and no fallback for a variable. `frequency_cap`: the recipient reached the team's or campaign's rolling contact frequency cap. `other`: any other reason. New codes may be added." + }, + "TestEventOverrides.failureReason": { + "type": "string", + "enum": [ + "invalid_recipient", + "session_not_connected", + "ack_timeout", + "send_failed", + "blocked_by_safety" + ] + } + }, + "paths": { + "/messaging/projects/{projectSlug}/campaigns/{id}/recipients": { + "get": { + "operationId": "listProjectCampaignRecipients", + "tags": ["Campaigns"], + "summary": "List campaign recipients", + "description": "**Required scope:** `campaigns:read`\n\nLists a campaign's recipients in queue order with cursor pagination. Filter by `status`, by `reason`, or both: for example `status=skipped&reason=opted_out` lists recipients skipped because they opted out. `failureReason` gives the stable reason a recipient was not sent.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "campaigns:read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "projectSlug", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": [ + "queued", + "sending", + "sent", + "delivered", + "read", + "failed", + "skipped" + ] + }, + "required": false, + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "opted_out", + "cold_held", + "campaign_cancelled", + "invalid_recipient", + "session_not_connected", + "ack_timeout", + "blocked_by_safety", + "send_failed", + "missing_variable", + "frequency_cap", + "other" + ], + "description": "Only recipients whose `failureReason` is this code." + }, + "required": false, + "name": "reason", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 256 + }, + "required": false, + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Page size, default 25." + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCampaignRecipientsResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "campaigns:read" + } + }, + "post": { + "operationId": "addProjectCampaignRecipients", + "tags": ["Campaigns"], + "summary": "Add recipients to a draft campaign", + "description": "**Required scope:** `campaigns:manage`\n\nAdds up to 1,000 recipients to a campaign that has not started sending. A phone already on the campaign is skipped. Invalid entries are reported and not added.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "campaigns:manage", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "projectSlug", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "required": false, + "description": "Optional key, 1 to 255 bytes of UTF-8, that makes this request safe to retry for 24 hours. A retry with the same key, credential, `Polymorfa-Version` and request is not applied again: it returns `200` with `Idempotent-Replayed: true` and the replay result described for this operation. Reusing the key for a different request returns `409 idempotency_conflict`. A retry sent while the original is still running waits for it and then returns its result. A request that failed left no record, so retrying it with the same key runs it again. Access to the resource is checked again on every retry. A replay returns the original counts, even after the campaign has launched.", + "name": "Idempotency-Key", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddCampaignRecipientsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Idempotent-Replayed": { + "description": "`true` when this response replays the result of an earlier request with the same Idempotency-Key.", + "schema": { + "type": "string", + "enum": ["true"] + } + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddCampaignRecipientsResponse" + } + } + } + }, + "400": { + "description": "Bad request \u2014 request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "The campaign has started sending and no longer accepts recipients, or the Idempotency-Key was already used for a different request (`idempotency_conflict`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "campaigns:manage" + } + } + } + } + }, + "platform": { + "sourcePath": "apps/api/docs/openapi.management.json", + "sourceSha256": "e28256daac1e8efd84998d7399b74c3200623c19ac807a4b0730523a667976ff", + "schemas": { + "CampaignRecipientFailureReason": { + "type": "string", + "enum": [ + "opted_out", + "cold_held", + "campaign_cancelled", + "invalid_recipient", + "session_not_connected", + "ack_timeout", + "blocked_by_safety", + "send_failed", + "missing_variable", + "frequency_cap", + "other" + ], + "description": "Why the recipient was not sent, or why its last attempt did not succeed. Null when nothing went wrong. `opted_out`: the number is on the team's opt-out list. `cold_held`: the number never wrote to the sending number and the campaign holds cold sends. `campaign_cancelled`: the campaign was stopped before this recipient was sent. `invalid_recipient`: the number is not a valid WhatsApp address. `session_not_connected`: the sending number was not connected. `ack_timeout`: WhatsApp did not acknowledge the send in time. `blocked_by_safety`: a safety check refused the send before it was attempted; the recipient waits for the campaign to resume. `send_failed`: WhatsApp refused the send, or its outcome could not be confirmed. `missing_variable`: the campaign was launched with `skipMissingVariables` and the recipient had no value and no fallback for a variable. `frequency_cap`: the recipient reached the team's or campaign's rolling contact frequency cap. `other`: any other reason. New codes may be added." + }, + "CampaignFrequencyCapSettings": { + "type": "object", + "additionalProperties": false, + "description": "The team's contact frequency cap.", + "properties": { + "teamId": { + "type": "string", + "format": "uuid" + }, + "maxPer24Hours": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 24 hours, 1 to 50. `null` sets no 24-hour limit." + }, + "maxPer7Days": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 7 days, 1 to 200. `null` sets no 7-day limit. Must not be lower than `maxPer24Hours`." + }, + "revision": { + "type": "integer", + "minimum": 0, + "description": "Send this value back to change the cap. 0 until the team first saves." + }, + "updatedAt": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Unix time in milliseconds of the last change; null until the team first saves." + } + }, + "required": [ + "teamId", + "maxPer24Hours", + "maxPer7Days", + "revision", + "updatedAt" + ] + }, + "UpdateCampaignFrequencyCapInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxPer24Hours": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 24 hours, 1 to 50. `null` sets no 24-hour limit." + }, + "maxPer7Days": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 7 days, 1 to 200. `null` sets no 7-day limit. Must not be lower than `maxPer24Hours`." + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 2147483646, + "description": "The `revision` from your last read." + } + }, + "required": ["maxPer24Hours", "maxPer7Days", "revision"] + } + }, + "fields": { + "NumberTierChange.failureReason": { + "type": "string", + "nullable": true + }, + "NumberHybridTransition.failureReason": { + "type": "string", + "nullable": true + }, + "CreatePlatformCampaignRequest.frequencyCap": { + "type": "object", + "additionalProperties": false, + "nullable": true, + "description": "Beta. This campaign's own contact frequency cap, counted over every campaign message the team sent to the phone number. Each limit applies together with the team cap and the lower one wins, so a campaign cap can only make sending stricter. A message over a limit is skipped with `frequency_cap_exceeded`; later sequence messages stop for that recipient. `null` uses the team cap alone. Setting a cap requires the contact frequency caps beta and otherwise answers `403 feature_unavailable`.", + "properties": { + "maxPer24Hours": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 24 hours, 1 to 50. `null` sets no 24-hour limit." + }, + "maxPer7Days": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 7 days, 1 to 200. `null` sets no 7-day limit. Must not be lower than `maxPer24Hours`." + } + }, + "required": ["maxPer24Hours", "maxPer7Days"] + }, + "PlatformCampaignAnalytics.frequencyCap": { + "type": "object", + "additionalProperties": false, + "description": "Recipients with a message skipped by the contact frequency cap.", + "properties": { + "skippedRecipientCount": { + "type": "integer", + "minimum": 0, + "description": "Recipients whose first, sequence or follow-up message was skipped by the cap." + } + }, + "required": ["skippedRecipientCount"] + }, + "UpdatePlatformCampaignRequest.frequencyCap": { + "type": "object", + "additionalProperties": false, + "nullable": true, + "description": "Beta. Replaces this campaign's own contact frequency cap; `null` removes it and leaves the team cap. Only while the campaign is a draft or paused; a running campaign answers `409`, so pause it first. Setting a cap requires the contact frequency caps beta; removing one does not.", + "properties": { + "maxPer24Hours": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 24 hours, 1 to 50. `null` sets no 24-hour limit." + }, + "maxPer7Days": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 7 days, 1 to 200. `null` sets no 7-day limit. Must not be lower than `maxPer24Hours`." + } + }, + "required": ["maxPer24Hours", "maxPer7Days"] + }, + "PlatformCampaign.frequencyCap": { + "type": "object", + "additionalProperties": false, + "nullable": true, + "description": "This campaign's own contact frequency cap; null when only the team cap applies.", + "properties": { + "maxPer24Hours": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 24 hours, 1 to 50. `null` sets no 24-hour limit." + }, + "maxPer7Days": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "nullable": true, + "description": "Most campaign messages one phone number receives from the team in any rolling 7 days, 1 to 200. `null` sets no 7-day limit. Must not be lower than `maxPer24Hours`." + } + }, + "required": ["maxPer24Hours", "maxPer7Days"] + }, + "CampaignRecipient.failureReason": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/CampaignRecipientFailureReason" + } + ] + }, + "PlatformAccessVoiceAudioAsset.failureReason": { + "$ref": "#/components/schemas/PlatformAccessVoiceAudioFailureReason" + } + }, + "paths": { + "/platform/campaigns/{campaignId}/recipients": { + "get": { + "operationId": "listCampaignRecipients", + "tags": ["campaigns"], + "summary": "List campaign recipients", + "description": "Lists a campaign's recipients in queue order with cursor pagination. Filter by `status`, by `reason`, or both: for example `status=skipped&reason=opted_out` lists recipients skipped because the number opted out. `failureReason` gives the stable reason a recipient was not sent.\n\nRequires `campaigns:read`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "campaigns:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "campaignId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectId", + "in": "query", + "required": false, + "description": "Project the campaign belongs to. Required with a team API key; a project token is bound to its own project and omits it.", + "schema": { + "type": "string", + "format": "uuid", + "description": "Project that owns the campaign. Required for team API keys; a project token is bound to its own project." + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "queued", + "sending", + "sent", + "delivered", + "read", + "failed", + "skipped" + ] + } + }, + { + "name": "reason", + "in": "query", + "required": false, + "description": "Only recipients whose `failureReason` is this code.", + "schema": { + "$ref": "#/components/schemas/CampaignRecipientFailureReason" + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignRecipient" + } + }, + "page": { + "$ref": "#/components/schemas/CursorPageMetadata" + } + }, + "required": ["data", "page"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "post": { + "operationId": "addCampaignRecipients", + "tags": ["campaigns"], + "summary": "Add recipients to a draft campaign", + "description": "Adds up to 1,000 recipients to a campaign that has not been launched. A number already on the campaign is skipped. Invalid entries are reported, not added. A campaign that has been launched answers `409`. Send `projectId` in the body when using a team API key.\n\nRequires `campaigns:manage`. Project tokens only access resources in their own project.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "project", + "x-excluded-from-public": false, + "x-required-scope": "campaigns:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "parameters": [ + { + "name": "campaignId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Optional key, 1 to 255 bytes of UTF-8, that makes this request safe to retry for 24 hours. A retry with the same key, credential, `Polymorfa-Version` and request is not applied again: it returns `200` with `Idempotent-Replayed: true` and the replay result described for this operation. Reusing the key for a different request returns `409 idempotency_conflict`. A retry sent while the original is still running waits for it and then returns its result. A request that failed left no record, so retrying it with the same key runs it again. Access to the resource is checked again on every retry. A replay returns the original counts, even after the campaign has launched.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "projectId": { + "type": "string", + "format": "uuid" + }, + "recipients": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "description": "At most 1,000 recipients per request.", + "items": { + "$ref": "#/components/schemas/RecipientInput" + } + } + }, + "required": ["recipients"] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + }, + "Idempotent-Replayed": { + "description": "`true` when this response replays the result of an earlier request with the same Idempotency-Key.", + "schema": { + "type": "string", + "enum": ["true"] + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/CampaignRecipientAppendResult" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/platform/campaign-settings/frequency-cap": { + "get": { + "operationId": "getCampaignFrequencyCap", + "tags": ["campaign-settings"], + "summary": "Get the contact frequency cap", + "description": "Returns the team's contact frequency cap: the most campaign messages one phone number receives from the team in any rolling 24 hours and any rolling 7 days. Every campaign message counts, first messages and later messages of a sequence, from every campaign, project and number in the team. Test sends and messages sent outside campaigns do not count. `null` means no limit for that window. A team that never saved a cap receives both limits `null` and `revision` 0. A campaign can set its own, stricter cap with `frequencyCap`. Accepts a team API key or a live signed-in team member.\n\nAPI keys require `campaigns:read`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "campaigns:read", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/CampaignFrequencyCapSettings" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "put": { + "operationId": "updateCampaignFrequencyCap", + "tags": ["campaign-settings"], + "summary": "Update the contact frequency cap", + "description": "Replaces the team's contact frequency cap using the `revision` you read; a changed revision answers `409`. Send `null` for a window to remove its limit, or both `null` to remove the cap. Setting a limit requires the contact frequency caps beta and otherwise answers `403 feature_unavailable`; removing the cap does not. Requires a team API key or a signed-in team owner or admin; project tokens answer `403`. A message over the cap is skipped with `frequency_cap_exceeded`; a later sequence message stops that recipient's remaining sequence. Settled skips are never retried when a cap changes.\n\nAPI keys require `campaigns:manage`. Project tokens are not accepted.", + "x-audience": "public", + "x-polymorfa-resource-boundary": "organization", + "x-excluded-from-public": false, + "x-required-scope": "campaigns:manage", + "security": [ + { + "ApiKeyAuth": [] + }, + { + "ConsoleSession": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCampaignFrequencyCapInput" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "headers": { + "X-Request-Id": { + "schema": { + "type": "string" + }, + "description": "Stable request correlation identifier." + }, + "X-Trace-Id": { + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "description": "W3C trace ID of this request. Your `traceparent` trace ID when you sent a valid one." + }, + "Polymorfa-Version": { + "schema": { + "type": "string" + }, + "description": "API version applied to this response." + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/CampaignFrequencyCapSettings" + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + } + } + } + } +} diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 09223233..6f926212 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -3168,3 +3168,16 @@ configuration changes that do not change capabilities produce no event. Treat the event ID as the deduplication key. Eligibility is checked before publication and delivery; withdrawn events wait for re-enablement and expire after seven days. These additions require an API and SDK release containing this contract. + +## Campaign contact frequency caps (beta) + +`client.campaigns.getFrequencyCap()` reads team limits and their revision. +`client.campaigns.updateFrequencyCap({ maxPer24Hours: 2, maxPer7Days: 5, revision })` +replaces both windows. Send both limits as null to remove them. A stale revision +raises `PolymorfaConflictError`; read and reconcile before trying again. A new +limit requires beta access and can return `403 feature_unavailable`. +Campaign create/update accepts `frequencyCap`; null removes a campaign cap. +Recipient reads expose `failureReason` and accept `reason: "frequency_cap"`. +Analytics adds `frequencyCap.skippedRecipientCount`. Raising limits never retries +settled skips. `contracts/campaign-frequency-cap.json` pins the merged API source; +package publication and API availability remain separate release gates. diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 4458cc41..eebac793 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -1325,3 +1325,12 @@ export type { BillingPriorities, ReorderBillingPrioritiesInput, } from "./platform/billing-controls.js"; + +export type { + CampaignFrequencyCap, + CampaignRecipientFailureReason, +} from "./messaging/types.js"; +export type { + CampaignFrequencyCapSettings, + UpdateCampaignFrequencyCapInput, +} from "./platform/types.js"; diff --git a/packages/typescript/src/messaging/campaigns.ts b/packages/typescript/src/messaging/campaigns.ts index cd0a6dc0..a2286799 100644 --- a/packages/typescript/src/messaging/campaigns.ts +++ b/packages/typescript/src/messaging/campaigns.ts @@ -163,6 +163,7 @@ export class MessagingCampaignsResource { path: recipientsPath(projectSlug, campaignId), query: { ...(params.status === undefined ? {} : { status: params.status }), + ...(params.reason === undefined ? {} : { reason: params.reason }), ...(params.cursor === undefined ? {} : { cursor: params.cursor }), ...(params.limit === undefined ? {} : { limit: params.limit }), }, diff --git a/packages/typescript/src/messaging/types.ts b/packages/typescript/src/messaging/types.ts index 4f000c6b..f2f4c577 100644 --- a/packages/typescript/src/messaging/types.ts +++ b/packages/typescript/src/messaging/types.ts @@ -300,6 +300,7 @@ export interface Campaign { readonly updatedAt: number; /** Local hours the campaign may send in, or null when sending is unrestricted. */ readonly sendWindow: CampaignSendWindow | null; + readonly frequencyCap?: CampaignFrequencyCap | null; /** Additional live repository fields omitted from the pinned OpenAPI schema. */ readonly composerBlueprint?: unknown; readonly messages?: unknown; @@ -368,7 +369,29 @@ export interface CampaignSendWindow { // Both names were exported on the integration branches. export type CampaignWeekday = CampaignSendWindowDay; +/** Rolling per-contact limits. Null means no additional limit for that window. */ +export interface CampaignFrequencyCap { + /** 1..50; cannot exceed the weekly limit when both are set. */ + readonly maxPer24Hours: number | null; + /** 1..200. */ + readonly maxPer7Days: number | null; +} + +export type CampaignRecipientFailureReason = + | "opted_out" + | "cold_held" + | "campaign_cancelled" + | "invalid_recipient" + | "session_not_connected" + | "ack_timeout" + | "blocked_by_safety" + | "send_failed" + | "missing_variable" + | "frequency_cap" + | "other"; + export interface CampaignAnalytics { + readonly frequencyCap?: { readonly skippedRecipientCount: number }; readonly campaignId: string; readonly recipientCount: number; readonly sentCount: number; @@ -393,6 +416,8 @@ export interface CreateCampaignRequest { readonly scheduledAt?: number; /** When the campaign may send; null or omitted sends at any time. */ readonly sendWindow?: CampaignSendWindowRequest | null; + /** Beta. A stricter limit than the team cap; null removes the campaign cap. */ + readonly frequencyCap?: CampaignFrequencyCap | null; /** * Up to 1,000 recipients to queue with the draft. Invalid entries reject the * whole request; use `campaigns.addRecipients` for partial acceptance. @@ -447,6 +472,7 @@ export interface CampaignRecipient { readonly attempts: number; /** `opted_out` means the phone is on the organization's opt-out list. */ readonly lastError: string | null; + readonly failureReason?: CampaignRecipientFailureReason | null; readonly externalMessageId: string | null; /** Epoch milliseconds, or null while the transition has not happened. */ readonly queuedAt: number; @@ -465,6 +491,7 @@ export interface CampaignRecipientPage { } export interface ListCampaignRecipientsParams { + readonly reason?: CampaignRecipientFailureReason; readonly status?: CampaignRecipientStatus; readonly cursor?: string; /** 1 to 100; the API defaults to 25. */ @@ -511,6 +538,8 @@ interface UpdateCampaignChanges { * paused edit requires in-flight sends to have settled. */ readonly sendWindow?: CampaignSendWindowRequest | null; + /** Beta. A stricter limit than the team cap; null removes the campaign cap. */ + readonly frequencyCap?: CampaignFrequencyCap | null; /** Replaces the weighted alternatives; null clears them. Only an unlaunched draft. */ readonly messageVariations?: readonly CampaignMessageVariation[] | null; /** Replaces the A/B variants; null with a null strategy makes a broadcast. Only an unlaunched draft. */ @@ -522,6 +551,9 @@ interface UpdateCampaignChanges { /** At least one draft field must be supplied. */ export type UpdateCampaignRequest = + | (UpdateCampaignChanges & { + readonly frequencyCap: CampaignFrequencyCap | null; + }) | (UpdateCampaignChanges & { readonly name: string }) | (UpdateCampaignChanges & { readonly recipientListId: string | null }) | (UpdateCampaignChanges & { diff --git a/packages/typescript/src/platform/campaigns.ts b/packages/typescript/src/platform/campaigns.ts index edc75445..da44c382 100644 --- a/packages/typescript/src/platform/campaigns.ts +++ b/packages/typescript/src/platform/campaigns.ts @@ -8,6 +8,8 @@ import type { AddPlatformCampaignRecipientsRequest, AddPlatformCampaignRecipientsResult, CampaignConversion, + CampaignFrequencyCapSettings, + UpdateCampaignFrequencyCapInput, CampaignConversionReport, CreatePlatformCampaignRequest, DataEnvelope, @@ -36,6 +38,37 @@ type CampaignRead = "analytics" | "events"; export class CampaignsResource { constructor(private readonly transport: HttpTransport) {} + /** Team API key with campaigns:read. Project tokens are not accepted. */ + getFrequencyCap( + options: RequestOptions = {}, + ): CampaignResult { + return this.transport.request({ + method: "GET", + path: "/platform/campaign-settings/frequency-cap", + ...options, + }); + } + + /** + * Team API key with campaigns:manage. Both limits and the last read revision + * are required. A stale revision raises PolymorfaConflictError; read again + * and reconcile before retrying. Limits require beta access (403 + * feature_unavailable). Both null removes limits without beta access. + * Sent once: a lost response must be reconciled with a fresh read. + */ + updateFrequencyCap( + body: UpdateCampaignFrequencyCapInput, + options: RequestOptions = {}, + ): CampaignResult { + return this.transport.request({ + method: "PUT", + path: "/platform/campaign-settings/frequency-cap", + body, + ...options, + maxNetworkRetries: 0, + }); + } + list( params: ListCampaignsParams, options: RequestOptions = {}, @@ -226,6 +259,7 @@ export class CampaignsResource { query: { projectId: params.projectId, ...(params.status === undefined ? {} : { status: params.status }), + ...(params.reason === undefined ? {} : { reason: params.reason }), ...(params.cursor === undefined ? {} : { cursor: params.cursor }), ...(params.limit === undefined ? {} : { limit: params.limit }), }, diff --git a/packages/typescript/src/platform/types.ts b/packages/typescript/src/platform/types.ts index fbaff112..3d374cb4 100644 --- a/packages/typescript/src/platform/types.ts +++ b/packages/typescript/src/platform/types.ts @@ -1,5 +1,7 @@ import type { Campaign, + CampaignFrequencyCap, + CampaignRecipientFailureReason, CampaignRecipient, CampaignRecipientInput, CampaignRecipientStatus, @@ -194,6 +196,7 @@ export interface CreatePlatformCampaignRequest { readonly scheduledAt?: number; /** When the campaign may send; null or omitted sends at any time. */ readonly sendWindow?: CampaignSendWindowRequest | null; + readonly frequencyCap?: CampaignFrequencyCap | null; /** At most 1,000 recipients. */ readonly recipients?: readonly CampaignRecipientInput[]; /** Ignored when inline recipients are supplied. */ @@ -261,6 +264,7 @@ export interface UpdatePlatformCampaignRequest { readonly scheduledAt?: number | null; /** Null removes the window. Only while the campaign is a draft or paused. */ readonly sendWindow?: CampaignSendWindowRequest | null; + readonly frequencyCap?: CampaignFrequencyCap | null; /** Replaces the weighted alternatives; null clears them. Only an unlaunched draft. */ readonly messageVariations?: readonly CampaignMessageVariation[] | null; /** Replaces the A/B variants; null with a null strategy makes a broadcast. Only an unlaunched draft. */ @@ -282,6 +286,7 @@ export interface PlatformCampaignAnalytics extends CampaignAnalytics { } export interface ListPlatformCampaignRecipientsParams { + readonly reason?: CampaignRecipientFailureReason; /** Owning project for this organization-client request. */ readonly projectId: string; readonly status?: CampaignRecipientStatus; @@ -1595,3 +1600,15 @@ export interface UpdateProjectHealthPolicyRequest { readonly emailNotification: boolean; readonly webhookNotification: boolean; } + +/** Team settings. Revision 0 and updatedAt null until first saved. */ +export interface CampaignFrequencyCapSettings extends CampaignFrequencyCap { + readonly teamId: string; + readonly revision: number; + readonly updatedAt: number | null; +} + +/** Complete replacement. A stale revision returns PolymorfaConflictError (409). */ +export interface UpdateCampaignFrequencyCapInput extends CampaignFrequencyCap { + readonly revision: number; +} diff --git a/packages/typescript/test/campaign-frequency-cap.test.ts b/packages/typescript/test/campaign-frequency-cap.test.ts new file mode 100644 index 00000000..a037482c --- /dev/null +++ b/packages/typescript/test/campaign-frequency-cap.test.ts @@ -0,0 +1,236 @@ +import { readFileSync } from "node:fs"; +import { afterEach, expect, expectTypeOf, it } from "vitest"; +import { + Client, + MessagingClient, + PolymorfaConflictError, + PolymorfaAuthorizationError, + type CampaignFrequencyCap, + type UpdateCampaignRequest, + type CreatePlatformCampaignRequest, + type UpdatePlatformCampaignRequest, + type CampaignRecipientFailureReason, +} from "../src/index.js"; +import { ORGANIZATION_API_KEY } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => s.close())); +}); +const limits = { + maxPer24Hours: 2, + maxPer7Days: 5, +} satisfies CampaignFrequencyCap; +const settings = { + ...limits, + teamId: "team", + revision: 1, + updatedAt: 1800000000000, +}; +const contract = JSON.parse( + readFileSync( + new URL("../../../contracts/campaign-frequency-cap.json", import.meta.url), + "utf8", + ), +); +it("pins bounds, fields, errors and reason filters to the merged contract", () => { + expect(contract.commit).toBe("e404a727ddb4abf42a8df768a5336fad922cc99a"); + for (const source of Object.values(contract.sources) as { + sourceSha256: string; + fields: Record; + }[]) { + expect(source.sourceSha256).toMatch(/^[a-f0-9]{64}$/); + expect( + Object.keys(source.fields).some((k) => k.endsWith(".frequencyCap")), + ).toBe(true); + } + const schema = contract.sources.platform.schemas; + expect(schema.UpdateCampaignFrequencyCapInput.required).toEqual([ + "maxPer24Hours", + "maxPer7Days", + "revision", + ]); + expect( + schema.UpdateCampaignFrequencyCapInput.properties.maxPer24Hours.maximum, + ).toBe(50); + expect( + schema.UpdateCampaignFrequencyCapInput.properties.maxPer7Days.maximum, + ).toBe(200); + const reason: CampaignRecipientFailureReason = "frequency_cap"; + expect(schema.CampaignRecipientFailureReason.enum).toContain(reason); + expectTypeOf().toMatchTypeOf<{ + readonly frequencyCap?: CampaignFrequencyCap | null; + }>(); + const create: CreatePlatformCampaignRequest = { + name: "cap", + projectId: "p", + frequencyCap: limits, + }; + const update: UpdatePlatformCampaignRequest = { frequencyCap: null }; + expect([create.frequencyCap, update.frequencyCap]).toEqual([limits, null]); +}); +it("reads, replaces and removes team limits with the observed revision", async () => { + const server = await startTestServer((request, i) => ({ + body: JSON.stringify({ + data: { + ...settings, + ...(request.body ? JSON.parse(request.body) : {}), + revision: i + 1, + }, + }), + })); + servers.push(server); + const client = new Client({ + credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + }); + const read = await client.campaigns.getFrequencyCap(); + const saved = await client.campaigns.updateFrequencyCap({ + ...limits, + revision: read.data.data.revision, + }); + await client.campaigns.updateFrequencyCap({ + maxPer24Hours: null, + maxPer7Days: null, + revision: saved.data.data.revision, + }); + expect(server.requests.map((r) => [r.method, r.path])).toEqual([ + ["GET", "/platform/campaign-settings/frequency-cap"], + ["PUT", "/platform/campaign-settings/frequency-cap"], + ["PUT", "/platform/campaign-settings/frequency-cap"], + ]); + expect(JSON.parse(server.requests[2]!.body)).toEqual({ + maxPer24Hours: null, + maxPer7Days: null, + revision: 2, + }); +}); +it.each([ + [409, "conflict", PolymorfaConflictError], + [403, "feature_unavailable", PolymorfaAuthorizationError], +] as const)( + "preserves %s errors without retrying the write", + async (status, code, ErrorClass) => { + const server = await startTestServer(() => ({ + status, + body: JSON.stringify({ + error: { code, message: "Cap unavailable or stale" }, + }), + })); + servers.push(server); + const client = new Client({ + credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + maxNetworkRetries: 2, + }); + const result = client.campaigns.updateFrequencyCap({ + ...limits, + revision: 0, + }); + await expect(result).rejects.toBeInstanceOf(ErrorClass); + await expect(result).rejects.toMatchObject({ status, code }); + expect(server.requests).toHaveLength(1); + }, +); +it("passes cap fields and recipient reason filters through both audiences", async () => { + const server = await startTestServer(() => ({ + body: JSON.stringify({ + success: true, + data: { frequencyCap: limits }, + page: { hasMore: false, nextCursor: null }, + }), + })); + servers.push(server); + const config = { + credential: { + type: "organizationApiKey" as const, + value: ORGANIZATION_API_KEY, + }, + baseUrl: server.url, + }; + const client = new Client(config); + const messaging = new MessagingClient({ + ...config, + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + }); + await client.campaigns.create({ + name: "cap", + projectId: "p", + frequencyCap: limits, + }); + await client.campaigns.update( + "c", + { frequencyCap: null }, + { projectId: "p" }, + ); + await messaging.campaigns.create("p", { name: "cap", frequencyCap: limits }); + await messaging.campaigns.update("p", "c", { frequencyCap: null }); + await client.campaigns.recipients("c", { + projectId: "p", + status: "skipped", + reason: "frequency_cap", + }); + await messaging.campaigns.listRecipients("p", "c", { + status: "skipped", + reason: "frequency_cap", + }); + expect( + server.requests.slice(0, 4).map((r) => JSON.parse(r.body).frequencyCap), + ).toEqual([limits, null, limits, null]); + expect( + server.requests + .slice(4) + .every((r) => r.path.includes("reason=frequency_cap")), + ).toBe(true); +}); + +it("preserves cap reads, skip analytics and recipient reasons on both surfaces", async () => { + const server = await startTestServer((request) => ({ + body: JSON.stringify({ + success: true, + data: request.path.includes("/analytics") + ? { frequencyCap: { skippedRecipientCount: 3 } } + : request.path.includes("/recipients") + ? [ + { + failureReason: "frequency_cap", + lastError: "frequency_cap_exceeded", + }, + ] + : { frequencyCap: limits }, + page: { hasMore: false, nextCursor: null }, + }), + })); + servers.push(server); + const client = new Client({ + credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + }); + const messaging = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + }); + expect( + (await client.campaigns.retrieve("c", { projectId: "p" })).data.data + ?.frequencyCap, + ).toEqual(limits); + expect( + (await messaging.campaigns.retrieve("p", "c")).data.data.frequencyCap, + ).toEqual(limits); + expect( + (await client.campaigns.analytics("c", { projectId: "p" })).data.data + .frequencyCap?.skippedRecipientCount, + ).toBe(3); + expect( + (await messaging.campaigns.analytics("p", "c")).data.data.frequencyCap + ?.skippedRecipientCount, + ).toBe(3); + expect( + (await client.campaigns.recipients("c", { projectId: "p" })).data.data[0] + ?.failureReason, + ).toBe("frequency_cap"); + expect( + (await messaging.campaigns.listRecipients("p", "c")).data.data[0] + ?.failureReason, + ).toBe("frequency_cap"); +});