Crm

Capabilities
@wabbit/tome-crmv0.5.0

Tome CRM layer — contacts, accounts, opportunities, activities with sister-layer integration adapters (intake, deals, realtime, territory, ai-stubs).

Installnpm install @wabbit/tome-crm

Overview

@wabbit/tome-crm

Tome CRM layer — contacts, accounts, opportunities, activities, with sister-layer integration adapters (intake, deals, realtime, territory, ai-stubs). Description copied verbatim from package.json.

Layer: domain (per ARCHITECTURE.md) — depends only on @wabbit/tome-core; nothing above it in the layer chain depends on it directly, but @wabbit/tome-deals and @wabbit/tome-marketing both integrate with it as an optional sister layer.

Install

pnpm add @wabbit/tome-crm

Peer ranges, copied from package.json (v0.4.3):

| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | typescript | >=5.7.0 | no | | lucide-react | >=0.460.0 | yes | | @wabbit/tome-core | >=1.1.0 <2.0.0 | no |

dependencies: server-only@^0.0.1 (guards the ./server subpath below).

60-second quickstart

The current API is a single initCrm(config) call that returns the four collection configs. Register custom activity types before calling it — the activity-type registry freezes the instant initCrm runs:

import { buildConfig } from 'payload'
import { initCrm, defineCrmActivityType } from '@wabbit/tome-crm'

defineCrmActivityType({ value: 'demo-call', label: 'Demo Call' }) // optional, must precede initCrm

export default buildConfig({
  collections: [
    ...initCrm({ matchStrategy: 'domain' }),
    // ...your other collections
  ],
})

initCrm also validates opportunityStageConfig (falls back to DEFAULT_OPPORTUNITY_STAGES — override with defineCrmStages(...)) and registers the layer into @wabbit/tome-core's layerRegistry for the admin sidebar manifest.

API surface

The exports map has four subpaths: ., ./server, ./widgets, ./test.

`.` — config-time surface, safe to import anywhere:

| Export | What it is | |---|---| | initCrm(config?) | Top-level init — returns CollectionConfig[] | | defineCrmContactCollection / defineCrmAccountCollection / defineCrmOpportunityCollection / defineCrmActivityCollection | Per-collection factories, for sites that want one collection in isolation | | defineCrmActivityType | Register a custom activity type — must be called before initCrm | | DEFAULT_OPPORTUNITY_STAGES, defineCrmStages, getStageDefinition, validateStageConfig | Opportunity-stage config helpers | | CRM_CAPABILITIES, CrmCapability | Capability vocabulary for consumer role wiring | | CRM_REALTIME_EVENTS, CrmRealtimeEvent | Realtime event name constants | | CRM_AI_FEATURES, CrmAiFeature | AI-stub feature name constants | | Types | TomeCrm{StageCategory,StageDefinition,OpportunityStageConfig,ContactLifecycleStage,AccountType,ActivityType,ActivityStatus,ActivityOutcome,Address,Activity*Payload,Contact,Account,Opportunity,Activity,MatchContactInput,MatchContactResult,ActivityTypeDefinition,Config,ExtraFields} |

`./server` — behind import 'server-only': matchOrCreateContact, advanceOpportunityStage, findContactByEmail, findOpenOpportunityForAccount, findOrCreateAccountByDomain, recordActivity, getOpportunityWithTimeline (+OpportunityTimeline type), suppression helpers (markContactUnsubscribed, markContactBounced, clearContactSuppression, isContactSendable, filterSendableContactIds, recordCrmActivityWithDedup), access presets (buildAccountDeleteGuard, crmContactsAccess, crmAccountsAccess, crmOpportunitiesAccess, crmActivitiesAccess, repWhereClause, accountRepWhereClause), and the cross-layer integration entry points: buildIntakeMatchStep/wireIntake/createIntakeOpportunity (intake), resolveOpportunityForDeal/onDealStatusChange/buildDealsCrmBridge/wireDeals (deals), lookupOwnerByZipLazy (territory), emitCrmEvent (realtime), registerCrmAiStubs (ai-stubs). All sister-layer detection is lazy/runtime via the layer registry, not a hard import.

`./widgets` and `./test` — both declared in package.json#exports but currently empty placeholders (export {}, verified by reading the files directly — no admin widgets or mock harness ship yet). Treat as reserved subpaths, not working surface.

Server / client posture

