feat(templates): white-labelled template variants - #312
Open
jeroenrinzema wants to merge 5 commits into
Open
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds campaign-scoped white-label template variants across storage, rendering, APIs, journeys, broadcasts, and the console.
Changes:
- Adds variant declarations, selectors, persistence, and enterprise gating.
- Selects templates by variant then locale, with metrics and provenance.
- Adds console controls and backend tests for variant workflows.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
internal/store/management/variants.go |
Defines variant models, validation, and API conversion. |
internal/store/management/templates.go |
Persists template variant keys. |
internal/store/management/migrations/1787000000004_template_variants.up.sql |
Adds variant columns and index. |
internal/store/management/migrations/1787000000004_template_variants.down.sql |
Reverts variant schema changes. |
internal/store/management/campaigns.go |
Persists campaign variant configuration. |
internal/store/management/broadcasts.go |
Persists broadcast selectors. |
internal/pubsub/schemas/events.go |
Carries selectors in send events. |
internal/pubsub/consumer/campaigns.go |
Records selected variants in provenance. |
internal/pubsub/consumer/campaigns_variant_test.go |
Tests selection and selector behavior. |
internal/pubsub/consumer/campaigns_render.go |
Implements variant-aware rendering and fallback. |
internal/pubsub/consumer/broadcasts_enterprise.go |
Forwards broadcast selectors. |
internal/node/metrics/metrics.go |
Adds variant-selection metrics. |
internal/journeys/campaign.go |
Resolves journey-step selectors. |
internal/http/controllers/v1/management/templates.go |
Validates variant template creation. |
internal/http/controllers/v1/management/templates_test.go |
Updates template tests. |
internal/http/controllers/v1/management/template_variants.go |
Disables configuration in OSS builds. |
internal/http/controllers/v1/management/template_variants_test.go |
Tests OSS gating. |
internal/http/controllers/v1/management/template_variants_enterprise.go |
Enables enterprise variants. |
internal/http/controllers/v1/management/template_variants_enterprise_test.go |
Tests enterprise workflows. |
internal/http/controllers/v1/management/oapi/resources.yml |
Extends the management API specification. |
internal/http/controllers/v1/management/oapi/resources_gen.go |
Regenerates Go API models. |
internal/http/controllers/v1/management/oapi/journeys.go |
Adds journey-step selector data. |
internal/http/controllers/v1/management/campaigns.go |
Handles campaign variant updates. |
internal/http/controllers/v1/management/campaigns_test.go |
Updates campaign tests. |
internal/http/controllers/v1/management/broadcasts_enterprise.go |
Validates broadcast selectors. |
console/src/views/journey/steps/Campaign.tsx |
Adds journey variant controls. |
console/src/views/campaign/VariantSelectorInput.tsx |
Provides shared selector input. |
console/src/views/campaign/template/VariantSwitcher.tsx |
Switches template variants. |
console/src/views/campaign/template/Template.tsx |
Navigates locale/variant template pairs. |
console/src/views/campaign/CampaignVariants.tsx |
Manages campaign variants. |
console/src/views/campaign/CampaignDetails.tsx |
Integrates variant settings. |
console/src/views/broadcast/CreateBroadcastDialog.tsx |
Adds broadcast selector controls. |
console/src/validation/broadcast/create-broadcast.ts |
Validates selector requests. |
console/src/validation/broadcast/broadcast-response.ts |
Validates selector responses. |
console/src/types.ts |
Adds frontend variant types. |
console/src/oapi/management.generated.ts |
Regenerates TypeScript API models. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 36 changed files in this pull request and generated 5 comments.
Suppressed comments (4)
internal/journeys/campaign.go:51
- An explicit journey selector that resolves to
""is discarded here, so both a static default pin and an expression resolving empty incorrectly defer to the campaign selector. Preserve the selector as a static selector even when the resolved key is empty; this distinction is the mechanism used to force house branding.
if resolved != "" {
variant = &management.VariantSelector{
Type: management.VariantSelectorStatic,
Key: resolved,
}
}
internal/pubsub/consumer/campaigns_render.go:117
- If the requested and default variants both lack templates, this chooses an arbitrary other client's template, including its sender identity and branding. A campaign can reach this state by deleting its default template, so a Globex send may be delivered as Acme; fail permanently instead of crossing variant boundaries when no safe fallback exists.
// Neither the requested variant nor the default has a template. Every
// remaining template belongs to some other variant, so there is no
// on-brand answer left - send one anyway rather than dropping the message,
// and let the caller's fallback counter surface it.
if len(candidates) == 0 {
candidates = templates
}
internal/store/management/templates.go:145
- Copying the per-template
variantdoes not make campaign duplication variant-preserving:DuplicateCampaigncreates the target without copyingcampaign.Variants, so every copied non-default template is undeclared and hidden by the console. Copy the declarations/selector into the target campaign in the same transaction as these templates.
console/src/views/broadcast/CreateBroadcastDialog.tsx:156 - The form retains
values.variantwhen the selected campaign changes, and this line submits that stale selector even when the variant editor is hidden for the new campaign. An expression or default pin can be accepted and silently override the new campaign. Clear the variant field whenevercampaign_idchanges.
...(values.variant ? { variant: values.variant } : {}),
A campaign's templates were keyed by locale alone, so giving a client
their own design or wording meant duplicating the whole campaign and
keeping the copies in step by hand. Variants add a second dimension to
the same lookup: a template is now identified by (campaign, locale,
variant), where the empty variant is the house brand every existing
template already belongs to.
Which variant a send uses is resolved in two layers. A journey step or
a broadcast can name one outright, which travels on the SendCampaign
event. Otherwise the campaign's variant_selector - a Liquid expression
such as "{{ user.data.tenant }}" - is rendered per recipient, so one
broadcast over a list spanning several clients still reaches each of
them under their own branding.
A variant with no template for a campaign falls back to the default
rather than failing: a missing white-label template must not stop a
message going out. Because that fallback is silent by design, the
selected variant is recorded on the send and counted under
lunogram_campaign_variant_selections_total, and the campaign page marks
variants that have no template yet.
Sender identity was already per-template, so a variant sends from the
client's own domain with no further work.
Two things worth noting in the selection change. The single-template
shortcut now runs after the variant filter - left where it was, a
campaign holding one variant template would have answered every send
with it. And a campaign with no templates at all returns an error
instead of indexing an empty slice, which would have panicked the
consumer.
Declaring variants and creating a template for one are gated behind the
enterprise build tag. The send path is deliberately not gated: with no
variant configurable in an open-source build every template is the
default variant and selection resolves to it unaided, so both builds
share one code path through the render hot loop.
The type had no reference anywhere in the repo and now reads as though it belongs to template variants, which it never did - a reader hitting it next to CampaignVariant would reasonably assume the two are related.
The first cut split a campaign's variants across two sibling fields - an
array of declared variants and a separate variant_selector string - and
then let each call site invent its own rule. A broadcast could only pin a
declared key and never write an expression; a journey step could only
write an expression and was never validated against the declared set.
Neither could do what the other did, and a static key worked in the
journey only because render.RenderString happens to return its input
untouched when it contains no "{{".
Variants now travel as one object holding both the declared options and
the rule that picks between them, and that rule is a VariantSelector -
either {type: static, key} or {type: expression, expression} - shared by
the campaign, the journey campaign step and the broadcast. The mode is
declared rather than inferred from whether the string happens to contain
Liquid syntax.
Making it explicit buys two things beyond symmetry. A static key is now
validated wherever it is written, so a stale or mistyped one is refused
on save instead of quietly falling back to house branding on every send
forever - previously only broadcasts checked this. And the console offers
one component with a mode toggle rather than a dropdown at one call site
and a Liquid box at another, hardcoded per surface.
A broadcast passes its selector through to the send unresolved, since an
expression there has to run once per recipient. A journey step still
resolves before publishing and sends a static selector, because the
journey context it reads - entrance data, earlier step state - is gone by
the time the send is rendered.
campaign_broadcasts.variant becomes JSONB to hold a selector. The
migration is rewritten in place rather than stacked on, since the array
shape it replaces was never merged.
Also renames the template editor's VariantSelect to VariantSwitcher: it
navigates the editor between variants and has nothing to do with what a
send resolves to, which the old name did not distinguish.
A step or broadcast could inherit the campaign's rule or name a declared
variant, but it could not ask for the default variant outright: Validate
rejected a static selector with an empty key, and the console's dropdown
only listed declared options. Carrying no selector is not the same thing
- that defers to the campaign, so a campaign resolving a client brand per
recipient had no way to send one message under house branding. A security
or account notice inside an otherwise white-labelled journey is exactly
that case.
A static selector with an empty key now pins the default variant, which
CampaignVariants.Has already reported as valid, so Validate needed no
special case beyond dropping the empty-key rejection. resolveVariant
already treated a present selector as beating the campaign's, so pinning
the default correctly stops the fall-through rather than reading as
"nothing set".
The consequence of allowing an empty key is that a malformed {type:
static} with no key at all now resolves to the default variant instead of
being refused. That is the safe direction to be lax in - house branding,
never another client's - and it matches the fallback behaviour everywhere
else in this feature.
Static mode in the console now starts on the default variant rather than
on whichever client sorts first, so an unfinished edit cannot pin someone
else's branding.
Selection and declaration both had ways to put one client's branding in front of another's recipient, or to leave a template nothing can reach. A journey step that resolved its variant to the empty key dropped the selector entirely and inherited the campaign's, so a step pinned to house branding was re-branded per recipient. An empty result is a decision and now travels as a static selector pinning the default. selectTemplate handed over an arbitrary other variant's template when neither the requested variant nor the default had one - reachable by deleting the default template. That crosses a tenant boundary, wording and sending domain included, so it fails instead. Undeclaring a variant that still has templates is refused, whether by removing the key or renaming it, which reaches the API as the same edit. The console locks the key and the delete button once templates exist rather than letting the operator find out from a 400. Declared keys are constrained to the slug syntax the console assumes, which also keeps its "__default__" and "__none__" dropdown sentinels unsaveable. Duplicating a campaign carries the variant declaration across; without it the copied templates kept keys the copy no longer declared, hiding them from the console and dropping their sends to house branding. A journey step's selector is validated against its campaign at publish time, so a mistyped static key is refused there rather than falling back silently on every send. Every send now records its selected variant in provenance, empty string included - fallback is silent by design, so an audit needs to see the default was used rather than infer it from a missing field. Changing campaign in the broadcast dialog resets a variant left over from the previous one.
jeroenrinzema
force-pushed
the
feat/template-variants
branch
from
September 1, 2026 09:23
8202e00 to
0a6d51e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets a single campaign carry client-specific editions of its templates, so a white-label customer gets their own design, wording and sending domain without the campaign being duplicated and hand-maintained.
The mechanism
A template was keyed by locale alone. Variants add a second, orthogonal dimension: a template is now identified by
(campaign, locale, variant), with the empty variant as the house brand every existing row already belongs to. Selection narrows by variant first, then applies the locale rules exactly as before.A campaign stores its declared variants and the rule that picks between them as one object, because a selector is meaningless without the set it resolves into:
That
VariantSelector—{type: static, key}or{type: expression, expression}— is one shared type used by all three layers, which resolve first-match-wins:The mode is declared rather than inferred from whether the value happens to contain Liquid syntax. That matters twice over: a static key is validated wherever it is written, so a stale or mistyped one is refused on save instead of silently falling back to house branding on every send; and the console offers one component with a mode toggle rather than a dropdown at one surface and a Liquid box at another.
A broadcast passes its selector to the send unresolved, since an expression there has to run once per recipient. A journey step resolves before publishing and sends a static selector, because the journey context it reads — entrance data, earlier step state — no longer exists when the send is rendered.
A static selector with an empty key pins the default variant, which is distinct from carrying no selector at all. Omitting the selector defers to the campaign; pinning the default overrides it. Without that distinction a campaign resolving a client brand per recipient could never send one message under house branding — a security or account notice inside an otherwise white-labelled journey.
A dedicated field rather than a reserved key in the existing
Variablesmap: that map never reaches broadcasts, would collide with a customer-defined variable namedvariant, and cannot be validated, offered as a picker, or recorded as provenance.Fallback is deliberate, and observable
A variant with no template for a campaign falls back to the default rather than failing — a missing white-label template must not stop a message going out. Because that is silent by design, three things make it visible: the selected variant is written into the send's provenance next to
template_id,lunogram_campaign_variant_selections_total{outcome="matched"|"fallback"}counts it, and the campaign page marks variants that have no template yet.Pinning a broadcast to an undeclared variant is refused outright instead, since falling back there would silently send the whole list the wrong branding while the operator believes they picked a client.
Two fixes that fell out of the selection change
selectTemplate's single-template shortcut now runs after the variant filter. Left where it was, a campaign holding one variant template would have answered every send with it, including default-variant sends.Enterprise gating
Declaring variants and creating a template for one are gated behind the
enterprisebuild tag, following the existing paired-file pattern. The send path is deliberately not gated: with no variant configurable in an OSS build, every template is the default variant and selection resolves to it unaided — so both builds share one code path through the render hot loop.Notes for review
(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. The migration adds(campaign_id, variant, locale)as a plain index; adding uniqueness wants a production audit first.DuplicateTemplatecarries variants across (covered by a test), and the missing-template marker catches typos, but key autocomplete across a project's campaigns is a sensible follow-up.organizationis in neither render context. If a customer's brand key lives on the client organization rather than on each user,buildRenderDataandGetJourneyEntryDataneed it added before{{ organization.data.brand }}works as a selector. Not done here — it wasn't needed for user-scoped keys and is a larger change to the journey context.console/src/types.tsdeclared an unusedVariantUpdateParamsthat predates this work and now reads as if it belongs to template variants. It had no reference anywhere in the repo.tsc -bor pointing the build attsconfig.app.jsonwould surface the 111 existing errors, so it needs its own cleanup rather than being folded in here.Verification
go vetand the full test suites for the touched packages pass under both-tags enterpriseand the default OSS build.vite buildpasses and the touched files are lint-clean. Note thatnpx tscproves nothing here —console/tsconfig.jsonis solution-style ("files": []), so plaintsccompiles zero files and always exits 0, which also meanspnpm builddoes not typecheck. The real check isnpx tsc -p tsconfig.app.json --noEmit, which reports 111 errors onmaintoday. This branch was verified by diffing that output against anorigin/mainbaseline: no new errors. (That check caught two real ones here before this was pushed.)