From 8049da150a859f283fa373d76a66af2eea0b75da Mon Sep 17 00:00:00 2001 From: RickjanHoornbeeck <51879@hoornbeeck.nl> Date: Tue, 17 Mar 2026 14:18:32 +0100 Subject: [PATCH 01/15] feat(campaigns): add transactional/subscriptions campaigns support - 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. --- console/src/oapi/management.generated.ts | 70 ++++++ console/src/types.ts | 7 +- .../src/views/campaign/CampaignDetails.tsx | 151 +++++++++++-- console/src/views/campaign/Campaigns.tsx | 89 +++++--- console/src/views/campaign/CreateCampaign.tsx | 168 ++++++++++++++- console/src/views/campaign/setup/Setup.tsx | 89 +++++++- .../views/campaign/template/mail/Setup.tsx | 13 +- .../controllers/v1/management/campaigns.go | 80 ++++++- .../v1/management/campaigns_test.go | 117 +++++++++- .../v1/management/oapi/resources.yml | 13 ++ .../v1/management/oapi/resources_gen.go | 10 +- internal/pubsub/consumer/campaigns.go | 2 +- internal/pubsub/consumer/campaigns_test.go | 82 +++++++ internal/store/management/campaigns.go | 46 ++-- ...34_add_transactional_to_campaigns.down.sql | 7 + ...6034_add_transactional_to_campaigns.up.sql | 3 + .../1764106035_sender_identities.down.sql | 64 ++++++ .../1764106035_sender_identities.up.sql | 204 ++++++++++++++++++ internal/store/management/subscriptions.go | 12 +- internal/wasm/test/action.wasm | Bin 504767 -> 504763 bytes internal/wasm/test/provider.wasm | Bin 506003 -> 505999 bytes 21 files changed, 1122 insertions(+), 105 deletions(-) create mode 100644 internal/pubsub/consumer/campaigns_test.go create mode 100644 internal/store/management/migrations/1764106034_add_transactional_to_campaigns.down.sql create mode 100644 internal/store/management/migrations/1764106034_add_transactional_to_campaigns.up.sql create mode 100644 internal/store/management/migrations/1764106035_sender_identities.down.sql create mode 100644 internal/store/management/migrations/1764106035_sender_identities.up.sql diff --git a/console/src/oapi/management.generated.ts b/console/src/oapi/management.generated.ts index 838377a7..d777410a 100644 --- a/console/src/oapi/management.generated.ts +++ b/console/src/oapi/management.generated.ts @@ -164,6 +164,26 @@ export interface paths { patch: operations["updateTemplate"]; trace?: never; }; + "/api/admin/projects/{projectID}/campaigns/{campaignID}/templates/{templateID}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send test + * @description Sends a test message using the template to the specified recipient + */ + post: operations["sendTest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/projects/{projectID}/campaigns/{campaignID}/duplicate": { parameters: { query?: never; @@ -2163,6 +2183,8 @@ export interface components { provider_id?: string; /** Format: uuid */ subscription_id?: string; + /** @example false */ + transactional?: boolean; }; UpdateCampaign: { /** @example epic hopper */ @@ -2172,6 +2194,10 @@ export interface components { * @example 5143f27c-cca9-4dc4-9059-e1dbb08144ad */ provider_id?: string; + /** Format: uuid */ + subscription_id?: string; + /** @example false */ + transactional?: boolean; variables?: components["schemas"]["CampaignVariable"][]; }; CampaignVariable: { @@ -2203,6 +2229,18 @@ export interface components { [key: string]: unknown; } | null; }; + SendTest: { + /** + * Format: email + * @description The recipient address to send the test to + * @example test@example.com + */ + to: string; + /** @description Optional template variables/props for rendering */ + props?: { + [key: string]: unknown; + }; + }; Campaign: { /** * Format: date-time @@ -2226,6 +2264,8 @@ export interface components { channel: components["schemas"]["Channel"]; /** Format: uuid */ subscription_id?: string; + /** @example false */ + transactional: boolean; provider?: components["schemas"]["Provider"]; templates: components["schemas"]["Template"][]; variables?: components["schemas"]["CampaignVariable"][]; @@ -3986,6 +4026,36 @@ export interface operations { default: components["responses"]["Error"]; }; }; + sendTest: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The project ID */ + projectID: string; + /** @description The campaign ID */ + campaignID: string; + /** @description The template ID */ + templateID: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendTest"]; + }; + }; + responses: { + /** @description Test sent successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Error"]; + }; + }; duplicateCampaign: { parameters: { query?: never; diff --git a/console/src/types.ts b/console/src/types.ts index 90fabd7f..8fad52d8 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -578,6 +578,7 @@ export interface Campaign { provider?: Provider subscription_id?: UUID subscription?: Subscription + transactional?: boolean templates: Template[] variables: CampaignVariable[] created_at: string @@ -587,9 +588,9 @@ export interface Campaign { export type CampaignSendState = "pending" | "sent" | "throttled" | "failed" | "bounced" | "aborted" export type CampaignUpdateParams = Partial< - Pick -> -export type CampaignCreateParams = Pick + Pick +> & { state?: string } +export type CampaignCreateParams = Pick export type CampaignUser = User & { state: CampaignSendState; send_at: string } interface NamedEmail { diff --git a/console/src/views/campaign/CampaignDetails.tsx b/console/src/views/campaign/CampaignDetails.tsx index 4039bbfc..9be0a0a8 100644 --- a/console/src/views/campaign/CampaignDetails.tsx +++ b/console/src/views/campaign/CampaignDetails.tsx @@ -1,11 +1,12 @@ -import { useContext, useState, useEffect } from "react" +import { useCallback, useContext, useMemo, useState, useEffect } from "react" import { CampaignContext, ProjectContext, TemplateContext } from "@/contexts" -import type { Campaign, Template, User } from "@/types" +import type { Campaign, Template, Subscription } from "@/types" import { useTranslation } from "react-i18next" import { Controller, useForm } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import api from "@/api" +import oapiClient from "@/oapi/client" +import { useResolver } from "@/hooks" import { channels } from "./template/channels" import { CampaignVariables } from "./CampaignVariables" @@ -16,6 +17,15 @@ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/c import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { ProviderSelect } from "@/components/provider/select" +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" const campaignVariableSchema = z.object({ name: z.string(), @@ -36,6 +46,27 @@ function CampaignReview({ campaign, template }: { campaign: Campaign; template: const [, setCampaign] = useContext(CampaignContext) const templateState = useState