feat(campaigns): add transactional/subscriptions campaigns support#206
feat(campaigns): add transactional/subscriptions campaigns support#206RickjanHoornbeeck wants to merge 12 commits intomainfrom
Conversation
- Introduced a new `transactional` field in the Campaign model to differentiate between transactional and non-transactional campaigns. - Updated the campaign creation and update endpoints to handle the new `transactional` field. - Implemented validation to ensure that a campaign cannot be both transactional and linked to a subscription. - Modified the UI to include a toggle for setting a campaign as transactional and to conditionally display subscription options based on this setting. - Updated database schema with migrations to add the `transactional` column to the campaigns table. - Enhanced tests to cover new transactional logic and ensure proper handling of subscription channel compatibility.
There was a problem hiding this comment.
Pull request overview
Adds support for transactional campaigns (mutually exclusive with subscription-linked campaigns) across the management API, persistence layer, pubsub rendering behavior, and console UI, along with accompanying schema changes.
Changes:
- Introduces a
transactionalflag on campaigns and enforces “transactional XOR subscription-linked” in create/update flows (incl. unsubscribe URL behavior). - Updates console campaign creation/edit flows to toggle transactional mode and conditionally select subscriptions (with channel compatibility filtering).
- Adds DB migrations for the new campaign field and introduces new
sender_identitiesstorage + data migrations for provider/template sender metadata.
Reviewed changes
Copilot reviewed 19 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/store/management/subscriptions.go | Normalizes legacy subscription channel values (e.g. sms → text) when mapping to OAPI models. |
| internal/store/management/migrations/1764106035_sender_identities.up.sql | Adds sender_identities table and migrates provider/template sender data into it; backfills templates.sender_identity_id. |
| internal/store/management/migrations/1764106035_sender_identities.down.sql | Attempts to reverse sender identity migration and restore provider/template JSON sender fields. |
| internal/store/management/migrations/1764106034_add_transactional_to_campaigns.up.sql | Adds campaigns.transactional boolean column (default false). |
| internal/store/management/migrations/1764106034_add_transactional_to_campaigns.down.sql | Drops campaigns.transactional. |
| internal/store/management/campaigns.go | Persists/returns transactional; updates create/list/get/update queries and update semantics for clearing subscription_id when transactional. |
| internal/pubsub/consumer/campaigns.go | Omits unsubscribe URL for transactional campaigns. |
| internal/pubsub/consumer/campaigns_test.go | Adds tests covering unsubscribe URL inclusion/omission based on subscription linkage + transactional. |
| internal/http/controllers/v1/management/oapi/resources_gen.go | Regenerates OAPI models to include transactional fields. |
| internal/http/controllers/v1/management/oapi/resources.yml | Adds transactional to campaign schemas (and includes other API surface updates). |
| internal/http/controllers/v1/management/campaigns.go | Adds request validation for transactional/subscription exclusivity + subscription channel compatibility; propagates transactional through create/update/duplicate. |
| internal/http/controllers/v1/management/campaigns_test.go | Adds/extends tests for subscription channel mismatch, transactional clearing subscription, and invalid subscription updates. |
| console/src/views/campaign/template/mail/Setup.tsx | Switches locale fetching to openapi-fetch client. |
| console/src/views/campaign/setup/Setup.tsx | Adds transactional toggle and subscription selection to campaign setup; includes them in update payload. |
| console/src/views/campaign/CreateCampaign.tsx | Updates create flow to select channel first, then optionally set transactional/subscription before POSTing via OAPI client. |
| console/src/views/campaign/Campaigns.tsx | Switches campaigns list fetching to OAPI client and offset pagination; updates duplicate/delete calls. |
| console/src/views/campaign/CampaignDetails.tsx | Adds transactional/subscription controls and updates campaign PATCH via OAPI client. |
| console/src/types.ts | Extends campaign types to include transactional and updates create/update param types. |
| console/src/oapi/management.generated.ts | Regenerates OpenAPI TS types (includes transactional fields and additional endpoints/schemas). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
internal/store/management/migrations/1764106035_sender_identities.down.sql
Show resolved
Hide resolved
| UPDATE providers p | ||
| SET data = jsonb_set(p.data, '{default_from}', to_jsonb(si.traits->>'address')) | ||
| FROM sender_identities si | ||
| WHERE p.data->>'default_from' IS NOT NULL | ||
| AND p.data->>'default_from' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' | ||
| AND si.id = (p.data->>'default_from')::uuid; |
internal/store/management/migrations/1764106035_sender_identities.down.sql
Show resolved
Hide resolved
internal/store/management/migrations/1764106035_sender_identities.down.sql
Show resolved
Hide resolved
internal/store/management/migrations/1764106035_sender_identities.up.sql
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull request overview
Adds first-class support for transactional campaigns (mutually exclusive with subscription-linked campaigns) across the management API, store layer, pubsub rendering, and the console UI. The PR also introduces a sizable sender identities schema + data migration affecting providers/templates.
Changes:
- Add
transactionalfield to campaigns (DB + store + OpenAPI models) and enforce “transactional XOR subscription-linked” in create/update flows. - Update render context generation to omit unsubscribe URLs for transactional campaigns; add tests for this behavior.
- Update console campaign creation/setup/details UI to toggle transactional mode and select a compatible subscription; migrate several console calls to
oapiClient.
Reviewed changes
Copilot reviewed 19 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/store/management/subscriptions.go | Normalizes subscription channel output (e.g., sms → OAPI text). |
| internal/store/management/migrations/1764106035_add_transactional_to_campaigns.up.sql | Adds transactional boolean column to campaigns. |
| internal/store/management/migrations/1764106035_add_transactional_to_campaigns.down.sql | Removes transactional column. |
| internal/store/management/migrations/1764106034_sender_identities.up.sql | Introduces sender_identities + migrates provider/template “from” data into identities and a new templates.sender_identity_id column. |
| internal/store/management/migrations/1764106034_sender_identities.down.sql | Attempts to reverse identity migration back into JSON fields and drop schema. |
| internal/store/management/campaigns.go | Adds Transactional field, persists it, exposes it via OAPI, and clears subscription on transactional updates. |
| internal/pubsub/consumer/campaigns.go | Omits unsubscribe_url when campaign is transactional. |
| internal/pubsub/consumer/campaigns_test.go | Tests unsubscribe URL presence/absence based on transactional/subscription state. |
| internal/http/controllers/v1/management/oapi/resources.yml | Adds transactional to Campaign schemas and update/create payloads. |
| internal/http/controllers/v1/management/oapi/resources_gen.go | Regenerated OAPI models with transactional fields. |
| internal/http/controllers/v1/management/campaigns.go | Validates transactional/subscription exclusivity and subscription channel compatibility; includes transactional in create/update/duplicate. |
| internal/http/controllers/v1/management/campaigns_test.go | Expands tests for subscription validation and transactional clearing behavior. |
| console/src/views/campaign/template/mail/Setup.tsx | Switches locale fetching to oapiClient. |
| console/src/views/campaign/setup/Setup.tsx | Adds transactional toggle + subscription selector with channel filtering. |
| console/src/views/campaign/CreateCampaign.tsx | Adds transactional toggle + subscription selector during creation; uses oapiClient. |
| console/src/views/campaign/Campaigns.tsx | Switches to offset pagination + oapiClient for list/duplicate/delete. |
| console/src/views/campaign/CampaignDetails.tsx | Adds transactional toggle + subscription selector; switches updates to oapiClient. |
| console/src/types.ts | Extends Campaign types to include transactional. |
| console/src/oapi/management.generated.ts | Updates generated OpenAPI TS types (includes campaign transactional and a template test endpoint). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| SET data = jsonb_set(p.data, '{default_from}', to_jsonb(si.traits->>'address')) | ||
| FROM sender_identities si | ||
| WHERE p.data->>'default_from' IS NOT NULL | ||
| AND p.data->>'default_from' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' | ||
| AND si.id = (p.data->>'default_from')::uuid; |
| -- Reverse: restore email template from.email from UUID → address | ||
| UPDATE templates t | ||
| SET data = jsonb_set(t.data, '{from,email}', to_jsonb(si.traits->>'address')) | ||
| FROM sender_identities si | ||
| WHERE t.data->'from'->>'email' IS NOT NULL | ||
| AND t.data->'from'->>'email' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' | ||
| AND si.id = (t.data->'from'->>'email')::uuid; | ||
|
|
| UPDATE templates t | ||
| SET data = jsonb_set(t.data, '{from}', to_jsonb(si.traits->>'address')) | ||
| FROM sender_identities si | ||
| WHERE t.data->>'from' IS NOT NULL | ||
| AND t.data->>'from' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' | ||
| AND si.id = (t.data->>'from')::uuid; |
| -- Sender identities table | ||
| -- Stores registered sender addresses (email, SMS, etc.) scoped to a provider. | ||
| -- Channel-specific metadata lives in the traits JSONB column; the address is | ||
| -- always stored as traits->>'address'. | ||
|
|
||
| CREATE TABLE sender_identities ( | ||
| id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), | ||
| project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, | ||
| provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, | ||
| channel VARCHAR(255) NOT NULL, | ||
| traits JSONB NOT NULL DEFAULT '{}', | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | ||
| ); | ||
|
|
||
| CREATE INDEX sender_identities_project_id_idx ON sender_identities(project_id); | ||
| CREATE INDEX sender_identities_provider_id_idx ON sender_identities(provider_id); | ||
| CREATE UNIQUE INDEX sender_identities_provider_channel_address_uniq | ||
| ON sender_identities (provider_id, channel, (traits->>'address')); | ||
|
|
||
| CREATE TRIGGER set_updated_at_sender_identities BEFORE UPDATE ON sender_identities FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); | ||
|
|
||
| -------------------------------------------------------------------------------- | ||
| -- Data migration: provider default_from → sender_identities | ||
| -------------------------------------------------------------------------------- |
| UPDATE providers p | ||
| SET data = data || jsonb_build_object('default_from_name', inn.from_name) | ||
| FROM identity_names inn | ||
| WHERE (p.data->>'default_from')::UUID = inn.identity_id | ||
| AND p.data->>'default_from' IS NOT NULL | ||
| AND p.data->>'default_from' ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; |
|
All contributors have signed the CLA ✍️ ✅ |
e25ddd9 to
a2077b0
Compare
|
I have read the CLA Document and I hereby sign the CLA |
f1f4c61 to
734e288
Compare
transactionalfield in the Campaign model to differentiate between transactional and non-transactional campaigns.transactionalfield.transactionalcolumn to the campaigns table.