diff --git a/console/src/oapi/management.generated.ts b/console/src/oapi/management.generated.ts index 18758fe0e..d66c9a895 100644 --- a/console/src/oapi/management.generated.ts +++ b/console/src/oapi/management.generated.ts @@ -3039,6 +3039,7 @@ export interface components { * @example 52f3f921-1343-48af-b795-87c0fd3b44aa */ campaign_id: string; + variant?: components["schemas"]["VariantSelector"]; }; /** @description Data for action step - execute WASM action */ ActionStepData: { @@ -3330,6 +3331,7 @@ export interface components { /** @example false */ transactional?: boolean; variables?: components["schemas"]["CampaignVariable"][]; + variants?: components["schemas"]["CampaignVariants"]; }; CampaignVariable: { /** @@ -3343,12 +3345,54 @@ export interface components { */ default?: string; }; + CampaignVariant: { + /** + * @description The value a send resolves against to pick this variant's templates. Lowercase letters, digits, dashes and underscores, starting with a letter or digit. The empty key is the default variant and is never declared here. + * @example acme + */ + key: string; + /** + * @description Human readable name shown in the console + * @example Acme Corp + */ + label?: string; + }; + /** @description Decides which template variant a send uses. The same shape appears on a campaign, on a journey campaign step and on a broadcast; the most specific layer wins. A static selector pins one variant for every recipient and is rejected when the campaign does not declare its key. An expression selector resolves a variant per recipient and can only be judged at send time, so one that resolves to an unknown variant falls back to the default variant. */ + VariantSelector: { + /** + * @description Whether the variant is pinned or resolved per recipient + * @example expression + * @enum {string} + */ + type: "static" | "expression"; + /** + * @description Variant key, used when type is static. An empty key pins the default variant, which is how one send is forced back to house branding past a campaign that resolves a client brand per recipient. That differs from omitting the selector entirely, which defers to the campaign. + * @example acme + */ + key?: string; + /** + * @description Liquid expression. Required when type is expression. + * @example {{ user.data.tenant }} + */ + expression?: string; + }; + /** @description A campaign's declared variants together with the rule that picks between them when a send does not pick one itself. */ + CampaignVariants: { + selector?: components["schemas"]["VariantSelector"]; + /** @description The variants this campaign declares. The default variant is always available and is not listed here. */ + options?: components["schemas"]["CampaignVariant"][]; + }; CreateTemplate: { /** * @description The locale/language code for the template * @example en */ locale: string; + /** + * @description The variant this template belongs to. Empty selects the default variant, which is the branding every campaign starts with. + * @example acme + */ + variant?: string; /** @description Template-specific data based on type. Structure varies by template type. */ data?: { [key: string]: unknown; @@ -3413,6 +3457,7 @@ export interface components { transactional: boolean; templates: components["schemas"]["Template"][]; variables?: components["schemas"]["CampaignVariable"][]; + variants?: components["schemas"]["CampaignVariants"]; delivery: components["schemas"]["Delivery"]; /** * @description Whether the campaign has been archived @@ -3691,6 +3736,11 @@ export interface components { data: components["schemas"]["EmailTemplateData"] | components["schemas"]["SmsTemplateData"] | components["schemas"]["PushTemplateData"]; /** @example en */ locale: string; + /** + * @description The variant this template belongs to. Empty is the default variant. + * @example acme + */ + variant: string; /** * Format: uuid * @description The ID of the sender identity to use for this template @@ -5433,6 +5483,7 @@ export interface components { * @description Optional scheduled send time. If provided, the broadcast is created in 'scheduled' state. */ scheduled_at?: string; + variant?: components["schemas"]["VariantSelector"]; }; UpdateBroadcast: { /** @@ -5454,6 +5505,7 @@ export interface components { list_name: string; /** @description Snapshot of the list type at broadcast creation time */ list_type: string; + variant?: components["schemas"]["VariantSelector"]; state: components["schemas"]["BroadcastState"]; /** * @description Total number of users in the audience at send time diff --git a/console/src/types.ts b/console/src/types.ts index a59b5a756..e653b23e3 100644 --- a/console/src/types.ts +++ b/console/src/types.ts @@ -741,6 +741,28 @@ export interface CampaignVariable { default?: string } +export interface CampaignVariant { + key: string + label?: string +} + +export type VariantSelectorType = "static" | "expression" + +/** + * Decides which template variant a send uses. The same shape appears on a + * campaign, a journey campaign step and a broadcast; the most specific wins. + */ +export interface VariantSelector { + type: VariantSelectorType + key?: string + expression?: string +} + +export interface CampaignVariants { + selector?: VariantSelector + options?: CampaignVariant[] +} + export interface Campaign { id: UUID project_id: UUID @@ -752,6 +774,7 @@ export interface Campaign { transactional?: boolean templates: Template[] variables: CampaignVariable[] + variants: CampaignVariants created_at: string updated_at: string } @@ -861,6 +884,7 @@ export type Template< campaign_id: UUID type: ChannelType locale: string + variant: string sender_identity_id: UUID | null data: DataObjectType screenshot_url: string @@ -885,9 +909,8 @@ export type Template< } ) -export type TemplateCreateParams = Pick +export type TemplateCreateParams = Pick & { variant?: string } export type TemplateUpdateParams = Pick -export type VariantUpdateParams = { id?: UUID } export interface TemplatePreviewParams { user: Record diff --git a/console/src/validation/broadcast/broadcast-response.ts b/console/src/validation/broadcast/broadcast-response.ts index cc8dc07c6..6af378664 100644 --- a/console/src/validation/broadcast/broadcast-response.ts +++ b/console/src/validation/broadcast/broadcast-response.ts @@ -7,6 +7,13 @@ export const broadcastResponseSchema = z.object({ list_id: z.string(), list_name: z.string(), list_type: z.enum(["static", "dynamic"]), + variant: z + .object({ + type: z.enum(["static", "expression"]), + key: z.string().optional(), + expression: z.string().optional(), + }) + .nullish(), state: z.enum(["scheduled", "pending", "sending", "completed", "failed", "cancelled"]), total: z.number(), sent: z.number().optional().default(0), diff --git a/console/src/validation/broadcast/create-broadcast.ts b/console/src/validation/broadcast/create-broadcast.ts index e1dd76cb1..1fe5c626f 100644 --- a/console/src/validation/broadcast/create-broadcast.ts +++ b/console/src/validation/broadcast/create-broadcast.ts @@ -6,6 +6,13 @@ export const createBroadcastSchema = z list_id: z.string().min(1, "List is required"), is_scheduled: z.boolean(), scheduled_at: z.string().optional(), + variant: z + .object({ + type: z.enum(["static", "expression"]), + key: z.string().optional(), + expression: z.string().optional(), + }) + .optional(), }) .refine((data) => !data.is_scheduled || data.scheduled_at, { message: "Scheduled time is required", diff --git a/console/src/views/broadcast/CreateBroadcastDialog.tsx b/console/src/views/broadcast/CreateBroadcastDialog.tsx index 328d1fe0c..01bed8b88 100644 --- a/console/src/views/broadcast/CreateBroadcastDialog.tsx +++ b/console/src/views/broadcast/CreateBroadcastDialog.tsx @@ -47,6 +47,9 @@ const channelIcons: Record = { inbox: Inbox, } +import { VariantSelectorInput } from "@/views/campaign/VariantSelectorInput" +import { isEnterprise } from "@/config/enterprise" + interface CreateBroadcastDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -150,6 +153,7 @@ export function CreateBroadcastDialog({ ...(values.is_scheduled && values.scheduled_at ? { scheduled_at: new Date(values.scheduled_at).toISOString() } : {}), + ...(values.variant ? { variant: values.variant } : {}), }, }, ) @@ -199,7 +203,15 @@ export function CreateBroadcastDialog({ render={({ field }) => ( + updateVariant(index, { + key: e.target.value.toLowerCase().replace(/\s/g, "-"), + }) + } + readOnly={keyLocked} + title={ + keyLocked + ? t( + "campaign.variants.key_locked", + "Delete this variant's templates before renaming or removing it.", + ) + : undefined + } + placeholder="key" + className="h-8 w-36 rounded-r-none font-mono text-sm shadow-none focus:z-10 read-only:cursor-not-allowed read-only:text-muted-foreground" + /> + + updateVariant(index, { + label: e.target.value || undefined, + }) + } + placeholder="display name (optional)" + className="-ml-px h-8 min-w-[120px] flex-1 rounded-none border-l-0 text-sm shadow-none focus:z-10" + /> + + + {keyError && variant.key && ( +

{keyError}

+ )} + {!keyError && variant.key && templateCount === 0 && ( +

+ {t( + "campaign.variants.no_template", + "No template yet — sends for this variant fall back to the default design.", + )} +

+ )} + + ) + })} + +
+ +
+ + ) + + return ( +
+
+ + {t("campaign.variants.title", "Variants")} + +
+ {editor} + {variants.length > 0 && ( +
+ +

+ {t( + "campaign.variants.selector.description", + "Used when a journey step or broadcast does not pick a variant itself.", + )} +

+ variant.key)} + onChange={(selector) => onChange({ ...value, selector })} + /> +
+ )} +
+ ) +} diff --git a/console/src/views/campaign/VariantSelectorInput.tsx b/console/src/views/campaign/VariantSelectorInput.tsx new file mode 100644 index 000000000..c4513290d --- /dev/null +++ b/console/src/views/campaign/VariantSelectorInput.tsx @@ -0,0 +1,139 @@ +import { useCallback } from "react" +import { useTranslation } from "react-i18next" + +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { TemplateInput } from "@/components/ui/template-input" +import type { CampaignVariant, VariantSelector, VariantSelectorType } from "@/types" + +const NONE = "__none__" + +// Radix reads an empty string as "nothing selected" and would show the +// placeholder, so the default variant needs a stand-in value in the dropdown. +const DEFAULT_KEY = "__default__" + +interface VariantSelectorInputProps { + value?: VariantSelector + options: CampaignVariant[] + onChange: (selector: VariantSelector | undefined) => void + /** Liquid variables offered by the surrounding context, when there are any. */ + variables?: React.ComponentProps["variables"] + /** Label for the "no selector" choice, which differs per surface. */ + emptyLabel?: string +} + +/** + * Picks a template variant either by pinning one or by writing a Liquid + * expression resolved per recipient. Shared by the campaign, the journey + * campaign step and the broadcast dialog so the three offer the same choices. + */ +export function VariantSelectorInput({ + value, + options, + onChange, + variables, + emptyLabel, +}: VariantSelectorInputProps) { + const { t } = useTranslation() + + const mode: string = value?.type ?? NONE + + const handleModeChange = useCallback( + (next: string) => { + if (next === NONE) return onChange(undefined) + // Static mode starts on the default variant rather than on + // whichever client happens to sort first, so an unfinished edit + // cannot pin someone else's branding. + onChange( + next === "static" + ? { type: "static", key: "" } + : { type: "expression", expression: "" }, + ) + }, + [onChange], + ) + + return ( +
+ + + {value?.type === "static" && ( + + )} + + {value?.type === "expression" && + (variables ? ( + + onChange({ type: "expression", expression }) + } + variables={variables} + placeholder="{{ user.data.tenant }}" + /> + ) : ( + + onChange({ type: "expression", expression: e.target.value }) + } + placeholder="{{ user.data.tenant }}" + className="h-9 font-mono text-sm shadow-none" + /> + ))} + + {value?.type === "expression" && ( +

+ {t( + "campaign.variants.selector.expression_help", + "A value matching no variant falls back to the default design.", + )} +

+ )} +
+ ) +} + +export type { VariantSelectorType } diff --git a/console/src/views/campaign/template/Template.tsx b/console/src/views/campaign/template/Template.tsx index c759ef1a2..e6e4acce1 100644 --- a/console/src/views/campaign/template/Template.tsx +++ b/console/src/views/campaign/template/Template.tsx @@ -8,6 +8,8 @@ import api from "@/api" import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination" import { LocaleSelect } from "@/components/locale/select" +import { VariantSwitcher } from "./VariantSwitcher" +import { isEnterprise } from "@/config/enterprise" import { Button } from "@/components/ui/button" import { TemplateWorkflowContext } from "./contexts" import { t } from "i18next" @@ -62,7 +64,10 @@ export default function Template() { const steps = useMemo(() => { const templates = campaign.templates || [] const selectedTemplateId = - templateId ?? templates.find((t) => t.locale === project.locale)?.id ?? templates[0]?.id + templateId ?? + templates.find((t) => t.locale === project.locale && !t.variant)?.id ?? + templates.find((t) => !t.variant)?.id ?? + templates[0]?.id const basePath = `/projects/${project.id}/campaigns/${campaign.id}/templates/${selectedTemplateId}` @@ -138,8 +143,13 @@ export default function Template() { [project.id, campaign.id, location.pathname, navigate], ) - const handleLocaleChange = useCallback( - async (localeKey: string) => { + const currentVariant = currentTemplate?.variant ?? "" + + // Switching either axis of the (locale, variant) pair keeps the other + // fixed, and creates the template when that combination has none yet - + // the same on-the-fly creation the locale switcher has always done. + const openTemplate = useCallback( + async (localeKey: string, variantKey: string) => { if (handler.current) { const next = await handler.current() if (!next) { @@ -148,7 +158,7 @@ export default function Template() { } const selectedTemplate = campaign.templates.find( - (template) => template.locale === localeKey, + (template) => template.locale === localeKey && template.variant === variantKey, ) if (selectedTemplate) { navigateToTemplate(selectedTemplate.id) @@ -156,22 +166,36 @@ export default function Template() { } setPageLoading(true) - const template = await api.campaigns.templates.create(project.id, campaign.id, { - locale: localeKey, - data: {}, - }) + try { + const template = await api.campaigns.templates.create(project.id, campaign.id, { + locale: localeKey, + variant: variantKey || undefined, + data: {}, + }) - setCampaign({ - ...campaign, - templates: [...campaign.templates, template], - }) + setCampaign({ + ...campaign, + templates: [...campaign.templates, template], + }) - navigateToTemplate(template.id) - setPageLoading(false) + navigateToTemplate(template.id) + } finally { + setPageLoading(false) + } }, [campaign, project?.id, setCampaign, navigateToTemplate], ) + const handleLocaleChange = useCallback( + (localeKey: string) => openTemplate(localeKey, currentVariant), + [openTemplate, currentVariant], + ) + + const handleVariantChange = useCallback( + (variantKey: string) => openTemplate(currentTemplate?.locale ?? project.locale, variantKey), + [openTemplate, currentTemplate?.locale, project.locale], + ) + // Fetch locales when template changes useEffect(() => { const fetchLocales = async () => { @@ -230,8 +254,15 @@ export default function Template() {
{templateId && ( -
+
+ {isEnterprise && (campaign.variants?.options?.length ?? 0) > 0 && ( + + )}
)} diff --git a/console/src/views/campaign/template/VariantSwitcher.tsx b/console/src/views/campaign/template/VariantSwitcher.tsx new file mode 100644 index 000000000..50285e184 --- /dev/null +++ b/console/src/views/campaign/template/VariantSwitcher.tsx @@ -0,0 +1,72 @@ +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { Palette } from "lucide-react" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { CampaignVariant } from "@/types" + +export const DEFAULT_VARIANT_VALUE = "__default__" + +/** + * Switches which variant's template the editor is showing. This navigates the + * editor; it does not decide what a send resolves to - that is + * VariantSelectorInput. + */ + +interface VariantSwitcherProps { + variants: CampaignVariant[] + value: string + onChange: (variant: string) => void + disabled?: boolean +} + +export function VariantSwitcher({ variants, value, onChange, disabled }: VariantSwitcherProps) { + const { t } = useTranslation() + + // The default variant is not a declared entry, so it gets a sentinel value: + // Radix treats an empty string as "nothing selected" and would render the + // placeholder instead of the option. + const options = useMemo( + () => [ + { value: DEFAULT_VARIANT_VALUE, label: t("campaign.variants.default", "Default") }, + ...variants + .filter((variant) => variant.key) + .map((variant) => ({ + value: variant.key, + label: variant.label || variant.key, + })), + ], + [variants, t], + ) + + return ( + + ) +} diff --git a/console/src/views/journey/steps/Campaign.tsx b/console/src/views/journey/steps/Campaign.tsx index ed967a5c6..c1377b183 100644 --- a/console/src/views/journey/steps/Campaign.tsx +++ b/console/src/views/journey/steps/Campaign.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react" import api from "../../../api" -import type { Campaign, CampaignVariable, JourneyStepType } from "../../../types" +import type { Campaign, CampaignVariable, JourneyStepType, VariantSelector } from "../../../types" import type { VariableGroup } from "../JourneyVariableContext" import { Combobox } from "@/components/ui/combobox" import { Label } from "@/components/ui/label" @@ -16,10 +16,13 @@ import { PlusIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { TemplateInput } from "@/components/ui/template-input" import { useJourneyVariableContext } from "../JourneyVariableContext" +import { isEnterprise } from "@/config/enterprise" +import { VariantSelectorInput } from "../../campaign/VariantSelectorInput" interface CampaignConfig { campaign_id: UUID data?: Record + variant?: VariantSelector } type CampaignOption = Campaign & { path: string } @@ -90,6 +93,7 @@ export const campaignStep: JourneyStepType = { ) const variables = campaign?.variables ?? [] + const campaignVariants = campaign?.variants?.options ?? [] const journeyVariables = nodeId ? getVariablesForNode(nodeId) : [] const handleVariableChange = (name: string, newValue: string) => { @@ -146,7 +150,12 @@ export const campaignStep: JourneyStepType = { value={value.campaign_id === NIL ? "" : value.campaign_id} displayValue={campaign?.name} onValueChange={(id) => - onChange({ ...value, campaign_id: (id || NIL) as UUID, data: {} }) + onChange({ + ...value, + campaign_id: (id || NIL) as UUID, + data: {}, + variant: undefined, + }) } placeholder={t("campaign.singular")} renderOption={(option) => option.name} @@ -167,6 +176,30 @@ export const campaignStep: JourneyStepType = { } /> + {isEnterprise && campaign && campaignVariants.length > 0 && ( +
+ +

+ {t( + "journey.campaign.variant_description", + "Which design this step sends.", + )} +

+ onChange({ ...value, variant })} + variables={journeyVariables} + emptyLabel={t( + "journey.campaign.variant_inherit", + "Whatever the campaign decides", + )} + /> +
+ )} + {campaign && variables.length > 0 && (
diff --git a/internal/http/controllers/v1/management/broadcasts_enterprise.go b/internal/http/controllers/v1/management/broadcasts_enterprise.go index 5e0f8e18b..2354fa687 100644 --- a/internal/http/controllers/v1/management/broadcasts_enterprise.go +++ b/internal/http/controllers/v1/management/broadcasts_enterprise.go @@ -87,6 +87,24 @@ func (srv *BroadcastsController) CreateBroadcast(w http.ResponseWriter, r *http. return } + // Pinning a broadcast to a variant the campaign never declared would send + // the whole list the default branding while the operator believes they + // picked a client, so refuse it outright rather than falling back. + var variant *management.VariantSelector + if body.Variant != nil { + if !variantsAvailable { + oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("template variants are not available in the open-source version"))) + return + } + + selector := management.VariantSelectorFromOAPI(*body.Variant) + if err := selector.Validate(campaign.Variants.Data); err != nil { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe(err.Error()))) + return + } + variant = &selector + } + list, err := srv.usrs.GetList(ctx, projectID, body.ListId) if errors.Is(err, sql.ErrNoRows) { logger.Info("list not found", zap.Stringer("list_id", body.ListId)) @@ -107,6 +125,7 @@ func (srv *BroadcastsController) CreateBroadcast(w http.ResponseWriter, r *http. ListName: list.Name, ListType: string(list.Type), ScheduledAt: body.ScheduledAt, + Variant: store.JSONB[*management.VariantSelector]{Data: variant}, }) if err != nil { logger.Error("failed to create broadcast", zap.Error(err)) diff --git a/internal/http/controllers/v1/management/campaigns.go b/internal/http/controllers/v1/management/campaigns.go index 8b4437e2d..ce018819c 100644 --- a/internal/http/controllers/v1/management/campaigns.go +++ b/internal/http/controllers/v1/management/campaigns.go @@ -3,7 +3,10 @@ package v1 import ( "database/sql" "errors" + "fmt" "net/http" + "sort" + "strings" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -117,7 +120,7 @@ func (srv *CampaignsController) CreateCampaign(w http.ResponseWriter, r *http.Re // TODO: create audit log - _, err = templates.CreateTemplate(ctx, project.ID, campaignID, string(body.Channel), project.Locale, nil) + _, err = templates.CreateTemplate(ctx, project.ID, campaignID, string(body.Channel), project.Locale, "", nil) if err != nil { logger.Error("failed to create template", zap.Error(err)) oapi.WriteProblem(w, err) @@ -251,6 +254,33 @@ func (srv *CampaignsController) UpdateCampaign(w http.ResponseWriter, r *http.Re updated.Variables = &store.JSONB[management.CampaignVariables]{Data: vars} } + if body.Variants != nil { + if !variantsAvailable { + oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("template variants are not available in the open-source version"))) + return + } + + variants, err := management.CampaignVariantsFromOAPI(*body.Variants) + if err != nil { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe(err.Error()))) + return + } + + // Templates are keyed by variant, so undeclaring a key - by removing it + // or by renaming it, which reaches the API as the same edit - would + // strand its templates: invisible in the console and unreachable by any + // send. Refuse while they exist rather than orphaning them. + if orphaned := undeclaredTemplateVariants(campaign.Templates, variants); len(orphaned) > 0 { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe(fmt.Sprintf( + "variant %s still has templates; delete them before removing or renaming the variant", + strings.Join(orphaned, ", "), + )))) + return + } + + updated.Variants = &store.JSONB[management.CampaignVariants]{Data: variants} + } + if body.SubscriptionId != nil { subscription, err := srv.mgmt.SubscriptionsStore.GetSubscription(ctx, projectID, *body.SubscriptionId) if errors.Is(err, sql.ErrNoRows) { @@ -392,12 +422,16 @@ func (srv *CampaignsController) DuplicateCampaign(w http.ResponseWriter, r *http campaigns := management.NewCampaignsStore(tx) templates := management.NewTemplatesStore(tx) + // The declared variants travel with the copy: the templates below carry + // their variant key, and a key the campaign no longer declares hides those + // templates from the console and drops their sends back to house branding. newCampaignID, err := campaigns.CreateCampaign(ctx, management.Campaign{ ProjectID: campaign.ProjectID, Name: "Copy of " + campaign.Name, Channel: campaign.Channel, SubscriptionID: campaign.SubscriptionID, Transactional: campaign.Transactional, + Variants: campaign.Variants, }) if err != nil { logger.Error("failed to create duplicated campaign", zap.Error(err)) @@ -476,3 +510,22 @@ func (srv *CampaignsController) GetCampaignUsers(w http.ResponseWriter, r *http. "offset": pagination.Offset, }) } + +// undeclaredTemplateVariants reports the variant keys that templates still +// carry but the supplied declaration no longer names, sorted for a stable +// error message. The default variant is always declared and never appears. +func undeclaredTemplateVariants(templates management.Templates, variants management.CampaignVariants) []string { + var orphaned []string + seen := make(map[string]bool) + + for _, template := range templates { + if template.Variant == "" || seen[template.Variant] || variants.Has(template.Variant) { + continue + } + seen[template.Variant] = true + orphaned = append(orphaned, template.Variant) + } + + sort.Strings(orphaned) + return orphaned +} diff --git a/internal/http/controllers/v1/management/campaigns_test.go b/internal/http/controllers/v1/management/campaigns_test.go index 07bb14e06..02c155fef 100644 --- a/internal/http/controllers/v1/management/campaigns_test.go +++ b/internal/http/controllers/v1/management/campaigns_test.go @@ -112,7 +112,7 @@ func TestListCampaigns(t *testing.T) { }) require.NoError(t, err) - _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) } @@ -234,7 +234,7 @@ func TestGetCampaign(t *testing.T) { }) require.NoError(t, err) - _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), @@ -323,7 +323,7 @@ func TestUpdateCampaign(t *testing.T) { }) require.NoError(t, err) - _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), @@ -419,7 +419,7 @@ func TestDeleteCampaign(t *testing.T) { }) require.NoError(t, err) - _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), @@ -485,7 +485,7 @@ func TestDuplicateCampaign(t *testing.T) { }) require.NoError(t, err) - _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), @@ -555,7 +555,7 @@ func TestGetCampaignUsers(t *testing.T) { }) require.NoError(t, err) - _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + _, err = templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), diff --git a/internal/http/controllers/v1/management/journeys.go b/internal/http/controllers/v1/management/journeys.go index 2b084d8cc..5d3651af8 100644 --- a/internal/http/controllers/v1/management/journeys.go +++ b/internal/http/controllers/v1/management/journeys.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "database/sql" stdjson "encoding/json" "errors" @@ -1051,12 +1052,20 @@ func (srv *JourneysController) PublishJourney(w http.ResponseWriter, r *http.Req return } - if err := validateEntranceSteps(draftSteps.OAPI()); err != nil { + steps := draftSteps.OAPI() + + if err := validateEntranceSteps(steps); err != nil { logger.Info("journey failed entrance validation", zap.Error(err)) oapi.WriteProblem(w, err) return } + if err := srv.validateCampaignStepVariants(ctx, projectID, steps); err != nil { + logger.Info("journey failed campaign variant validation", zap.Error(err)) + oapi.WriteProblem(w, err) + return + } + tx, err := srv.journeyDB.BeginTxx(ctx, nil) if err != nil { logger.Error("failed to begin transaction", zap.Error(err)) @@ -1185,3 +1194,48 @@ func validateEntranceSteps(steps oapi.JourneyStepMap) error { return nil } + +// validateCampaignStepVariants checks every campaign step's variant selector +// against the campaign it sends. Draft saves stay lenient, so this is the first +// point a mistyped static key can be refused; left unchecked it would resolve +// to nothing at send time and quietly deliver house branding to a client's +// recipients. An expression is only checked for being present, since what it +// resolves to is per-recipient. +func (srv *JourneysController) validateCampaignStepVariants(ctx context.Context, projectID uuid.UUID, steps oapi.JourneyStepMap) error { + campaigns := make(map[uuid.UUID]*management.Campaign) + + for id, step := range steps { + if step.Type != oapi.JourneyStepTypeCampaign { + continue + } + + var data oapi.CampaignStepData + if err := json.Unmarshal(step.Data, &data); err != nil { + return problem.ErrBadRequest(problem.Describe(fmt.Sprintf("campaign step %q: %v", id, err))) + } + + if data.Variant == nil { + continue + } + + campaign, ok := campaigns[data.CampaignId] + if !ok { + found, err := srv.mgmt.GetCampaign(ctx, projectID, data.CampaignId) + if errors.Is(err, sql.ErrNoRows) { + return problem.ErrBadRequest(problem.Describe(fmt.Sprintf("campaign step %q: campaign not found", id))) + } + if err != nil { + return err + } + campaign = found + campaigns[data.CampaignId] = found + } + + selector := management.VariantSelectorFromOAPI(*data.Variant) + if err := selector.Validate(campaign.Variants.Data); err != nil { + return problem.ErrBadRequest(problem.Describe(fmt.Sprintf("campaign step %q: %v", id, err))) + } + } + + return nil +} diff --git a/internal/http/controllers/v1/management/oapi/journeys.go b/internal/http/controllers/v1/management/oapi/journeys.go index a03ad036b..7abaf14d8 100644 --- a/internal/http/controllers/v1/management/oapi/journeys.go +++ b/internal/http/controllers/v1/management/oapi/journeys.go @@ -228,10 +228,13 @@ type DelayStepData struct { ExclusionDays *[]int `json:"exclusion_days,omitempty"` } -// CampaignStepData represents data for campaign step - send campaign +// CampaignStepData represents data for campaign step - send campaign. +// Variant overrides the campaign's own selector for this step; an expression is +// resolved against the journey context. type CampaignStepData struct { CampaignId uuid.UUID `json:"campaign_id" yaml:"campaign_id"` Data map[string]string `json:"data,omitempty" yaml:"data,omitempty"` + Variant *VariantSelector `json:"variant,omitempty" yaml:"variant,omitempty"` } // ActionStepData represents data for action step - execute WASM action diff --git a/internal/http/controllers/v1/management/oapi/resources.yml b/internal/http/controllers/v1/management/oapi/resources.yml index 969cd88ac..18d83e61c 100644 --- a/internal/http/controllers/v1/management/oapi/resources.yml +++ b/internal/http/controllers/v1/management/oapi/resources.yml @@ -7194,6 +7194,8 @@ components: description: Campaign to send example: "52f3f921-1343-48af-b795-87c0fd3b44aa" x-go-type: uuid.UUID + variant: + $ref: "#/components/schemas/VariantSelector" ActionStepData: type: object @@ -7617,6 +7619,8 @@ components: type: array items: $ref: "#/components/schemas/CampaignVariable" + variants: + $ref: "#/components/schemas/CampaignVariants" CampaignVariable: type: object @@ -7632,6 +7636,70 @@ components: description: Default value for the variable example: "there" + CampaignVariant: + type: object + required: + - key + properties: + key: + type: string + pattern: "^[a-z0-9][a-z0-9_-]*$" + description: >- + The value a send resolves against to pick this variant's templates. + Lowercase letters, digits, dashes and underscores, starting with a + letter or digit. The empty key is the default variant and is never + declared here. + example: "acme" + label: + type: string + description: Human readable name shown in the console + example: "Acme Corp" + + VariantSelector: + type: object + description: >- + Decides which template variant a send uses. The same shape appears on a + campaign, on a journey campaign step and on a broadcast; the most + specific layer wins. A static selector pins one variant for every + recipient and is rejected when the campaign does not declare its key. An + expression selector resolves a variant per recipient and can only be + judged at send time, so one that resolves to an unknown variant falls + back to the default variant. + required: + - type + properties: + type: + type: string + enum: [static, expression] + description: Whether the variant is pinned or resolved per recipient + example: "expression" + key: + type: string + description: >- + Variant key, used when type is static. An empty key pins the default + variant, which is how one send is forced back to house branding past + a campaign that resolves a client brand per recipient. That differs + from omitting the selector entirely, which defers to the campaign. + example: "acme" + expression: + type: string + description: Liquid expression. Required when type is expression. + example: "{{ user.data.tenant }}" + + CampaignVariants: + type: object + description: >- + A campaign's declared variants together with the rule that picks between + them when a send does not pick one itself. + properties: + selector: + $ref: "#/components/schemas/VariantSelector" + options: + type: array + description: The variants this campaign declares. The default variant is always available and is not listed here. + items: + $ref: "#/components/schemas/CampaignVariant" + CreateTemplate: type: object required: @@ -7641,6 +7709,12 @@ components: type: string example: "en" description: The locale/language code for the template + variant: + type: string + example: "acme" + description: >- + The variant this template belongs to. Empty selects the default + variant, which is the branding every campaign starts with. data: type: object nullable: true @@ -7738,6 +7812,8 @@ components: type: array items: $ref: "#/components/schemas/CampaignVariable" + variants: + $ref: "#/components/schemas/CampaignVariants" delivery: $ref: "#/components/schemas/Delivery" archived: @@ -8098,6 +8174,7 @@ components: - data - type - locale + - variant - created_at - updated_at properties: @@ -8132,6 +8209,10 @@ components: locale: type: string example: "en" + variant: + type: string + example: "acme" + description: The variant this template belongs to. Empty is the default variant. sender_identity_id: type: string format: uuid @@ -10498,6 +10579,8 @@ components: type: string format: date-time description: Optional scheduled send time. If provided, the broadcast is created in 'scheduled' state. + variant: + $ref: "#/components/schemas/VariantSelector" UpdateBroadcast: type: object @@ -10540,6 +10623,8 @@ components: list_type: type: string description: Snapshot of the list type at broadcast creation time + variant: + $ref: "#/components/schemas/VariantSelector" state: $ref: "#/components/schemas/BroadcastState" total: diff --git a/internal/http/controllers/v1/management/oapi/resources_gen.go b/internal/http/controllers/v1/management/oapi/resources_gen.go index 2393c69ef..13d55a58c 100644 --- a/internal/http/controllers/v1/management/oapi/resources_gen.go +++ b/internal/http/controllers/v1/management/oapi/resources_gen.go @@ -598,6 +598,24 @@ func (e UpdateUserScheduledRequestResume) Valid() bool { } } +// Defines values for VariantSelectorType. +const ( + Expression VariantSelectorType = "expression" + Static VariantSelectorType = "static" +) + +// Valid indicates whether the value is a known member of the VariantSelectorType enum. +func (e VariantSelectorType) Valid() bool { + switch e { + case Expression: + return true + case Static: + return true + default: + return false + } +} + // Defines values for ListProjectInvitesParamsStatus. const ( Accepted ListProjectInvitesParamsStatus = "accepted" @@ -953,6 +971,9 @@ type Broadcast struct { // Total Total number of users in the audience at send time Total int `json:"total"` UpdatedAt time.Time `json:"updated_at"` + + // Variant Decides which template variant a send uses. The same shape appears on a campaign, on a journey campaign step and on a broadcast; the most specific layer wins. A static selector pins one variant for every recipient and is rejected when the campaign does not declare its key. An expression selector resolves a variant per recipient and can only be judged at send time, so one that resolves to an unknown variant falls back to the default variant. + Variant *VariantSelector `json:"variant,omitempty"` } // BroadcastState Current state of the broadcast @@ -975,6 +996,9 @@ type Campaign struct { Transactional bool `json:"transactional"` UpdatedAt time.Time `json:"updated_at"` Variables *[]CampaignVariable `json:"variables,omitempty"` + + // Variants A campaign's declared variants together with the rule that picks between them when a send does not pick one itself. + Variants *CampaignVariants `json:"variants,omitempty"` } // CampaignUser defines model for CampaignUser. @@ -1000,6 +1024,24 @@ type CampaignVariable struct { Name string `json:"name"` } +// CampaignVariant defines model for CampaignVariant. +type CampaignVariant struct { + // Key The value a send resolves against to pick this variant's templates. Lowercase letters, digits, dashes and underscores, starting with a letter or digit. The empty key is the default variant and is never declared here. + Key string `json:"key"` + + // Label Human readable name shown in the console + Label *string `json:"label,omitempty"` +} + +// CampaignVariants A campaign's declared variants together with the rule that picks between them when a send does not pick one itself. +type CampaignVariants struct { + // Options The variants this campaign declares. The default variant is always available and is not listed here. + Options *[]CampaignVariant `json:"options,omitempty"` + + // Selector Decides which template variant a send uses. The same shape appears on a campaign, on a journey campaign step and on a broadcast; the most specific layer wins. A static selector pins one variant for every recipient and is rejected when the campaign does not declare its key. An expression selector resolves a variant per recipient and can only be judged at send time, so one that resolves to an unknown variant falls back to the default variant. + Selector *VariantSelector `json:"selector,omitempty"` +} + // ChangePasswordRequest defines model for ChangePasswordRequest. type ChangePasswordRequest struct { // CurrentPassword The password currently on the account @@ -1074,6 +1116,9 @@ type CreateBroadcast struct { // ScheduledAt Optional scheduled send time. If provided, the broadcast is created in 'scheduled' state. ScheduledAt *time.Time `json:"scheduled_at,omitempty"` + + // Variant Decides which template variant a send uses. The same shape appears on a campaign, on a journey campaign step and on a broadcast; the most specific layer wins. A static selector pins one variant for every recipient and is rejected when the campaign does not declare its key. An expression selector resolves a variant per recipient and can only be judged at send time, so one that resolves to an unknown variant falls back to the default variant. + Variant *VariantSelector `json:"variant,omitempty"` } // CreateCampaign defines model for CreateCampaign. @@ -1228,6 +1273,9 @@ type CreateTemplate struct { // SenderIdentityId The ID of the sender identity to use for this template SenderIdentityId *openapi_types.UUID `json:"sender_identity_id,omitempty"` + + // Variant The variant this template belongs to. Empty selects the default variant, which is the branding every campaign starts with. + Variant *string `json:"variant,omitempty"` } // CreateUserDevice defines model for CreateUserDevice. @@ -2098,6 +2146,9 @@ type Template struct { // Type Communication channel type Type Channel `json:"type"` UpdatedAt time.Time `json:"updated_at"` + + // Variant The variant this template belongs to. Empty is the default variant. + Variant string `json:"variant"` } // TestActionFunctionRequest defines model for TestActionFunctionRequest. @@ -2191,6 +2242,9 @@ type UpdateCampaign struct { SubscriptionId *openapi_types.UUID `json:"subscription_id,omitempty"` Transactional *bool `json:"transactional,omitempty"` Variables *[]CampaignVariable `json:"variables,omitempty"` + + // Variants A campaign's declared variants together with the rule that picks between them when a send does not pick one itself. + Variants *CampaignVariants `json:"variants,omitempty"` } // UpdateJourney defines model for UpdateJourney. @@ -2539,6 +2593,21 @@ type UserSubscriptionList struct { Total int `json:"total"` } +// VariantSelector Decides which template variant a send uses. The same shape appears on a campaign, on a journey campaign step and on a broadcast; the most specific layer wins. A static selector pins one variant for every recipient and is rejected when the campaign does not declare its key. An expression selector resolves a variant per recipient and can only be judged at send time, so one that resolves to an unknown variant falls back to the default variant. +type VariantSelector struct { + // Expression Liquid expression. Required when type is expression. + Expression *string `json:"expression,omitempty"` + + // Key Variant key, used when type is static. An empty key pins the default variant, which is how one send is forced back to house branding past a campaign that resolves a client brand per recipient. That differs from omitting the selector entirely, which defers to the campaign. + Key *string `json:"key,omitempty"` + + // Type Whether the variant is pinned or resolved per recipient + Type VariantSelectorType `json:"type"` +} + +// VariantSelectorType Whether the variant is pinned or resolved per recipient +type VariantSelectorType string + // IncludeDeleted defines model for IncludeDeleted. type IncludeDeleted = bool diff --git a/internal/http/controllers/v1/management/template_variants.go b/internal/http/controllers/v1/management/template_variants.go new file mode 100644 index 000000000..db8196a43 --- /dev/null +++ b/internal/http/controllers/v1/management/template_variants.go @@ -0,0 +1,12 @@ +//go:build !enterprise + +package v1 + +// variantsAvailable reports whether a project may configure template variants. +// +// Variants are an enterprise capability, so open-source builds refuse to +// declare them or to create a template for one. The send path deliberately has +// no equivalent guard: with no variant configurable here, every template is the +// default variant and selection resolves to it on its own, which keeps one code +// path through the render hot loop for both builds. +const variantsAvailable = false diff --git a/internal/http/controllers/v1/management/template_variants_enterprise.go b/internal/http/controllers/v1/management/template_variants_enterprise.go new file mode 100644 index 000000000..23dc0f0ca --- /dev/null +++ b/internal/http/controllers/v1/management/template_variants_enterprise.go @@ -0,0 +1,5 @@ +//go:build enterprise + +package v1 + +const variantsAvailable = true diff --git a/internal/http/controllers/v1/management/template_variants_enterprise_test.go b/internal/http/controllers/v1/management/template_variants_enterprise_test.go new file mode 100644 index 000000000..65058c043 --- /dev/null +++ b/internal/http/controllers/v1/management/template_variants_enterprise_test.go @@ -0,0 +1,484 @@ +//go:build enterprise + +package v1 + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/lunogram/platform/internal/http/controllers/v1/management/oapi" + "github.com/lunogram/platform/internal/ptr" + "github.com/lunogram/platform/internal/pubsub" + "github.com/lunogram/platform/internal/pubsub/consumer" + "github.com/lunogram/platform/internal/rbac" + "github.com/lunogram/platform/internal/store" + "github.com/lunogram/platform/internal/store/management" + teststore "github.com/lunogram/platform/internal/store/test" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// A template may only be created for a variant the campaign declares. Without +// that check a typo produces a template no send can ever resolve to and the +// console has no name to list it under. +func TestCreateTemplateRejectsUndeclaredVariant(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + + projects := management.NewProjectsStore(mgmt) + projectID, err := projects.CreateProject(ctx, DefaultProject) + require.NoError(t, err) + + campaigns := management.NewCampaignsStore(mgmt) + campaignID, err := campaigns.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, + Name: "Test Campaign", + Channel: "email", + }) + require.NoError(t, err) + + err = campaigns.UpdateCampaign(ctx, projectID, campaignID, management.CampaignUpdate{ + Variants: &store.JSONB[management.CampaignVariants]{ + Data: management.CampaignVariants{ + Options: []management.CampaignVariant{{Key: "acme", Label: "Acme Corp"}}, + }, + }, + }) + require.NoError(t, err) + + actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), + rbac.WithOrganizationID(uuid.New()), + rbac.WithProjectID(projectID), + ) + engine, actorCtx := rbac.TestSetup(t, ctx, actor, "owner", "admin") + + controller := NewTemplatesController(logger, mgmt, mgmt, pubsub.NewEmailRenderer(pubsub.NewNoopCaller()), nil, engine, nil, "") + + tests := map[string]struct { + variant *string + code int + }{ + "declared variant": {variant: ptr.To("acme"), code: 201}, + "undeclared variant": {variant: ptr.To("globex"), code: 400}, + "default variant": {variant: nil, code: 201}, + "empty variant": {variant: ptr.To(""), code: 201}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(oapi.CreateTemplate{Locale: "en", Variant: test.variant}) + require.NoError(t, err) + + res := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/v1/campaigns/"+campaignID.String()+"/templates", bytes.NewReader(body)) + req = req.WithContext(actorCtx) + controller.CreateTemplate(res, req, projectID, campaignID) + + require.Equal(t, test.code, res.Code, res.Body.String()) + }) + } +} + +// Variants and their selector round-trip through the campaign update path, and +// an empty selector clears it rather than being ignored as a no-op. +func TestUpdateCampaignVariants(t *testing.T) { + t.Parallel() + + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + + projects := management.NewProjectsStore(mgmt) + projectID, err := projects.CreateProject(ctx, DefaultProject) + require.NoError(t, err) + + campaigns := management.NewCampaignsStore(mgmt) + campaignID, err := campaigns.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, + Name: "Test Campaign", + Channel: "email", + }) + require.NoError(t, err) + + campaign, err := campaigns.GetCampaign(ctx, projectID, campaignID) + require.NoError(t, err) + require.Empty(t, campaign.Variants.Data.Options) + require.Nil(t, campaign.Variants.Data.Selector) + + selector := management.VariantSelector{ + Type: management.VariantSelectorExpression, + Expression: "{{ user.data.tenant }}", + } + err = campaigns.UpdateCampaign(ctx, projectID, campaignID, management.CampaignUpdate{ + Variants: &store.JSONB[management.CampaignVariants]{ + Data: management.CampaignVariants{ + Selector: &selector, + Options: []management.CampaignVariant{{Key: "acme", Label: "Acme Corp"}}, + }, + }, + }) + require.NoError(t, err) + + campaign, err = campaigns.GetCampaign(ctx, projectID, campaignID) + require.NoError(t, err) + require.Equal(t, []management.CampaignVariant{{Key: "acme", Label: "Acme Corp"}}, campaign.Variants.Data.Options) + require.Equal(t, selector, *campaign.Variants.Data.Selector) + + // An update that does not touch variants leaves the whole object alone. + err = campaigns.UpdateCampaign(ctx, projectID, campaignID, management.CampaignUpdate{ + Name: ptr.To("Renamed"), + }) + require.NoError(t, err) + + campaign, err = campaigns.GetCampaign(ctx, projectID, campaignID) + require.NoError(t, err) + require.Len(t, campaign.Variants.Data.Options, 1) + require.NotNil(t, campaign.Variants.Data.Selector) + + // Sending the object without a selector clears it. + err = campaigns.UpdateCampaign(ctx, projectID, campaignID, management.CampaignUpdate{ + Variants: &store.JSONB[management.CampaignVariants]{ + Data: management.CampaignVariants{ + Options: []management.CampaignVariant{{Key: "acme", Label: "Acme Corp"}}, + }, + }, + }) + require.NoError(t, err) + + campaign, err = campaigns.GetCampaign(ctx, projectID, campaignID) + require.NoError(t, err) + require.Nil(t, campaign.Variants.Data.Selector) + require.Len(t, campaign.Variants.Data.Options, 1) +} + +// A request body is rejected before it reaches the database when it declares a +// duplicate or empty key, or a selector pointing at a variant that is not in +// the same body. +func TestCampaignVariantsFromOAPI(t *testing.T) { + t.Parallel() + + options := []oapi.CampaignVariant{{Key: "acme"}} + + tests := map[string]struct { + body oapi.CampaignVariants + wantErr bool + }{ + "options only": { + body: oapi.CampaignVariants{Options: &options}, + }, + "selector naming a declared variant": { + body: oapi.CampaignVariants{ + Options: &options, + Selector: &oapi.VariantSelector{ + Type: "static", + Key: ptr.To("acme"), + }, + }, + }, + "selector naming an undeclared variant": { + body: oapi.CampaignVariants{ + Options: &options, + Selector: &oapi.VariantSelector{ + Type: "static", + Key: ptr.To("globex"), + }, + }, + wantErr: true, + }, + "empty key": { + body: oapi.CampaignVariants{Options: &[]oapi.CampaignVariant{{Key: " "}}}, + wantErr: true, + }, + "duplicate key": { + body: oapi.CampaignVariants{Options: &[]oapi.CampaignVariant{{Key: "acme"}, {Key: "acme"}}}, + wantErr: true, + }, + // The console reserves values starting with an underscore as its + // "default" and "none" dropdown sentinels. A saved key that collides + // with one is unselectable once it is stored. + "key using a console sentinel": { + body: oapi.CampaignVariants{Options: &[]oapi.CampaignVariant{{Key: "__default__"}}}, + wantErr: true, + }, + "key with uppercase and spaces": { + body: oapi.CampaignVariants{Options: &[]oapi.CampaignVariant{{Key: "Acme Corp"}}}, + wantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := management.CampaignVariantsFromOAPI(test.body) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +// Duplicating a campaign has to carry variant templates across, or the copy +// silently loses every white-labelled edition. +func TestDuplicateTemplateCarriesVariant(t *testing.T) { + t.Parallel() + + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + + projects := management.NewProjectsStore(mgmt) + projectID, err := projects.CreateProject(ctx, DefaultProject) + require.NoError(t, err) + + campaigns := management.NewCampaignsStore(mgmt) + templates := management.NewTemplatesStore(mgmt) + + sourceID, err := campaigns.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, Name: "Source", Channel: "email", + }) + require.NoError(t, err) + + targetID, err := campaigns.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, Name: "Target", Channel: "email", + }) + require.NoError(t, err) + + templateID, err := templates.CreateTemplate(ctx, projectID, sourceID, "email", "en", "acme", nil) + require.NoError(t, err) + + require.NoError(t, templates.DuplicateTemplate(ctx, projectID, templateID, targetID)) + + copied, err := templates.ListTemplates(ctx, projectID, targetID) + require.NoError(t, err) + require.Len(t, copied, 1) + require.Equal(t, "acme", copied[0].Variant) +} + +// A broadcast may pin one variant or carry its own expression. A static key is +// checked against the campaign's declared variants when the broadcast is +// created: falling back at send time would quietly put the whole list under +// house branding while the operator believes they picked a client. +func TestCreateBroadcastVariantSelector(t *testing.T) { + t.Parallel() + env := newBroadcastTestEnv(t) + + err := env.mgmtState.CampaignsStore.UpdateCampaign(t.Context(), env.projectID, env.campaignID, management.CampaignUpdate{ + Variants: &store.JSONB[management.CampaignVariants]{ + Data: management.CampaignVariants{ + Options: []management.CampaignVariant{{Key: "acme", Label: "Acme Corp"}}, + }, + }, + }) + require.NoError(t, err) + + tests := map[string]struct { + selector *oapi.VariantSelector + code int + }{ + "no selector defers to the campaign": { + code: 201, + }, + "static key the campaign declares": { + selector: &oapi.VariantSelector{Type: "static", Key: ptr.To("acme")}, + code: 201, + }, + "static key the campaign does not declare": { + selector: &oapi.VariantSelector{Type: "static", Key: ptr.To("globex")}, + code: 400, + }, + "static without a key pins the default variant": { + selector: &oapi.VariantSelector{Type: "static"}, + code: 201, + }, + "static empty key pins the default variant": { + selector: &oapi.VariantSelector{Type: "static", Key: ptr.To("")}, + code: 201, + }, + "expression is accepted unvalidated": { + selector: &oapi.VariantSelector{Type: "expression", Expression: ptr.To("{{ user.data.tenant }}")}, + code: 201, + }, + "expression without an expression": { + selector: &oapi.VariantSelector{Type: "expression"}, + code: 400, + }, + "unknown selector type": { + selector: &oapi.VariantSelector{Type: "whatever", Key: ptr.To("acme")}, + code: 400, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(oapi.CreateBroadcastJSONRequestBody{ + CampaignId: env.campaignID, + ListId: env.listID, + Variant: test.selector, + }) + require.NoError(t, err) + + res := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/v1/broadcasts", bytes.NewReader(body)) + req = env.actorCtx(req) + env.controller.CreateBroadcast(res, req, env.projectID) + + require.Equal(t, test.code, res.Code, res.Body.String()) + if test.code != 201 { + return + } + + var broadcast oapi.Broadcast + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &broadcast)) + + if test.selector == nil { + require.Nil(t, broadcast.Variant) + return + } + require.NotNil(t, broadcast.Variant) + require.Equal(t, test.selector.Type, broadcast.Variant.Type) + }) + } +} + +// Undeclaring a variant that still has templates strands those rows: hidden +// from the console switcher and unreachable by any send. A rename reaches the +// API as the same edit, so one guard covers both. +func TestUndeclaredTemplateVariants(t *testing.T) { + t.Parallel() + + templates := management.Templates{ + {Variant: ""}, + {Variant: "acme"}, + {Variant: "acme"}, + {Variant: "globex"}, + } + + declared := management.CampaignVariants{Options: []management.CampaignVariant{{Key: "acme"}}} + require.Equal(t, []string{"globex"}, undeclaredTemplateVariants(templates, declared)) + + both := management.CampaignVariants{Options: []management.CampaignVariant{{Key: "acme"}, {Key: "globex"}}} + require.Empty(t, undeclaredTemplateVariants(templates, both)) + + // Renaming acme to acme-corp undeclares acme while its templates remain. + renamed := management.CampaignVariants{Options: []management.CampaignVariant{{Key: "acme-corp"}, {Key: "globex"}}} + require.Equal(t, []string{"acme"}, undeclaredTemplateVariants(templates, renamed)) +} + +// Duplicating a campaign has to carry its variant declaration across as well as +// the template rows: templates keyed by a variant the copy no longer declares +// are invisible in the console and fall back to house branding on every send. +func TestDuplicateCampaignCarriesVariants(t *testing.T) { + t.Parallel() + + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + + projects := management.NewProjectsStore(mgmt) + projectID, err := projects.CreateProject(ctx, DefaultProject) + require.NoError(t, err) + + campaigns := management.NewCampaignsStore(mgmt) + + variants := management.CampaignVariants{ + Selector: &management.VariantSelector{Type: management.VariantSelectorStatic, Key: "acme"}, + Options: []management.CampaignVariant{{Key: "acme", Label: "Acme Corp"}}, + } + + copyID, err := campaigns.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, + Name: "Copy of Source", + Channel: "email", + Variants: store.JSONB[management.CampaignVariants]{Data: variants}, + }) + require.NoError(t, err) + + copied, err := campaigns.GetCampaign(ctx, projectID, copyID) + require.NoError(t, err) + require.Equal(t, variants.Options, copied.Variants.Data.Options) + require.Equal(t, *variants.Selector, *copied.Variants.Data.Selector) +} + +// A journey step's variant selector is only checked when the journey is +// published: draft saves stay lenient, so without this a mistyped static key +// reaches the send path and quietly delivers house branding to a client's +// recipients. +func TestValidateCampaignStepVariants(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + ctx := t.Context() + mgmt, usrs, jrny := teststore.RunPostgreSQL(t) + + projects := management.NewProjectsStore(mgmt) + projectID, err := projects.CreateProject(ctx, DefaultProject) + require.NoError(t, err) + + mgmtState := management.NewState(mgmt) + campaignID, err := mgmtState.CampaignsStore.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, + Name: "Test Campaign", + Channel: "email", + Variants: store.JSONB[management.CampaignVariants]{ + Data: management.CampaignVariants{Options: []management.CampaignVariant{{Key: "acme"}}}, + }, + }) + require.NoError(t, err) + + actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), + rbac.WithOrganizationID(uuid.New()), + rbac.WithProjectID(projectID), + ) + engine, _ := rbac.TestSetup(t, ctx, actor, "owner", "admin") + journeys := NewJourneysController(logger, jrny, usrs, mgmtState, nil, nil, engine, consumer.Namespace("")) + + campaignStep := func(t *testing.T, selector *oapi.VariantSelector) oapi.JourneyStepMap { + t.Helper() + raw, err := json.Marshal(oapi.CampaignStepData{CampaignId: campaignID, Variant: selector}) + require.NoError(t, err) + return oapi.JourneyStepMap{ + "step": oapi.JourneyStep{Type: oapi.JourneyStepTypeCampaign, Data: raw}, + } + } + + tests := map[string]struct { + selector *oapi.VariantSelector + wantErr bool + }{ + "no selector defers to the campaign": {selector: nil}, + "declared static key": { + selector: &oapi.VariantSelector{Type: "static", Key: ptr.To("acme")}, + }, + "empty static key pins the default variant": { + selector: &oapi.VariantSelector{Type: "static", Key: ptr.To("")}, + }, + "undeclared static key": { + selector: &oapi.VariantSelector{Type: "static", Key: ptr.To("globex")}, + wantErr: true, + }, + "expression": { + selector: &oapi.VariantSelector{Type: "expression", Expression: ptr.To("{{ user.data.tenant }}")}, + }, + "empty expression": { + selector: &oapi.VariantSelector{Type: "expression", Expression: ptr.To(" ")}, + wantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + err := journeys.validateCampaignStepVariants(ctx, projectID, campaignStep(t, test.selector)) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/internal/http/controllers/v1/management/template_variants_test.go b/internal/http/controllers/v1/management/template_variants_test.go new file mode 100644 index 000000000..bba515b28 --- /dev/null +++ b/internal/http/controllers/v1/management/template_variants_test.go @@ -0,0 +1,71 @@ +//go:build !enterprise + +package v1 + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/lunogram/platform/internal/http/controllers/v1/management/oapi" + "github.com/lunogram/platform/internal/ptr" + "github.com/lunogram/platform/internal/pubsub" + "github.com/lunogram/platform/internal/rbac" + "github.com/lunogram/platform/internal/store/management" + teststore "github.com/lunogram/platform/internal/store/test" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// Variants are an enterprise capability. An open-source build must refuse to +// create one rather than accept a template no build of the console can manage. +func TestCreateTemplateRejectsVariantInOSS(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + ctx := t.Context() + mgmt, _, _ := teststore.RunPostgreSQL(t) + + projects := management.NewProjectsStore(mgmt) + projectID, err := projects.CreateProject(ctx, DefaultProject) + require.NoError(t, err) + + campaigns := management.NewCampaignsStore(mgmt) + campaignID, err := campaigns.CreateCampaign(ctx, management.Campaign{ + ProjectID: projectID, + Name: "Test Campaign", + Channel: "email", + }) + require.NoError(t, err) + + actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), + rbac.WithOrganizationID(uuid.New()), + rbac.WithProjectID(projectID), + ) + engine, actorCtx := rbac.TestSetup(t, ctx, actor, "owner", "admin") + + controller := NewTemplatesController(logger, mgmt, mgmt, pubsub.NewEmailRenderer(pubsub.NewNoopCaller()), nil, engine, nil, "") + + body, err := json.Marshal(oapi.CreateTemplate{Locale: "en", Variant: ptr.To("acme")}) + require.NoError(t, err) + + res := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/v1/campaigns/"+campaignID.String()+"/templates", bytes.NewReader(body)) + req = req.WithContext(actorCtx) + controller.CreateTemplate(res, req, projectID, campaignID) + + require.Equal(t, 404, res.Code, res.Body.String()) + + // The default variant is not gated: every open-source template is one. + body, err = json.Marshal(oapi.CreateTemplate{Locale: "en"}) + require.NoError(t, err) + + res = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/v1/campaigns/"+campaignID.String()+"/templates", bytes.NewReader(body)) + req = req.WithContext(actorCtx) + controller.CreateTemplate(res, req, projectID, campaignID) + + require.Equal(t, 201, res.Code, res.Body.String()) +} diff --git a/internal/http/controllers/v1/management/templates.go b/internal/http/controllers/v1/management/templates.go index 335e247aa..91289a670 100644 --- a/internal/http/controllers/v1/management/templates.go +++ b/internal/http/controllers/v1/management/templates.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "net/http" + "strings" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -151,7 +152,25 @@ func (srv *TemplatesController) CreateTemplate(w http.ResponseWriter, r *http.Re senderIdentityID = &id } - templateID, err := srv.store.TemplatesStore.CreateTemplate(ctx, projectID, campaignID, campaign.Channel, body.Locale, senderIdentityID) + variant := "" + if body.Variant != nil { + variant = strings.TrimSpace(*body.Variant) + } + + if variant != "" && !variantsAvailable { + oapi.WriteProblem(w, problem.ErrNotFound(problem.Describe("template variants are not available in the open-source version"))) + return + } + + // A template for a variant the campaign never declared is unreachable: no + // send resolves to it, and the console has no name to show it under. Reject + // it here rather than letting it accumulate as a template nobody can find. + if !campaign.Variants.Data.Has(variant) { + oapi.WriteProblem(w, problem.ErrBadRequest(problem.Describe("campaign does not declare variant "+variant))) + return + } + + templateID, err := srv.store.TemplatesStore.CreateTemplate(ctx, projectID, campaignID, campaign.Channel, body.Locale, variant, senderIdentityID) if err != nil { logger.Error("failed to create template", zap.Error(err)) oapi.WriteProblem(w, err) diff --git a/internal/http/controllers/v1/management/templates_test.go b/internal/http/controllers/v1/management/templates_test.go index 8930460be..0f68b4753 100644 --- a/internal/http/controllers/v1/management/templates_test.go +++ b/internal/http/controllers/v1/management/templates_test.go @@ -40,7 +40,7 @@ func TestGetTemplate(t *testing.T) { }) require.NoError(t, err) - templateID, err := templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + templateID, err := templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), @@ -168,7 +168,7 @@ func TestUpdateTemplate(t *testing.T) { }) require.NoError(t, err) - templateID, err := templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + templateID, err := templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), @@ -238,7 +238,7 @@ func TestDeleteTemplate(t *testing.T) { }) require.NoError(t, err) - templateID, err := templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", nil) + templateID, err := templates.CreateTemplate(ctx, projectID, campaignID, "email", "en", "", nil) require.NoError(t, err) actor := rbac.NewActor(rbac.ActorAdmin, uuid.New().String(), diff --git a/internal/journeys/campaign.go b/internal/journeys/campaign.go index 874890e63..26e8ea182 100644 --- a/internal/journeys/campaign.go +++ b/internal/journeys/campaign.go @@ -7,6 +7,7 @@ import ( "github.com/lunogram/platform/internal/pubsub/schemas" "github.com/lunogram/platform/internal/render" "github.com/lunogram/platform/internal/store/journey" + "github.com/lunogram/platform/internal/store/management" ) func HandleCampaign(ctx HandlerContext, step journey.JourneyVersionStep, state journey.JourneyUserState) (journey.JourneyUserState, journey.JourneyVersionStepChildren, error) { @@ -30,6 +31,30 @@ func HandleCampaign(ctx HandlerContext, step journey.JourneyVersionStep, state j } } + // A step that names a variant decides the branding for this send. An + // expression is resolved here rather than at render time because it reads + // the journey context - entrance data, earlier step state - which no longer + // exists once the send is rendered; the event therefore always carries a + // static selector. Left unset, the campaign's own selector applies. + // + // An empty result is still a decision: the step asked for the default + // variant, or its expression matched nothing. Either way it must reach the + // send as a static selector pinning the default, because falling through to + // the campaign selector would re-brand a message the step deliberately kept + // on the house design. + var variant *management.VariantSelector + if config.Variant != nil { + selector := management.VariantSelectorFromOAPI(*config.Variant) + resolved, err := selector.Resolve(ctx.Data) + if err != nil { + return state, nil, fmt.Errorf("failed to resolve campaign variant: %w", err) + } + variant = &management.VariantSelector{ + Type: management.VariantSelectorStatic, + Key: resolved, + } + } + msg := schemas.SendCampaign{ ProjectID: ctx.ProjectID, UserID: ctx.UserID, @@ -40,6 +65,7 @@ func HandleCampaign(ctx HandlerContext, step journey.JourneyVersionStep, state j JourneyStepID: &step.ExternalID, }, Variables: data, + Variant: variant, } err = ctx.Publisher.Publish(ctx, schemas.Subject(schemas.CampaignsSend(ctx.ProjectID, config.CampaignId)), msg) diff --git a/internal/journeys/campaign_test.go b/internal/journeys/campaign_test.go new file mode 100644 index 000000000..5466a5741 --- /dev/null +++ b/internal/journeys/campaign_test.go @@ -0,0 +1,89 @@ +package journeys + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/lunogram/platform/internal/http/controllers/v1/management/oapi" + "github.com/lunogram/platform/internal/ptr" + "github.com/lunogram/platform/internal/pubsub/schemas" + "github.com/lunogram/platform/internal/store/journey" + "github.com/lunogram/platform/internal/store/management" + "github.com/stretchr/testify/require" +) + +// A campaign step resolves its variant against the journey context and hands +// the send a static selector, because the context it reads no longer exists at +// render time. An empty result is still a decision - the step asked for house +// branding, or its expression matched nothing - and has to reach the send as a +// pinned default rather than falling through to the campaign's own selector. +func TestHandleCampaignVariant(t *testing.T) { + t.Parallel() + + campaignID := uuid.New() + + tests := map[string]struct { + variant *oapi.VariantSelector + data map[string]any + want *management.VariantSelector + }{ + "no selector defers to the campaign": { + variant: nil, + want: nil, + }, + "static key is pinned as written": { + variant: &oapi.VariantSelector{Type: "static", Key: ptr.To("acme")}, + want: &management.VariantSelector{Type: management.VariantSelectorStatic, Key: "acme"}, + }, + "empty static key pins the default variant": { + variant: &oapi.VariantSelector{Type: "static", Key: ptr.To("")}, + want: &management.VariantSelector{Type: management.VariantSelectorStatic, Key: ""}, + }, + "expression resolves against the journey context": { + variant: &oapi.VariantSelector{Type: "expression", Expression: ptr.To("{{ user.data.tenant }}")}, + data: map[string]any{"user": map[string]any{"data": map[string]any{"tenant": "acme"}}}, + want: &management.VariantSelector{Type: management.VariantSelectorStatic, Key: "acme"}, + }, + "expression matching nothing still pins the default": { + variant: &oapi.VariantSelector{Type: "expression", Expression: ptr.To("{{ user.data.tenant }}")}, + data: map[string]any{"user": map[string]any{"data": map[string]any{}}}, + want: &management.VariantSelector{Type: management.VariantSelectorStatic, Key: ""}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(oapi.CampaignStepData{ + CampaignId: campaignID, + Variant: test.variant, + }) + require.NoError(t, err) + + pub := &mockPublisher{} + hctx := HandlerContext{ + Context: context.Background(), + Publisher: pub, + ProjectID: uuid.New(), + UserID: uuid.New(), + Data: test.data, + } + + _, _, err = HandleCampaign(hctx, journey.JourneyVersionStep{Data: data}, journey.JourneyUserState{}) + require.NoError(t, err) + require.Len(t, pub.publishedEvents, 1) + + msg, ok := pub.publishedEvents[0].data.(schemas.SendCampaign) + require.True(t, ok) + + if test.want == nil { + require.Nil(t, msg.Variant) + return + } + require.Equal(t, *test.want, *msg.Variant) + }) + } +} diff --git a/internal/node/metrics/metrics.go b/internal/node/metrics/metrics.go index 099372036..3de2f6cb9 100644 --- a/internal/node/metrics/metrics.go +++ b/internal/node/metrics/metrics.go @@ -89,6 +89,20 @@ var JourneyExperimentAssignmentsTotal = promauto.NewCounterVec(prometheus.Counte Help: "Total experiment/A-B test branch assignments", }, []string{"project_id", "branch"}) +// ============================================================================ +// Campaign Sends +// ============================================================================ + +// CampaignVariantSelectionsTotal counts template variant selections by +// outcome: "matched" when the requested variant had a template, "fallback" +// when it did not and the send used another variant's template instead. A +// fallback still delivers a message, so this counter is the only signal that a +// white-label template is missing. +var CampaignVariantSelectionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "lunogram_campaign_variant_selections_total", + Help: "Total campaign template variant selections by outcome", +}, []string{"project_id", "outcome"}) + // ============================================================================ // Reconciliation (Leader Scheduler) // ============================================================================ diff --git a/internal/pubsub/consumer/broadcasts_enterprise.go b/internal/pubsub/consumer/broadcasts_enterprise.go index d68e0d222..f67870010 100644 --- a/internal/pubsub/consumer/broadcasts_enterprise.go +++ b/internal/pubsub/consumer/broadcasts_enterprise.go @@ -111,6 +111,10 @@ func BroadcastBatchHandler(logger *zap.Logger, mgmt *management.State, usrs *sub UserID: userID, CampaignID: broadcast.CampaignID, BroadcastID: &event.BroadcastID, + // Passed through unresolved: an expression selector here has + // to run once per recipient, and only the render step has the + // context to do that. Nil defers to the campaign's selector. + Variant: broadcast.Variant.Data, } // Deterministic message ID so that if NATS redelivers this batch diff --git a/internal/pubsub/consumer/campaigns.go b/internal/pubsub/consumer/campaigns.go index 8c782f8c1..9b55add78 100644 --- a/internal/pubsub/consumer/campaigns.go +++ b/internal/pubsub/consumer/campaigns.go @@ -130,6 +130,10 @@ func createCampaignInboxMessageAndPublish(ctx context.Context, db *sqlx.DB, pub "template_id": item.TemplateID.String(), "campaign_id": event.CampaignID.String(), } + // Recorded unconditionally, empty string included: variant selection falls + // back silently by design, so an audit needs to see that a send went out + // under the default variant rather than infer it from a missing field. + provenance["variant"] = item.Variant if event.BroadcastID != nil { provenance["broadcast_id"] = event.BroadcastID.String() } diff --git a/internal/pubsub/consumer/campaigns_render.go b/internal/pubsub/consumer/campaigns_render.go index 81b2da3e8..cc9fd2799 100644 --- a/internal/pubsub/consumer/campaigns_render.go +++ b/internal/pubsub/consumer/campaigns_render.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lunogram/platform/internal/node/metrics" "github.com/lunogram/platform/internal/providers/channels" "github.com/lunogram/platform/internal/pubsub" "github.com/lunogram/platform/internal/pubsub/schemas" @@ -56,31 +57,98 @@ func userToMap(user *subjects.User) map[string]any { return m } -// selectTemplate picks the best template for a user based on locale. -// Priority: user's locale → project's default locale → first template. -func selectTemplate(templates management.Templates, user *subjects.User, project *management.Project) management.Template { - if len(templates) == 1 { - return templates[0] +// resolveVariant works out which template variant a send must use. +// +// A selector on the event wins - a journey step or a broadcast has decided for +// this send in particular. Otherwise the campaign's own selector runs, against +// the same context the templates render against, so a project can drive +// branding off recipient data such as "{{ user.data.tenant }}" without +// configuring anything per send. Neither present means the default variant. +// +// A selector that fails to resolve also yields the default variant. Expressions +// are customer-authored and a broken one must not take a campaign down; the +// fallback is counted where the template is selected. +func resolveVariant(logger *zap.Logger, campaign *management.Campaign, event schemas.SendCampaign, data map[string]any) string { + selector := event.Variant + if selector == nil { + selector = campaign.Variants.Data.Selector + } + + if selector == nil { + return "" + } + + resolved, err := selector.Resolve(data) + if err != nil { + logger.Warn("failed to resolve variant selector, using default variant", + zap.Error(err), + zap.String("selector_type", string(selector.Type))) + return "" + } + + return resolved +} + +// selectTemplate picks the template for a send, narrowing by variant before +// applying the locale rules. +// +// Locale priority within a variant is unchanged: user's locale → project's +// default locale → first template. A variant carrying no template for this +// campaign falls back to the default variant rather than failing, because a +// missing white-label template must not stop a message going out; callers see +// that happened by comparing the returned template's Variant against the one +// they asked for. +func selectTemplate(templates management.Templates, variant string, user *subjects.User, project *management.Project) (management.Template, error) { + if len(templates) == 0 { + return management.Template{}, Permanentf("campaign has no templates") + } + + candidates := templatesForVariant(templates, variant) + if len(candidates) == 0 { + candidates = templatesForVariant(templates, "") + } + + // Neither the requested variant nor the default has a template - the + // default one can be deleted, so this is reachable. Every remaining + // template belongs to some other variant, and sending one would put another + // client's wording and sending domain in front of this recipient. Refuse: + // crossing a variant boundary is worse than not sending. + if len(candidates) == 0 { + return management.Template{}, Permanentf("campaign has no template for variant %q and none for the default variant", variant) + } + + if len(candidates) == 1 { + return candidates[0], nil } - byLocale := make(map[string]management.Template, len(templates)) - for _, t := range templates { + byLocale := make(map[string]management.Template, len(candidates)) + for _, t := range candidates { byLocale[t.Locale] = t } if user != nil && user.Locale != nil { if t, ok := byLocale[*user.Locale]; ok { - return t + return t, nil } } if project != nil { if t, ok := byLocale[project.Locale]; ok { - return t + return t, nil } } - return templates[0] + return candidates[0], nil +} + +func templatesForVariant(templates management.Templates, variant string) management.Templates { + matched := make(management.Templates, 0, len(templates)) + for _, template := range templates { + if template.Variant == variant { + matched = append(matched, template) + } + } + return matched } // buildRenderData builds the Liquid render context for a campaign send. @@ -127,7 +195,12 @@ type renderedCampaignInboxMessage struct { Channel providers.Channel SenderIdentityID *uuid.UUID TemplateID uuid.UUID - RenderedPayload json.RawMessage + // Variant is the variant actually rendered, which is not always the one + // the send asked for: a missing white-label template falls back to the + // default. Recording it makes the branding a recipient received auditable + // without re-deriving it from the template. + Variant string + RenderedPayload json.RawMessage } type renderedPushDispatch struct { @@ -146,13 +219,27 @@ type renderedPushPayload struct { } func renderCampaignInboxMessages(ctx context.Context, logger *zap.Logger, mgmt *management.State, usrs *subjects.State, renderer *pubsub.EmailRenderer, publicURL string, linkKey []byte, trackingURL string, event schemas.SendCampaign, campaign *management.Campaign, project *management.Project, user *subjects.User) ([]renderedCampaignInboxMessage, error) { - template := selectTemplate(campaign.Templates, user, project) channel := providers.Channel(campaign.Channel) data := buildRenderData(publicURL, user, campaign, event.Variables) + variant := resolveVariant(logger, campaign, event, data) + template, err := selectTemplate(campaign.Templates, variant, user, project) + if err != nil { + return nil, err + } + + outcome := "matched" + if template.Variant != variant { + outcome = "fallback" + logger.Warn("no template for requested variant, falling back", + zap.String("requested_variant", variant), + zap.String("selected_variant", template.Variant), + zap.Stringer("template_id", template.ID)) + } + metrics.CampaignVariantSelectionsTotal.WithLabelValues(event.ProjectID.String(), outcome).Inc() + switch channel { case providers.ChannelEmail: - var err error template.Data, err = channels.ComposeEmailTemplateData(ctx, renderer, event.ProjectID, template.Data, data) if err != nil { return nil, fmt.Errorf("compose email template data: %w", err) @@ -163,7 +250,6 @@ func renderCampaignInboxMessages(ctx context.Context, logger *zap.Logger, mgmt * return nil, Permanent(err) } case providers.ChannelSMS, providers.ChannelPush, providers.ChannelInbox: - var err error template.Data, err = render.RenderJSON(template.Data, data) if err != nil { return nil, Permanent(err) @@ -180,6 +266,7 @@ func renderCampaignInboxMessages(ctx context.Context, logger *zap.Logger, mgmt * return []renderedCampaignInboxMessage{{ Channel: channel, TemplateID: template.ID, + Variant: template.Variant, RenderedPayload: template.Data, }}, nil } @@ -236,6 +323,7 @@ func renderCampaignInboxMessages(ctx context.Context, logger *zap.Logger, mgmt * return []renderedCampaignInboxMessage{{ Channel: channel, TemplateID: template.ID, + Variant: template.Variant, RenderedPayload: payload, }}, nil } @@ -275,6 +363,7 @@ func renderCampaignInboxMessages(ctx context.Context, logger *zap.Logger, mgmt * Channel: channel, SenderIdentityID: template.SenderIdentityID, TemplateID: template.ID, + Variant: template.Variant, RenderedPayload: request.Payload, }}, nil } diff --git a/internal/pubsub/consumer/campaigns_variant_test.go b/internal/pubsub/consumer/campaigns_variant_test.go new file mode 100644 index 000000000..956f69e42 --- /dev/null +++ b/internal/pubsub/consumer/campaigns_variant_test.go @@ -0,0 +1,271 @@ +package consumer + +import ( + "testing" + + "github.com/google/uuid" + "github.com/lunogram/platform/internal/ptr" + "github.com/lunogram/platform/internal/pubsub/schemas" + "github.com/lunogram/platform/internal/store" + "github.com/lunogram/platform/internal/store/management" + "github.com/lunogram/platform/internal/store/subjects" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func template(variant, locale string) management.Template { + return management.Template{ID: uuid.New(), Variant: variant, Locale: locale} +} + +func TestSelectTemplate(t *testing.T) { + t.Parallel() + + house := template("", "en") + houseNL := template("", "nl") + acme := template("acme", "en") + acmeNL := template("acme", "nl") + + project := &management.Project{Locale: "en"} + + tests := []struct { + name string + templates management.Templates + variant string + user *subjects.User + want management.Template + }{ + { + name: "picks the requested variant over the default", + templates: management.Templates{house, acme}, + variant: "acme", + user: &subjects.User{Locale: ptr.To("en")}, + want: acme, + }, + { + name: "applies locale within the requested variant", + templates: management.Templates{house, houseNL, acme, acmeNL}, + variant: "acme", + user: &subjects.User{Locale: ptr.To("nl")}, + want: acmeNL, + }, + { + name: "falls back to the project locale within the variant", + templates: management.Templates{house, acme, acmeNL}, + variant: "acme", + user: &subjects.User{Locale: ptr.To("de")}, + want: acme, + }, + { + name: "falls back to the default variant when the variant has no template", + templates: management.Templates{house, houseNL}, + variant: "acme", + user: &subjects.User{Locale: ptr.To("nl")}, + want: houseNL, + }, + { + // The single-template shortcut must not run before the variant + // filter, or a campaign holding one variant template would answer + // every send with it. + name: "does not hand a lone variant template to a default send", + templates: management.Templates{acme, house}, + variant: "", + user: nil, + want: house, + }, + { + name: "uses the only template when it is the default variant", + templates: management.Templates{house}, + variant: "", + user: nil, + want: house, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := selectTemplate(test.templates, test.variant, test.user, project) + require.NoError(t, err) + require.Equal(t, test.want.ID, got.ID) + }) + } +} + +func TestSelectTemplateWithoutTemplates(t *testing.T) { + t.Parallel() + + _, err := selectTemplate(nil, "", nil, &management.Project{Locale: "en"}) + require.Error(t, err) +} + +// A campaign whose only templates belong to other variants has no on-brand +// answer left. Handing one over would send another client's wording and sending +// domain to this recipient, so the send fails instead. +func TestSelectTemplateRefusesToCrossVariants(t *testing.T) { + t.Parallel() + + acme := template("acme", "en") + + _, err := selectTemplate(management.Templates{acme}, "globex", nil, &management.Project{Locale: "en"}) + require.Error(t, err) +} + +func TestResolveVariant(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + data := map[string]any{ + "user": map[string]any{ + "data": map[string]any{"tenant": "acme"}, + }, + } + + expression := func(value string) *management.VariantSelector { + return &management.VariantSelector{ + Type: management.VariantSelectorExpression, + Expression: value, + } + } + static := func(key string) *management.VariantSelector { + return &management.VariantSelector{ + Type: management.VariantSelectorStatic, + Key: key, + } + } + + tests := []struct { + name string + campaign *management.VariantSelector + event *management.VariantSelector + want string + }{ + { + name: "the event selector wins over the campaign selector", + campaign: expression("{{ user.data.tenant }}"), + event: static("globex"), + want: "globex", + }, + { + name: "renders the campaign expression when the event carries nothing", + campaign: expression("{{ user.data.tenant }}"), + want: "acme", + }, + { + // Pinning the default variant has to beat the campaign's selector + // rather than read as "nothing set" and fall through to it - this + // is what forces one send back to house branding inside an + // otherwise white-labelled campaign. + name: "an event pinning the default variant overrides the campaign expression", + campaign: expression("{{ user.data.tenant }}"), + event: static(""), + want: "", + }, + { + name: "trims whitespace an expression leaves behind", + campaign: expression(" {{ user.data.tenant }} "), + want: "acme", + }, + { + name: "a static campaign selector pins every recipient", + campaign: static("acme"), + want: "acme", + }, + { + name: "an expression matching nothing resolves to the default variant", + campaign: expression("{{ user.data.missing }}"), + want: "", + }, + { + name: "no selector anywhere resolves to the default variant", + want: "", + }, + { + name: "a broken expression resolves to the default variant rather than failing the send", + campaign: expression("{{ user.data.tenant | }}"), + want: "", + }, + { + // Static never renders, so a key that happens to look like Liquid + // is still used verbatim rather than evaluated. + name: "a static key is never rendered", + campaign: static("{{ user.data.tenant }}"), + want: "{{ user.data.tenant }}", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + campaign := &management.Campaign{ + Variants: store.JSONB[management.CampaignVariants]{ + Data: management.CampaignVariants{Selector: test.campaign}, + }, + } + event := schemas.SendCampaign{Variant: test.event} + require.Equal(t, test.want, resolveVariant(logger, campaign, event, data)) + }) + } +} + +func TestCampaignVariantsHas(t *testing.T) { + t.Parallel() + + variants := management.CampaignVariants{ + Options: []management.CampaignVariant{{Key: "acme"}, {Key: "globex"}}, + } + + require.True(t, variants.Has("acme")) + require.True(t, variants.Has(""), "the default variant always exists") + require.False(t, variants.Has("initech")) + require.True(t, management.CampaignVariants{}.Has("")) +} + +func TestVariantSelectorValidate(t *testing.T) { + t.Parallel() + + variants := management.CampaignVariants{ + Options: []management.CampaignVariant{{Key: "acme"}}, + } + + tests := map[string]struct { + selector management.VariantSelector + wantErr bool + }{ + "declared static key": { + selector: management.VariantSelector{Type: management.VariantSelectorStatic, Key: "acme"}, + }, + "undeclared static key": { + selector: management.VariantSelector{Type: management.VariantSelectorStatic, Key: "globex"}, + wantErr: true, + }, + "static without a key pins the default variant": { + selector: management.VariantSelector{Type: management.VariantSelectorStatic}, + }, + "expression": { + selector: management.VariantSelector{Type: management.VariantSelectorExpression, Expression: "{{ user.data.tenant }}"}, + }, + "expression without an expression": { + selector: management.VariantSelector{Type: management.VariantSelectorExpression}, + wantErr: true, + }, + "unknown type": { + selector: management.VariantSelector{Type: "whatever"}, + wantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := test.selector.Validate(variants) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/internal/pubsub/schemas/events.go b/internal/pubsub/schemas/events.go index 9a0a92aa4..48203ce6f 100644 --- a/internal/pubsub/schemas/events.go +++ b/internal/pubsub/schemas/events.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lunogram/platform/internal/store/management" "github.com/lunogram/platform/internal/store/subjects" ) @@ -136,6 +137,15 @@ type SendCampaign struct { BroadcastID *uuid.UUID `json:"broadcast_id,omitempty"` Data *SendCampaignData `json:"data,omitempty"` Variables map[string]string `json:"variables,omitempty"` + // Variant overrides the campaign's own rule for picking a template variant. + // Nil defers to the campaign. + // + // A journey step resolves its expression before publishing and sends a + // static selector, because the journey context it renders against - entrance + // data, earlier step state - does not exist by the time the send is + // rendered. A broadcast passes its selector through untouched, since an + // expression there has to run once per recipient. + Variant *management.VariantSelector `json:"variant,omitempty"` } // InboxOrigin resolves the inbox source label and the external_id key used diff --git a/internal/store/management/broadcasts.go b/internal/store/management/broadcasts.go index 108b9d8bc..0863f4e5d 100644 --- a/internal/store/management/broadcasts.go +++ b/internal/store/management/broadcasts.go @@ -25,13 +25,17 @@ const ( type Broadcasts []Broadcast type Broadcast struct { - ID uuid.UUID `db:"id"` - ProjectID uuid.UUID `db:"project_id"` - CampaignID uuid.UUID `db:"campaign_id"` - ListID uuid.UUID `db:"list_id"` - ListName string `db:"list_name"` - ListType string `db:"list_type"` - State BroadcastState `db:"state"` + ID uuid.UUID `db:"id"` + ProjectID uuid.UUID `db:"project_id"` + CampaignID uuid.UUID `db:"campaign_id"` + ListID uuid.UUID `db:"list_id"` + ListName string `db:"list_name"` + ListType string `db:"list_type"` + // Variant overrides the campaign's rule for this broadcast, either pinning + // one variant or carrying its own expression for a list spanning several + // clients. Nil defers to the campaign. + Variant store.JSONB[*VariantSelector] `db:"variant"` + State BroadcastState `db:"state"` // Total is the number of messages published to NATS during broadcast // processing. Sent tracks the number of messages actually delivered, and // Failed the number that terminally will not be - a suppressed recipient or @@ -73,6 +77,11 @@ func (b Broadcast) OAPI() oapi.Broadcast { UpdatedAt: b.UpdatedAt, } + if b.Variant.Data != nil { + selector := b.Variant.Data.OAPI() + result.Variant = &selector + } + if b.Campaign != nil { channel := b.Campaign.Channel result.Campaign = &struct { @@ -117,12 +126,23 @@ func (s *BroadcastsStore) CreateBroadcast(ctx context.Context, broadcast Broadca } stmt := ` - INSERT INTO campaign_broadcasts (project_id, campaign_id, list_id, list_name, list_type, state, scheduled_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, project_id, campaign_id, list_id, list_name, list_type, state, total, sent, failed, error, scheduled_at, created_at, updated_at, started_at, completed_at` + INSERT INTO campaign_broadcasts (project_id, campaign_id, list_id, list_name, list_type, state, scheduled_at, variant) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, project_id, campaign_id, list_id, list_name, list_type, variant, state, total, sent, failed, error, scheduled_at, created_at, updated_at, started_at, completed_at` + + // Absent selectors are stored as SQL NULL rather than a JSON null, so a + // broadcast that defers to the campaign reads back as one. + var variantVal any + if broadcast.Variant.Data != nil { + v, err := broadcast.Variant.Value() + if err != nil { + return Broadcast{}, err + } + variantVal = v + } var result Broadcast - err := s.db.GetContext(ctx, &result, stmt, broadcast.ProjectID, broadcast.CampaignID, broadcast.ListID, broadcast.ListName, broadcast.ListType, string(state), broadcast.ScheduledAt) + err := s.db.GetContext(ctx, &result, stmt, broadcast.ProjectID, broadcast.CampaignID, broadcast.ListID, broadcast.ListName, broadcast.ListType, string(state), broadcast.ScheduledAt, variantVal) if err != nil { return Broadcast{}, err } @@ -133,7 +153,7 @@ func (s *BroadcastsStore) CreateBroadcast(ctx context.Context, broadcast Broadca func (s *BroadcastsStore) GetBroadcast(ctx context.Context, projectID, broadcastID uuid.UUID) (*Broadcast, error) { query := ` SELECT - cb.id, cb.project_id, cb.campaign_id, cb.list_id, cb.list_name, cb.list_type, + cb.id, cb.project_id, cb.campaign_id, cb.list_id, cb.list_name, cb.list_type, cb.variant, cb.state, cb.total, cb.sent, cb.failed, cb.error, cb.scheduled_at, cb.created_at, cb.updated_at, cb.started_at, cb.completed_at, c.name AS campaign_name, c.channel AS campaign_channel FROM campaign_broadcasts cb @@ -164,7 +184,7 @@ func (s *BroadcastsStore) GetBroadcast(ctx context.Context, projectID, broadcast func (s *BroadcastsStore) ListBroadcasts(ctx context.Context, projectID uuid.UUID, pagination store.Pagination, search string, campaignID *uuid.UUID, listID *uuid.UUID, state *BroadcastState) (Broadcasts, int, error) { query := ` SELECT - cb.id, cb.project_id, cb.campaign_id, cb.list_id, cb.list_name, cb.list_type, + cb.id, cb.project_id, cb.campaign_id, cb.list_id, cb.list_name, cb.list_type, cb.variant, cb.state, cb.total, cb.sent, cb.failed, cb.error, cb.scheduled_at, cb.created_at, cb.updated_at, cb.started_at, cb.completed_at, c.name AS campaign_name, c.channel AS campaign_channel, COUNT(*) OVER () AS total_count @@ -380,7 +400,7 @@ func (s *BroadcastsStore) CancelBroadcast(ctx context.Context, projectID, broadc // briefly both act as leader. func (s *BroadcastsStore) ScanScheduledBroadcasts(ctx context.Context, limit int, scanner func(Broadcast) error) (int, error) { query := ` - SELECT id, project_id, campaign_id, list_id, list_name, list_type, state, total, sent, failed, error, scheduled_at, created_at, updated_at, started_at, completed_at + SELECT id, project_id, campaign_id, list_id, list_name, list_type, variant, state, total, sent, failed, error, scheduled_at, created_at, updated_at, started_at, completed_at FROM campaign_broadcasts WHERE state = 'scheduled' AND scheduled_at IS NOT NULL @@ -515,7 +535,7 @@ func (s *BroadcastsStore) GetBroadcastUsers(ctx context.Context, usersDB store.D // the consumer failed without updating state. func (s *BroadcastsStore) ScanStuckBroadcasts(ctx context.Context, stuckThreshold time.Duration, scanner func(Broadcast) error) (int, error) { query := ` - SELECT id, project_id, campaign_id, list_id, list_name, list_type, state, total, sent, failed, error, scheduled_at, created_at, updated_at, started_at, completed_at + SELECT id, project_id, campaign_id, list_id, list_name, list_type, variant, state, total, sent, failed, error, scheduled_at, created_at, updated_at, started_at, completed_at FROM campaign_broadcasts WHERE state = 'sending' AND started_at IS NOT NULL diff --git a/internal/store/management/campaigns.go b/internal/store/management/campaigns.go index 7c86a3336..3d5d6ca25 100644 --- a/internal/store/management/campaigns.go +++ b/internal/store/management/campaigns.go @@ -35,6 +35,7 @@ type Campaign struct { Transactional bool `db:"transactional"` Delivery store.JSONB[Delivery] `db:"delivery"` Variables store.JSONB[CampaignVariables] `db:"variables"` + Variants store.JSONB[CampaignVariants] `db:"variants"` Templates Templates `db:"-"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -50,6 +51,8 @@ func (campaign Campaign) OAPI() oapi.Campaign { } } + variants := campaign.Variants.Data.OAPI() + archived := campaign.DeletedAt != nil result := oapi.Campaign{ Id: campaign.ID, @@ -60,6 +63,7 @@ func (campaign Campaign) OAPI() oapi.Campaign { Transactional: campaign.Transactional, Delivery: campaign.Delivery.Data.OAPI(), Variables: &variables, + Variants: &variants, Templates: campaign.Templates.OAPI(), CreatedAt: campaign.CreatedAt, UpdatedAt: campaign.UpdatedAt, @@ -99,12 +103,17 @@ type CampaignsStore struct { func (s *CampaignsStore) CreateCampaign(ctx context.Context, campaign Campaign) (uuid.UUID, error) { stmt := ` - INSERT INTO campaigns (project_id, name, channel, subscription_id, transactional) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO campaigns (project_id, name, channel, subscription_id, transactional, variants) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id` + variants, err := campaign.Variants.Value() + if err != nil { + return uuid.Nil, err + } + var id uuid.UUID - err := s.db.GetContext(ctx, &id, stmt, campaign.ProjectID, campaign.Name, campaign.Channel, campaign.SubscriptionID, campaign.Transactional) + err = s.db.GetContext(ctx, &id, stmt, campaign.ProjectID, campaign.Name, campaign.Channel, campaign.SubscriptionID, campaign.Transactional, variants) if err != nil { return uuid.Nil, err } @@ -114,7 +123,7 @@ func (s *CampaignsStore) CreateCampaign(ctx context.Context, campaign Campaign) func (s *CampaignsStore) ListCampaigns(ctx context.Context, project uuid.UUID, pagination store.Pagination, search string, archivedOnly bool) (Campaigns, int, error) { query := ` - SELECT id, project_id, COALESCE(name, '') AS name, channel, subscription_id, transactional, delivery, variables, created_at, updated_at, deleted_at, + SELECT id, project_id, COALESCE(name, '') AS name, channel, subscription_id, transactional, delivery, variables, variants, created_at, updated_at, deleted_at, COUNT(*) OVER () AS total_count FROM campaigns WHERE project_id = $1 @@ -147,7 +156,7 @@ func (s *CampaignsStore) ListCampaigns(ctx context.Context, project uuid.UUID, p func (s *CampaignsStore) GetCampaign(ctx context.Context, projectID, campaignID uuid.UUID) (*Campaign, error) { query := ` - SELECT id, project_id, COALESCE(name, '') AS name, channel, subscription_id, transactional, delivery, variables, created_at, updated_at, deleted_at + SELECT id, project_id, COALESCE(name, '') AS name, channel, subscription_id, transactional, delivery, variables, variants, created_at, updated_at, deleted_at FROM campaigns WHERE project_id = $1 AND id = $2 @@ -172,6 +181,7 @@ type CampaignUpdate struct { SubscriptionID *uuid.UUID Transactional *bool Variables *store.JSONB[CampaignVariables] + Variants *store.JSONB[CampaignVariants] } func (s *CampaignsStore) UpdateCampaign(ctx context.Context, projectID, campaignID uuid.UUID, update CampaignUpdate) error { @@ -184,9 +194,10 @@ func (s *CampaignsStore) UpdateCampaign(ctx context.Context, projectID, campaign subscription_id = CASE WHEN COALESCE($3, transactional) THEN NULL ELSE COALESCE($4, subscription_id) - END - WHERE project_id = $5 - AND id = $6 + END, + variants = COALESCE($5, variants) + WHERE project_id = $6 + AND id = $7 AND deleted_at IS NULL` var variablesVal any @@ -198,7 +209,16 @@ func (s *CampaignsStore) UpdateCampaign(ctx context.Context, projectID, campaign variablesVal = v } - _, err := s.db.ExecContext(ctx, query, update.Name, variablesVal, update.Transactional, update.SubscriptionID, projectID, campaignID) + var variantsVal any + if update.Variants != nil { + v, err := update.Variants.Value() + if err != nil { + return err + } + variantsVal = v + } + + _, err := s.db.ExecContext(ctx, query, update.Name, variablesVal, update.Transactional, update.SubscriptionID, variantsVal, projectID, campaignID) return err } diff --git a/internal/store/management/migrations/1787000000007_template_variants.down.sql b/internal/store/management/migrations/1787000000007_template_variants.down.sql new file mode 100644 index 000000000..b2a22a22e --- /dev/null +++ b/internal/store/management/migrations/1787000000007_template_variants.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE campaign_broadcasts DROP COLUMN IF EXISTS variant; +ALTER TABLE campaigns DROP COLUMN IF EXISTS variants; +DROP INDEX IF EXISTS templates_campaign_variant_locale_idx; +ALTER TABLE templates DROP COLUMN IF EXISTS variant; diff --git a/internal/store/management/migrations/1787000000007_template_variants.up.sql b/internal/store/management/migrations/1787000000007_template_variants.up.sql new file mode 100644 index 000000000..30ba7e0d4 --- /dev/null +++ b/internal/store/management/migrations/1787000000007_template_variants.up.sql @@ -0,0 +1,29 @@ +-- A campaign's templates are keyed by locale today. White-labelling adds a +-- second, orthogonal dimension: the same campaign carries one template per +-- (locale, variant) pair, where the empty variant is the house brand every +-- existing row already belongs to. Defaulting to '' rather than NULL keeps the +-- lookup a plain equality match instead of forcing every query to spell out an +-- IS NULL branch. +ALTER TABLE templates ADD COLUMN variant VARCHAR(255) NOT NULL DEFAULT ''; + +-- Send-time selection narrows by campaign, then variant, then locale, which is +-- exactly this column order. No unique index yet: (campaign_id, locale) has +-- never been constrained, so existing projects may already hold duplicate rows +-- that a unique index over the new triple would refuse to build. +CREATE INDEX templates_campaign_variant_locale_idx ON templates(campaign_id, variant, locale); + +-- Variants are declared per campaign and edited on the campaign page, mirroring +-- how campaign variables are stored and edited. The declared set and the rule +-- that picks between them are one concept, so they live in one object: +-- +-- {"selector": {"type": "expression", "expression": "{{ user.data.tenant }}"}, +-- "options": [{"key": "acme", "label": "Acme Corp"}]} +-- +-- selector is optional; without one, a send that names no variant of its own +-- gets the default variant. +ALTER TABLE campaigns ADD COLUMN variants JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- A broadcast may override the campaign's rule, either pinning one variant +-- ({"type": "static", "key": "acme"}) or carrying its own expression for a list +-- that spans several clients. NULL defers to the campaign. +ALTER TABLE campaign_broadcasts ADD COLUMN variant JSONB; diff --git a/internal/store/management/templates.go b/internal/store/management/templates.go index d1bfb1f6c..c18b2359a 100644 --- a/internal/store/management/templates.go +++ b/internal/store/management/templates.go @@ -27,6 +27,7 @@ type Template struct { Type string `db:"type"` Data json.RawMessage `db:"data"` Locale string `db:"locale"` + Variant string `db:"variant"` SenderIdentityID *uuid.UUID `db:"sender_identity_id"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -39,6 +40,7 @@ func (template Template) OAPI() oapi.Template { Type: oapi.Channel(template.Type), Data: template.Data, Locale: template.Locale, + Variant: template.Variant, SenderIdentityId: template.SenderIdentityID, ProjectId: template.ProjectID, UpdatedAt: template.UpdatedAt, @@ -54,15 +56,15 @@ type TemplatesStore struct { db store.DB } -func (s *TemplatesStore) CreateTemplate(ctx context.Context, projectID, campaignID uuid.UUID, channel string, locale string, senderIdentityID *uuid.UUID) (uuid.UUID, error) { +func (s *TemplatesStore) CreateTemplate(ctx context.Context, projectID, campaignID uuid.UUID, channel string, locale string, variant string, senderIdentityID *uuid.UUID) (uuid.UUID, error) { // TODO: remove channel type, this type is needed within the "legacy" NodeJS back-end stmt := ` - INSERT INTO templates (project_id, campaign_id, type, locale, sender_identity_id) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO templates (project_id, campaign_id, type, locale, variant, sender_identity_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id` var id uuid.UUID - err := s.db.QueryRowContext(ctx, stmt, projectID, campaignID, channel, locale, senderIdentityID).Scan(&id) + err := s.db.QueryRowContext(ctx, stmt, projectID, campaignID, channel, locale, variant, senderIdentityID).Scan(&id) if err != nil { return uuid.Nil, err } @@ -72,7 +74,7 @@ func (s *TemplatesStore) CreateTemplate(ctx context.Context, projectID, campaign func (s *TemplatesStore) GetTemplate(ctx context.Context, projectID, templateID uuid.UUID) (*Template, error) { query := ` - SELECT templates.id, templates.project_id, templates.campaign_id, campaigns.channel AS type, templates.data, templates.locale, templates.sender_identity_id, templates.created_at, templates.updated_at + SELECT templates.id, templates.project_id, templates.campaign_id, campaigns.channel AS type, templates.data, templates.locale, templates.variant, templates.sender_identity_id, templates.created_at, templates.updated_at FROM templates JOIN campaigns ON templates.campaign_id = campaigns.id WHERE templates.project_id = $1 @@ -89,7 +91,7 @@ func (s *TemplatesStore) GetTemplate(ctx context.Context, projectID, templateID func (s *TemplatesStore) ListTemplates(ctx context.Context, projectID, campaignID uuid.UUID) ([]Template, error) { query := ` - SELECT templates.id, templates.project_id, templates.campaign_id, campaigns.channel AS type, templates.data, templates.locale, templates.sender_identity_id, templates.created_at, templates.updated_at + SELECT templates.id, templates.project_id, templates.campaign_id, campaigns.channel AS type, templates.data, templates.locale, templates.variant, templates.sender_identity_id, templates.created_at, templates.updated_at FROM templates JOIN campaigns ON templates.campaign_id = campaigns.id WHERE templates.project_id = $1 @@ -139,8 +141,8 @@ func (s *TemplatesStore) DeleteTemplate(ctx context.Context, projectID, template func (s *TemplatesStore) DuplicateTemplate(ctx context.Context, projectID, templateID, newCampaignID uuid.UUID) error { query := ` - INSERT INTO templates (project_id, campaign_id, type, data, locale, sender_identity_id) - SELECT project_id, $1, type, data, locale, sender_identity_id + INSERT INTO templates (project_id, campaign_id, type, data, locale, variant, sender_identity_id) + SELECT project_id, $1, type, data, locale, variant, sender_identity_id FROM templates WHERE project_id = $2 AND id = $3` diff --git a/internal/store/management/variants.go b/internal/store/management/variants.go new file mode 100644 index 000000000..f2d98927a --- /dev/null +++ b/internal/store/management/variants.go @@ -0,0 +1,199 @@ +package management + +import ( + "fmt" + "regexp" + "strings" + + "github.com/lunogram/platform/internal/http/controllers/v1/management/oapi" + "github.com/lunogram/platform/internal/render" +) + +// VariantSelectorType distinguishes the two ways a send decides which template +// variant it wants. The mode is explicit rather than inferred from whether the +// value happens to contain Liquid syntax: a static key can then be checked +// against the campaign's declared variants when it is saved, and the console +// knows which editor to offer without inspecting the string. +type VariantSelectorType string + +const ( + // VariantSelectorStatic pins one variant for every recipient. An empty + // key pins the default variant, which is how a single send is forced + // back to house branding past a campaign that resolves a client brand + // per recipient - a security notice inside an otherwise white-labelled + // journey, say. That is distinct from carrying no selector at all, which + // defers to the campaign. + VariantSelectorStatic VariantSelectorType = "static" + // VariantSelectorExpression resolves a variant per recipient from a Liquid + // expression such as "{{ user.data.tenant }}". + VariantSelectorExpression VariantSelectorType = "expression" +) + +// VariantSelector decides which template variant a send uses. The same type +// appears on a campaign, on a journey campaign step and on a broadcast, so the +// three layers offer identical choices; the more specific layer wins. +type VariantSelector struct { + Type VariantSelectorType `json:"type"` + Key string `json:"key,omitempty"` + Expression string `json:"expression,omitempty"` +} + +// Resolve produces the variant key this selector points at, rendering the +// expression against the supplied context when there is one. An empty result +// means the default variant. +func (selector VariantSelector) Resolve(data map[string]any) (string, error) { + switch selector.Type { + case VariantSelectorStatic: + return strings.TrimSpace(selector.Key), nil + case VariantSelectorExpression: + resolved, err := render.RenderString(selector.Expression, data) + if err != nil { + return "", err + } + return strings.TrimSpace(resolved), nil + default: + return "", fmt.Errorf("unknown variant selector type %q", selector.Type) + } +} + +// Validate reports whether the selector is usable against a campaign's declared +// variants. A static key is checked here so a stale or mistyped one is refused +// when it is saved rather than silently falling back to house branding on every +// send; an expression can only be judged at send time. +func (selector VariantSelector) Validate(variants CampaignVariants) error { + switch selector.Type { + case VariantSelectorStatic: + // Has reports true for the empty key, so pinning the default variant + // needs no special case here. + if key := strings.TrimSpace(selector.Key); !variants.Has(key) { + return fmt.Errorf("campaign does not declare variant %s", key) + } + return nil + case VariantSelectorExpression: + if strings.TrimSpace(selector.Expression) == "" { + return fmt.Errorf("expression variant selector requires an expression") + } + return nil + default: + return fmt.Errorf("unknown variant selector type %q", selector.Type) + } +} + +// variantKeyPattern constrains a declared variant key. Keys are written into +// template rows and into console dropdown values, so they are kept to a plain +// slug: leading punctuation is refused, which keeps the sentinel values the +// console reserves for "default" and "none" out of the declared set. +var variantKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// CampaignVariant declares one white-labelled edition of a campaign. Key is +// what a send resolves against to pick a set of templates; the empty key is the +// default variant every campaign starts with and is never declared here. +type CampaignVariant struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` +} + +// CampaignVariants holds a campaign's declared variants together with the rule +// that picks between them when a send does not pick one itself. They are one +// concept - a selector is meaningless without the set it resolves into - so +// they are stored and edited as one object. +type CampaignVariants struct { + Selector *VariantSelector `json:"selector,omitempty"` + Options []CampaignVariant `json:"options,omitempty"` +} + +// Has reports whether key names a declared variant. The default variant is +// always available and is not part of the declared set. +func (variants CampaignVariants) Has(key string) bool { + if key == "" { + return true + } + for _, option := range variants.Options { + if option.Key == key { + return true + } + } + return false +} + +func (selector VariantSelector) OAPI() oapi.VariantSelector { + result := oapi.VariantSelector{Type: oapi.VariantSelectorType(selector.Type)} + if selector.Key != "" { + result.Key = &selector.Key + } + if selector.Expression != "" { + result.Expression = &selector.Expression + } + return result +} + +func VariantSelectorFromOAPI(selector oapi.VariantSelector) VariantSelector { + result := VariantSelector{Type: VariantSelectorType(selector.Type)} + if selector.Key != nil { + result.Key = strings.TrimSpace(*selector.Key) + } + if selector.Expression != nil { + result.Expression = strings.TrimSpace(*selector.Expression) + } + return result +} + +func (variants CampaignVariants) OAPI() oapi.CampaignVariants { + options := make([]oapi.CampaignVariant, len(variants.Options)) + for i, option := range variants.Options { + options[i] = oapi.CampaignVariant{Key: option.Key} + if option.Label != "" { + options[i].Label = &variants.Options[i].Label + } + } + + result := oapi.CampaignVariants{Options: &options} + if variants.Selector != nil { + selector := variants.Selector.OAPI() + result.Selector = &selector + } + return result +} + +// CampaignVariantsFromOAPI converts a request body into the stored shape, +// rejecting duplicate and empty keys. The empty key is the default variant: it +// always exists and is never declared, so accepting it here would create a +// duplicate of something that cannot be removed. +func CampaignVariantsFromOAPI(variants oapi.CampaignVariants) (CampaignVariants, error) { + var result CampaignVariants + + if variants.Options != nil { + result.Options = make([]CampaignVariant, 0, len(*variants.Options)) + seen := make(map[string]bool, len(*variants.Options)) + + for _, option := range *variants.Options { + key := strings.TrimSpace(option.Key) + if key == "" { + return result, fmt.Errorf("variant key cannot be empty") + } + if !variantKeyPattern.MatchString(key) { + return result, fmt.Errorf("variant key %s must start with a lowercase letter or digit and contain only lowercase letters, digits, dashes and underscores", key) + } + if seen[key] { + return result, fmt.Errorf("duplicate variant key %s", key) + } + seen[key] = true + + converted := CampaignVariant{Key: key} + if option.Label != nil { + converted.Label = strings.TrimSpace(*option.Label) + } + result.Options = append(result.Options, converted) + } + } + + if variants.Selector != nil { + selector := VariantSelectorFromOAPI(*variants.Selector) + if err := selector.Validate(result); err != nil { + return result, err + } + result.Selector = &selector + } + + return result, nil +}