Fully server-side. The main barrel is Payload CollectionConfig factories, constants, and types — no React. ./server is hard-guarded with import 'server-only' so a client-component import fails the Next.js build rather than silently bundling. sideEffects: false in package.json (a bare boolean, not an array) — crm ships no import-side-effect registration modules.

Links

Extending this package

  • New activity types: call defineCrmActivityType before initCrm — the registry throws on duplicate keys and freezes at initCrm call time, so late registration is a hard error, not a silent no-op.
  • Custom opportunity stages: build a config with defineCrmStages(...) and pass it as initCrm({ opportunityStageConfig }); it must include at least one won-category stage or validateStageConfig throws.
  • Sister-layer integrations (deals, marketing, intake, territory) are all optional and detected via hasLayer() at runtime — no peer dependency is required to use crm standalone.

Exports

  • @wabbit/tome-crm
  • @wabbit/tome-crm/server
  • @wabbit/tome-crm/widgets
  • @wabbit/tome-crm/test

Changelog

v0.5.0minor

6bc419c: R4 rulings #2 + #3 (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.

  • 6bc419c: R4 rulings #2 + #3 (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
  • 36e537a: Email lookup paths adopt core's `normalizeEmail` instead of hand-rolled lowercasing: crm's `find-contact-by-email` / `match-or-create-contact`, and deals' `create-from-intake` — the latter was missing `.trim()`, so a padded intake email could fork a duplicate CRM contact.
  • 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
  • aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
v0.4.3patch

