diff --git a/README.md b/README.md index c874eb0..c7bd81f 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,7 @@ beta feature. ```shell polymorfa cloud catalog list --waba WABA_ID --graph-version v26.0 --limit 25 +polymorfa cloud catalog products --waba WABA_ID --catalog CATALOG_ID --graph-version v26.0 --limit 25 polymorfa cloud template list --session number_123 polymorfa cloud template get order_update --session number_123 --language en_US polymorfa cloud template create --session number_123 --input template.json --idempotency-key tpl-order-update @@ -497,6 +498,11 @@ Credential reads use `sessions:read`; reauthorization uses `quicklink:manage`. `cloud catalog list` reads linked product-catalog IDs and names with `sessions:read` and Graph access. Pass the returned opaque cursor using `--after` for another page. Catalog visibility does not grant merchant ownership or permission to sell. +`cloud catalog products` reads only IDs, optional retailer IDs, names and +availability from one catalog linked to the authenticated WABA. Pass its +`paging.cursors.after` value as `--after` to read the next page. The API rejects +unlinked catalogs and simulated Numbers. This read does not send a product or +establish that the merchant may sell it. Reauthorization creates a QuickLink for a stopped or disconnected standalone Official Number. It never stops the Number or opens the link automatically. diff --git a/docs/internal/2026-09-26-catalog-product-inventory.md b/docs/internal/2026-09-26-catalog-product-inventory.md new file mode 100644 index 0000000..0ea185f --- /dev/null +++ b/docs/internal/2026-09-26-catalog-product-inventory.md @@ -0,0 +1,17 @@ +# Catalog product inventory command gate + +`cloud catalog products` consumes the typed +`MessagingClient.cloudCatalogs.listProducts` method from the SDK follow-up to +PR #313. The exact API source is `polymorfa/polymorfa` PR #380 at +`bbfb2f4af3a8e1289074d9b7d0ed058b96881410`; its Graph OpenAPI copy is +`docs/api-reference/openapi.graph.json` with SHA-256 +`b9d3778f17a90fd0c58e0cb1b3f6ef689661980fa83ad7565bc55ae4ab93fab4`. +The API checks live WABA/project authority and a linked catalog before reading +Meta's `/{catalogId}/products` edge. The response carries only the projected +fields and opaque cursors, never Meta paging URLs. + +The CLI remains on its older exact SDK registry version. Merge the upstream +API and SDK PR chains, publish and verify the SDK artifact, then pin that exact +version and regenerate the CLI lockfile before CLI merge or release. No live +catalog permissions, product visibility, deployed path or customer access were +verified here. No release flag or Admin capability changes. diff --git a/src/commands/cloud/catalog/products.ts b/src/commands/cloud/catalog/products.ts new file mode 100644 index 0000000..79c1cd4 --- /dev/null +++ b/src/commands/cloud/catalog/products.ts @@ -0,0 +1,56 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../../base-command.js' + +export default class CloudCatalogProducts extends BaseCommand { + static override description = + 'List products in a catalog linked to an Official API WABA (beta). Requires sessions:read and Graph access.' + + static override flags = { + waba: Flags.string({ + description: 'WhatsApp Business Account ID.', + required: true, + }), + catalog: Flags.string({ + description: 'Numeric product catalog ID linked to the WABA.', + required: true, + }), + 'graph-version': Flags.string({ + description: 'Meta Graph version, for example v26.0.', + required: true, + }), + limit: Flags.integer({ + description: 'Page size from 1 to 100; API default is 25.', + min: 1, + max: 100, + }), + after: Flags.string({ + description: 'Opaque cursor from the previous response.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(CloudCatalogProducts) + if ( + !flags.waba.trim() || + !/^[0-9]+$/.test(flags.catalog) || + !flags['graph-version'].trim() + ) + throw new PolymorfaConfigurationError( + 'Provide a WABA ID, numeric catalog ID, and Graph version.', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.cloudCatalogs.listProducts( + flags.waba, + flags.catalog, + { + version: flags['graph-version'], + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.after === undefined ? {} : {after: flags.after}), + }, + ) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + } +} diff --git a/test/commands/cloud.test.ts b/test/commands/cloud.test.ts index 882a930..3bafb10 100644 --- a/test/commands/cloud.test.ts +++ b/test/commands/cloud.test.ts @@ -398,6 +398,39 @@ describe('Official API commands', () => { expect(result.stdout).not.toContain('"success":true') }) + test('lists linked catalog products with an exact cursor', async () => { + const data = { + data: [{id: '111', retailer_id: 'sku-1', name: 'Mug'}], + paging: {cursors: {after: 'next+/='}}, + } + server = await startHttpServer({body: data}) + const result = await credential( + () => + runCommand( + 'cloud catalog products --waba 1234 --catalog 98765432109876543210 --graph-version v26.0 --limit 10 --after next+/= --json', + ), + 'project', + ) + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(data) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'GET', + url: '/graph/whatsapp/v26.0/1234/product_catalogs/98765432109876543210/products?limit=10&after=next%2B%2F%3D', + }) + }) + + test('rejects a non-numeric catalog ID before any request', async () => { + server = await startHttpServer({body: {data: []}}) + const result = await credential(() => + runCommand( + 'cloud catalog products --waba 1234 --catalog ../x --graph-version v26.0 --json', + ), + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + async function credential( run: () => Promise, kind: 'api' | 'project' | 'client' = 'api',