@happyvertical/smrt-marketing 0.57.35 → 0.57.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Declared recipes for smrt-marketing (#4409, #4437): user-facing units of
3
+ * functionality an app or agent can pick instead of the whole package.
4
+ * The scanner reads these statics into the `recipes` array of `manifest.json`
5
+ * and `smrt-knowledge.json`; nothing here runs at that point.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import { SmrtRecipe } from '@happyvertical/smrt-core';
10
+ import { Campaign } from './models/Campaign.js';
11
+ import { CampaignChannel } from './models/CampaignChannel.js';
12
+ import { CampaignMetricSnapshot } from './models/CampaignMetricSnapshot.js';
13
+ /** Campaigns: Plan a campaign, choose where it runs and see what it brought in. */
14
+ export declare class MarketingCampaignsRecipe extends SmrtRecipe {
15
+ static id: string;
16
+ static help: string;
17
+ static label: string;
18
+ static summary: string;
19
+ static synonyms: string[];
20
+ static section: {
21
+ id: string;
22
+ label: string;
23
+ icon: string;
24
+ description: string;
25
+ };
26
+ static models: (typeof Campaign | typeof CampaignChannel | typeof CampaignMetricSnapshot)[];
27
+ static nav: ({
28
+ label: string;
29
+ model: typeof Campaign;
30
+ icon: string;
31
+ description: string;
32
+ noun: string;
33
+ } | {
34
+ label: string;
35
+ model: typeof CampaignChannel;
36
+ icon: string;
37
+ description: string;
38
+ noun: string;
39
+ })[];
40
+ static options: {
41
+ readonly Campaign: {
42
+ readonly fields: {
43
+ readonly name: {
44
+ readonly order: 1;
45
+ };
46
+ readonly objective: {
47
+ readonly order: 2;
48
+ };
49
+ readonly status: {
50
+ readonly order: 3;
51
+ };
52
+ readonly startAt: {
53
+ readonly label: "Starts";
54
+ readonly order: 4;
55
+ };
56
+ readonly endAt: {
57
+ readonly label: "Ends";
58
+ readonly order: 5;
59
+ };
60
+ readonly budgetCents: {
61
+ readonly label: "Budget (cents)";
62
+ readonly order: 6;
63
+ };
64
+ readonly currency: {
65
+ readonly order: 7;
66
+ };
67
+ readonly customerId: {
68
+ readonly label: "Customer";
69
+ readonly visibility: "advanced";
70
+ };
71
+ readonly campaignKey: {
72
+ readonly label: "Reference";
73
+ readonly visibility: "advanced";
74
+ };
75
+ readonly tenantId: {
76
+ readonly visibility: "hidden";
77
+ };
78
+ readonly metadata: {
79
+ readonly visibility: "hidden";
80
+ };
81
+ };
82
+ };
83
+ readonly CampaignChannel: {
84
+ readonly fields: {
85
+ readonly campaignId: {
86
+ readonly label: "Campaign";
87
+ readonly order: 1;
88
+ };
89
+ readonly channelKind: {
90
+ readonly label: "Kind";
91
+ readonly order: 2;
92
+ };
93
+ readonly channelRef: {
94
+ readonly label: "Name";
95
+ readonly order: 3;
96
+ };
97
+ readonly allocatedBudgetCents: {
98
+ readonly label: "Budget share (cents)";
99
+ readonly order: 4;
100
+ };
101
+ readonly status: {
102
+ readonly order: 5;
103
+ };
104
+ readonly startAt: {
105
+ readonly label: "Starts";
106
+ readonly order: 6;
107
+ };
108
+ readonly endAt: {
109
+ readonly label: "Ends";
110
+ readonly order: 7;
111
+ };
112
+ readonly tenantId: {
113
+ readonly visibility: "hidden";
114
+ };
115
+ };
116
+ };
117
+ readonly CampaignMetricSnapshot: {
118
+ readonly fields: {
119
+ readonly campaignId: {
120
+ readonly label: "Campaign";
121
+ readonly order: 1;
122
+ };
123
+ readonly campaignChannelId: {
124
+ readonly label: "Channel";
125
+ readonly order: 2;
126
+ };
127
+ readonly periodStart: {
128
+ readonly label: "From";
129
+ readonly order: 3;
130
+ };
131
+ readonly periodEnd: {
132
+ readonly label: "To";
133
+ readonly order: 4;
134
+ };
135
+ readonly spendCents: {
136
+ readonly label: "Spend (cents)";
137
+ readonly order: 5;
138
+ };
139
+ readonly impressions: {
140
+ readonly order: 6;
141
+ };
142
+ readonly clicks: {
143
+ readonly order: 7;
144
+ };
145
+ readonly conversions: {
146
+ readonly order: 8;
147
+ };
148
+ readonly leads: {
149
+ readonly order: 9;
150
+ };
151
+ readonly revenueCents: {
152
+ readonly label: "Revenue (cents)";
153
+ readonly order: 10;
154
+ };
155
+ readonly source: {
156
+ readonly order: 11;
157
+ };
158
+ readonly tenantId: {
159
+ readonly visibility: "hidden";
160
+ };
161
+ readonly dedupeKey: {
162
+ readonly visibility: "hidden";
163
+ };
164
+ };
165
+ };
166
+ };
167
+ }
168
+ //# sourceMappingURL=recipes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,oCAAoC,CAAC;AAE5E,mFAAmF;AACnF,qBAAa,wBAAyB,SAAQ,UAAU;IACtD,MAAM,CAAC,EAAE,SAAyB;IAClC,MAAM,CAAC,IAAI,SAA2B;IACtC,MAAM,CAAC,KAAK,SAAe;IAC3B,MAAM,CAAC,OAAO,SAC4F;IAC1G,MAAM,CAAC,QAAQ,WAOb;IACF,MAAM,CAAC,OAAO;;;;;MAKZ;IACF,MAAM,CAAC,MAAM,+EAAuD;IACpE,MAAM,CAAC,GAAG;;;;;;;;;;;;SAiBR;IACF,MAAM,CAAC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MA6CH;CACZ"}
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-marketing",
6
- "packageVersion": "0.57.35",
6
+ "packageVersion": "0.57.37",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "d94c3cf925d57998e6a0d49764a6889aef2d0354a02956862eda15ec8a3451b9",
11
- "packageJson": "ee2dadef120a9152f28286a474bfff9d1d7e45dcd9b3fb2bcb57f3ea197b62bf",
12
- "agents": "3b81365608ec4f1929b51db17194f9c1f74d420b49886277e0f94b42238915d8"
10
+ "manifest": "1fb1f0edecc448a8615510f61192531e8ac410d6efd6a97c2d4e463648916070",
11
+ "packageJson": "13b0b2ae46d6ad7c93116001fcdd5a936daa241932c5203dcb08de52a58f89c1",
12
+ "agents": "65754c7b32b54e664f41ff3905773caa42b6fe730027e8340d526d583eee4012"
13
13
  },
14
14
  "exports": [
15
15
  ".",
@@ -346,61 +346,70 @@
346
346
  "type": "crossPackageRef",
347
347
  "required": false,
348
348
  "related": "@happyvertical/smrt-commerce:Customer",
349
- "columnType": "UUID"
349
+ "columnType": "UUID",
350
+ "description": "The customer this campaign is run for, if any."
350
351
  },
351
352
  {
352
353
  "name": "campaignKey",
353
354
  "type": "text",
354
355
  "required": true,
355
356
  "columnType": "TEXT",
356
- "default": ""
357
+ "default": "",
358
+ "description": "A short reference of your own for this campaign, kept the same once it is in use."
357
359
  },
358
360
  {
359
361
  "name": "name",
360
362
  "type": "text",
361
363
  "required": true,
362
364
  "columnType": "TEXT",
363
- "default": ""
365
+ "default": "",
366
+ "description": "What the campaign is called."
364
367
  },
365
368
  {
366
369
  "name": "objective",
367
370
  "type": "text",
368
371
  "required": false,
369
372
  "columnType": "TEXT",
370
- "default": ""
373
+ "default": "",
374
+ "description": "What the campaign is meant to achieve, such as awareness or new leads."
371
375
  },
372
376
  {
373
377
  "name": "status",
374
378
  "type": "text",
375
379
  "required": false,
376
380
  "columnType": "TEXT",
377
- "default": "draft"
381
+ "default": "draft",
382
+ "description": "Where the campaign stands, from draft through active to completed and archived."
378
383
  },
379
384
  {
380
385
  "name": "startAt",
381
386
  "type": "datetime",
382
387
  "required": false,
383
- "columnType": "TIMESTAMP"
388
+ "columnType": "TIMESTAMP",
389
+ "description": "When the campaign starts."
384
390
  },
385
391
  {
386
392
  "name": "endAt",
387
393
  "type": "datetime",
388
394
  "required": false,
389
- "columnType": "TIMESTAMP"
395
+ "columnType": "TIMESTAMP",
396
+ "description": "When the campaign ends."
390
397
  },
391
398
  {
392
399
  "name": "budgetCents",
393
400
  "type": "integer",
394
401
  "required": false,
395
402
  "columnType": "INTEGER",
396
- "default": 0
403
+ "default": 0,
404
+ "description": "The total you plan to spend on the campaign, in cents."
397
405
  },
398
406
  {
399
407
  "name": "currency",
400
408
  "type": "text",
401
409
  "required": false,
402
410
  "columnType": "TEXT",
403
- "default": "USD"
411
+ "default": "USD",
412
+ "description": "The currency every amount on this campaign is in."
404
413
  },
405
414
  {
406
415
  "name": "metadata",
@@ -610,47 +619,54 @@
610
619
  "type": "foreignKey",
611
620
  "required": true,
612
621
  "related": "Campaign",
613
- "columnType": "UUID"
622
+ "columnType": "UUID",
623
+ "description": "The campaign this channel runs for."
614
624
  },
615
625
  {
616
626
  "name": "channelKind",
617
627
  "type": "text",
618
628
  "required": true,
619
629
  "columnType": "TEXT",
620
- "default": ""
630
+ "default": "",
631
+ "description": "What sort of channel this is, such as an ad group, a social post, a message, content or an event."
621
632
  },
622
633
  {
623
634
  "name": "channelRef",
624
635
  "type": "text",
625
636
  "required": true,
626
637
  "columnType": "TEXT",
627
- "default": ""
638
+ "default": "",
639
+ "description": "The name or reference the channel goes by, so its results can be matched to it."
628
640
  },
629
641
  {
630
642
  "name": "allocatedBudgetCents",
631
643
  "type": "integer",
632
644
  "required": false,
633
645
  "columnType": "INTEGER",
634
- "default": 0
646
+ "default": 0,
647
+ "description": "The share of the campaign budget set aside for this channel, in cents."
635
648
  },
636
649
  {
637
650
  "name": "startAt",
638
651
  "type": "datetime",
639
652
  "required": false,
640
- "columnType": "TIMESTAMP"
653
+ "columnType": "TIMESTAMP",
654
+ "description": "When this channel starts, if different from the campaign."
641
655
  },
642
656
  {
643
657
  "name": "endAt",
644
658
  "type": "datetime",
645
659
  "required": false,
646
- "columnType": "TIMESTAMP"
660
+ "columnType": "TIMESTAMP",
661
+ "description": "When this channel ends, if different from the campaign."
647
662
  },
648
663
  {
649
664
  "name": "status",
650
665
  "type": "text",
651
666
  "required": false,
652
667
  "columnType": "TEXT",
653
- "default": "planned"
668
+ "default": "planned",
669
+ "description": "Where this channel stands, from planned through active to completed or cancelled."
654
670
  }
655
671
  ],
656
672
  "relationships": [
@@ -826,81 +842,93 @@
826
842
  "type": "foreignKey",
827
843
  "required": true,
828
844
  "related": "Campaign",
829
- "columnType": "UUID"
845
+ "columnType": "UUID",
846
+ "description": "The campaign these results belong to."
830
847
  },
831
848
  {
832
849
  "name": "campaignChannelId",
833
850
  "type": "foreignKey",
834
851
  "required": false,
835
852
  "related": "CampaignChannel",
836
- "columnType": "UUID"
853
+ "columnType": "UUID",
854
+ "description": "The channel these results came from. Empty when they cover the whole campaign."
837
855
  },
838
856
  {
839
857
  "name": "periodStart",
840
858
  "type": "datetime",
841
859
  "required": true,
842
- "columnType": "TIMESTAMP"
860
+ "columnType": "TIMESTAMP",
861
+ "description": "The first day of the period these results cover."
843
862
  },
844
863
  {
845
864
  "name": "periodEnd",
846
865
  "type": "datetime",
847
866
  "required": true,
848
- "columnType": "TIMESTAMP"
867
+ "columnType": "TIMESTAMP",
868
+ "description": "The last day of the period these results cover."
849
869
  },
850
870
  {
851
871
  "name": "spendCents",
852
872
  "type": "integer",
853
873
  "required": false,
854
874
  "columnType": "INTEGER",
855
- "default": 0
875
+ "default": 0,
876
+ "description": "What was spent in the period, in cents."
856
877
  },
857
878
  {
858
879
  "name": "impressions",
859
880
  "type": "integer",
860
881
  "required": false,
861
882
  "columnType": "INTEGER",
862
- "default": 0
883
+ "default": 0,
884
+ "description": "How many times the campaign was shown."
863
885
  },
