@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
- Apply the generated schema migration that adds nullable native-UUID
campaigns.customer_idand the(tenant_id, customer_id, start_at, id)index. - 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 writecustomer_id. Stop on missing, malformed, or mismatched values. - Verify every expected association through
listByCustomer()orsummarizeByCustomers(), then update consumers to use these APIs. - 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.