Admin label polish + formatted commerce money columns (PR #208): explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

  • Admin label polish + formatted commerce money columns (PR #208): explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).
v0.4.1patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
v0.4.0minor

61af0ea: Add configurable `winTriggerStatus` to the deals->CRM cascade (`TomeCrmConfig`). The linked opportunity advances to its `won` stage when a deal reaches this status. Default is `'accepted'` — unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle). Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set `winTriggerStatus: 'paid'` so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; the `paid` payment-note activity is unaffected.

  • 61af0ea: Add configurable `winTriggerStatus` to the deals->CRM cascade (`TomeCrmConfig`). The linked opportunity advances to its `won` stage when a deal reaches this status. Default is `'accepted'` — unchanged behavior: a signed deal wins the opportunity (the quote/SOW lifecycle). Consumers whose payment is decoupled from signature (e.g. proposals paid via Stripe Checkout) set `winTriggerStatus: 'paid'` so the win — and any spawn-on-won side-effect — fires on payment, not on signature. No behavior change for existing consumers; the `paid` payment-note activity is unaffected.
v0.3.2patch

b027075: Fix two consumer-breaking defects found by bickley-site-core adoption (first post-0.3.x consumer): 1. **`linkedMember` is now composition-gated on `config.memberSlug`** — previously the contacts factory built the relationship unconditionally with `relationTo: memberSlug ?? 'members'`, throwing `InvalidFieldRelationship` at Payload init for any consumer without a `members` collection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern as `sourceSubmission`/`intakeSubmissionsSlug`. Both existing consumers (wabbit-site-core, tome-starter) set `memberSlug: 'members'` explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior. 2. **`@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0`** — crm's dist imports `@wabbit/tome-core/utilities/normalize`, a subpath only exported from core 1.1.0, so the declared floor produced `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime on core 1.0.x installs.

  • b027075: Fix two consumer-breaking defects found by bickley-site-core adoption (first post-0.3.x consumer): 1. **`linkedMember` is now composition-gated on `config.memberSlug`** — previously the contacts factory built the relationship unconditionally with `relationTo: memberSlug ?? 'members'`, throwing `InvalidFieldRelationship` at Payload init for any consumer without a `members` collection. The CRM spec makes member identity consumer-wired and optional; the field now follows the same presence pattern as `sourceSubmission`/`intakeSubmissionsSlug`. Both existing consumers (wabbit-site-core, tome-starter) set `memberSlug: 'members'` explicitly and keep the field unchanged; consumers that omitted it were crashing, so no working configuration changes behavior. 2. **`@wabbit/tome-core` peer floor raised `>=1.0.0` → `>=1.1.0`** — crm's dist imports `@wabbit/tome-core/utilities/normalize`, a subpath only exported from core 1.1.0, so the declared floor produced `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime on core 1.0.x installs.
v0.3.1patch

a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
  • 4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
v0.3.0minor

c5175e9: v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit): - **`extraFields` config seam** — `TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}` appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-side `withCrmExtensions()` post-processing pattern. - **`onOpportunityStageChange` now fires on every stage transition** — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw `payload.update` calls dispatch the adapter, not just `advanceOpportunityStage()`. The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - **`findOpenOpportunityForAccount(payload, accountId, { config })`** — canonical "one open opportunity per account" dedup helper, exported from `/server`. - **`buildDealsCrmBridge(config)`** — ready-made callback for `initDeals({ onStatusChange })` (sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (`CrmDealLike`); no dependency on @wabbit/tome-deals. **`wireDeals` is deprecated** — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.

  • c5175e9: v0.3 consumer seams — promotes the four platform gaps wabbit-site-core's dogfood proved (2026-06-10 audit): - **`extraFields` config seam** — `TomeCrmConfig.extraFields.{contacts,accounts,opportunities,activities}` appends site-specific fields (attribution, lead scoring, …) after platform fields. Retires the consumer-side `withCrmExtensions()` post-processing pattern. - **`onOpportunityStageChange` now fires on every stage transition** — the opportunities collection's afterChange hook is the dispatch point, so admin-UI edits and raw `payload.update` calls dispatch the adapter, not just `advanceOpportunityStage()`. The helper suppresses the hook via request context when its own call-time config carries the adapter, so each transition dispatches exactly once. Does not fire on create. - **`findOpenOpportunityForAccount(payload, accountId, { config })`** — canonical "one open opportunity per account" dedup helper, exported from `/server`. - **`buildDealsCrmBridge(config)`** — ready-made callback for `initDeals({ onStatusChange })` (sent→activity, accepted→won, rejected→lost, paid→payment note). Structurally typed (`CrmDealLike`); no dependency on @wabbit/tome-deals. **`wireDeals` is deprecated** — it was a layer-presence probe that attached nothing (the GAP-3 trap) and will be removed in v1.0.
v0.2.0minor

Add the v0.2 marketing-substrate contract (consumed by `@wabbit/tome-marketing`). All new fields are nullable/optional — non-breaking for existing consumers. - **Suppression state + maintenance helpers:** `markContactUnsubscribed`, `markContactBounced` (soft-bounce threshold with auto-suppress), `clearContactSuppression`, `isContactSendable`, `filterSendableContactIds`. New `crm-contacts` fields `bouncedAt` / `bounceType` / `suppressionReason` / `suppressionSource`, and an `onSuppressionChange` config adapter (carries `source` so consumers can loop-guard provider suppression mirrors). - **Provider-event ingestion:** `recordCrmActivityWithDedup` with aggregate-at-ingest — a composite `aggregationKey` collapses `opened`/`clicked`/`site-visited` per contact/campaign/day, while `sent`/`replied`/`bounced`/`unsubscribed` stay 1:1 — plus a `(provider, externalId)` unique index for idempotent webhook ingestion. New `crm-activities` fields `provider` / `eventType` / `aggregationKey` / `eventCount` / `firstEventAt` / `lastEventAt`, a `buildSuppressionCascadeHook`, and `buildStampLastContactedHook` now skips provider events that are not "we contacted them". NOTE: the event-payload param on `recordCrmActivityWithDedup` is `eventPayload` (not `payload`, which is the Payload instance). - **Optional `crm-activities.campaign` relationship** (config-driven via `activityCampaignSlug`, default `marketing-campaigns`) for campaign attribution — only registered when the slug is set; CRM never imports marketing.

  • Add the v0.2 marketing-substrate contract (consumed by `@wabbit/tome-marketing`). All new fields are nullable/optional — non-breaking for existing consumers. - **Suppression state + maintenance helpers:** `markContactUnsubscribed`, `markContactBounced` (soft-bounce threshold with auto-suppress), `clearContactSuppression`, `isContactSendable`, `filterSendableContactIds`. New `crm-contacts` fields `bouncedAt` / `bounceType` / `suppressionReason` / `suppressionSource`, and an `onSuppressionChange` config adapter (carries `source` so consumers can loop-guard provider suppression mirrors). - **Provider-event ingestion:** `recordCrmActivityWithDedup` with aggregate-at-ingest — a composite `aggregationKey` collapses `opened`/`clicked`/`site-visited` per contact/campaign/day, while `sent`/`replied`/`bounced`/`unsubscribed` stay 1:1 — plus a `(provider, externalId)` unique index for idempotent webhook ingestion. New `crm-activities` fields `provider` / `eventType` / `aggregationKey` / `eventCount` / `firstEventAt` / `lastEventAt`, a `buildSuppressionCascadeHook`, and `buildStampLastContactedHook` now skips provider events that are not "we contacted them". NOTE: the event-payload param on `recordCrmActivityWithDedup` is `eventPayload` (not `payload`, which is the Payload instance). - **Optional `crm-activities.campaign` relationship** (config-driven via `activityCampaignSlug`, default `marketing-campaigns`) for campaign attribution — only registered when the slug is set; CRM never imports marketing.
v0.1.5patch

Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.1.4patch

Updated dependencies [36dc023]

  • Updated dependencies [36dc023]
  • Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
v0.1.2patch

433d892: Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer: 1. **`crm-activities` duplicate field name.** Both rich-text `body` and the textarea fallback declared `name: 'body'`, discriminated by `admin.condition`. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwing `DuplicateFieldName: 'body'` at config build (no consumer could call `payload generate:types` or boot dev). Renamed the textarea to `summary`. `TomeCrmActivity` type and `recordActivity` helper updated to route input.body to `body` for note/email types and input.summary to `summary` otherwise. 2. **Hook type narrowing fails cross-repo.** Consumers with a populated `payload-types.ts` widen `DataFromCollectionSlug<CollectionSlug>` to a 50+ collection union, hiding `lifecycleStage` / `lastContactedAt` from the dynamic-slug `findByID` results in `cascade-contact-lifecycle` and `stamp-last-contacted`. The `auto-link-account` hook also tripped Wabbit's `noUncheckedIndexedAccess`. Read results now narrow structurally to `{ lifecycleStage?: string }` / `{ lastContactedAt?: string | null }`; update payloads cast through `unknown as never`; array indexing guarded. 3. **`sourceSubmission` field hard-required `intake-submissions` collection.** The relation field on `crm-contacts` and `crm-opportunities` resolved to `config.intakeSubmissionsSlug ?? 'intake-submissions'` unconditionally, throwing Payload init for any consumer without `@wabbit/tome-intake` registered. Field is now conditional: declared only when `intakeSubmissionsSlug` is passed in config. Mirrors the composition-presence pattern used by `@wabbit/tome-lms`. No public API changes outside `TomeCrmActivity` (added `summary?: string | null`) and the `recordActivity` helper (now reads `input.summary` for non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle.

  • 433d892: Phase 1.5 dogfood findings — three bugs surfaced when Wabbit became the layer's first consumer: 1. **`crm-activities` duplicate field name.** Both rich-text `body` and the textarea fallback declared `name: 'body'`, discriminated by `admin.condition`. Payload's sanitizer rejects same-level field-name collisions regardless of conditions, throwing `DuplicateFieldName: 'body'` at config build (no consumer could call `payload generate:types` or boot dev). Renamed the textarea to `summary`. `TomeCrmActivity` type and `recordActivity` helper updated to route input.body to `body` for note/email types and input.summary to `summary` otherwise. 2. **Hook type narrowing fails cross-repo.** Consumers with a populated `payload-types.ts` widen `DataFromCollectionSlug<CollectionSlug>` to a 50+ collection union, hiding `lifecycleStage` / `lastContactedAt` from the dynamic-slug `findByID` results in `cascade-contact-lifecycle` and `stamp-last-contacted`. The `auto-link-account` hook also tripped Wabbit's `noUncheckedIndexedAccess`. Read results now narrow structurally to `{ lifecycleStage?: string }` / `{ lastContactedAt?: string | null }`; update payloads cast through `unknown as never`; array indexing guarded. 3. **`sourceSubmission` field hard-required `intake-submissions` collection.** The relation field on `crm-contacts` and `crm-opportunities` resolved to `config.intakeSubmissionsSlug ?? 'intake-submissions'` unconditionally, throwing Payload init for any consumer without `@wabbit/tome-intake` registered. Field is now conditional: declared only when `intakeSubmissionsSlug` is passed in config. Mirrors the composition-presence pattern used by `@wabbit/tome-lms`. No public API changes outside `TomeCrmActivity` (added `summary?: string | null`) and the `recordActivity` helper (now reads `input.summary` for non-rich types). v0.1.0 had zero published consumers; Wabbit's wiring is being adjusted in the same Phase 1.5 cycle.