Skip to content

feat: support the reworked bulk contact import (TPL-2105) - #13

Merged
voj-tech-j merged 2 commits into
mainfrom
feat/bulk-contacts-tpl-2105
Aug 14, 2026
Merged

voj-tech-j merged 2 commits into
mainfrom
feat/bulk-contacts-tpl-2105

Conversation

@voj-tech-j

Copy link
Copy Markdown
Contributor

Adds SDK coverage for the reworked bulk contact import and the duplicate-create fix from lettr-api PR #412. Bumps to 2.5.0 (minor).

Important

Do not tag v2.5.0 until API PR #412 is merged and deployed. The API changes are additive, so this branch is safe to merge and review at any time — but a released 2.5.0 would hand users bulkSubscribeTopics() / bulkUnsubscribeTopics() against endpoints that 404, and a ContactAlreadyExistsException that never fires.

Backward compatibility

Everything is additive. Code written against 2.4.0 keeps compiling and sends byte-identical payloads:

  • BulkCreateAudienceContactsData keeps (emails, listId, properties) as its leading positional parameters; the new fields are appended as optional ones.
  • update_existing is only emitted when true, so a legacy call's JSON is unchanged.
  • BulkStoreAudienceContactsResult's new fields all default, so the DTO also parses a pre-TPL-2105 response body.
  • ConflictException lost its final (BC), and the new exception subclasses it — existing catch (ConflictException) / catch (ApiException) handlers are unaffected.
  • Every touched exception constructor gained a trailing optional parameter only.

Bulk create — both shapes on one DTO

Two named constructors make the chosen shape explicit:

BulkCreateAudienceContactsData::forEmails(['a@x.com'], listId: 'l-1');   // the original shape

BulkCreateAudienceContactsData::forContacts(
    contacts: [new BulkAudienceContactRow('cara@x.com',
        properties: ['plan' => 'pro'],
        listIds: ['l-vip'],
        topics: [AudienceTopicSubscription::optOut('t-newsletter')])],
    listIds: ['l-everyone'],
    updateExisting: true,
);

An empty payload (neither shape filled) now throws InvalidValueException instead of being sent to the API.

Result reporting

BulkStoreAudienceContactsResult gains updated, errorCount, errors[] (BulkAudienceContactError) and contacts[] (BulkAudienceContactRef), plus hasErrors(), contactIds() and idFor($email) (case-insensitive, since the API normalizes addresses).

The two traps from the API notes are documented on the class and in the CHANGELOG:

  • 201 does not mean everything landed. Rows that fail validation are skipped and reported in errors; the rest of the batch commits. Callers must check hasErrors(), not the status.
  • alreadyExisted and updated overlap by design and do not sum to the row count.

New endpoints

bulkSubscribeTopics() / bulkUnsubscribeTopics() on $lettr->audience->contacts(), mirroring the existing bulkAttachLists() / bulkDetachLists() pair. Feed them $result->contactIds() from a bulk create — no id lookup needed. One shared BulkAudienceContactTopicsData serves both directions; the lists pair has two identical DTOs, but duplicating that seemed worse than the small inconsistency.

409 on duplicate create

ContactAlreadyExistsException extends ConflictException, carrying the colliding ->email. A 409 with any other error code stays a plain ConflictException.

To route on the code, ApiException now exposes errorCode() — threaded through the Client for every mapped status, not just 409, so it is uniformly available rather than a special case on one exception. This is the one piece slightly beyond the strict scope of the API changes.

Note for downstream consumers: if a retry policy retries 5xx, duplicate creates are no longer retried (they used to escape as a 500 with the misleading send_error code).

Notes for review

  • New enum AudienceTopicSubscriptionState rather than reusing AudienceTopicDefaultSubscription. Same two values, but one describes a topic's behavior for new contacts and the other is an instruction in a request — collapsing them would make opt_out read as "this topic auto-subscribes" at the call site, the opposite of what it does there.
  • BulkAudienceContactError->errorCode is typed BulkAudienceContactErrorCode|string so a server-side code addition survives as a raw string instead of throwing ValueError (same pattern as CampaignStatus).
  • MockTransporter gained a $throws hook for testing error translation.
  • README needed no change (it points at the hosted docs). The docs site still needs the new bulk shape written up.

Verification

pint, phpstan (level 8) and pest all pass — 238 tests, 733 assertions, including new coverage for both request shapes, the legacy-response path, the empty-payload guard, both topic-bulk endpoints, and both 409 branches. Also smoke-checked 2.4.0-style positional and named calls to confirm the payloads are unchanged.

🤖 Generated with Claude Code

voj-tech-j and others added 2 commits August 13, 2026 19:54
Adds SDK coverage for the additive API changes on lettr-api PR #412, plus
the duplicate-create fix. Everything here is backward compatible: code
written against 2.4.0 keeps compiling and sends byte-identical payloads.

Bulk create now supports a per-contact shape alongside the flat `emails`
list. `BulkCreateAudienceContactsData` keeps `(emails, listId, properties)`
as its leading positional parameters and appends `contacts`, `listIds`,
`topics` and `updateExisting` as optional ones, with `forEmails()` and
`forContacts()` named constructors to make the chosen shape explicit.
`update_existing` is only emitted when true so legacy payloads are
unchanged.

`BulkStoreAudienceContactsResult` gains `updated`, `errorCount`, `errors`
and `contacts`, all defaulted so the DTO still reads a pre-TPL-2105
response, plus `hasErrors()`, `contactIds()` and `idFor()`. A bulk create
can partially succeed and still return 201, so callers must check
`hasErrors()` rather than the status.

Also adds the bulk topic subscribe/unsubscribe endpoints, and maps the
duplicate-email 409 to a dedicated `ContactAlreadyExistsException`
(a subclass of `ConflictException`, which is no longer final). To route
on it, every `ApiException` now exposes the response `error_code`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI resolves laravel/pint fresh (composer.lock is gitignored) and picked up
1.30.5, whose `fully_qualified_strict_types` also rewrites FQCNs inside
docblocks. Reworded the `@see` tags on the two files that referenced
AudienceContactService so they no longer pull a service import into a DTO
and an exception, and imported Enums\ErrorCode in ApiException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@voj-tech-j
voj-tech-j merged commit c22a016 into main Aug 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant