diff --git a/contracts/README.md b/contracts/README.md index 635501b1..39e447a7 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -85,6 +85,11 @@ drafts with 400 before submission. The typed `MessagingClient.templates.submit` method makes one network attempt, including when callers supply an idempotency key or retry override; the route declares no replay contract. +API PR #380, merged at the pinned commit, adds the read-only linked-catalog +product page. `MessagingClient.cloudCatalogs.listProducts` follows its WABA and +catalog path, `sessions:read` credential boundary, projected product fields, +and opaque cursor response. It does not establish live catalog access. + The merged Campaigns failed-state follow-up updates the `campaign.failed` reason example and the public archive/delete `409` descriptions. It changes no operation fingerprint or SDK method mapping. diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json index ef6b9a42..eb316591 100644 --- a/contracts/cloud-api-supplements.json +++ b/contracts/cloud-api-supplements.json @@ -7,8 +7,11 @@ { "sourcePath": "docs/api-reference/openapi.graph.json", "sha256": "87e3a56aee42de041dd38db08d0b263123f84a5a4982ce0f20b780c3cfae3684", - "operationIds": ["graphListProductCatalogs"], - "methods": ["MessagingClient.cloudCatalogs.list"], + "operationIds": ["graphListProductCatalogs", "graphListCatalogProducts"], + "methods": [ + "MessagingClient.cloudCatalogs.list", + "MessagingClient.cloudCatalogs.listProducts" + ], "merged": true }, { diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 7dbc3613..6760adfa 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -407,6 +407,13 @@ requires `sessions:read` and Graph access. The response retains opaque cursors; it does not expose upstream pagination URLs or grant merchant ownership. Use an organization API key or project token, with the latter confined to its project. +`messaging.cloudCatalogs.listProducts(wabaId, catalogId, { version: "v26.0", limit: 25, after })` +reads a page of product IDs, optional retailer IDs, names, and availability +from a catalog linked to that WABA. It has the same server-credential and +`sessions:read` requirements. Use the returned opaque `paging.cursors.after` +to request the next page. The API rejects catalogs it cannot verify as linked; +listing products does not grant permission to send them. + `messaging.flowEncryption.retrieve(phoneNumberId, { version: "v26.0" })` reads the registered public key and Meta signature status with `sessions:read`. `messaging.flowEncryption.register(phoneNumberId, diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index e16617ad..eb29149d 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -69,8 +69,11 @@ export type { } from "./messaging/flow-encryption.js"; export type { CloudProductCatalog, + CloudCatalogProduct, ListCloudCatalogsParams, ListCloudCatalogsResponse, + ListCloudCatalogProductsParams, + ListCloudCatalogProductsResponse, } from "./messaging/cloud-catalogs.js"; export { BridgeClient, diff --git a/packages/typescript/src/messaging/cloud-catalogs.ts b/packages/typescript/src/messaging/cloud-catalogs.ts index dd9e0c33..a7afa9b0 100644 --- a/packages/typescript/src/messaging/cloud-catalogs.ts +++ b/packages/typescript/src/messaging/cloud-catalogs.ts @@ -20,6 +20,19 @@ export interface ListCloudCatalogsResponse { readonly cursors: { readonly before?: string; readonly after?: string }; }; } +export interface CloudCatalogProduct { + readonly id: string; + readonly retailer_id?: string; + readonly name?: string; + readonly availability?: string; +} +export type ListCloudCatalogProductsParams = ListCloudCatalogsParams; +export interface ListCloudCatalogProductsResponse { + readonly data: readonly CloudCatalogProduct[]; + readonly paging?: { + readonly cursors: { readonly before?: string; readonly after?: string }; + }; +} /** Reads catalog links visible to the authenticated WABA. Requires sessions:read. */ export class CloudCatalogsResource { @@ -51,4 +64,34 @@ export class CloudCatalogsResource { ...options, }); } + + /** Read a page of products only after the API verifies that this catalog is linked to the WABA. */ + listProducts( + wabaId: string, + catalogId: string, + params: ListCloudCatalogProductsParams, + options: RequestOptions = {}, + ): Promise> { + if (this.credentialType === "clientToken") { + throw new PolymorfaConfigurationError( + "Official API catalogs require an organization API key or project token.", + "credential", + ); + } + if ( + !wabaId.trim() || + !/^[0-9]+$/.test(catalogId) || + !params.version.trim() + ) { + throw new PolymorfaConfigurationError( + "Provide a WABA ID, numeric catalog ID, and Graph version.", + ); + } + return this.transport.request({ + method: "GET", + path: `/graph/whatsapp/${encodeURIComponent(params.version)}/${encodeURIComponent(wabaId)}/product_catalogs/${encodeURIComponent(catalogId)}/products`, + query: { limit: params.limit, after: params.after }, + ...options, + }); + } } diff --git a/packages/typescript/test/cloud-catalogs.test.ts b/packages/typescript/test/cloud-catalogs.test.ts index 18de3c9f..57888eee 100644 --- a/packages/typescript/test/cloud-catalogs.test.ts +++ b/packages/typescript/test/cloud-catalogs.test.ts @@ -58,6 +58,50 @@ describe("Official API catalog discovery", () => { ).rejects.toMatchObject({ status: 403 }); expect(server.requests).toHaveLength(1); }); + it("reads one linked catalog product page through the exact Graph route", async () => { + const payload = { + data: [ + { + id: "111", + retailer_id: "sku-1", + name: "Mug", + availability: "in stock", + }, + ], + paging: { cursors: { after: "next+/=" } }, + }; + const server = await startTestServer(() => ({ + body: JSON.stringify(payload), + })); + servers.push(server); + const client = new MessagingClient({ + credential: { type: "projectToken", value: PROJECT_TOKEN }, + baseUrl: server.url, + }); + const page = await client.cloudCatalogs.listProducts( + "12345678901234567890", + "98765432109876543210", + { version: "v26.0", limit: 10, after: "next+/=" }, + ); + expect(page.data).toEqual(payload); + expect(server.requests[0]!.path).toBe( + "/graph/whatsapp/v26.0/12345678901234567890/product_catalogs/98765432109876543210/products?limit=10&after=next%2B%2F%3D", + ); + }); + it("rejects a non-numeric catalog ID or client token before dispatch", () => { + const server = new MessagingClient({ + credential: { type: "projectToken", value: PROJECT_TOKEN }, + }); + expect(() => + server.cloudCatalogs.listProducts("123", "../x", { version: "v26.0" }), + ).toThrow("numeric catalog ID"); + const browser = new MessagingClient({ + credential: { type: "clientToken", value: `pmfa_ct_${"A".repeat(94)}` }, + }); + expect(() => + browser.cloudCatalogs.listProducts("123", "456", { version: "v26.0" }), + ).toThrow("organization API key or project token"); + }); it("rejects client credentials before dispatch", () => { const client = new MessagingClient({ credential: { type: "clientToken", value: `pmfa_ct_${"A".repeat(94)}` },