campaign

package
v1.801.477 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package campaign is one go-to-market push across paid, organic and email at once.

A campaign — audience, creatives, schedule, budget — launches to every channel and reads back as one funnel with each channel's spend.

A Campaign is a VALUE — {name, audience, content[], schedule, budget, channels[], status} — that SPANS channels and fans out to orthogonal executors. It is the capability layer that CONSUMES the connector plane: the campaign object never touches a credential; each channel executor resolves the org's connector token itself through the integrations.TokenFor custody seam.

THE DECOMPLECT (HIP-0126 — Integrations, Connectors & the Extension Runtime): a Connector is a connection (credential custody + auth); a capability is what you DO with it. /v1/campaign is a CONSUMER of connectors — the role HIP-0126 gives Flows — never a second credential path. "campaign" used to be braided across three packages — an ad campaign (apps/ads), an email campaign (apps/marketing), social posts (apps/social). This plane lifts the GTM campaign to the ONE value it is and makes the channels orthogonal EXECUTORS it fans out to (channel.go):

paid    → apps/ads       (meta_ads/google_ads/tiktok_ads/… — REGISTERED)
organic → apps/social    (the social connectors — NO executor registered yet)
email   → apps/marketing (sendgrid/mailchimp/… — NO executor registered yet)

Only the paid executor is wired today (plugin/campaign/seams.go). A campaign carrying an organic or email channel launches its paid channels and records the others "unavailable" — honest, never a faked launch. Until those two executors exist, /v1/social and /v1/marketing are the ONLY way to run those channels, and each is ALSO usable standalone once they are; this plane composes them. Metrics are NOT stored here — a campaign's results are read at query time from the ONE analytics plane (metrics.go: analytics.CampaignMetrics over the utm_campaign-tagged events) plus each channel connector's reported spend. A creative A/B is an experiment whose variant = creative and whose metric = the campaign result from analytics; it composes the experiment seam (experiment.go), never a second assignment or evidence store.

Tenant isolation is enforced SERVER-SIDE on every request: the org is principal.Org(c) — the value SanitizeIdentity minted from the VALIDATED bearer owner claim (HIP-0026) — and NEVER a client-supplied header. Every store query filters WHERE org=?, and the org is the value passed to every channel executor, so a campaign can only ever resolve its OWN org's connector token.

Surface (all org-scoped; /v1 only):

GET    /v1/campaign/summary               per-org roll-up + wired channels
GET    /v1/campaign                        list campaigns (?status=)   -> {data:[…]}
POST   /v1/campaign                        create a campaign (draft)    -> Campaign (201)
GET    /v1/campaign/:id                    campaign detail              -> Campaign
PUT    /v1/campaign/:id                    update a draft campaign      -> Campaign
DELETE /v1/campaign/:id                    delete a campaign
POST   /v1/campaign/:id/launch             fan out to channels          -> Campaign
POST   /v1/campaign/:id/pause              pause every live channel     -> Campaign
GET    /v1/campaign/:id/metrics            analytics results + spend    -> Metrics
POST   /v1/campaign/:id/channels           add a channel                -> Campaign
DELETE /v1/campaign/:id/channels/:kind     remove a channel             -> Campaign

serve.go auto-registers GET /v1/campaign/health (no OwnsHealth here).

Index

Constants

View Source
const (
	KindPaid    = "paid"    // ad campaigns across Meta/Google/TikTok/… (the ad connectors)
	KindOrganic = "organic" // content syndication / social posts (the social connectors)
	KindEmail   = "email"   // drip / broadcast through the org's email provider (the email connectors)
)

Kinds — the three orthogonal go-to-market channels. A campaign fans out to a subset of these; each is also usable standalone at its own /v1 surface (paid=/v1/ads, organic=/v1/social, email=/v1/marketing). Only paid has a registered executor; a channel with none records "unavailable" at launch.

View Source
const (
	StatusDraft     = "draft"
	StatusScheduled = "scheduled"
	StatusLive      = "live"
	StatusPaused    = "paused"
	StatusCompleted = "completed"
	StatusFailed    = "failed"
)

Campaign lifecycle. draft is the only fully-mutable state; launch fans the campaign out to its channels and moves it to live (or failed if every channel failed); pause stops every live channel.

Variables

This section is empty.

Functions

func ExperimentKey

func ExperimentKey(campaignID string) string

ExperimentKey is the stable experiment identity for a campaign's creative A/B. A campaign opts into A/B by creating an experiment with THIS id (variants = creatives) on the experiments plane; if none exists, Assign fails and the campaign runs a single creative — no coupling, no auto-creation.

func Mount

func Mount(app cloud.Router, deps cloud.Deps) error