864
886
  {
865
887
  "name": "clicks",
866
888
  "type": "integer",
867
889
  "required": false,
868
890
  "columnType": "INTEGER",
869
- "default": 0
891
+ "default": 0,
892
+ "description": "How many times someone clicked through."
870
893
  },
871
894
  {
872
895
  "name": "conversions",
873
896
  "type": "integer",
874
897
  "required": false,
875
898
  "columnType": "INTEGER",
876
- "default": 0
899
+ "default": 0,
900
+ "description": "How many people did what the campaign asked, such as buying."
877
901
  },
878
902
  {
879
903
  "name": "leads",
880
904
  "type": "integer",
881
905
  "required": false,
882
906
  "columnType": "INTEGER",
883
- "default": 0
907
+ "default": 0,
908
+ "description": "How many new leads came in."
884
909
  },
885
910
  {
886
911
  "name": "revenueCents",
887
912
  "type": "integer",
888
913
  "required": false,
889
- "columnType": "INTEGER"
914
+ "columnType": "INTEGER",
915
+ "description": "The sales brought in during the period, in cents, if known."
890
916
  },
891
917
  {
892
918
  "name": "source",
893
919
  "type": "text",
894
920
  "required": true,
895
921
  "columnType": "TEXT",
896
- "default": ""
922
+ "default": "",
923
+ "description": "Where these results were reported from."
897
924
  },
898
925
  {
899
926
  "name": "dedupeKey",
900
927
  "type": "text",
901
928
  "required": true,
902
929
  "columnType": "TEXT",
903
- "default": ""
930
+ "default": "",
931
+ "description": "The reference this result is filed under, so the same report is never counted twice."
904
932
  }
905
933
  ],
906
934
  "relationships": [
@@ -1280,7 +1308,212 @@
1280
1308
  "polymorphicAssociations": 0,
1281
1309
  "uuidColumns": 20
1282
1310
  },
