diff --git a/CHANGELOG.md b/CHANGELOG.md index e1426f2..641b404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,36 @@ ## Unreleased +These import changes require `@brew.new/sdk` 13.3.0 or newer, which adds +`contacts.import` and the shared import response. + +### Added + - 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. +- **`contacts import`** (`POST /v1/contacts/import`): bulk-import up to 1000 + contacts from a JSON file (an array, or `{ "contacts": [...] }`) or stdin as + one batch in Brew's Import History. Takes `--file-name`, `--validate` and + `--consent-source`, and prints the batch's `importId`. A JSON object body + keeps its own `validate`, `consent` and `fileName`; a flag overrides them + only when given. +- `contacts import-csv` names its batch in Import History: `--file-name`, else + a `fileName` in `--input`, else the `--file` name. Both imports return + `importId`. +- On a terminal, `contacts import` and `contacts import-csv` print the API's + submitted total and outcome counts, plus failed rows and warnings with + available row, email and field details. `--json` preserves the API envelope. + CSV skip warnings list the first 100 skipped rows and summarize the rest; + column-wide and aggregate warnings have no row index. +- Completed import deliverability checks show valid, risky, invalid and + unscored counts, plus available persistence counts, in terminal output. + +### Changed + +- `contacts upsert-many` says what it is for: up to 1000 contacts per call + (its summary said 100) with no Import History entry, for recurring syncs. ## 0.17.0 diff --git a/RELEASING.md b/RELEASING.md index 7e1acf6..15f8170 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,8 +13,8 @@ JSON response. Do not release these commands with an SDK version that discards that response. SDK 13.0.0 preserves the revocation JSON response and is the minimum supported -SDK release for these commands. The dependency and Bun lockfile consume the -published registry artifact; the temporary Git source pin has been removed. +SDK release for these commands. CLI 0.17.0 consumes that published registry +artifact. For future upgrades, release the SDK first, then update the dependency and regenerate `bun.lock` from its actual published artifact. Run frozen @@ -23,6 +23,12 @@ CLI. A locally packed SDK must not hide dependency failures, and future lockfile checksums must never be invented. The canvas tests must preserve `{ revoked: true }`, the `--yes` gate, and one-attempt create/refresh requests. +### Contact Import SDK Minimum + +`contacts import` and the `contacts import-csv` naming and row results use +`contacts.import` and the import response from SDK 13.3.0, the minimum this +CLI depends on (`^13.3.0`). + ```bash # on a clean main with CI green npm version --no-git-tag-version diff --git a/bun.lock b/bun.lock index 8904a83..cf75c69 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@brew.new/cli", "dependencies": { - "@brew.new/sdk": "^13.0.0", + "@brew.new/sdk": "^13.3.0", "commander": "^14.0.0", }, "devDependencies": { @@ -20,6 +20,9 @@ }, }, }, + "trustedDependencies": [ + "@brew.new/sdk", + ], "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -43,7 +46,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], - "@brew.new/sdk": ["@brew.new/sdk@13.0.0", "", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "sha512-oqQnXRJY/duMsL7Sn+i18+omB+oDkp7fzK6f7/27YWkB5nxIY5X+aQ7sRjRBBnb/JPeHvfVmHb7DuZ2ABJ3HFw=="], + "@brew.new/sdk": ["@brew.new/sdk@13.3.0", "", { "peerDependencies": { "react": ">=18.2.0 <20" }, "optionalPeers": ["react"] }, "sha512-GpdjjF148y9vN4I9mKhA9ndfvVW2Wse1AOfdwfG2XMNMpUKXTdd/3Lb3p769xZz+Fy5bdbMMqklwE/Iu/LwyWg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/docs/commands/README.md b/docs/commands/README.md index ab7ddf2..14d0297 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -148 commands. Classes: read (always safe), write +149 commands. Classes: read (always safe), write (mutating, retry-safe), destructive (irreversible — the confirmation protocol applies: interactive y/N on a TTY, exit 4 + JSON envelope with a `confirmCommand` otherwise, `--yes` to proceed). @@ -35,12 +35,13 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli contacts count` | read | `POST /v1/contacts/search` | Count contacts matching a filter | | `brew-cli contacts count-by` | read | `POST /v1/contacts/search` | Count contacts per field value, email domain, or signup period (largest group first) | | `brew-cli contacts upsert` | write | `POST /v1/contacts` | Create or update one contact by email | -| `brew-cli contacts upsert-many` | write | `POST /v1/contacts` | Create or update a batch of contacts (up to 100 per call) | +| `brew-cli contacts upsert-many` | write | `POST /v1/contacts` | Create or update up to 1000 contacts per call, with no Import History entry (for syncs; use contacts import for a batch you want listed) | | `brew-cli contacts update` | write | `PATCH /v1/contacts/{email}` | Partially update one contact (PATCH; never retried) | | `brew-cli contacts delete` | destructive | `DELETE /v1/contacts/{email}` | Delete one contact by email (idempotent) | | `brew-cli contacts delete-many` | destructive | `POST /v1/contacts/batch-delete` | Delete up to 1000 contacts by email | | `brew-cli contacts validate` | write ($) | `POST /v1/contacts/validate` | Batch-validate email deliverability (no contacts created) | -| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin (free; --validate costs 2 credits per address) | +| `brew-cli contacts import` | write | `POST /v1/contacts/import` | Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address) | +| `brew-cli contacts import-csv` | write | `POST /v1/contacts/import-csv` | Bulk-import contacts from a CSV file or stdin as one Import History batch (free; validation charged separately) | | `brew-cli fields list` | read | `GET /v1/fields` | List custom contact fields | | `brew-cli fields get` | read | `GET /v1/fields/{fieldName}` | Fetch one contact field definition by name — the bare row | | `brew-cli fields create` | write | `POST /v1/fields` | Create a custom contact field | @@ -374,7 +375,7 @@ brew-cli contacts upsert --email j@x.com --custom plan=pro --custom seats=4 ### brew-cli contacts upsert-many -Create or update a batch of contacts (up to 100 per call) +Create or update up to 1000 contacts per call, with no Import History entry (for syncs; use contacts import for a batch you want listed) - Route: `POST /v1/contacts` - Class: write @@ -451,17 +452,37 @@ brew-cli contacts validate --emails jane@example.com bob@example.com brew-cli contacts validate --input '{"emails":["jane@example.com"]}' ``` +### brew-cli contacts import + +Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address) + +- Route: `POST /v1/contacts/import` +- Class: write +- SDK: `brew.contacts.import(...)` +- `--file ` — JSON file of contacts (an array, or { "contacts": [...] }), or - for stdin +- `--file-name ` — Name the batch in Brew's Import History (default: the --file name) +- `--validate` — Deliverability-check every imported address (2 credits per address) +- `--consent-source ` — Default marketing consent record for rows without their own: api, form or import +- `--input ` — Full JSON request body, or - to read stdin (flags override it) +- `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) + +```bash +brew-cli contacts import --file contacts.json --consent-source import +cat contacts.json | brew-cli contacts import --file - --file-name crm-export.json +``` + ### brew-cli contacts import-csv -Bulk-import contacts from a CSV file or stdin (free; --validate costs 2 credits per address) +Bulk-import contacts from a CSV file or stdin as one Import History batch (free; validation charged separately) - Route: `POST /v1/contacts/import-csv` - Class: write - SDK: `brew.contacts.importCsv(...)` - `--file ` — CSV file to import, or - for stdin (or csv in --input) +- `--file-name ` — Name the batch in Brew's Import History (default: the --file name) - `--mapping ` — Column mapping csvColumn=fieldName, repeatable - `--date-order ` — month_first or day_first: how to read dates like 03/04/2026 (a day over 12 in the column wins; default month-first, with a DATE_ORDER_ASSUMED warning) -- `--validate` — Deliverability-check every imported address (2 credits per address) +- `--validate` — Deliverability checks: up to 100 submitted CSV data rows cost 2 credits/row inline; larger files use background checks when needed, charged per address - `--consent-source ` — Stamp a marketing consent record on every row: api, form or import (sets the source of a record given in --input) - `--input ` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key ` — Idempotency-Key for safe retries (auto-generated otherwise) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 64aaf40..0a815de 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -2247,6 +2247,7 @@ components: - RESUBSCRIBE_SKIPPED - CORE_FIELD_IGNORED - DATE_ORDER_ASSUMED + - CSV_ROW_SKIPPED - CSV_COLUMN_IGNORED - RECIPIENTS_EXCLUDED - CONSENT_RECORD_MISSING @@ -12743,6 +12744,10 @@ components: properties: email: type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted)." code: type: string field: @@ -12765,6 +12770,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." field: type: string minLength: 1 @@ -12956,6 +12965,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." field: type: string minLength: 1 @@ -13974,7 +13987,7 @@ components: required: - emails additionalProperties: false - ContactsImportCsvResponse: + ContactsImportResponse: type: object properties: summary: @@ -13992,11 +14005,17 @@ components: skipped: type: integer minimum: 0 + description: CSV rows omitted for a missing/invalid email or merged into a later duplicate. Up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows. Always 0 for JSON. + total: + type: integer + minimum: 0 + description: Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. required: - inserted - updated - failed - skipped + - total additionalProperties: false fieldsCreated: type: array @@ -14009,6 +14028,10 @@ components: properties: email: type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted)." code: type: string field: @@ -14031,6 +14054,10 @@ components: email: type: string minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." field: type: string minLength: 1 @@ -14093,11 +14120,15 @@ components: additionalProperties: false validationJobId: type: string + importId: + type: string + description: This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. required: - summary - fieldsCreated - errors - warnings + - importId additionalProperties: false ContactsImportCsvRequest: type: object @@ -14112,7 +14143,7 @@ components: type: string validate: type: boolean - description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. + description: Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted CSV data rows validate inline, metered at 2 credits per submitted row on a successful response, including skipped rows and repeated addresses. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. consent: type: object properties: @@ -14145,9 +14176,99 @@ components: - month_first - day_first description: "Order for a date column whose dates read either way (`03/04/2026`): `day_first` is 3 April, `month_first` 4 March. A day over 12 in the column overrides it. Default month-first, with a DATE_ORDER_ASSUMED warning." + fileName: + type: string + minLength: 1 + maxLength: 255 + description: Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. required: - csv additionalProperties: false + ContactsImportRequest: + type: object + properties: + contacts: + type: array + items: + type: object + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + customFields: + type: object + additionalProperties: {} + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" + capturedAt: + type: string + format: date-time + description: ISO 8601; defaults to now. + policyVersion: + type: string + minLength: 1 + maxLength: 64 + evidence: + type: string + minLength: 1 + maxLength: 500 + required: + - source + additionalProperties: false + description: Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. + additionalProperties: false + minItems: 1 + maxItems: 1000 + validate: + type: boolean + description: Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted contacts validate inline, metered at 2 credits per submitted contact on a successful response, including rows that fail. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" + capturedAt: + type: string + format: date-time + description: ISO 8601; defaults to now. + policyVersion: + type: string + minLength: 1 + maxLength: 64 + evidence: + type: string + minLength: 1 + maxLength: 500 + required: + - source + additionalProperties: false + description: 'Default consent record for every row that does not carry its own `consent` (typically `{ "source": "import" }`).' + fileName: + type: string + minLength: 1 + maxLength: 255 + description: Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. + required: + - contacts + additionalProperties: false ContactsBatchDeleteResponse: type: object properties: @@ -48107,30 +48228,480 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: contacts.read - /v1/contacts/validate: + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: contacts.read + /v1/contacts/validate: + post: + operationId: validateContacts + tags: + - Contacts + summary: Validate email deliverability + description: "Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Partial outages retain the legacy `status: risky` with `checkOutcome: unavailable` (not a completed risky verdict) and preserve the stored verdict. `persistence` reports matched, modified and failed update-only writes; validation never grants subscription or clears suppression. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns `503` (transient outages are retryable; configuration failures need support) and is NOT billed." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsValidateRequest" + example: + emails: + - ada@gmail.com + - temp@mailinator.com + - admin@stripe.com + responses: + "200": + description: One classification row per input address. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsValidateResponse" + example: + data: + - email: ada@gmail.com + valid: true + status: valid + checkOutcome: completed + - email: temp@mailinator.com + valid: false + status: invalid + checkOutcome: completed + reason: undeliverable + - email: ada@gmial.com + valid: false + status: risky + checkOutcome: completed + reason: unknown + didYouMean: ada@gmail.com + - email: admin@stripe.com + valid: false + status: risky + checkOutcome: completed + reason: role_address + persistence: + matched: 4 + modified: 4 + failed: 0 + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 60 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 60 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + parameters: + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + x-brew-scope: contacts + x-brew-brand-scope: brand + x-brew-idempotency: replay + x-brew-credited: true + x-brew-rate-policy: content.transform + /v1/contacts/import-csv: post: - operationId: validateContacts + operationId: importContactsCsv tags: - Contacts - summary: Validate email deliverability - description: "Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Partial outages retain the legacy `status: risky` with `checkOutcome: unavailable` (not a completed risky verdict) and preserve the stored verdict. `persistence` reports matched, modified and failed update-only writes; validation never grants subscription or clears suppression. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns `503` (transient outages are retryable; configuration failures need support) and is NOT billed." + summary: Bulk-import contacts from CSV + description: |- + Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. + + CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). + + A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. + + A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsValidateRequest" + $ref: "#/components/schemas/ContactsImportCsvRequest" example: - emails: - - ada@gmail.com - - temp@mailinator.com - - admin@stripe.com + csv: |- + email,firstName,lastName + ada@example.com,Ada,Lovelace + grace@example.com,Grace,Hopper + mapping: + email: email + firstName: firstName + lastName: lastName + fileName: customers.csv responses: "200": - description: One classification row per input address. + description: Import summary (all rows valid). headers: x-request-id: schema: @@ -48163,33 +48734,76 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsValidateResponse" + $ref: "#/components/schemas/ContactsImportResponse" example: - data: - - email: ada@gmail.com - valid: true - status: valid - checkOutcome: completed - - email: temp@mailinator.com - valid: false - status: invalid - checkOutcome: completed - reason: undeliverable - - email: ada@gmial.com - valid: false - status: risky - checkOutcome: completed - reason: unknown - didYouMean: ada@gmail.com - - email: admin@stripe.com - valid: false - status: risky - checkOutcome: completed - reason: role_address - persistence: - matched: 4 - modified: 4 + summary: + inserted: 2 + updated: 0 failed: 0 + skipped: 0 + total: 2 + fieldsCreated: [] + errors: [] + warnings: [] + importId: imp_01JABCDEF0123456789XYZ + "207": + description: Partial success — some rows failed validation. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsImportResponse" + example: + summary: + inserted: 1 + updated: 0 + failed: 1 + skipped: 2 + total: 4 + fieldsCreated: [] + errors: + - email: john@example.com + row: 2 + code: FIELD_TYPE_MISMATCH + field: customFields.plan + message: Field 'customFields.plan' is type 'number' but received string '$49'. + warnings: + - code: CSV_ROW_SKIPPED + row: 3 + message: Row 3 has no email address, so it was skipped. + - code: CSV_ROW_SKIPPED + row: 4 + email: not-an-email + message: Row 4 holds 'not-an-email', which is not a valid email address, so it was skipped. + importId: imp_01JABCDEF0123456789XYZ "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -48433,7 +49047,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -48474,7 +49088,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -48547,36 +49161,34 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: true - x-brew-rate-policy: content.transform - /v1/contacts/import-csv: + x-brew-rate-policy: contacts.write_batch + /v1/contacts/import: post: - operationId: importContactsCsv + operationId: importContacts tags: - Contacts - summary: Bulk-import contacts from CSV + summary: Bulk-import contacts from JSON description: |- - Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create — `207` when some rows fail. + Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. - CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). - - A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. - - A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing. A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsImportCsvRequest" + $ref: "#/components/schemas/ContactsImportRequest" example: - csv: |- - email,firstName,lastName - ada@example.com,Ada,Lovelace - grace@example.com,Grace,Hopper - mapping: - email: email - firstName: firstName - lastName: lastName + contacts: + - email: ada@example.com + firstName: Ada + - email: grace@example.com + firstName: Grace + customFields: + plan: pro + consent: + source: import + fileName: crm-export.json responses: "200": description: Import summary (all rows valid). @@ -48612,16 +49224,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsImportCsvResponse" + $ref: "#/components/schemas/ContactsImportResponse" example: summary: inserted: 2 updated: 0 failed: 0 skipped: 0 - fieldsCreated: [] + total: 2 + fieldsCreated: + - plan errors: [] warnings: [] + importId: imp_01JABCDEF0123456789XYZ "207": description: Partial success — some rows failed validation. headers: @@ -48656,143 +49271,22 @@ paths: content: application/json: schema: - type: object - properties: - summary: - type: object - properties: - inserted: - type: integer - minimum: 0 - updated: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - skipped: - type: integer - minimum: 0 - required: - - inserted - - updated - - failed - - skipped - additionalProperties: false - fieldsCreated: - type: array - items: - type: string - errors: - type: array - items: - type: object - properties: - email: - type: string - code: - type: string - field: - type: string - message: - type: string - required: - - email - - code - - message - additionalProperties: false - warnings: - type: array - items: - type: object - properties: - code: - type: string - minLength: 1 - email: - type: string - minLength: 1 - field: - type: string - minLength: 1 - message: - type: string - minLength: 1 - from: - type: string - minLength: 1 - to: - type: string - minLength: 1 - original: - type: string - minLength: 1 - normalized: - type: string - minLength: 1 - required: - - code - - message - additionalProperties: false - validation: - type: object - properties: - valid: - type: integer - minimum: 0 - risky: - type: integer - minimum: 0 - invalid: - type: integer - minimum: 0 - unscored: - type: integer - minimum: 0 - persistence: - type: object - properties: - matched: - type: integer - minimum: 0 - modified: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - required: - - matched - - modified - - failed - additionalProperties: false - required: - - valid - - risky - - invalid - - unscored - additionalProperties: false - validationJobId: - type: string - required: - - summary - - fieldsCreated - - errors - - warnings - additionalProperties: false + $ref: "#/components/schemas/ContactsImportResponse" example: summary: inserted: 1 updated: 0 failed: 1 - skipped: 2 + skipped: 0 + total: 2 fieldsCreated: [] errors: - - email: john@example.com - code: FIELD_TYPE_MISMATCH - field: customFields.plan - message: Field 'customFields.plan' is type 'number' but received string '$49'. + - email: not-an-email + row: 2 + code: INVALID_EMAIL + message: "'not-an-email' is not a valid email address." warnings: [] + importId: imp_01JABCDEF0123456789XYZ "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. diff --git a/package.json b/package.json index 7453d83..702815b 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "smoke:signals": "bun run scripts/signal-smoke.ts" }, "dependencies": { - "@brew.new/sdk": "^13.0.0", + "@brew.new/sdk": "^13.3.0", "commander": "^14.0.0" }, "devDependencies": { @@ -66,5 +66,8 @@ "vitest": "^4.1.2", "yaml": "^2.8.0" }, - "packageManager": "bun@1.3.10" + "packageManager": "bun@1.3.10", + "trustedDependencies": [ + "@brew.new/sdk" + ] } diff --git a/src/commands/contacts/import-csv.ts b/src/commands/contacts/import-csv.ts index 5990b15..3d03343 100644 --- a/src/commands/contacts/import-csv.ts +++ b/src/commands/contacts/import-csv.ts @@ -1,3 +1,4 @@ +import { basename } from 'node:path' import type { ContactsResource } from '@brew.new/sdk' import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' @@ -12,16 +13,14 @@ import { readTextFlag, requestOptions, } from '../../lib/input' +import { renderImportResult } from './import-summary' -// SDK 10's `importCsv` types only `{ csv, mapping, validate }` but sends -// its input as the body, so `dateOrder` and `consent` reach the API through -// the boundary cast until the CLI adopts SDK 11.2. type ImportCsvContactsInput = Parameters[0] export const contactsImportCsvCommand = defineCommand({ path: ['contacts', 'import-csv'], summary: - 'Bulk-import contacts from a CSV file or stdin (free; --validate costs 2 credits per address)', + 'Bulk-import contacts from a CSV file or stdin as one Import History batch (free; validation charged separately)', sdkMethod: 'contacts.importCsv', route: { method: 'POST', path: '/v1/contacts/import-csv' }, commandClass: 'write', @@ -30,6 +29,11 @@ export const contactsImportCsvCommand = defineCommand({ flag: '--file ', summary: 'CSV file to import, or - for stdin (or csv in --input)', }, + { + flag: '--file-name ', + summary: + "Name the batch in Brew's Import History (default: the --file name)", + }, { flag: '--mapping ', summary: 'Column mapping csvColumn=fieldName, repeatable', @@ -42,7 +46,7 @@ export const contactsImportCsvCommand = defineCommand({ { flag: '--validate', summary: - 'Deliverability-check every imported address (2 credits per address)', + 'Deliverability checks: up to 100 submitted CSV data rows cost 2 credits/row inline; larger files use background checks when needed, charged per address', }, { flag: '--consent-source ', @@ -69,6 +73,10 @@ export const contactsImportCsvCommand = defineCommand({ dateOrder: flagString(flags.dateOrder), validate: flags.validate === true ? true : undefined, }) + const fileName = importFileName(flags, input) + if (fileName !== undefined) { + input.fileName = fileName + } if (typeof input.csv !== 'string' || input.csv.trim() === '') { throw new CliUsageError( 'Pass the CSV with --file (a path, or - for stdin) or as csv in --input.' @@ -89,6 +97,33 @@ export const contactsImportCsvCommand = defineCommand({ asSdkInput(input), requestOptions(flags) ) - return { data: result } + return { + data: result, + human: () => + renderImportResult( + result, + typeof input.fileName === 'string' ? input.fileName : undefined + ), + } }, }) + +/** + * The Import History name: `--file-name`, else a `fileName` the JSON body + * already carries, else the file's own name (stdin has none, so the batch is + * named by its source in Brew). `undefined` leaves the body's value alone. + */ +export function importFileName( + flags: { readonly fileName?: unknown; readonly file?: unknown }, + body: Readonly> = {} +): string | undefined { + const explicit = flagString(flags.fileName) + if (explicit !== undefined) { + return explicit + } + if (typeof body.fileName === 'string') { + return + } + const file = flagString(flags.file) + return file === undefined || file === '-' ? undefined : basename(file) +} diff --git a/src/commands/contacts/import-summary.ts b/src/commands/contacts/import-summary.ts new file mode 100644 index 0000000..6351b47 --- /dev/null +++ b/src/commands/contacts/import-summary.ts @@ -0,0 +1,91 @@ +import type { ImportContactsResponse } from '@brew.new/sdk' +import { renderTable } from '../../lib/output' + +/** Rows of each table a terminal shows before pointing at `--json`. */ +export const MAX_TERMINAL_ROWS = 20 + +/** + * The terminal rendering of `contacts import` and `contacts import-csv`: one + * line of counts, then the failures and warnings returned by the API. + * `--json` prints the full envelope. + */ +export function renderImportResult( + result: ImportContactsResponse, + fileName: string | undefined +): string { + const { summary } = result + const counts = [ + `${summary.inserted} added`, + `${summary.updated} updated`, + `${summary.failed} failed`, + ...(summary.skipped > 0 ? [`${summary.skipped} skipped`] : []), + ].join(', ') + const name = fileName ? `${fileName} ` : '' + const rowNoun = summary.total === 1 ? 'row' : 'rows' + const lines = [ + `Imported ${name}as ${result.importId}: ${counts} (${summary.total} ${rowNoun} submitted).`, + ] + if (result.errors.length > 0) { + lines.push( + '', + 'FAILED ROWS', + limitedTable(result.errors, [ + { key: 'row', header: 'ROW' }, + { key: 'email', header: 'EMAIL' }, + { key: 'code', header: 'CODE' }, + { key: 'field', header: 'FIELD' }, + { key: 'message', header: 'MESSAGE' }, + ]) + ) + } + if (result.warnings.length > 0) { + lines.push( + '', + 'WARNINGS', + limitedTable(result.warnings, [ + { key: 'row', header: 'ROW' }, + { key: 'email', header: 'EMAIL' }, + { key: 'code', header: 'CODE' }, + { key: 'field', header: 'FIELD' }, + { key: 'message', header: 'MESSAGE' }, + ]) + ) + } + if (result.fieldsCreated.length > 0) { + lines.push('', `New custom fields: ${result.fieldsCreated.join(', ')}.`) + } + if (result.validation) { + const { valid, risky, invalid, unscored, persistence } = result.validation + lines.push( + '', + 'DELIVERABILITY CHECK', + `${valid} valid, ${risky} risky, ${invalid} invalid, ${unscored} unscored.` + ) + if (persistence) { + lines.push( + `Saved results: ${persistence.matched} matched, ${persistence.modified} updated, ${persistence.failed} failed to save.` + ) + } + } + if (result.validationJobId) { + lines.push( + '', + `Deliverability check running as validation job ${result.validationJobId}.` + ) + } + return lines.join('\n') +} + +function limitedTable( + rows: ReadonlyArray, + columns: Parameters[1] +): string { + const shown = rows.slice(0, MAX_TERMINAL_ROWS) as ReadonlyArray< + Record + > + const table = renderTable(shown, columns) + const hidden = rows.length - shown.length + return hidden > 0 + ? `${table}\n… ${hidden} more; --json prints the full response.` + : table +} diff --git a/src/commands/contacts/import.ts b/src/commands/contacts/import.ts new file mode 100644 index 0000000..7c77d22 --- /dev/null +++ b/src/commands/contacts/import.ts @@ -0,0 +1,129 @@ +import type { ContactsResource } from '@brew.new/sdk' +import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' +import { + asSdkInput, + flagString, + IDEMPOTENCY_FLAG, + INPUT_FLAG, + mergeInput, + readJsonFlag, + readTextFlag, + requestOptions, +} from '../../lib/input' +import { importFileName } from './import-csv' +import { renderImportResult } from './import-summary' + +type ImportContactsInput = Parameters[0] + +export const contactsImportCommand = defineCommand({ + path: ['contacts', 'import'], + summary: + 'Bulk-import up to 1000 contacts from a JSON file or stdin as one Import History batch (free; --validate costs 2 credits per address)', + sdkMethod: 'contacts.import', + route: { method: 'POST', path: '/v1/contacts/import' }, + commandClass: 'write', + flags: [ + { + flag: '--file ', + summary: + 'JSON file of contacts (an array, or { "contacts": [...] }), or - for stdin', + }, + { + flag: '--file-name ', + summary: + "Name the batch in Brew's Import History (default: the --file name)", + }, + { + flag: '--validate', + summary: + 'Deliverability-check every imported address (2 credits per address)', + }, + { + flag: '--consent-source ', + summary: + 'Default marketing consent record for rows without their own: api, form or import', + }, + INPUT_FLAG, + IDEMPOTENCY_FLAG, + ], + examples: [ + 'brew-cli contacts import --file contacts.json --consent-source import', + 'cat contacts.json | brew-cli contacts import --file - --file-name crm-export.json', + ], + run: async ({ ctx, flags }) => { + if (flags.input === '-' && flags.file === '-') { + throw new CliUsageError('Only one of --input and --file can read stdin.') + } + const fromFile = parseContactsFile( + await readTextFlag(ctx, flags.file, '--file') + ) + const base = await readJsonFlag(ctx, flags.input, '--input') + // The file's body (an array, or an object that may carry `validate`, + // `consent` or `fileName`) layers over --input; flags apply only when + // given, so a value the body set is never replaced by an absent flag. + const body = mergeInput( + Array.isArray(base) ? { contacts: base } : base, + asBody(fromFile) + ) + const input = mergeInput(body, { + fileName: importFileName(flags, body), + validate: flags.validate === true ? true : undefined, + }) + if (!Array.isArray(input.contacts) || input.contacts.length === 0) { + throw new CliUsageError( + 'Pass the contacts with --file (a JSON array or { "contacts": [...] }, or - for stdin) or as contacts in --input.' + ) + } + const consentSource = flagString(flags.consentSource) + if (consentSource !== undefined) { + const record = input.consent + input.consent = { + ...(typeof record === 'object' && record !== null ? record : {}), + source: consentSource, + } + } + const result = await ctx + .client() + .contacts.import( + asSdkInput(input), + requestOptions(flags) + ) + return { + data: result, + human: () => + renderImportResult( + result, + typeof input.fileName === 'string' ? input.fileName : undefined + ), + } + }, +}) + +function asBody(fromFile: unknown): Record { + if (fromFile === undefined) { + return {} + } + if (Array.isArray(fromFile)) { + return { contacts: fromFile } + } + if (fromFile === null || typeof fromFile !== 'object') { + throw new CliUsageError( + '--file must hold JSON: an array of contacts or { "contacts": [...] }' + ) + } + return fromFile as Record +} + +function parseContactsFile(text: string | undefined): unknown { + if (text === undefined) { + return + } + try { + return JSON.parse(text) as unknown + } catch { + throw new CliUsageError( + '--file must hold JSON: an array of contacts or { "contacts": [...] }' + ) + } +} diff --git a/src/commands/contacts/upsert-many.ts b/src/commands/contacts/upsert-many.ts index c27816c..46e8416 100644 --- a/src/commands/contacts/upsert-many.ts +++ b/src/commands/contacts/upsert-many.ts @@ -11,7 +11,8 @@ import { export const contactsUpsertManyCommand = defineCommand({ path: ['contacts', 'upsert-many'], - summary: 'Create or update a batch of contacts (up to 100 per call)', + summary: + 'Create or update up to 1000 contacts per call, with no Import History entry (for syncs; use contacts import for a batch you want listed)', sdkMethod: 'contacts.upsertMany', route: { method: 'POST', path: '/v1/contacts' }, commandClass: 'write', diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index e82bbcc..8403a8b 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1545,7 +1545,7 @@ export interface paths { put?: never; /** * Bulk-import contacts from CSV - * @description Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create — `207` when some rows fail. + * @description Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to a field of the same name (`email` is required). A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. * * CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). * @@ -1560,6 +1560,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/contacts/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk-import contacts from JSON + * @description Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. + * + * The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + */ + post: operations["importContacts"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/contacts/batch-delete": { parameters: { query?: never; @@ -3426,7 +3448,7 @@ export interface components { }; ApiWarning: { /** @enum {string} */ - code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; + code: "DRAFT_SAVED_NOT_LIVE" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "CSV_ROW_SKIPPED" | "CSV_COLUMN_IGNORED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; message: string; field?: string; }; @@ -7099,6 +7121,8 @@ export interface components { fieldsCreated: string[]; errors: { email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted). */ + row?: number; code: string; field?: string; message: string; @@ -7106,6 +7130,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ + row?: number; field?: string; message: string; from?: string; @@ -7186,6 +7212,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ + row?: number; field?: string; message: string; from?: string; @@ -7640,16 +7668,21 @@ export interface components { ContactsValidateRequest: { emails: string[]; }; - ContactsImportCsvResponse: { + ContactsImportResponse: { summary: { inserted: number; updated: number; failed: number; + /** @description CSV rows omitted for a missing/invalid email or merged into a later duplicate. Up to 100 `CSV_ROW_SKIPPED` warnings name each `row`; a final warning counts further skipped rows. Always 0 for JSON. */ skipped: number; + /** @description Rows submitted (JSON contacts, or CSV data rows). Always `inserted + updated + failed + skipped`. */ + total: number; }; fieldsCreated: string[]; errors: { email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted). */ + row?: number; code: string; field?: string; message: string; @@ -7657,6 +7690,8 @@ export interface components { warnings: { code: string; email?: string; + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ + row?: number; field?: string; message: string; from?: string; @@ -7676,13 +7711,15 @@ export interface components { }; }; validationJobId?: string; + /** @description This batch's Import History id. Each contact it wrote carries it as `importId` until a later import touches that contact, so a contact filter on `importId` lists them. */ + importId: string; }; ContactsImportCsvRequest: { csv: string; mapping?: { [key: string]: string; }; - /** @description Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. */ + /** @description Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted CSV data rows validate inline, metered at 2 credits per submitted row on a successful response, including skipped rows and repeated addresses. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. */ validate?: boolean; /** @description Consent record stamped on every imported row (typically `{ "source": "import" }`). */ consent?: { @@ -7704,6 +7741,53 @@ export interface components { * @enum {string} */ dateOrder?: "month_first" | "day_first"; + /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ + fileName?: string; + }; + ContactsImportRequest: { + contacts: { + email?: string; + firstName?: string; + lastName?: string; + subscribed?: boolean; + customFields?: { + [key: string]: unknown; + }; + /** @description Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. */ + consent?: { + /** + * @description Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.) + * @enum {string} + */ + source: "api" | "form" | "import"; + /** + * Format: date-time + * @description ISO 8601; defaults to now. + */ + capturedAt?: string; + policyVersion?: string; + evidence?: string; + }; + }[]; + /** @description Optional deliverability check on ingestion. When true, each imported address is validated with the provider and the verdict is saved to the contact’s `validationStatus`. Imports of at most 100 submitted contacts validate inline, metered at 2 credits per submitted contact on a successful response, including rows that fail. Larger imports write first and use a background validation workflow billed per address as it runs; `validationJobId` is returned when a job is queued. */ + validate?: boolean; + /** @description Default consent record for every row that does not carry its own `consent` (typically `{ "source": "import" }`). */ + consent?: { + /** + * @description Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.) + * @enum {string} + */ + source: "api" | "form" | "import"; + /** + * Format: date-time + * @description ISO 8601; defaults to now. + */ + capturedAt?: string; + policyVersion?: string; + evidence?: string; + }; + /** @description Name this batch is listed under in Import History (usually the source file name, e.g. `customers.csv`), also stamped on every contact it writes as `csvFileName`. A path keeps only its last segment. */ + fileName?: string; }; ContactsBatchDeleteResponse: { deletedCount: number; @@ -22004,7 +22088,8 @@ export interface operations { * "email": "email", * "firstName": "firstName", * "lastName": "lastName" - * } + * }, + * "fileName": "customers.csv" * } */ "application/json": components["schemas"]["ContactsImportCsvRequest"]; @@ -22031,14 +22116,16 @@ export interface operations { * "inserted": 2, * "updated": 0, * "failed": 0, - * "skipped": 0 + * "skipped": 0, + * "total": 2 * }, * "fieldsCreated": [], * "errors": [], - * "warnings": [] + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" * } */ - "application/json": components["schemas"]["ContactsImportCsvResponse"]; + "application/json": components["schemas"]["ContactsImportResponse"]; }; }; /** @description Partial success — some rows failed validation. */ @@ -22061,57 +22148,282 @@ export interface operations { * "inserted": 1, * "updated": 0, * "failed": 1, - * "skipped": 2 + * "skipped": 2, + * "total": 4 * }, * "fieldsCreated": [], * "errors": [ * { * "email": "john@example.com", + * "row": 2, * "code": "FIELD_TYPE_MISMATCH", * "field": "customFields.plan", * "message": "Field 'customFields.plan' is type 'number' but received string '$49'." * } * ], - * "warnings": [] + * "warnings": [ + * { + * "code": "CSV_ROW_SKIPPED", + * "row": 3, + * "message": "Row 3 has no email address, so it was skipped." + * }, + * { + * "code": "CSV_ROW_SKIPPED", + * "row": 4, + * "email": "not-an-email", + * "message": "Row 4 holds 'not-an-email', which is not a valid email address, so it was skipped." + * } + * ], + * "importId": "imp_01JABCDEF0123456789XYZ" * } */ - "application/json": { - summary: { - inserted: number; - updated: number; - failed: number; - skipped: number; - }; - fieldsCreated: string[]; - errors: { - email: string; - code: string; - field?: string; - message: string; - }[]; - warnings: { - code: string; - email?: string; - field?: string; - message: string; - from?: string; - to?: string; - original?: string; - normalized?: string; - }[]; - validation?: { - valid: number; - risky: number; - invalid: number; - unscored: number; - persistence?: { - matched: number; - modified: number; - failed: number; - }; - }; - validationJobId?: string; - }; + "application/json": components["schemas"]["ContactsImportResponse"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed. */ + 402: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * + * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. */ + 503: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + importContacts: { + parameters: { + query?: never; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "contacts": [ + * { + * "email": "ada@example.com", + * "firstName": "Ada" + * }, + * { + * "email": "grace@example.com", + * "firstName": "Grace", + * "customFields": { + * "plan": "pro" + * } + * } + * ], + * "consent": { + * "source": "import" + * }, + * "fileName": "crm-export.json" + * } + */ + "application/json": components["schemas"]["ContactsImportRequest"]; + }; + }; + responses: { + /** @description Import summary (all rows valid). */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "summary": { + * "inserted": 2, + * "updated": 0, + * "failed": 0, + * "skipped": 0, + * "total": 2 + * }, + * "fieldsCreated": [ + * "plan" + * ], + * "errors": [], + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" + * } + */ + "application/json": components["schemas"]["ContactsImportResponse"]; + }; + }; + /** @description Partial success — some rows failed validation. */ + 207: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "summary": { + * "inserted": 1, + * "updated": 0, + * "failed": 1, + * "skipped": 0, + * "total": 2 + * }, + * "fieldsCreated": [], + * "errors": [ + * { + * "email": "not-an-email", + * "row": 2, + * "code": "INVALID_EMAIL", + * "message": "'not-an-email' is not a valid email address." + * } + * ], + * "warnings": [], + * "importId": "imp_01JABCDEF0123456789XYZ" + * } + */ + "application/json": components["schemas"]["ContactsImportResponse"]; }; }; /** diff --git a/src/registry.ts b/src/registry.ts index 995dd48..04fd866 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -73,6 +73,7 @@ import { contactsCountByCommand } from './commands/contacts/count-by' import { contactsDeleteCommand } from './commands/contacts/delete' import { contactsDeleteManyCommand } from './commands/contacts/delete-many' import { contactsGetCommand } from './commands/contacts/get' +import { contactsImportCommand } from './commands/contacts/import' import { contactsImportCsvCommand } from './commands/contacts/import-csv' import { contactsListCommand } from './commands/contacts/list' import { contactsSearchCommand } from './commands/contacts/search' @@ -175,6 +176,7 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ contactsDeleteCommand, contactsDeleteManyCommand, contactsValidateCommand, + contactsImportCommand, contactsImportCsvCommand, fieldsListCommand, fieldsGetCommand, diff --git a/tests/commands/contacts-import-summary.test.ts b/tests/commands/contacts-import-summary.test.ts new file mode 100644 index 0000000..58c54e3 --- /dev/null +++ b/tests/commands/contacts-import-summary.test.ts @@ -0,0 +1,152 @@ +import type { ImportContactsResponse } from '@brew.new/sdk' +import { describe, expect, it } from 'vitest' +import { + MAX_TERMINAL_ROWS, + renderImportResult, +} from '../../src/commands/contacts/import-summary' + +function result( + overrides: Partial = {} +): ImportContactsResponse { + return { + summary: { inserted: 2, updated: 0, failed: 1, skipped: 3, total: 6 }, + fieldsCreated: [], + errors: [ + { + email: 'grace@brewmail.dev', + row: 5, + code: 'FIELD_TYPE_MISMATCH', + field: 'customFields.plan', + message: "Field 'customFields.plan' is type 'number'.", + }, + ], + warnings: [ + { + code: 'CSV_COLUMN_IGNORED', + field: 'Created At', + message: 'Created At is managed by Brew and was ignored.', + }, + ], + importId: 'imp_123', + ...overrides, + } +} + +describe('renderImportResult (contacts import / import-csv on a terminal)', () => { + it('renders submitted totals, failed row positions and warning fields', () => { + const text = renderImportResult(result(), 'customers.csv') + expect(text.split('\n')[0]).toBe( + 'Imported customers.csv as imp_123: 2 added, 0 updated, 1 failed, 3 skipped (6 rows submitted).' + ) + expect(text).toContain('FAILED ROWS') + expect(text).toMatch( + /5\s+grace@brewmail\.dev\s+FIELD_TYPE_MISMATCH\s+customFields.plan/ + ) + expect(text).toMatch( + /CSV_COLUMN_IGNORED\s+Created At\s+Created At is managed/ + ) + expect(text).not.toContain('undefined') + }) + + it('omits empty sections and a zero skipped count', () => { + const text = renderImportResult( + result({ + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, + errors: [], + warnings: [], + fieldsCreated: ['plan'], + validationJobId: 'val_1', + }), + undefined + ) + expect(text).toBe( + [ + 'Imported as imp_123: 1 added, 0 updated, 0 failed (1 row submitted).', + '', + 'New custom fields: plan.', + '', + 'Deliverability check running as validation job val_1.', + ].join('\n') + ) + }) + + it('caps a long table and points at --json for the rest', () => { + const errors = Array.from({ length: MAX_TERMINAL_ROWS + 5 }, (_, i) => ({ + email: `x${i}@brewmail.dev`, + code: 'INVALID_EMAIL', + message: 'Invalid.', + })) + const text = renderImportResult( + result({ + summary: { inserted: 0, updated: 0, failed: 25, skipped: 0, total: 25 }, + errors, + warnings: [], + }), + 'a.json' + ) + expect(text).toContain('… 5 more; --json prints the full response.') + expect(text).not.toContain(`x${MAX_TERMINAL_ROWS}@brewmail.dev`) + }) + + it('caps skipped-row details without promising every skipped row in JSON', () => { + const text = renderImportResult( + result({ + summary: { + inserted: 1, + updated: 0, + failed: 0, + skipped: 150, + total: 151, + }, + errors: [], + warnings: [ + ...Array.from({ length: 100 }, (_, index) => ({ + row: index + 1, + code: 'CSV_ROW_SKIPPED', + message: `Row ${index + 1} has no email address, so it was skipped.`, + })), + { + code: 'CSV_ROW_SKIPPED', + message: + '50 more rows were skipped; only the first 100 are listed.', + }, + ], + }), + undefined + ) + expect(text).toMatch(/2\s+CSV_ROW_SKIPPED\s+Row 2 has no email address/) + expect(text).toContain('150 skipped (151 rows submitted).') + expect(text).toContain('… 81 more; --json prints the full response.') + expect(text).not.toContain('every row') + expect(text).not.toContain('undefined') + }) + + it('shows completed deliverability outcomes and failed saves on a terminal', () => { + const text = renderImportResult( + result({ + validation: { + valid: 2, + risky: 1, + invalid: 3, + unscored: 4, + persistence: { matched: 6, modified: 5, failed: 1 }, + }, + }), + undefined + ) + expect(text).toContain('DELIVERABILITY CHECK') + expect(text).toContain('2 valid, 1 risky, 3 invalid, 4 unscored.') + expect(text).toContain( + 'Saved results: 6 matched, 5 updated, 1 failed to save.' + ) + }) + + it('shows completed outcomes when persistence details are absent', () => { + const text = renderImportResult( + result({ validation: { valid: 1, risky: 0, invalid: 0, unscored: 0 } }), + undefined + ) + expect(text).toContain('1 valid, 0 risky, 0 invalid, 0 unscored.') + expect(text).not.toContain('Saved results:') + }) +}) diff --git a/tests/commands/contacts-import-validation-output.test.ts b/tests/commands/contacts-import-validation-output.test.ts new file mode 100644 index 0000000..9817ed5 --- /dev/null +++ b/tests/commands/contacts-import-validation-output.test.ts @@ -0,0 +1,54 @@ +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +describe('completed import validation output', () => { + it.each(['import', 'import-csv'])( + '%s shows paid check outcomes on a terminal and preserves JSON', + async (command) => { + const response = { + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, + fieldsCreated: [], + errors: [], + warnings: [], + importId: 'imp_checked', + validation: { + valid: 0, + risky: 0, + invalid: 0, + unscored: 1, + persistence: { matched: 1, modified: 0, failed: 1 }, + }, + } + server.use( + http.post( + `https://brew.new/api/v1/contacts/${command}`, + async ({ request }) => { + expect(await request.json()).toMatchObject({ validate: true }) + return HttpResponse.json(response) + } + ) + ) + const options = { + env: { + BREW_API_KEY: 'brew_validation_output_abcdefghijklmnopqrstuvwxyz', + }, + stdin: + command === 'import' + ? '[{"email":"jane@example.com"}]' + : 'Email\njane@example.com\n', + ttyOut: true, + } + const args = ['contacts', command, '--file', '-', '--validate'] + const human = await runCli(args, options) + expect(human.code).toBe(0) + expect(human.stdout).toContain('0 valid, 0 risky, 0 invalid, 1 unscored.') + expect(human.stdout).toContain('1 failed to save.') + + const json = await runCli([...args, '--json'], options) + expect(json.code).toBe(0) + expect(json.json).toEqual(response) + } + ) +}) diff --git a/tests/commands/contacts.test.ts b/tests/commands/contacts.test.ts index 41244f0..0af98a4 100644 --- a/tests/commands/contacts.test.ts +++ b/tests/commands/contacts.test.ts @@ -303,6 +303,7 @@ describe('contacts import-csv', () => { expect(body).toEqual({ csv: 'Email\njane@example.com\n', mapping: { Email: 'email' }, + fileName: 'contacts.csv', }) }) @@ -340,6 +341,7 @@ describe('contacts import-csv', () => { dateOrder: 'day_first', validate: true, consent: { source: 'import' }, + fileName: 'contacts.csv', }) }) @@ -375,6 +377,7 @@ describe('contacts import-csv', () => { csv: 'Email\njane@example.com\n', dateOrder: 'day_first', consent: { source: 'form', evidence: 'Footer form' }, + fileName: 'contacts.csv', }) }) @@ -413,6 +416,7 @@ describe('contacts import-csv', () => { evidence: 'Footer form', policyVersion: 'v3', }, + fileName: 'contacts.csv', }) }) @@ -459,6 +463,197 @@ describe('contacts import-csv', () => { ) expect(result.code).toBe(2) }) + it('keeps a fileName from --input over the --file name', async () => { + let body: unknown + server.use( + http.post( + 'https://brew.new/api/v1/contacts/import-csv', + async ({ request }) => { + body = await request.json() + return HttpResponse.json({ imported: 1 }) + } + ) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'export.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const result = await runCli( + [ + 'contacts', + 'import-csv', + '--file', + csvPath, + '--input', + '{"fileName":"Webinar sign-ups"}', + ], + { env: env() } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + csv: 'Email\njane@example.com\n', + fileName: 'Webinar sign-ups', + }) + }) + + it('names the batch with --file-name, and sends no name for stdin', async () => { + const bodies: Array = [] + server.use( + http.post( + 'https://brew.new/api/v1/contacts/import-csv', + async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ imported: 1 }) + } + ) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-csv-')) + const csvPath = join(dir, 'export-2026.csv') + writeFileSync(csvPath, 'Email\njane@example.com\n') + const named = await runCli( + [ + 'contacts', + 'import-csv', + '--file', + csvPath, + '--file-name', + 'October webinar', + ], + { env: env() } + ) + const piped = await runCli(['contacts', 'import-csv', '--file', '-'], { + env: env(), + stdin: 'Email\njane@example.com\n', + }) + expect(named.code).toBe(0) + expect(piped.code).toBe(0) + expect(bodies).toEqual([ + { csv: 'Email\njane@example.com\n', fileName: 'October webinar' }, + { csv: 'Email\njane@example.com\n' }, + ]) + }) +}) + +describe('contacts import', () => { + const IMPORT_URL = 'https://brew.new/api/v1/contacts/import' + + it('keeps validate, consent and fileName a JSON body sets; flags win only when given', async () => { + const bodies: Array = [] + server.use( + http.post(IMPORT_URL, async ({ request }) => { + bodies.push(await request.json()) + return HttpResponse.json({ importId: 'imp_3' }) + }) + ) + const stdin = JSON.stringify({ + contacts: [{ email: 'a@example.com' }], + validate: true, + consent: { source: 'form' }, + fileName: 'crm.json', + }) + const kept = await runCli(['contacts', 'import', '--file', '-'], { + env: env(), + stdin, + }) + const overridden = await runCli( + ['contacts', 'import', '--file', '-', '--file-name', 'October list'], + { env: env(), stdin } + ) + expect(kept.code).toBe(0) + expect(overridden.code).toBe(0) + expect(bodies).toEqual([ + { + contacts: [{ email: 'a@example.com' }], + validate: true, + consent: { source: 'form' }, + fileName: 'crm.json', + }, + { + contacts: [{ email: 'a@example.com' }], + validate: true, + consent: { source: 'form' }, + fileName: 'October list', + }, + ]) + }) + + it('refuses a --file that is not an array or an object', async () => { + const result = await runCli(['contacts', 'import', '--file', '-'], { + env: env(), + stdin: '"nope"', + }) + expect(result.code).toBe(2) + expect(result.stderr).toContain('--file must hold JSON') + }) + + it('reads a JSON array file and sends it as one named batch', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ + summary: { inserted: 1, updated: 0, failed: 0, skipped: 0, total: 1 }, + fieldsCreated: [], + errors: [], + warnings: [], + importId: 'imp_1', + }) + }) + ) + const dir = mkdtempSync(join(tmpdir(), 'brew-cli-json-')) + const jsonPath = join(dir, 'crm-export.json') + writeFileSync( + jsonPath, + JSON.stringify([{ email: 'jane@example.com', firstName: 'Jane' }]) + ) + const result = await runCli( + [ + 'contacts', + 'import', + '--file', + jsonPath, + '--consent-source', + 'import', + '--validate', + ], + { env: env() } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + contacts: [{ email: 'jane@example.com', firstName: 'Jane' }], + fileName: 'crm-export.json', + validate: true, + consent: { source: 'import' }, + }) + expect(result.stdout).toContain('imp_1') + }) + + it('takes { contacts } from stdin and refuses an empty batch', async () => { + let body: unknown + server.use( + http.post(IMPORT_URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ importId: 'imp_2' }) + }) + ) + const result = await runCli( + ['contacts', 'import', '--file', '-', '--file-name', 'sync.json'], + { + env: env(), + stdin: JSON.stringify({ contacts: [{ email: 'a@example.com' }] }), + } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + contacts: [{ email: 'a@example.com' }], + fileName: 'sync.json', + }) + + const empty = await runCli(['contacts', 'import', '--input', '[]'], { + env: env(), + }) + expect(empty.code).toBe(2) + expect(empty.stderr).toContain('--file') + }) }) describe('fields', () => {