@happyvertical/smrt-marketing 0.42.7 → 0.43.0

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.
package/AGENTS.md CHANGED
@@ -20,7 +20,8 @@ publish-surface changes.
20
20
 
21
21
  - **Campaign**: optional-tenant umbrella with stable natural key
22
22
  `(tenant_id, campaign_key)`, open objective, integer-cent budget, currency,
23
- schedule, guarded metadata helpers, and lifecycle
23
+ schedule, optional native UUID reference to the canonical commerce Customer,
24
+ guarded metadata helpers, and lifecycle
24
25
  `draft → scheduled → active ↔ paused → completed → archived`. Raw saves are
25
26
  protected by an authoritative prior-status re-read; use
26
27
  `CampaignLifecycleService` for lifecycle writes.
@@ -52,6 +53,11 @@ All generated surfaces are explicit; never omit `api`, `mcp`, or `cli` config.
52
53
  pacing prefers campaign rollups and otherwise sums channel snapshots,
53
54
  preventing double counting without dropping channel-only periods. Status
54
55
  uses a five-percent budget tolerance around schedule-derived expected spend.
56
+ - **CampaignCollection** exposes bounded, tenant-and-Customer-scoped cursor
57
+ pages ordered by `start_at DESC, id DESC` and bounded batch summaries for
58
+ total count, active count, and latest start time. Customer scope is validated
59
+ through runtime relationship metadata; there is no static commerce import or
60
+ tenant-wide campaign materialization.
55
61
 
56
62
  ## Svelte
57
63
 
@@ -66,6 +72,13 @@ render time.
66
72
 
67
73
  - Runtime dependencies stay limited to `smrt-core`, `smrt-tenancy`, and
68
74
  `smrt-ui`. Never statically import sibling domain packages.
75
+ - `Campaign.customerId` targets `@happyvertical/smrt-commerce:Customer` through
76
+ `@crossPackageRef`. Saves and customer-scoped reads require exact tenant
77
+ agreement (including global-to-global only) and fail without revealing
78
+ whether a Customer is absent or belongs elsewhere. Associated saves validate
79
+ and persist through one transaction-bound Campaign instance; scoped reads
80
+ validate and query through one fresh transaction-bound collection. PostgreSQL
81
+ locks the validated Customer rows for the duration of each operation.
69
82
  - Lead loop-closing is conventional: CRM stores `sourceKind: 'campaign'` and a
70
83
  campaign key in `sourceId`; marketing does not import or mutate Lead.
71
84
  - Attribution math remains in sales/referrals. Marketing stores performance
package/README.md CHANGED
@@ -19,6 +19,7 @@ const campaigns = await CampaignCollection.create({ db });
19
19
  const channels = await CampaignChannelCollection.create({ db });
20
20
  const campaign = await campaigns.create({
21
21
  tenantId,
22
+ customerId,
22
23
  campaignKey: 'summer-demand-2026',
23
24
  name: 'Summer demand 2026',
24
25
  objective: 'demand_generation',
@@ -59,6 +60,50 @@ const pacing = await BudgetPacingService.create({ db });
59
60
  console.log(await pacing.getCampaignPacing(campaign.id));
60
61
  ```
61
62
 
63
+ ## Customer-scoped campaign reads
64
+
65
+ `Campaign.customerId` is the native UUID relationship to the canonical
66
+ `@happyvertical/smrt-commerce:Customer`. A campaign and its Customer must have
67
+ exactly the same tenant, and customer-scoped reads require that tenant
68
+ explicitly (`null` selects the global/global scope). Associated Campaign saves
69
+ validate and persist in one transaction; customer-scoped reads validate and
70
+ query in one transaction. Missing and cross-tenant Customers fail with
71
+ `CampaignCustomerScopeError` without disclosing which condition occurred.
72
+
73
+ ```ts
74
+ const firstPage = await campaigns.listByCustomer(tenantId, customerId, {
75
+ limit: 50,
76
+ });
77
+ const secondPage = firstPage.nextCursor
78
+ ? await campaigns.listByCustomer(tenantId, customerId, {
79
+ limit: 50,
80
+ after: firstPage.nextCursor,
81
+ })
82
+ : null;
83
+
84
+ const summaries = await campaigns.summarizeByCustomers(tenantId, customerIds);
85
+ // [{ customerId, totalCount, activeCount, latestStartAt }]
86
+ ```
87
+
88
+ Pages and summary batches are capped at 100 items and reject larger inputs.
89
+ Pagination is newest-first by `startAt`, then UUID; campaigns without a start
90
+ time follow scheduled campaigns. Summary resolution uses a bounded grouped
91
+ query rather than loading tenant campaigns or issuing one query per Customer.
92
+
93
+ ### Migrating metadata-backed associations
94
+
95
+ 1. Apply the generated schema migration that adds nullable native-UUID
96
+ `campaigns.customer_id` and the
97
+ `(tenant_id, customer_id, start_at, id)` index.
98
+ 2. In an operator-owned data migration, extract the old metadata Customer id,
99
+ validate that it exists in commerce and has the exact same `tenant_id`, then
100
+ write `customer_id`. Stop on missing, malformed, or mismatched values.
101
+ 3. Verify every expected association through `listByCustomer()` or
102
+ `summarizeByCustomers()`, then update consumers to use these APIs.
103
+ 4. Remove the old metadata key after verification. Marketing never reads it as
104
+ a compatibility fallback, so there is no tenant-wide JSON or raw-SQL path to
105
+ keep in sync.
106
+
62
107
  Svelte components are exported from `@happyvertical/smrt-marketing/svelte`.
63
108
  They are presentational and accept plain view models; consumers remain in
64
109
  control of fetching and mutations.
@@ -1,10 +1,37 @@
1
- import { SmrtCollection } from '@happyvertical/smrt-core';
1
+ import { CollectionCacheConfig, SmrtCollection, SmrtListOptions, SmrtSelectedRow, SmrtSelectField, SmrtWhereClause } from '@happyvertical/smrt-core';
2
2
  import { Campaign } from '../models/Campaign.js';
3
- import { CampaignStatus } from '../types.js';
3
+ import { CampaignCustomerPage, CampaignCustomerSummary, CampaignStatus, ListCampaignsByCustomerOptions } from '../types.js';
4
+ export declare const MAX_CAMPAIGN_CUSTOMER_PAGE_SIZE = 100;
5
+ export declare const MAX_CAMPAIGN_CUSTOMER_BATCH_SIZE = 100;
4
6
  export declare class CampaignCollection extends SmrtCollection<Campaign> {
5
7
  static readonly _itemClass: typeof Campaign;
8
+ get(filter: string | SmrtWhereClause<Campaign>, options?: {
9
+ cache?: CollectionCacheConfig | false;
10
+ }): Promise<Campaign | null>;
11
+ list<const Select extends readonly SmrtSelectField<Campaign>[]>(options: SmrtListOptions<Campaign> & {
12
+ select: Select;
13
+ include?: never;
14
+ }): Promise<SmrtSelectedRow<Campaign, Select>[]>;
15
+ list(options?: Omit<SmrtListOptions<Campaign>, 'select'> & {
16
+ select?: undefined;
17
+ }): Promise<Campaign[]>;
6
18
  findByCampaignKey(campaignKey: string, tenantId?: string | null): Promise<Campaign | null>;
7
19
  findByStatus(status: CampaignStatus): Promise<Campaign[]>;
20
+ /**
21
+ * List one tenant/customer lane newest-first using a stable UUID tiebreaker.
22
+ * Null start times follow all scheduled rows on every supported database.
23
+ */
24
+ listByCustomer(tenantId: string | null, customerId: string, options?: ListCampaignsByCustomerOptions): Promise<CampaignCustomerPage>;
25
+ private listByCustomerInTransaction;
26
+ /**
27
+ * Summarize a bounded customer batch with one scope check and one grouped
28
+ * aggregate query. Every requested customer receives a row, including zeros.
29
+ */
30
+ summarizeByCustomers(tenantId: string | null, customerIds: string[]): Promise<CampaignCustomerSummary[]>;
31
+ private summarizeByCustomersInTransaction;
32
+ private inCustomerReadTransaction;
33
+ private listScheduledLane;
34
+ private listNullStartLane;
8
35
  }
9
36
  export default CampaignCollection;
10
37
  //# sourceMappingURL=CampaignCollection.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CampaignCollection.d.ts","sourceRoot":"","sources":["../../src/collections/CampaignCollection.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,qBAAa,kBAAmB,SAAQ,cAAc,CAAC,QAAQ,CAAC;IAC9D,MAAM,CAAC,QAAQ,CAAC,UAAU,kBAAY;IAEhC,iBAAiB,CACrB,WAAW,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GACvB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAQrB,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;CAMhE;AAED,eAAe,kBAAkB,CAAC"}
1
+ {"version":3,"file":"CampaignCollection.d.ts","sourceRoot":"","sources":["../../src/collections/CampaignCollection.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,qBAAqB,EAE1B,cAAc,EACd,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,eAAe,EACrB,MAAM,0BAA0B,CAAC;AAalC,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACjD,OAAO,KAAK,EAGV,oBAAoB,EACpB,uBAAuB,EACvB,cAAc,EACd,8BAA8B,EAC/B,MAAM,aAAa,CAAC;AAErB,eAAO,MAAM,+BAA+B,MAAM,CAAC;AACnD,eAAO,MAAM,gCAAgC,MAAM,CAAC;AAEpD,qBAAa,kBAAmB,SAAQ,cAAc,CAAC,QAAQ,CAAC;IAC9D,MAAM,CAAC,QAAQ,CAAC,UAAU,kBAAY;IAEvB,GAAG,CAChB,MAAM,EAAE,MAAM,GAAG,eAAe,CAAC,QAAQ,CAAC,EAC1C,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,qBAAqB,GAAG,KAAK,CAAA;KAAO,GACtD,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAMZ,IAAI,CACjB,KAAK,CAAC,MAAM,SAAS,SAAS,eAAe,CAAC,QAAQ,CAAC,EAAE,EAEzD,OAAO,EAAE,eAAe,CAAC,QAAQ,CAAC,GAAG;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAE,GACvE,OAAO,CAAC,eAAe,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC;IAChC,IAAI,CACjB,OAAO,CAAC,EAAE,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,GAAG;QACpD,MAAM,CAAC,EAAE,SAAS,CAAC;KACpB,GACA,OAAO,CAAC,QAAQ,EAAE,CAAC;IAYhB,iBAAiB,CACrB,WAAW,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GACvB,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAQrB,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAO/D;;;OAGG;IACG,cAAc,CAClB,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,oBAAoB,CAAC;YAgClB,2BAA2B;IA2BzC;;;OAGG;IACG,oBAAoB,CACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,WAAW,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,uBAAuB,EAAE,CAAC;YAgCvB,iCAAiC;YAkDjC,yBAAyB;YAoBzB,iBAAiB;YA4BjB,iBAAiB;CAiBhC;AA4JD,eAAe,kBAAkB,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { SmrtObjectOptions } from '@happyvertical/smrt-core';
2
+ interface DatabaseOptions {
3
+ db?: SmrtObjectOptions['db'];
4
+ }
5
+ /**
6
+ * Verify a bounded set of canonical commerce Customers in one database read.
7
+ * The generic failure deliberately does not reveal whether an id is missing or
8
+ * belongs to another tenant.
9
+ */
10
+ export declare function assertCustomersBelongToTenant(options: DatabaseOptions, tenantId: string | null, customerIds: readonly string[], label: string, lock?: 'none' | 'share' | 'update'): Promise<void>;
11
+ /** Parse and canonicalize one UUID without echoing the rejected value. */
12
+ export declare function normalizeUuid(value: string, label: string): string;
13
+ export {};
14
+ //# sourceMappingURL=customer-scope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-scope.d.ts","sourceRoot":"","sources":["../src/customer-scope.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AAWlC,UAAU,eAAe;IACvB,EAAE,CAAC,EAAE,iBAAiB,CAAC,IAAI,CAAC,CAAC;CAC9B;AA+BD;;;;GAIG;AACH,wBAAsB,6BAA6B,CACjD,OAAO,EAAE,eAAe,EACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,EACvB,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,MAAM,GAAG,OAAO,GAAG,QAAiB,GACzC,OAAO,CAAC,IAAI,CAAC,CAgCf;AAUD,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAKlE"}
@@ -0,0 +1,6 @@
1
+ /** Fail-closed customer/tenant mismatch without revealing row existence. */
2
+ export declare class CampaignCustomerScopeError extends Error {
3
+ readonly code = "CAMPAIGN_CUSTOMER_SCOPE";
4
+ constructor(label: string);
5
+ }
6
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,QAAQ,CAAC,IAAI,6BAA6B;gBAE9B,KAAK,EAAE,MAAM;CAI1B"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './collections/index.js';
2
+ export * from './errors.js';
2
3
  export * from './models/index.js';
3
4
  export * from './services/index.js';
4
5
  export * from './types.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAE/E,OAAO,wBAAwB,CAAC;AAEhC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAE/E,OAAO,wBAAwB,CAAC;AAEhC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC"}