1283
- "agentDoc": "# @happyvertical/smrt-marketing\n\nCross-channel campaign coordination for SMRT. This package owns Campaign\nidentity, budgets, lifecycle, execution links, immutable performance evidence,\ncomputed pacing, and props-driven Svelte surfaces. It does not execute ads,\npublish social posts, send messages, or own sales attribution.\n\n## Validation\n\nRun `pnpm --filter @happyvertical/smrt-marketing test` and\n`pnpm --filter @happyvertical/smrt-marketing typecheck`; use `test:coverage`\nto exercise the model/service and component coverage configurations.\nNatural-key changes must also run\n`pnpm --filter @happyvertical/smrt-marketing test:postgres` when\nthe disposable PostgreSQL harness is available. Use\n`pnpm --filter @happyvertical/smrt-marketing build` and `verify:pack` for\npublish-surface changes.\n\n## Models\n\n- **Campaign**: optional-tenant umbrella with stable natural key\n `(tenant_id, campaign_key)`, open objective, integer-cent budget, currency,\n schedule, optional native UUID reference to the canonical commerce Customer,\n guarded metadata helpers, and lifecycle\n `draft → scheduled → active ↔ paused → completed → archived`. Raw saves are\n protected by an authoritative prior-status re-read; use\n `CampaignLifecycleService` for lifecycle writes.\n- **CampaignChannel**: one generic execution link per\n `(campaign_id, channel_kind, channel_ref)`. `channelKind`/`channelRef` are\n intentionally plain strings so ads, social, messages, content, events, and\n referral programs remain peer packages. Allocation and schedule overrides\n use integer cents and dates.\n- **CampaignMetricSnapshot**: immutable period evidence with a global\n `dedupe_key` natural key. A snapshot always carries `campaignId`; a null\n `campaignChannelId` means campaign-level rollup evidence; when present, the\n channel must belong to that same campaign. API/MCP/CLI expose create/list/get\n only, and the save guard rejects hydrated edits, blind-id overwrites, and\n natural-key overwrites. Programmatic deletion is rejected.\n\nEvery model is `@TenantScoped({ mode: 'optional' })` with nullable `tenantId`.\nAll generated surfaces are explicit; never omit `api`, `mcp`, or `cli` config.\n\n## Services\n\n- **CampaignLifecycleService** loads the current row before applying one legal\n transition. Paused campaigns may resume; completed campaigns may only be\n archived; archived campaigns are terminal.\n- **MetricIngestionService** validates required scope/source/period fields and\n delegates to `getOrCreateByDedupeKey()`. A replay returns the original row\n unchanged even if the replay payload differs.\n- **BudgetPacingService** computes campaign/channel pacing from snapshots and\n never persists derived balances. For each exact reporting period, campaign\n pacing prefers campaign rollups and otherwise sums channel snapshots,\n preventing double counting without dropping channel-only periods. Status\n uses a five-percent budget tolerance around schedule-derived expected spend.\n- **CampaignCollection** exposes bounded, tenant-and-Customer-scoped cursor\n pages ordered by `start_at DESC, id DESC` and bounded batch summaries for\n total count, active count, and latest start time. Its reporting-page variant\n preserves that order/cursor and adds channel count/mix, immutable metric\n totals, and canonical pacing with two page-wide grouped reads. Customer scope\n is validated through runtime relationship metadata; there is no static\n commerce import, per-campaign callback, or tenant-wide materialization.\n\n## Svelte\n\n`@happyvertical/smrt-marketing/svelte` exports `MarketingDashboard`,\n`CampaignList`, `CampaignDetail`, `ChannelMix`, and `BudgetPacing`, plus view\ninterfaces and pure helpers. Components are props-only: no fetching, no model\nimports, no providers. Use Provider-free `smrt-ui` primitives, `--smrt-*`\ntokens, named `$props()` interfaces, and integer-cent inputs formatted only at\nrender time.\n\n## Boundaries and gotchas\n\n- Runtime dependencies stay limited to `smrt-core`, `smrt-tenancy`, and\n `smrt-ui`. Never statically import sibling domain packages.\n- `Campaign.customerId` targets `@happyvertical/smrt-commerce:Customer` through\n `@crossPackageRef`. Saves and customer-scoped reads require exact tenant\n agreement (including global-to-global only) and fail without revealing\n whether a Customer is absent or belongs elsewhere. Associated saves validate\n and persist through one transaction-bound Campaign instance; scoped reads\n validate and query through one fresh transaction-bound collection. PostgreSQL\n locks the validated Customer rows for the duration of each operation.\n- Lead loop-closing is conventional: CRM stores `sourceKind: 'campaign'` and a\n campaign key in `sourceId`; marketing does not import or mutate Lead.\n- Attribution math remains in sales/referrals. Marketing stores performance\n evidence only.\n- Campaign rollups and channel snapshots may describe the same period. The\n pacing service deliberately chooses one evidence level instead of summing\n both.\n- Corrections append a new metric snapshot with a new dedupe key; never edit\n persisted evidence.\n- Table names are global: `campaigns`, `campaign_channels`, and\n `campaign_metric_snapshots`.\n",
1311
+ "agentDoc": "# @happyvertical/smrt-marketing\n\nCross-channel campaign coordination for SMRT. This package owns Campaign\nidentity, budgets, lifecycle, execution links, immutable performance evidence,\ncomputed pacing, and props-driven Svelte surfaces. It does not execute ads,\npublish social posts, send messages, or own sales attribution.\n\n## Validation\n\nRun `pnpm --filter @happyvertical/smrt-marketing test` and\n`pnpm --filter @happyvertical/smrt-marketing typecheck`; use `test:coverage`\nto exercise the model/service and component coverage configurations.\nNatural-key changes must also run\n`pnpm --filter @happyvertical/smrt-marketing test:postgres` when\nthe disposable PostgreSQL harness is available. Use\n`pnpm --filter @happyvertical/smrt-marketing build` and `verify:pack` for\npublish-surface changes.\n\n## Models\n\n- **Campaign**: optional-tenant umbrella with stable natural key\n `(tenant_id, campaign_key)`, open objective, integer-cent budget, currency,\n schedule, optional native UUID reference to the canonical commerce Customer,\n guarded metadata helpers, and lifecycle\n `draft → scheduled → active ↔ paused → completed → archived`. Raw saves are\n protected by an authoritative prior-status re-read; use\n `CampaignLifecycleService` for lifecycle writes.\n- **CampaignChannel**: one generic execution link per\n `(campaign_id, channel_kind, channel_ref)`. `channelKind`/`channelRef` are\n intentionally plain strings so ads, social, messages, content, events, and\n referral programs remain peer packages. Allocation and schedule overrides\n use integer cents and dates.\n- **CampaignMetricSnapshot**: immutable period evidence with a global\n `dedupe_key` natural key. A snapshot always carries `campaignId`; a null\n `campaignChannelId` means campaign-level rollup evidence; when present, the\n channel must belong to that same campaign. API/MCP/CLI expose create/list/get\n only, and the save guard rejects hydrated edits, blind-id overwrites, and\n natural-key overwrites. Programmatic deletion is rejected.\n\nEvery model is `@TenantScoped({ mode: 'optional' })` with nullable `tenantId`.\nAll generated surfaces are explicit; never omit `api`, `mcp`, or `cli` config.\n\n- **Recipe `marketing.campaigns`** (`src/recipes.ts`, help in\n `src/campaigns.recipe.md`): lists campaigns and channels for the planner and\n other catalog hosts; results (snapshots) are a child record of the campaign,\n not a menu entry. Results are read-only there (snapshots are\n written by `MetricIngestionService`) and the help never tells a person to set\n the lifecycle status by hand. Fields it shows carry `@field({ description })`\n in plain words; keep them when editing the models.\n\n## Services\n\n- **CampaignLifecycleService** loads the current row before applying one legal\n transition. Paused campaigns may resume; completed campaigns may only be\n archived; archived campaigns are terminal.\n- **MetricIngestionService** validates required scope/source/period fields and\n delegates to `getOrCreateByDedupeKey()`. A replay returns the original row\n unchanged even if the replay payload differs.\n- **BudgetPacingService** computes campaign/channel pacing from snapshots and\n never persists derived balances. For each exact reporting period, campaign\n pacing prefers campaign rollups and otherwise sums channel snapshots,\n preventing double counting without dropping channel-only periods. Status\n uses a five-percent budget tolerance around schedule-derived expected spend.\n- **CampaignCollection** exposes bounded, tenant-and-Customer-scoped cursor\n pages ordered by `start_at DESC, id DESC` and bounded batch summaries for\n total count, active count, and latest start time. Its reporting-page variant\n preserves that order/cursor and adds channel count/mix, immutable metric\n totals, and canonical pacing with two page-wide grouped reads. Customer scope\n is validated through runtime relationship metadata; there is no static\n commerce import, per-campaign callback, or tenant-wide materialization.\n\n## Svelte\n\n`@happyvertical/smrt-marketing/svelte` exports `MarketingDashboard`,\n`CampaignList`, `CampaignDetail`, `ChannelMix`, and `BudgetPacing`, plus view\ninterfaces and pure helpers. Components are props-only: no fetching, no model\nimports, no providers. Use Provider-free `smrt-ui` primitives, `--smrt-*`\ntokens, named `$props()` interfaces, and integer-cent inputs formatted only at\nrender time.\n\n## Boundaries and gotchas\n\n- Runtime dependencies stay limited to `smrt-core`, `smrt-tenancy`, and\n `smrt-ui`. Never statically import sibling domain packages.\n- `Campaign.customerId` targets `@happyvertical/smrt-commerce:Customer` through\n `@crossPackageRef`. Saves and customer-scoped reads require exact tenant\n agreement (including global-to-global only) and fail without revealing\n whether a Customer is absent or belongs elsewhere. Associated saves validate\n and persist through one transaction-bound Campaign instance; scoped reads\n validate and query through one fresh transaction-bound collection. PostgreSQL\n locks the validated Customer rows for the duration of each operation.\n- Lead loop-closing is conventional: CRM stores `sourceKind: 'campaign'` and a\n campaign key in `sourceId`; marketing does not import or mutate Lead.\n- Attribution math remains in sales/referrals. Marketing stores performance\n evidence only.\n- Campaign rollups and channel snapshots may describe the same period. The\n pacing service deliberately chooses one evidence level instead of summing\n both.\n- Corrections append a new metric snapshot with a new dedupe key; never edit\n persisted evidence.\n- Table names are global: `campaigns`, `campaign_channels`, and\n `campaign_metric_snapshots`.\n",
1312
+ "recipes": [
1313
+ {
1314
+ "id": "marketing.campaigns",
1315
+ "className": "MarketingCampaignsRecipe",
1316
+ "label": "Campaigns",
1317
+ "summary": "Plan a marketing campaign, set its budget and dates, choose where it runs and see what it brought in.",
1318
+ "synonyms": [
1319
+ "marketing",
1320
+ "marketing campaigns",
1321
+ "promotions",
1322
+ "advertising",
1323
+ "ad campaigns",
1324
+ "campaign results"
1325
+ ],
1326
+ "models": [
1327
+ "@happyvertical/smrt-marketing:Campaign",
1328
+ "@happyvertical/smrt-marketing:CampaignChannel",
1329
+ "@happyvertical/smrt-marketing:CampaignMetricSnapshot"
1330
+ ],
1331
+ "nav": [
1332
+ {
1333
+ "label": "Campaigns",
1334
+ "model": "@happyvertical/smrt-marketing:Campaign",
1335
+ "icon": "tag",
1336
+ "description": "Every campaign, with its budget, dates and where it stands.",
1337
+ "noun": "campaign"
1338
+ },
1339
+ {
1340
+ "label": "Channels",
1341
+ "model": "@happyvertical/smrt-marketing:CampaignChannel",
1342
+ "icon": "layers",
1343
+ "description": "Where each campaign runs, such as an ad group or a social post, and its share of the budget.",
1344
+ "noun": "channel"
1345
+ }
1346
+ ],
1347
+ "requires": [],
1348
+ "section": {
1349
+ "id": "grow",
1350
+ "label": "Grow",
1351
+ "icon": "tag",
1352
+ "description": "Bring in new customers and reward the people who help."
1353
+ },
1354
+ "options": {
1355
+ "@happyvertical/smrt-marketing:Campaign": {
1356
+ "fields": {
1357
+ "name": {
1358
+ "order": 1
1359
+ },
1360
+ "objective": {
1361
+ "order": 2
1362
+ },
1363
+ "status": {
1364
+ "order": 3
1365
+ },
1366
+ "startAt": {
1367
+ "label": "Starts",
1368
+ "order": 4
1369
+ },
1370
+ "endAt": {
1371
+ "label": "Ends",
1372
+ "order": 5
1373
+ },
1374
+ "budgetCents": {
1375
+ "label": "Budget (cents)",
1376
+ "order": 6
1377
+ },
1378
+ "currency": {
1379
+ "order": 7
1380
+ },
1381
+ "customerId": {
1382
+ "label": "Customer",
1383
+ "visibility": "advanced"
1384
+ },
1385
+ "campaignKey": {
1386
+ "label": "Reference",
1387
+ "visibility": "advanced"
1388
+ },
1389
+ "tenantId": {
1390
+ "visibility": "hidden"
1391
+ },
1392
+ "metadata": {
1393
+ "visibility": "hidden"
1394
+ }
1395
+ }
1396
+ },
1397
+ "@happyvertical/smrt-marketing:CampaignChannel": {
1398
+ "fields": {
1399
+ "campaignId": {
1400
+ "label": "Campaign",
1401
+ "order": 1
1402
+ },
1403
+ "channelKind": {
1404
+ "label": "Kind",
1405
+ "order": 2
1406
+ },
1407
+ "channelRef": {
1408
+ "label": "Name",
1409
+ "order": 3
1410
+ },
1411
+ "allocatedBudgetCents": {
1412
+ "label": "Budget share (cents)",
1413
+ "order": 4
1414
+ },
1415
+ "status": {
1416
+ "order": 5
1417
+ },
1418
+ "startAt": {
1419
+ "label": "Starts",
1420
+ "order": 6
1421
+ },
1422
+ "endAt": {
1423
+ "label": "Ends",
1424
+ "order": 7
1425
+ },
1426
+ "tenantId": {
1427
+ "visibility": "hidden"
1428
+ }
1429
+ }
1430
+ },
1431
+ "@happyvertical/smrt-marketing:CampaignMetricSnapshot": {
1432
+ "fields": {
1433
+ "campaignId": {
1434
+ "label": "Campaign",
1435
+ "order": 1
1436
+ },
1437
+ "campaignChannelId": {
1438
+ "label": "Channel",
1439
+ "order": 2
1440
+ },
1441
+ "periodStart": {
1442
+ "label": "From",
1443
+ "order": 3
1444
+ },
1445
+ "periodEnd": {
1446
+ "label": "To",
1447
+ "order": 4
1448
+ },
1449
+ "spendCents": {
1450
+ "label": "Spend (cents)",
1451
+ "order": 5
1452
+ },
1453
+ "impressions": {
1454
+ "order": 6
1455
+ },
1456
+ "clicks": {
1457
+ "order": 7
1458
+ },
1459
+ "conversions": {
1460
+ "order": 8
1461
+ },
1462
+ "leads": {
1463
+ "order": 9
1464
+ },
1465
+ "revenueCents": {
1466
+ "label": "Revenue (cents)",
1467
+ "order": 10
1468
+ },
1469
+ "source": {
1470
+ "order": 11
1471
+ },
1472
+ "tenantId": {
1473
+ "visibility": "hidden"
1474
+ },
1475
+ "dedupeKey": {
1476
+ "visibility": "hidden"
1477
+ }
1478
+ }
1479
+ }
1480
+ },
1481
+ "help": {
1482
+ "markdown": "## Overview\n\nCampaigns is where you plan a push to bring in customers: a sale, a launch, a\nseasonal promotion. Each campaign has a budget, start and end dates and a\nstatus that shows where it stands, from draft through active to completed. A\ncampaign runs on one or more channels, such as an ad group, a social post, an\nemail or an event, each with its share of the budget. Results come back from\nthe channels' reporting, period by period, so you can see what each campaign\nand channel brought in for what it cost.\n\n## Tasks\n\n### Plan a campaign\n\n1. Open the list of campaigns and choose **New**.\n2. Type the **{field:Campaign.name}**, for example \"Spring widget sale\".\n3. Say what it is for in **{field:Campaign.objective}**, such as awareness or new leads.\n4. Set when it **{field:Campaign.startAt}** and when it **{field:Campaign.endAt}**. The end cannot be before the start.\n5. Enter the **{field:Campaign.budgetCents}** as a whole number of cents, so $500 is 50000, and check the **{field:Campaign.currency}**.\n6. Save. A new campaign starts as a draft.\n\n### See where a campaign stands\n\n1. Open the list of campaigns.\n2. The **{field:Campaign.status}** shows whether each one is still a draft, scheduled, active, paused, completed or archived. It moves forward as the campaign is scheduled, run and wrapped up; you do not set it by hand.\n3. Open a campaign to change its name, dates or budget while it is still being planned.\n\n### Add a channel to a campaign\n\n1. Open the list of channels and choose **New**.\n2. Pick the **{field:CampaignChannel.campaignId}** it belongs to.\n3. Choose the **{field:CampaignChannel.channelKind}**, such as an ad group, a social post, a message, content or an event.\n4. Type the **{field:CampaignChannel.channelRef}** the channel goes by, so its results can be matched to it later.\n5. Enter its **{field:CampaignChannel.allocatedBudgetCents}** as a whole number of cents.\n6. If the channel runs on different dates from the campaign, set when it **{field:CampaignChannel.startAt}** and **{field:CampaignChannel.endAt}**.\n7. Save. You can edit a channel later, or remove one that will not run.\n\n### Read the results\n\nResults arrive from the channels' reporting; nobody types them in. Each one\ncovers a period for one campaign, or for one channel within it. A result is\nnever changed once it is in: a correction arrives as a new result for the same\nperiod.\n\n1. Open a campaign and look at its results, listed under the campaign.\n2. Each result names the **{field:CampaignMetricSnapshot.campaignId}** it belongs to. A result with no **{field:CampaignMetricSnapshot.campaignChannelId}** covers the whole campaign.\n3. Check the period, **{field:CampaignMetricSnapshot.periodStart}** to **{field:CampaignMetricSnapshot.periodEnd}**.\n4. Compare the **{field:CampaignMetricSnapshot.spendCents}** with the **{field:CampaignMetricSnapshot.clicks}**, **{field:CampaignMetricSnapshot.leads}** and **{field:CampaignMetricSnapshot.conversions}** it brought in, and the **{field:CampaignMetricSnapshot.revenueCents}** where the channel reports it.\n5. The **{field:CampaignMetricSnapshot.source}** says which channel's reporting sent the result.\n",
1483
+ "fieldRefs": [
1484
+ "Campaign.budgetCents",
1485
+ "Campaign.currency",
1486
+ "Campaign.endAt",
1487
+ "Campaign.name",
1488
+ "Campaign.objective",
1489
+ "Campaign.startAt",
1490
+ "Campaign.status",
1491
+ "CampaignChannel.allocatedBudgetCents",
1492
+ "CampaignChannel.campaignId",
1493
+ "CampaignChannel.channelKind",
1494
+ "CampaignChannel.channelRef",
1495
+ "CampaignChannel.endAt",
1496
+ "CampaignChannel.startAt",
1497
+ "CampaignMetricSnapshot.campaignChannelId",
1498
+ "CampaignMetricSnapshot.campaignId",
1499
+ "CampaignMetricSnapshot.clicks",
1500
+ "CampaignMetricSnapshot.conversions",
1501
+ "CampaignMetricSnapshot.leads",
1502
+ "CampaignMetricSnapshot.periodEnd",
1503
+ "CampaignMetricSnapshot.periodStart",
1504
+ "CampaignMetricSnapshot.revenueCents",
1505
+ "CampaignMetricSnapshot.source",
1506
+ "CampaignMetricSnapshot.spendCents"
1507
+ ]
1508
+ },
1509
+ "demo": {
1510
+ "mode": "server",
1511
+ "reasons": [
1512
+ "Needs a server because its package depends on @happyvertical/smrt-tenancy, which do not build for a browser (#3624)."
1513
+ ]
1514
+ }
1515
+ }
1516
+ ],
1284
1517
  "browser": {
1285
1518
  "status": "server-only",
1286
1519
  "issues": [