Mount wires the campaign surface onto app per HIP-0106. It keeps a package global (mounted) for Shutdown, so it constructs the Service value directly — the same "complex flavour" clients/ads uses.

func RegisterChannel

func RegisterChannel(ch Channel)

RegisterChannel installs a channel executor. Last registration for a kind wins (idempotent re-wire). Called from the composition root, never from the orchestrator.

func SetExperiment

func SetExperiment(assign AssignFunc, analyze AnalyzeFunc)

SetExperiment wires the experiments primitive into the campaign plane. Called once at the composition root (apps/wire_seams.go) with the experiments.Assign + experiments.Analyze adapters. Passing nils clears the seam (single-creative mode).

func Shutdown

func Shutdown() error

Shutdown closes the campaign store. Idempotent.

Types

type AnalyzeFunc

type AnalyzeFunc func(ctx context.Context, org, experimentID string, start, end time.Time) (json.RawMessage, error)

AnalyzeFunc returns an experiment's current analysis JSON — it composes experiments.Analyze (pull-model: it reads the metric from analytics itself). The result is opaque JSON so campaign stays decoupled from the experiments analysis type; the metrics endpoint embeds it verbatim.

type AssignFunc

type AssignFunc func(ctx context.Context, org, experimentID, subject string) (variant string, err error)

AssignFunc resolves the creative variant a launch runs — it composes experiments.Assign (the root supplies the project and extracts the variant key from the flags.Assignment). campaign maps the returned key to utm_content. An error or empty result means "no assignment" → single creative.

type Campaign

type Campaign struct {
	ID         string        `json:"id"`
	Org        string        `json:"-"` // tenant key — server-set from the validated owner claim, never client
	Name       string        `json:"name"`
	Audience   string        `json:"audience,omitempty"` // segment/audience selector ref
	Content    []string      `json:"content"`            // creative(s)
	Channels   []ChannelSpec `json:"channels"`           // fan-out targets
	ScheduleAt int64         `json:"scheduleAt,omitempty"`
	Budget     int64         `json:"budget"` // cents
	Status     string        `json:"status"`
	CreatedAt  int64         `json:"createdAt"`
	UpdatedAt  int64         `json:"updatedAt"`
}

Campaign is the top-level GTM object — a VALUE that spans channels. Budget is minor units (cents). Content is the ordered creative set (Content[0] is the active creative; the rest are A/B variants when an experiment is composed). Metrics are deliberately NOT a field: they are read at query time from the ONE analytics plane (metrics.go), never stored here.

type Channel

type Channel interface {
	Kind() string
	Launch(ctx context.Context, org string, p Plan) (Ref, error)
	Spend(ctx context.Context, org string, ref Ref) (int64, error)
	Pause(ctx context.Context, org string, ref Ref) error
}

