Skip to content

feat(templates): white-labelled template variants - #312

Open
jeroenrinzema wants to merge 5 commits into
mainfrom
feat/template-variants
Open

feat(templates): white-labelled template variants#312
jeroenrinzema wants to merge 5 commits into
mainfrom
feat/template-variants

Conversation

@jeroenrinzema

@jeroenrinzema jeroenrinzema commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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:

"variants": {
  "selector": { "type": "expression", "expression": "{{ user.data.tenant }}" },
  "options":  [ { "key": "acme", "label": "Acme Corp" } ]
}

That VariantSelector{type: static, key} or {type: expression, expression} — is one shared type used by all three layers, which resolve first-match-wins:

Layer Purpose
1 Selector on the journey step or broadcast This send in particular
2 Selector on the campaign Every send that does not decide for itself
3 Default variant

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 Variables map: that map never reaches broadcasts, would collide with a customer-defined variable named variant, 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.
  • A campaign with no templates now returns an error rather than indexing an empty slice, which would have panicked the consumer.

Enterprise gating

Declaring variants and creating a template for one are gated behind the enterprise build 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

  • 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. The migration adds (campaign_id, variant, locale) as a plain index; adding uniqueness wants a production audit first.
  • Variants are campaign-scoped, per product decision — managed on the campaign page, not in project settings. A client spanning many campaigns therefore has its key entered per campaign. DuplicateTemplate carries 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.
  • organization is in neither render context. If a customer's brand key lives on the client organization rather than on each user, buildRenderData and GetJourneyEntryData need 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.
  • Sender identity was already per-template, so a variant sends from the client's own domain with no new model — though that domain still needs a verified sender identity and provider, which is operational work outside this PR.
  • One unrelated line removed in a separate commit: console/src/types.ts declared an unused VariantUpdateParams that predates this work and now reads as if it belongs to template variants. It had no reference anywhere in the repo.
  • The console has no working typecheck gate (see Verification). Worth fixing separately — tsc -b or pointing the build at tsconfig.app.json would surface the 111 existing errors, so it needs its own cleanup rather than being folded in here.

Verification

  • go vet and the full test suites for the touched packages pass under both -tags enterprise and the default OSS build.
  • New tests: variant × locale selection including both fallback paths and the single-template shortcut, selector resolution (explicit wins, Liquid, literal pin, broken expression, unmatched value), the undeclared-variant guard, OSS rejection of a non-default variant, campaign variant/selector round-trip including clearing the selector, and variant-preserving campaign duplication.
  • Console: vite build passes and the touched files are lint-clean. Note that npx tsc proves nothing hereconsole/tsconfig.json is solution-style ("files": []), so plain tsc compiles zero files and always exits 0, which also means pnpm build does not typecheck. The real check is npx tsc -p tsconfig.app.json --noEmit, which reports 111 errors on main today. This branch was verified by diffing that output against an origin/main baseline: no new errors. (That check caught two real ones here before this was pushed.)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/journeys/campaign.go Outdated
Comment thread internal/pubsub/consumer/campaigns_render.go Outdated
Comment thread internal/store/management/templates.go
Comment thread console/src/views/broadcast/CreateBroadcastDialog.tsx
Comment thread console/src/views/campaign/CampaignVariants.tsx
Comment thread internal/http/controllers/v1/management/oapi/journeys.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 variant does not make campaign duplication variant-preserving: DuplicateCampaign creates the target without copying campaign.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.variant when 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 whenever campaign_id changes.
                        ...(values.variant ? { variant: values.variant } : {}),

Comment thread console/src/views/campaign/CampaignVariants.tsx
Comment thread internal/pubsub/consumer/campaigns.go Outdated
Comment thread internal/journeys/campaign.go
Comment thread internal/http/controllers/v1/management/campaigns.go
Comment thread internal/store/management/variants.go
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants