From 00a64e702fd1fadae7053ba4cbd515b747ce6432 Mon Sep 17 00:00:00 2001 From: Alex Pate Date: Fri, 9 Oct 2026 15:09:02 +0200 Subject: [PATCH] Add template republish --- .changeset/template-republish.md | 11 + ecosystem-tests/bun/smoke.mjs | 1 + ecosystem-tests/deno/smoke.mjs | 1 + ecosystem-tests/mock-server.mjs | 1 + ecosystem-tests/node-js/smoke.mjs | 1 + openapi.json | 470 +++++++++++++++++++++++++++++- src/generated/event-types.ts | 1 + src/generated/openapi.ts | 306 ++++++++++++++++++- src/index.ts | 2 + src/resources/templates.ts | 23 ++ src/types.ts | 10 + test/client.test.ts | 18 ++ test/index.test.ts | 2 +- test/resources.test.ts | 29 ++ test/types.test-d.ts | 36 +++ test/webhooks.test.ts | 1 + 16 files changed, 904 insertions(+), 9 deletions(-) create mode 100644 .changeset/template-republish.md diff --git a/.changeset/template-republish.md b/.changeset/template-republish.md new file mode 100644 index 0000000..0fa5cc1 --- /dev/null +++ b/.changeset/template-republish.md @@ -0,0 +1,11 @@ +--- +'@passmint/node': minor +--- + +Add `templates.republish()` and `templates.retrieveRepublish()` to push a template's current design to passes already issued from it, plus the `TemplateRepublish` and `TemplateRepublishedEvent` types. Existing call shapes are unchanged. + +- `templates.republish(id)` sends an `Idempotency-Key` (override with `{ idempotencyKey }`). A 429 `republish_in_progress` throws `PassmintRateLimitError` immediately; the SDK never auto-retries 429s, and this one can last minutes. +- New `template.republished` event type. `PassmintEventType` and `Event['type']` gain it, so exhaustive `switch`es over event types will flag the new case. `PASSMINT_EVENT_TYPES` gains an entry at runtime: code that subscribes with `[...PASSMINT_EVENT_TYPES]` will now receive it. It carries `data.object.template_republish` and no `pass`, so narrow with `event.type === 'template.republished'` and use `TemplateRepublishedEvent`. +- `template.republished` is only delivered to endpoints subscribed to it by name, never to `"*"`, so existing wildcard handlers that read `data.object.pass` keep working. +- `PassDelivery` gains optional `apple.retrying` and `google.retrying`, set when a failed push was queued for automatic retry. +- Server behaviour: `pass.update_failed` now fires only for permanent failures or once retries are exhausted, not for the first transient failure. diff --git a/ecosystem-tests/bun/smoke.mjs b/ecosystem-tests/bun/smoke.mjs index 62d590f..e025bf5 100644 --- a/ecosystem-tests/bun/smoke.mjs +++ b/ecosystem-tests/bun/smoke.mjs @@ -34,6 +34,7 @@ assert.deepEqual([...PASSMINT_EVENT_TYPES], [ 'pass.removed', 'pass.voided', 'pass.redeemed', + 'template.republished', ]) assert.equal(detectMode(apiKey), 'test') diff --git a/ecosystem-tests/deno/smoke.mjs b/ecosystem-tests/deno/smoke.mjs index 62d590f..e025bf5 100644 --- a/ecosystem-tests/deno/smoke.mjs +++ b/ecosystem-tests/deno/smoke.mjs @@ -34,6 +34,7 @@ assert.deepEqual([...PASSMINT_EVENT_TYPES], [ 'pass.removed', 'pass.voided', 'pass.redeemed', + 'template.republished', ]) assert.equal(detectMode(apiKey), 'test') diff --git a/ecosystem-tests/mock-server.mjs b/ecosystem-tests/mock-server.mjs index 80fd613..da768e9 100644 --- a/ecosystem-tests/mock-server.mjs +++ b/ecosystem-tests/mock-server.mjs @@ -28,6 +28,7 @@ const CANONICAL_EVENT_TYPES = [ 'pass.removed', 'pass.voided', 'pass.redeemed', + 'template.republished', ] function errorBody(type, message, extra = {}) { diff --git a/ecosystem-tests/node-js/smoke.mjs b/ecosystem-tests/node-js/smoke.mjs index 62d590f..e025bf5 100644 --- a/ecosystem-tests/node-js/smoke.mjs +++ b/ecosystem-tests/node-js/smoke.mjs @@ -34,6 +34,7 @@ assert.deepEqual([...PASSMINT_EVENT_TYPES], [ 'pass.removed', 'pass.voided', 'pass.redeemed', + 'template.republished', ]) assert.equal(detectMode(apiKey), 'test') diff --git a/openapi.json b/openapi.json index 08256b8..83b087f 100644 --- a/openapi.json +++ b/openapi.json @@ -1058,7 +1058,7 @@ "items": { "type": "string" }, - "description": "Required, non-empty. Event types to deliver, or `*` for all.", + "description": "Required, non-empty. Event types to deliver, or `*` for all pass events (template.republished must be subscribed by name).", "example": [ "pass.redeemed" ] @@ -1090,7 +1090,7 @@ "items": { "type": "string" }, - "description": "Event types to deliver (for example `pass.redeemed`), or `*` for all of them.", + "description": "Event types to deliver (for example `pass.redeemed`), or `*` for all pass events (template.republished must be subscribed by name).", "example": [ "pass.redeemed" ] @@ -2250,6 +2250,226 @@ ], "description": "`data.object.redemption`, present on `pass.redeemed`." }, + "TemplateRepublishedEvent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "enum": [ + "event" + ] + }, + "type": { + "type": "string", + "enum": [ + "template.republished" + ] + }, + "api_version": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "idempotency_key": { + "type": "string" + }, + "livemode": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "object": { + "type": "object", + "properties": { + "template_republish": { + "$ref": "#/components/schemas/TemplateRepublish" + } + }, + "required": [ + "template_republish" + ] + } + }, + "required": [ + "object" + ] + }, + "source": { + "type": "object", + "properties": { + "platform": { + "type": [ + "string", + "null" + ], + "enum": [ + "apple", + "google", + null + ] + }, + "unit": { + "type": [ + "string", + "null" + ], + "enum": [ + "device", + "object", + null + ] + }, + "confidence": { + "type": "string", + "enum": [ + "exact", + "best_effort", + "unconfirmed" + ] + } + }, + "required": [ + "platform", + "unit", + "confidence" + ] + }, + "previous_attributes": { + "type": "null" + } + }, + "required": [ + "id", + "object", + "type", + "api_version", + "created_at", + "idempotency_key", + "livemode", + "data", + "source", + "previous_attributes" + ] + }, + "TemplateRepublish": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "template_republish" + ] + }, + "id": { + "type": "string" + }, + "template_id": { + "type": "string" + }, + "livemode": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "completed" + ] + }, + "google": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "synced", + "failed", + "skipped" + ], + "description": "The Google Wallet class sync: one write that carries the issuer name, colour, logo and hero to every Google holder. `skipped`: no pass on this template has a Google object." + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "objects": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "done": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + }, + "required": [ + "total", + "done", + "failed" + ], + "description": "Per-pass Google object updates. Zero when the change only touched the class (name, logo, hero)." + } + }, + "required": [ + "status", + "error", + "objects" + ] + }, + "apple": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "done": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + }, + "required": [ + "total", + "done", + "failed" + ], + "description": "Apple passes re-signed and pushed: those with an installed device, plus never-installed ones when the artwork changed." + }, + "created_at": { + "type": "string" + }, + "completed_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "object", + "id", + "template_id", + "livemode", + "status", + "google", + "apple", + "created_at", + "completed_at" + ] + }, "ScannerPairing": { "type": "object", "properties": { @@ -2320,7 +2540,7 @@ }, "code": { "type": "string", - "description": "A stable, machine-readable code when the error has a specific cause. Not exhaustive: treat unknown codes as the generic error for the `type`. Codes include `api_key_expired` (401, the key's expiry has passed), `insufficient_scope` (403, the key's scopes do not allow this route), `invalid_limit` (400, `limit` is negative, fractional or non-numeric; 0 is valid and a limit over the maximum is clamped), `invalid_cursor` (400, `starting_after` is not an id on this list) and `plan_limit_exceeded` (402).", + "description": "A stable, machine-readable code when the error has a specific cause. Not exhaustive: treat unknown codes as the generic error for the `type`. Codes include `api_key_expired` (401, the key's expiry has passed), `insufficient_scope` (403, the key's scopes do not allow this route), `invalid_limit` (400, `limit` is negative, fractional or non-numeric; 0 is valid and a limit over the maximum is clamped), `invalid_cursor` (400, `starting_after` is not an id on this list), `republish_in_progress` (429, too many template republishes running), `republish_unavailable` (503, try again shortly) and `plan_limit_exceeded` (402).", "examples": [ "api_key_expired", "insufficient_scope", @@ -2604,7 +2824,8 @@ "pass.update_failed", "pass.removed", "pass.voided", - "pass.redeemed" + "pass.redeemed", + "template.republished" ] }, "api_version": { @@ -4374,6 +4595,10 @@ }, "unregistered": { "type": "integer" + }, + "retrying": { + "type": "boolean", + "description": "Present and true when some pushes failed for a reason that may clear (Apple busy or unreachable) and Passmint queued an automatic retry. Absent otherwise." } }, "required": [ @@ -4401,6 +4626,10 @@ }, "error": { "type": "string" + }, + "retrying": { + "type": "boolean", + "description": "Present and true when the Google update failed for a reason that may clear (a Google 5xx, 409 or 429, or a network error) and Passmint queued an automatic retry." } }, "required": [ @@ -6094,6 +6323,215 @@ } } }, + "/v1/templates/{id}/republish": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Push template changes to issued passes", + "description": "Publishes the template's current design to the passes already issued from it, in this key's mode. Google Wallet gets one class update (issuer name, logo, hero) plus per-pass object updates when fields, colours or the barcode changed; Apple Wallet passes with an installed device are re-signed and pushed. Saving a template never does this on its own — call it once your edits are done. Returns 202 with the new republish, or 200 with the one already running; in that case another runs when it finishes, so the latest design reaches every pass. Archived templates can be republished too: their passes are still in wallets.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "A republish of this template was already running; it is returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateRepublish" + } + } + } + }, + "202": { + "description": "The republish was queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateRepublish" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "402": { + "description": "Plan limit reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (API key scope, plan or account)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Idempotency conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Temporarily unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/v1/templates/{id}/republishes/{rid}": { + "get": { + "tags": [ + "Templates" + ], + "summary": "Retrieve a template republish", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "rid", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The republish, with live counts while it runs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateRepublish" + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden (API key scope, plan or account)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/v1/webhooks": { "get": { "tags": [ @@ -8063,6 +8501,30 @@ } } } + }, + "template.republished": { + "post": { + "tags": [ + "Webhooks" + ], + "summary": "template.republished event", + "description": "Sent to your webhook endpoint when a `template.republished` event occurs. Verify the signature, then return any 2xx to acknowledge.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateRepublishedEvent" + } + } + } + }, + "responses": { + "200": { + "description": "Return any 2xx to acknowledge" + } + } + } } } } diff --git a/src/generated/event-types.ts b/src/generated/event-types.ts index 6c3cf31..756ce2a 100644 --- a/src/generated/event-types.ts +++ b/src/generated/event-types.ts @@ -14,6 +14,7 @@ export const PASSMINT_EVENT_TYPES = [ "pass.removed", "pass.voided", "pass.redeemed", + "template.republished", ] as const /** A canonical lifecycle event type (one of `PASSMINT_EVENT_TYPES`). */ diff --git a/src/generated/openapi.ts b/src/generated/openapi.ts index 0a3d7fb..f672f70 100644 --- a/src/generated/openapi.ts +++ b/src/generated/openapi.ts @@ -913,6 +913,203 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/templates/{id}/republish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Push template changes to issued passes + * @description Publishes the template's current design to the passes already issued from it, in this key's mode. Google Wallet gets one class update (issuer name, logo, hero) plus per-pass object updates when fields, colours or the barcode changed; Apple Wallet passes with an installed device are re-signed and pushed. Saving a template never does this on its own — call it once your edits are done. Returns 202 with the new republish, or 200 with the one already running; in that case another runs when it finishes, so the latest design reaches every pass. Archived templates can be republished too: their passes are still in wallets. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A republish of this template was already running; it is returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateRepublish"]; + }; + }; + /** @description The republish was queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateRepublish"]; + }; + }; + /** @description Authentication failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Plan limit reached */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden (API key scope, plan or account) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Idempotency conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Temporarily unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/templates/{id}/republishes/{rid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Retrieve a template republish */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + rid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The republish, with live counts while it runs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateRepublish"]; + }; + }; + /** @description Authentication failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden (API key scope, plan or account) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/webhooks": { parameters: { query?: never; @@ -2733,6 +2930,47 @@ export interface webhooks { patch?: never; trace?: never; }; + "template.republished": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * template.republished event + * @description Sent to your webhook endpoint when a `template.republished` event occurs. Verify the signature, then return any 2xx to acknowledge. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TemplateRepublishedEvent"]; + }; + }; + responses: { + /** @description Return any 2xx to acknowledge */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export interface components { schemas: { @@ -2975,7 +3213,7 @@ export interface components { */ url: string; /** - * @description Required, non-empty. Event types to deliver, or `*` for all. + * @description Required, non-empty. Event types to deliver, or `*` for all pass events (template.republished must be subscribed by name). * @example [ * "pass.redeemed" * ] @@ -2988,7 +3226,7 @@ export interface components { UpdateWebhookBody: { url?: string; /** - * @description Event types to deliver (for example `pass.redeemed`), or `*` for all of them. + * @description Event types to deliver (for example `pass.redeemed`), or `*` for all pass events (template.republished must be subscribed by name). * @example [ * "pass.redeemed" * ] @@ -3267,6 +3505,62 @@ export interface components { scans_used: number | null; policy_mode: string | null; }; + TemplateRepublishedEvent: { + id: string; + /** @enum {string} */ + object: "event"; + /** @enum {string} */ + type: "template.republished"; + api_version: string; + created_at: string; + idempotency_key: string; + livemode: boolean; + data: { + object: { + template_republish: components["schemas"]["TemplateRepublish"]; + }; + }; + source: { + /** @enum {string|null} */ + platform: "apple" | "google" | null; + /** @enum {string|null} */ + unit: "device" | "object" | null; + /** @enum {string} */ + confidence: "exact" | "best_effort" | "unconfirmed"; + }; + previous_attributes: null; + }; + TemplateRepublish: { + /** @enum {string} */ + object: "template_republish"; + id: string; + template_id: string; + livemode: boolean; + /** @enum {string} */ + status: "queued" | "running" | "completed"; + google: { + /** + * @description The Google Wallet class sync: one write that carries the issuer name, colour, logo and hero to every Google holder. `skipped`: no pass on this template has a Google object. + * @enum {string} + */ + status: "pending" | "synced" | "failed" | "skipped"; + error: string | null; + /** @description Per-pass Google object updates. Zero when the change only touched the class (name, logo, hero). */ + objects: { + total: number; + done: number; + failed: number; + }; + }; + /** @description Apple passes re-signed and pushed: those with an installed device, plus never-installed ones when the artwork changed. */ + apple: { + total: number; + done: number; + failed: number; + }; + created_at: string; + completed_at: string | null; + }; ScannerPairing: { /** @enum {string} */ object: "scanner_pairing"; @@ -3284,7 +3578,7 @@ export interface components { /** @enum {string} */ type: "authentication_error" | "authorization_error" | "invalid_request_error" | "not_found_error" | "rate_limit_error" | "idempotency_error" | "api_error" | "billing_error"; /** - * @description A stable, machine-readable code when the error has a specific cause. Not exhaustive: treat unknown codes as the generic error for the `type`. Codes include `api_key_expired` (401, the key's expiry has passed), `insufficient_scope` (403, the key's scopes do not allow this route), `invalid_limit` (400, `limit` is negative, fractional or non-numeric; 0 is valid and a limit over the maximum is clamped), `invalid_cursor` (400, `starting_after` is not an id on this list) and `plan_limit_exceeded` (402). + * @description A stable, machine-readable code when the error has a specific cause. Not exhaustive: treat unknown codes as the generic error for the `type`. Codes include `api_key_expired` (401, the key's expiry has passed), `insufficient_scope` (403, the key's scopes do not allow this route), `invalid_limit` (400, `limit` is negative, fractional or non-numeric; 0 is valid and a limit over the maximum is clamped), `invalid_cursor` (400, `starting_after` is not an id on this list), `republish_in_progress` (429, too many template republishes running), `republish_unavailable` (503, try again shortly) and `plan_limit_exceeded` (402). * @example api_key_expired * @example insufficient_scope * @example invalid_limit @@ -3354,7 +3648,7 @@ export interface components { /** @enum {string} */ object: "event"; /** @enum {string} */ - type: "pass.issued" | "pass.add_intent" | "pass.added_to_wallet" | "pass.update_pushed" | "pass.update_delivered" | "pass.update_failed" | "pass.removed" | "pass.voided" | "pass.redeemed"; + type: "pass.issued" | "pass.add_intent" | "pass.added_to_wallet" | "pass.update_pushed" | "pass.update_delivered" | "pass.update_failed" | "pass.removed" | "pass.voided" | "pass.redeemed" | "template.republished"; api_version: string; created_at: string; idempotency_key: string; @@ -3812,12 +4106,16 @@ export interface components { pushed: number; failed: number; unregistered: number; + /** @description Present and true when some pushes failed for a reason that may clear (Apple busy or unreachable) and Passmint queued an automatic retry. Absent otherwise. */ + retrying?: boolean; } | null; /** @description Null when the pass has no Google Wallet object. `not_saved`: no holder has saved it yet. `skipped`: Google delivery was skipped at issue. */ google: { /** @enum {string} */ status: "updated" | "not_saved" | "failed" | "skipped"; error?: string; + /** @description Present and true when the Google update failed for a reason that may clear (a Google 5xx, 409 or 429, or a network error) and Passmint queued an automatic retry. */ + retrying?: boolean; } | null; }; PassVoidResponse: { diff --git a/src/index.ts b/src/index.ts index e999889..fed58be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,8 @@ export type { TemplateImageOptions, TemplateImageSlot, TemplateLocation, + TemplateRepublish, + TemplateRepublishedEvent, TemplateType, UpdatePassParams, UpdateTemplateParams, diff --git a/src/resources/templates.ts b/src/resources/templates.ts index b4b4c81..7133e23 100644 --- a/src/resources/templates.ts +++ b/src/resources/templates.ts @@ -11,6 +11,7 @@ import type { TemplateImage, TemplateImageOptions, TemplateImageSlot, + TemplateRepublish, UpdateTemplateParams, VariantImageSlot, } from '../types' @@ -93,6 +94,28 @@ export class TemplatesResource { }) } + /** + * Push the template's current design to passes already issued from it, in + * this key's mode. Returns a job to poll with `retrieveRepublish` (or watch + * for the `template.republished` webhook). A 429 `republish_in_progress` + * means too many republishes are running; it can last minutes, so the SDK + * throws it immediately instead of retrying. + */ + republish(id: string, options: RequestOptions = {}): Promise { + return this.http.request({ + method: 'POST', + path: `/v1/templates/${encodeURIComponent(id)}/republish`, + idempotencyKey: options.idempotencyKey ?? generateIdempotencyKey(), + }) + } + + retrieveRepublish(id: string, republishId: string): Promise { + return this.http.request({ + method: 'GET', + path: `/v1/templates/${encodeURIComponent(id)}/republishes/${encodeURIComponent(republishId)}`, + }) + } + archive(id: string): Promise<{ id: string; deleted: true }> { return this.http.request({ method: 'DELETE', diff --git a/src/types.ts b/src/types.ts index 92930ab..4b52f61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -405,6 +405,16 @@ export type Account = Omit & { organization_slug: string } +/** A template republish job: progress of pushing a template's design to issued passes. */ +export type TemplateRepublish = Schemas['TemplateRepublish'] + +/** + * The `template.republished` event. It carries `data.object.template_republish` + * and no `pass`. `PassmintEvent` keeps describing pass events; narrow with + * `event.type === 'template.republished'` and use this type. + */ +export type TemplateRepublishedEvent = Schemas['TemplateRepublishedEvent'] + /** Delivery counts for a pass update, per wallet. */ export type PassDelivery = Schemas['PassDelivery'] diff --git a/test/client.test.ts b/test/client.test.ts index ea465e0..d3ba02c 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -7,6 +7,7 @@ import { PassmintError, PassmintRateLimitError, } from '../src/errors' +import { Passmint } from '../src/index' import { VERSION } from '../src/version' type FetchCall = { url: string; init: RequestInit } @@ -219,6 +220,23 @@ describe('PassmintHttpClient.request', () => { expect((err as PassmintRateLimitError).retryAfterSeconds).toBe(4) }) + it('does not retry republish_in_progress 429s even with retries enabled', async () => { + const { impl } = makeFetch([ + { + status: 429, + body: { error: { type: 'rate_limited', code: 'republish_in_progress', message: 'busy' } }, + headers: { 'retry-after': '60' }, + }, + ]) + const client = new Passmint({ apiKey: 'pmk_test_1', fetch: impl, maxRetries: 3 }) + const p = client.templates.republish('tmpl_1').catch((e) => e) + await flush() + const err = await p + expect(err).toBeInstanceOf(PassmintRateLimitError) + expect((err as PassmintRateLimitError).retryAfterSeconds).toBe(60) + expect(impl).toHaveBeenCalledTimes(1) + }) + it('retries 5xx up to maxRetries, then throws PassmintAPIError', async () => { const { impl } = makeFetch([ { status: 500, body: { error: { type: 'api_error', message: 'boom' } } }, diff --git a/test/index.test.ts b/test/index.test.ts index 72ad938..d73ad5f 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -14,6 +14,6 @@ describe('Passmint client', () => { }) it('re-exports the canonical event type list', () => { - expect(PASSMINT_EVENT_TYPES).toHaveLength(9) + expect(PASSMINT_EVENT_TYPES).toHaveLength(10) }) }) diff --git a/test/resources.test.ts b/test/resources.test.ts index b96a0ed..06ba635 100644 --- a/test/resources.test.ts +++ b/test/resources.test.ts @@ -226,6 +226,35 @@ describe('TemplatesResource', () => { }) }) + it('republish POSTs with an idempotency key and returns the republish', async () => { + const { http, calls } = fakeHttp({ object: 'template_republish', id: 'tmplRepub_1' }) + const templates = new TemplatesResource(http) + + const out = await templates.republish('tmpl_1') + + expect(out.id).toBe('tmplRepub_1') + expect(calls[0]).toMatchObject({ method: 'POST', path: '/v1/templates/tmpl_1/republish' }) + expect(calls[0]?.body).toBeUndefined() + expect(calls[0]?.idempotencyKey).toBeTruthy() + }) + + it('republish honours a caller-supplied idempotency key', async () => { + const { http, calls } = fakeHttp() + await new TemplatesResource(http).republish('tmpl_1', { idempotencyKey: 'key_1' }) + expect(calls[0]?.idempotencyKey).toBe('key_1') + }) + + it('retrieveRepublish GETs the republish', async () => { + const { http, calls } = fakeHttp({ object: 'template_republish', id: 'tmplRepub_1' }) + + await new TemplatesResource(http).retrieveRepublish('tmpl_1', 'tmplRepub_1') + + expect(calls[0]).toMatchObject({ + method: 'GET', + path: '/v1/templates/tmpl_1/republishes/tmplRepub_1', + }) + }) + it('retrieve, list, update, archive hit the right paths and methods', async () => { const { http, calls } = fakeHttp() const templates = new TemplatesResource(http) diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 594a92b..9488663 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -14,6 +14,7 @@ import type { PassImages, PassPlatformStatus, PassmintEvent, + PassmintEventType, PlatformDeliveryStatus, Redemption, RedemptionPolicy, @@ -23,6 +24,8 @@ import type { TemplateImage, TemplateImageSlot, TemplateLocation, + TemplateRepublish, + TemplateRepublishedEvent, UpdatePassParams, VariantImageSlot, WalletPlatform, @@ -218,3 +221,36 @@ describe('TemplateDesign.google', () => { void bad }) }) + +describe('template republish types', () => { + it('types the republish job and the retrying flag', () => { + expectTypeOf().toEqualTypeOf<{ + total: number + done: number + failed: number + }>() + expectTypeOf['retrying']>().toEqualTypeOf< + boolean | undefined + >() + expectTypeOf['retrying']>().toEqualTypeOf< + boolean | undefined + >() + expectTypeOf<'template.republished'>().toExtend() + }) + + it('gives the template event its own shape', () => { + expectTypeOf().toEqualTypeOf<'template.republished'>() + expectTypeOf< + TemplateRepublishedEvent['data']['object']['template_republish'] + >().toEqualTypeOf() + }) + + it('resource methods return TemplateRepublish', () => { + expectTypeOf< + TemplatesResource['republish'] + >().returns.resolves.toEqualTypeOf() + expectTypeOf< + TemplatesResource['retrieveRepublish'] + >().returns.resolves.toEqualTypeOf() + }) +}) diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index 5cce3b3..299e505 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -134,6 +134,7 @@ describe('PASSMINT_EVENT_TYPES', () => { 'pass.removed', 'pass.voided', 'pass.redeemed', + 'template.republished', ]) expectTypeOf().toEqualTypeOf<(typeof PASSMINT_EVENT_TYPES)[number]>() })