Channel is a go-to-market executor. Launch runs the campaign's slice on the channel's provider (via the org's connector token); Spend reads the provider-reported spend for a launched Ref; Pause stops it. Every method is org-scoped and fails closed when the org has not connected the channel's connector — the orchestrator records that honestly, never fabricates a launch.

func NewChannel

func NewChannel(kind string, launch LaunchFunc, spend SpendFunc, pause PauseFunc) Channel

NewChannel builds a Channel from a kind and its injected executor funcs. The composition root calls it once per channel (apps/wire_seams.go) with the concrete ads/publish/marketing execution funcs, then RegisterChannel-s it.

type ChannelMetric

type ChannelMetric struct {
	Kind       string `json:"kind"`
	Platform   string `json:"platform"`
	Status     string `json:"status"`
	ExternalID string `json:"externalId,omitempty"`
	SpendCents int64  `json:"spendCents"`
	SpendError string `json:"spendError,omitempty"` // honest: connector spend read failed
}

ChannelMetric is one channel's spend contribution to a campaign's metrics.

type ChannelSpec

type ChannelSpec struct {
	Kind       string `json:"kind"`              // paid | organic | email
	Platform   string `json:"platform"`          // meta | google | x | instagram | (email provider)
	Account    string `json:"account,omitempty"` // provider account ref (ad-account/page/list id)
	ExternalID string `json:"externalId,omitempty"`
	Status     string `json:"status"`           // pending | live | paused | failed | unavailable
	Detail     string `json:"detail,omitempty"` // honest last-outcome detail (never a secret)
}

ChannelSpec is one fan-out target on a Campaign: which kind (paid/organic/ email), the provider platform + account it runs on, and — after launch — the provider-side id + status the orchestrator recorded. It carries NO credential; the executor resolves the org's connector token itself at launch time.

type LaunchFunc

type LaunchFunc func(ctx context.Context, org string, p Plan) (Ref, error)

The three executor operations, as primitive-typed function seams so a concrete channel package never has to import campaign to be registered.

type Metrics

type Metrics struct {
	CampaignID  string          `json:"campaignId"`
	Name        string          `json:"name"`
	Status      string          `json:"status"`
	Range       string          `json:"range"`
	Start       string          `json:"start"`
	End         string          `json:"end"`
	Available   bool            `json:"available"`
	Impressions int64           `json:"impressions"`
	Clicks      int64           `json:"clicks"`
	Conversions int64           `json:"conversions"`
	Revenue     float64         `json:"revenue"`
	Visitors    int64           `json:"visitors"`
	SpendCents  int64           `json:"spendCents"`
	CTR         float64         `json:"ctr"`  // clicks / impressions
	CVR         float64         `json:"cvr"`  // conversions / clicks
	CAC         float64         `json:"cac"`  // spend $ per conversion
	ROAS        float64         `json:"roas"` // revenue per spend $
	Channels    []ChannelMetric `json:"channels"`
	Source      string          `json:"source"`
	// ABTest is the creative A/B analysis from the experiments primitive
	// (experiments.Analyze, pull-model), present only when the campaign runs
	// more than one creative and an experiment is wired. Opaque JSON — campaign
	// stays decoupled from the experiments analysis type.
	ABTest json.RawMessage `json:"abTest,omitempty"`
}

Metrics is a campaign's results view: the analytics-sourced funnel + the connector-sourced spend + the derived growth KPIs. Available reflects the analytics events lens (false = warehouse not yet emitting, honest-empty).

type PauseFunc

type PauseFunc func(ctx context.Context, org string, ref Ref) error

The three executor operations, as primitive-typed function seams so a concrete channel package never has to import campaign to be registered.

type Plan

type Plan struct {
	CampaignID  string
	Name        string
	Objective   string
	Platform    string   // paid→meta|google|tiktok|…; organic→x|instagram|…; email→(provider)
	Account     string   // provider account ref (ad-account id, page id, list id)
	Content     []string // creative(s); Content[0] is the active creative for this launch
	Variant     string   // A/B creative id (utm_content) assigned by the experiment seam, or ""
	BudgetCents int64
	ScheduleAt  int64 // unix; 0 = launch now
}

Plan is the org-scoped, channel-specific slice a campaign hands ONE executor at launch. It carries no credential — the executor resolves the org's connector token itself (integrations.TokenFor), so credential custody never crosses this seam. Variant is the A/B creative assigned for this fan-out (the utm_content the executor tags its events with); "" when the campaign runs a single creative.

type Ref

type Ref struct {
	Platform   string
	Account    string
	ExternalID string
	Status     string
	Detail     string
}

Ref is an executor's durable handle on a launched execution — the provider-side id plus the platform/account needed to read spend or pause it later. The orchestrator records it onto the campaign's channel row and hands it back verbatim for Spend / Pause.

type SpendFunc

type SpendFunc func(ctx context.Context, org string, ref Ref) (int64, error) // provider-reported spend, minor units (cents)

The three executor operations, as primitive-typed function seams so a concrete channel package never has to import campaign to be registered.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is the campaign database. ONE SQLite file — the system namespace's "campaign" — holds every org's records; tenant isolation is the `org` column, enforced on EVERY query. Mirrors clients/ads exactly (the ONE storage pattern). MaxOpenConns(1) serializes writes against the single-writer file.

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database. Idempotent-safe via sql.DB.

func (*Store) Counts

func (s *Store) Counts(ctx context.Context, org string) (total, live int, budget int64, err error)

Counts returns the per-org campaign roll-up: total campaigns, how many are live, and the summed budget (cents) — a real, non-fabricated overview.

func (*Store) CreateCampaign

func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (Campaign, error)

func (*Store) DeleteCampaign

func (s *Store) DeleteCampaign(ctx context.Context, org, id string) (bool, error)

func (*Store) GetCampaign

func (s *Store) GetCampaign(ctx context.Context, org, id string) (Campaign, error)

func (*Store) ListCampaigns

func (s *Store) ListCampaigns(ctx context.Context, org, status string, limit int) ([]Campaign, error)

ListCampaigns lists the org's campaigns, optionally filtered by status (status=="" means all). Most-recently-updated first.

func (*Store) Save

func (s *Store) Save(ctx context.Context, c Campaign) (Campaign, error)

Save persists the full campaign row (name, audience, content, channels, schedule, budget, status). It is the ONE write used by update AND by launch/ pause (which rewrite channels + status), always org-scoped: a cross-tenant id affects zero rows → errNotFound, never a foreign mutation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL