diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f2ed87..e1426f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Synchronize email-category and template browse-group contracts with app + revision `9bf2e01f14d8260bdde0825e901a05497b66c70c`. Template reads accept + `--group`, and `--count --group-by group` labels browse groups correctly in + human output. These additions require the matching API deployment. + ## 0.17.0 Requires `@brew.new/sdk` `^13.0.0`. This is a minor release because it adds diff --git a/docs/commands/README.md b/docs/commands/README.md index c18e2ed1..ab7ddf29 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2408,9 +2408,10 @@ List public templates (each row carries the rendered html) - SDK: `brew.templates.list(...)` - `--brand-name ` — Filter by gallery brand name - `--category ` — Filter by category +- `--group ` — Filter by category browse group - `--semantic ` — Semantic search over the gallery - `--count` — Return matching template counts instead of rows -- `--group-by ` — Group counts by brand or category (requires --count) +- `--group-by ` — Group counts by brand, category or group (requires --count) - `--limit ` — Page size, 1-100 (default 100) - `--cursor ` — Opaque pagination cursor from a previous page - `--all` — Follow the cursor and return every page as one result @@ -2419,6 +2420,7 @@ List public templates (each row carries the rendered html) ```bash brew-cli templates list --category welcome brew-cli templates list --count --group-by category --json +brew-cli templates list --group events --count --group-by group --json brew-cli templates list --semantic "minimal product launch" --json ``` diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 2185e646..64aaf405 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -5208,30 +5208,36 @@ components: type: string enum: - welcome + - onboarding - newsletter - - promotional + - content + - event-invitation + - event-reminder + - event-recap - product-launch - product-update + - product-pitch + - promotional + - cart-abandonment + - re-engagement + - subscription + - feedback-request + - referral + - company-news + - fundraising - order-confirmation - shipping-update - receipt - - cart-abandonment - - subscription - password-reset - verification - security-alert - account-update - - event-invitation - - event-reminder - - feedback-request - - re-engagement - - referral + - notification - support - - business - internal - - notification + - business - general - description: "Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger." + description: "Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger." subjectLine: type: string minLength: 1 @@ -58713,7 +58719,7 @@ paths: tags: - Templates summary: List templates - description: Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters; query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand and category (refreshed every 15 minutes), instead of rows; add groupBy=brand or groupBy=category for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query). + description: "Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters and a group filter (a browse group: every category in it); query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand, category and group (refreshed every 15 minutes), instead of rows; add groupBy=brand, groupBy=category or groupBy=group for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query)." parameters: - schema: type: string @@ -58726,11 +58732,30 @@ paths: - schema: type: string minLength: 1 - description: "Email category, matched case-insensitively (unknown → empty page): welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general." + description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." required: false - description: "Email category, matched case-insensitively (unknown → empty page): welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general." + description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." name: category in: query + - schema: + type: string + enum: + - welcome-onboarding + - newsletter + - content + - events + - product-news + - sales + - retention + - company + - transactional + - internal + - other + description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + required: false + description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + name: group + in: query - schema: type: string minLength: 1 @@ -58766,9 +58791,9 @@ paths: enum: - 'true' - 'false' - description: Return `{ count }`, the number of templates matching `brand`/`category`, instead of rows. Not with `semantic` or `query`. + description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. required: false - description: Return `{ count }`, the number of templates matching `brand`/`category`, instead of rows. Not with `semantic` or `query`. + description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. name: count in: query - schema: @@ -58776,12 +58801,14 @@ paths: enum: - brand - category - description: "With `count: true`: also count per brand or per category, largest first, 100 groups a page; continue with `cursor`." + - group + description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." x-brew-group-by-tokens: - brand - category + - group required: false - description: "With `count: true`: also count per brand or per category, largest first, 100 groups a page; continue with `cursor`." + description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." name: groupBy in: query - schema: @@ -58912,6 +58939,7 @@ paths: enum: - brand - category + - group groups: type: array items: @@ -58920,7 +58948,7 @@ paths: value: type: string minLength: 1 - description: The brand domain or category; pass it back as the `brand`/`category` filter. + description: The brand domain, category or group; pass it back as the filter of the same name (`brand`, `category` or `group`). name: type: string minLength: 1 diff --git a/src/commands/templates/list.ts b/src/commands/templates/list.ts index 7af60161..d122356e 100644 --- a/src/commands/templates/list.ts +++ b/src/commands/templates/list.ts @@ -1,8 +1,5 @@ -import type { - ListTemplateRowsInput, - ListTemplatesInput, - TemplatesCountResponse, -} from '@brew.new/sdk' +import type { ListTemplateRowsInput, ListTemplatesInput } from '@brew.new/sdk' +import type { operations } from '../../generated/openapi-types' import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' import { @@ -21,6 +18,17 @@ import { LIMIT_FLAG, } from '../../lib/paginate' +type TemplatesCountResponse = Extract< + operations['listTemplates']['responses'][200]['content']['application/json'], + { count: number } +> + +const GROUP_LABELS = { + brand: { header: 'BRAND', noun: 'brands', singular: 'brand' }, + category: { header: 'CATEGORY', noun: 'categories', singular: 'category' }, + group: { header: 'GROUP', noun: 'groups', singular: 'group' }, +} as const + export const templatesListCommand = defineCommand({ path: ['templates', 'list'], summary: 'List public templates (each row carries the rendered html)', @@ -34,6 +42,7 @@ export const templatesListCommand = defineCommand({ summary: 'Filter by gallery brand name', }, { flag: '--category ', summary: 'Filter by category' }, + { flag: '--group ', summary: 'Filter by category browse group' }, { flag: '--semantic ', summary: 'Semantic search over the gallery', @@ -44,7 +53,7 @@ export const templatesListCommand = defineCommand({ }, { flag: '--group-by ', - summary: 'Group counts by brand or category (requires --count)', + summary: 'Group counts by brand, category or group (requires --count)', }, LIMIT_FLAG, CURSOR_FLAG, @@ -54,6 +63,7 @@ export const templatesListCommand = defineCommand({ examples: [ 'brew-cli templates list --category welcome', 'brew-cli templates list --count --group-by category --json', + 'brew-cli templates list --group events --count --group-by group --json', 'brew-cli templates list --semantic "minimal product launch" --json', ], run: async ({ ctx, flags }) => { @@ -61,6 +71,7 @@ export const templatesListCommand = defineCommand({ const input = mergeInput(base, { brand: flagString(flags.brandName), category: flagString(flags.category), + group: flagString(flags.group), semantic: flagString(flags.semantic), count: flags.count === true ? true : undefined, groupBy: flagString(flags.groupBy), @@ -113,13 +124,13 @@ function renderCount(body: TemplatesCountResponse): string { if (body.groupBy === undefined && groups.length === 0) { return total } - const noun = body.groupBy === 'category' ? 'categories' : 'brands' + const { header, noun, singular } = GROUP_LABELS[body.groupBy ?? 'brand'] const table = renderTable( groups.map((group) => ({ ...group })), [ { key: 'value', - header: body.groupBy === 'category' ? 'CATEGORY' : 'BRAND', + header, }, ...(groups.some((group) => group.name !== undefined) ? [{ key: 'name', header: 'NAME' }] @@ -128,12 +139,11 @@ function renderCount(body: TemplatesCountResponse): string { ] ) const cursor = body.pagination?.hasMore ? body.pagination.cursor : null + const groupNoun = body.groupCount === 1 ? singular : noun const footer = [ `${total} in total`, - body.groupCount === undefined ? '' : `${body.groupCount} ${noun}`, - body.ungroupedCount - ? `${body.ungroupedCount} with no ${noun === 'brands' ? 'brand' : 'category'}` - : '', + body.groupCount === undefined ? '' : `${body.groupCount} ${groupNoun}`, + body.ungroupedCount ? `${body.ungroupedCount} with no ${singular}` : '', cursor === null ? '' : `more groups: --cursor ${cursor}`, ] .filter(Boolean) diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 4b4d685a..e82bbcc9 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1965,7 +1965,7 @@ export interface paths { }; /** * List templates - * @description Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters; query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand and category (refreshed every 15 minutes), instead of rows; add groupBy=brand or groupBy=category for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query). + * @description Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters and a group filter (a browse group: every category in it); query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand, category and group (refreshed every 15 minutes), instead of rows; add groupBy=brand, groupBy=category or groupBy=group for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query). */ get: operations["listTemplates"]; put?: never; @@ -4446,10 +4446,10 @@ export interface components { /** @description The `emailId` of an existing design to redraw in this brand's voice — one of this brand's own designs (GET /v1/emails) or a public gallery template (GET /v1/templates). Its layout and structure are the reference; the copy, imagery and styling become this brand's. */ referenceEmailId?: string; /** - * @description Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger. + * @description Email category that steers the design treatment (exemplars, hero recipe, personalization) — mirrors what the in-app agent infers per request. One of: welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general. Omit for a general treatment. Transactional categories (receipt, password-reset, order-confirmation, …) steer receipt/reset design conventions; to DELIVER those emails, wire the design into an automation with a trigger and a transactional-purpose sending domain, then fire the trigger. * @enum {string} */ - category?: "welcome" | "newsletter" | "promotional" | "product-launch" | "product-update" | "order-confirmation" | "shipping-update" | "receipt" | "cart-abandonment" | "subscription" | "password-reset" | "verification" | "security-alert" | "account-update" | "event-invitation" | "event-reminder" | "feedback-request" | "re-engagement" | "referral" | "support" | "business" | "internal" | "notification" | "general"; + category?: "welcome" | "onboarding" | "newsletter" | "content" | "event-invitation" | "event-reminder" | "event-recap" | "product-launch" | "product-update" | "product-pitch" | "promotional" | "cart-abandonment" | "re-engagement" | "subscription" | "feedback-request" | "referral" | "company-news" | "fundraising" | "order-confirmation" | "shipping-update" | "receipt" | "password-reset" | "verification" | "security-alert" | "account-update" | "notification" | "support" | "internal" | "business" | "general"; /** @description Inbox subject line to set on the design (`subjectLine` — distinct from `title`, the canvas name). Sends still take an explicit per-send `subject`; this is the design's default, seeded into the send dialog and returned by `GET /v1/emails/{emailId}`. */ subjectLine?: string; /** @description File the design under an existing group (`grp_…`), or `ungrouped`. Mutually exclusive with `groupName`. */ @@ -26555,18 +26555,20 @@ export interface operations { query?: { /** @description Only this brand's templates, by domain (e.g. `brew.new`); case-insensitive. */ brand?: string; - /** @description Email category, matched case-insensitively (unknown → empty page): welcome, newsletter, promotional, product-launch, product-update, order-confirmation, shipping-update, receipt, cart-abandonment, subscription, password-reset, verification, security-alert, account-update, event-invitation, event-reminder, feedback-request, re-engagement, referral, support, business, internal, notification, general. */ + /** @description Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general. */ category?: string; + /** @description A browse group: every category in it (e.g. `events` = invitations, reminders and recaps). */ + group?: "welcome-onboarding" | "newsletter" | "content" | "events" | "product-news" | "sales" | "retention" | "company" | "transactional" | "internal" | "other"; /** @description Rank by relevance to this text instead of newest first (top 200; `brand`/`category` still narrow). */ semantic?: string; /** @description Case-insensitive title substring or exact public template identifier. Applied within semantic results when semantic is supplied. */ query?: string; /** @description Full rows include HTML. Summary rows include selection metadata and links only. */ representation?: "full" | "summary"; - /** @description Return `{ count }`, the number of templates matching `brand`/`category`, instead of rows. Not with `semantic` or `query`. */ + /** @description Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. */ count?: boolean | ("true" | "false"); - /** @description With `count: true`: also count per brand or per category, largest first, 100 groups a page; continue with `cursor`. */ - groupBy?: "brand" | "category"; + /** @description With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`. */ + groupBy?: "brand" | "category" | "group"; /** * @description Page size, 1–100 (default 50: each row carries its full HTML). * @example 50 @@ -26637,10 +26639,10 @@ export interface operations { /** @description Templates matching `brand`/`category`. */ count: number; /** @enum {string} */ - groupBy?: "brand" | "category"; + groupBy?: "brand" | "category" | "group"; /** @description With `groupBy`: up to 100 groups, largest first. */ groups?: { - /** @description The brand domain or category; pass it back as the `brand`/`category` filter. */ + /** @description The brand domain, category or group; pass it back as the filter of the same name (`brand`, `category` or `group`). */ value: string; /** @description The brand's curated display name, when it has one. */ name?: string; diff --git a/tests/commands/template-count-flags.test.ts b/tests/commands/template-count-flags.test.ts index 5d288089..42a8435f 100644 --- a/tests/commands/template-count-flags.test.ts +++ b/tests/commands/template-count-flags.test.ts @@ -37,4 +37,41 @@ describe('template count flags', () => { expect(result.code).toBe(2) expect(result.stderr).toContain('--all pages through rows') }) + + it('filters browse groups and labels group counts in human output', async () => { + let url: URL | undefined + server.use( + http.get('https://brew.new/api/v1/templates', ({ request }) => { + url = new URL(request.url) + return HttpResponse.json({ + count: 42, + groupBy: 'group', + groupCount: 1, + groups: [{ value: 'events', name: 'Events', count: 42 }], + pagination: { limit: 20, cursor: null, hasMore: false }, + }) + }) + ) + const result = await runCli( + [ + 'templates', + 'list', + '--group', + 'events', + '--count', + '--group-by', + 'group', + ], + { env, ttyOut: true } + ) + expect(result.code).toBe(0) + expect(url?.searchParams.get('group')).toBe('events') + expect(url?.searchParams.get('groupBy')).toBe('group') + expect(result.stdout).toContain('GROUP') + expect(result.stdout).toContain('Events') + expect(result.stdout).toContain('1 group') + expect(result.stdout).not.toContain('1 groups') + expect(result.stdout).not.toContain('BRAND') + expect(result.stdout).not.toContain('brands') + }) })