Skip to main content

@happyvertical/smrt-marketing

Cross-channel Campaign models, immutable performance snapshots, computed budget pacing, and reusable Svelte marketing surfaces for s-m-r-t.

pnpm add @happyvertical/smrt-marketing
import {
BudgetPacingService,
CampaignChannelCollection,
CampaignCollection,
MetricIngestionService,
} from '@happyvertical/smrt-marketing';

const campaigns = await CampaignCollection.create({ db });
const channels = await CampaignChannelCollection.create({ db });
const campaign = await campaigns.create({
tenantId,
customerId,
campaignKey: 'summer-demand-2026',
name: 'Summer demand 2026',
objective: 'demand_generation',
budgetCents: 200_000,
currency: 'CAD',
});
if (!campaign.id) throw new Error('Campaign did not persist');

const adGroup = await channels.create({
tenantId,
campaignId: campaign.id,
channelKind: 'ad_group',
channelRef: 'ad-group-42',
allocatedBudgetCents: 150_000,
});
if (!adGroup.id) throw new Error('Campaign channel did not persist');

const ingestion = await MetricIngestionService.create({ db });
await ingestion.ingest({
tenantId,
campaignId: campaign.id,
campaignChannelId: adGroup.id,
periodStart: new Date('2026-07-01T00:00:00Z'),
periodEnd: new Date('2026-07-01T23:59:59Z'),
spendCents: 12_500,
impressions: 25_000,
clicks: 800,
conversions: 35,
leads: 20,
source: 'ad-platform',
dedupeKey: `${tenantId}:summer-demand-2026:ad-group-42:2026-07-01`,
});

// Channel-scoped evidence is accepted only when the channel belongs to the
// supplied campaign. Reporting periods are required valid date-like values.

const pacing = await BudgetPacingService.create({ db });
console.log(await pacing.getCampaignPacing(campaign.id));

Customer-scoped campaign reads

Campaign.customerId is the native UUID relationship to the canonical @happyvertical/smrt-commerce:Customer. A campaign and its Customer must have exactly the same tenant, and customer-scoped reads require that tenant explicitly (null selects the global/global scope). Associated Campaign saves validate and persist in one transaction; customer-scoped reads validate and query in one transaction. Missing and cross-tenant Customers fail with CampaignCustomerScopeError without disclosing which condition occurred.

const firstPage = await campaigns.listByCustomer(tenantId, customerId, {
limit: 50,
});
const secondPage = firstPage.nextCursor
? await campaigns.listByCustomer(tenantId, customerId, {
limit: 50,
after: firstPage.nextCursor,
})
: null;

const summaries = await campaigns.summarizeByCustomers(tenantId, customerIds);
// [{ customerId, totalCount, activeCount, latestStartAt }]

const reporting = await campaigns.listReportingByCustomer(
tenantId,
customerId,
{
limit: 50,
after: firstPage.nextCursor ?? undefined,
at: new Date('2026-08-15T00:00:00Z'),
},
);
// reporting.items keeps the same newest-first page order. Every item contains:
// { campaign, channelCount, channelMix, metricTotals, pacing }

Pages, reporting pages, and summary batches are capped at 100 items and reject larger inputs. Pagination is newest-first by startAt, then UUID; campaigns without a start time follow scheduled campaigns. listReportingByCustomer() validates Customer scope and reads the page in one transaction, then performs one grouped channel read and one grouped immutable-evidence read regardless of page size. metricTotals use the same evidence selection as BudgetPacingService: for each exact period, a campaign rollup replaces its channel snapshots while channel-only periods remain. pacing is therefore equivalent to getCampaignPacing() without per-campaign callbacks or lazy loads. Summary resolution likewise uses a bounded grouped query rather than loading tenant campaigns or issuing one query per Customer.

Migrating metadata-backed associations

  1. Apply the generated schema migration that adds nullable native-UUID campaigns.customer_id and the (tenant_id, customer_id, start_at, id) index.
  2. In an operator-owned data migration, extract the old metadata Customer id, validate that it exists in commerce and has the exact same tenant_id, then write customer_id. Stop on missing, malformed, or mismatched values.
  3. Verify every expected association through listByCustomer() or summarizeByCustomers(), then update consumers to use these APIs.
  4. Remove the old metadata key after verification. Marketing never reads it as a compatibility fallback, so there is no tenant-wide JSON or raw-SQL path to keep in sync.

Svelte components are exported from @happyvertical/smrt-marketing/svelte. They are presentational and accept plain view models; consumers remain in control of fetching and mutations.

See AGENTS.md for lifecycle, evidence, and package-boundary invariants.