@classytic/crm 0.1.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,181 @@
2
2
 
3
3
  All notable changes to `@classytic/crm` are documented here.
4
4
 
5
+ ## [0.5.0] — 2026-06-14
6
+
7
+ Stack upgrade + verified correctness fixes + PACKAGE_RULES compliance. No
8
+ new features. Clean break — no deprecation aliases.
9
+
10
+ ### Changed — stack currency
11
+
12
+ - Peers bumped: `@classytic/mongokit` `>=3.16.0`, `@classytic/repo-core`
13
+ `>=0.6.0`, `@classytic/primitives` `>=0.7.1`, `mongoose` `>=9.4.1`.
14
+ Clean-installed from npm and verified resolved (3.16.0 / 0.6.0 / 0.7.2).
15
+
16
+ ### Added — PACKAGE_RULES compliance
17
+
18
+ - **Zod event catalog (rule 18).** New `crmEventDefinitions[]` (56 events,
19
+ one Zod schema each) exported from the package root and a new `./events`
20
+ subpath. Timestamps are `z.iso.datetime()`. A no-drift invariant test
21
+ asserts every `CRM_EVENTS` constant has a definition. Hosts wire these
22
+ into arc's `EventRegistry` for publish-time validation + OpenAPI.
23
+ - **Model-collision policy (rule 21).** `createCrm()` now throws
24
+ `CrmModelCollisionError` when a model name is already registered; pass
25
+ `forceRecreate: true` (test/hot-reload) to delete + re-register.
26
+ - **Index management (rule 35).** New `autoIndex` config flag (default
27
+ `true`) and `engine.syncIndexes()` for `autoIndex: false` production
28
+ boots.
29
+ - **Capability boot gate.** `createCrm()` asserts the backend supports
30
+ `upsert` / `getOrCreate` / `duplicateKeyError` (and `transactions`,
31
+ unless `allowNonTransactional: true`) so misconfigured deployments fail
32
+ loud at boot instead of mid-convert.
33
+
34
+ ### Fixed — domain correctness
35
+
36
+ - **Lead / Subscription / Campaign dedup are now race-safe.** Added
37
+ authoritative partial-unique indexes — lead `(organizationId, email)`,
38
+ campaign `(organizationId, name)`, subscription `(organizationId,
39
+ contactId, campaignId)` scoped to live statuses — and catch the
40
+ residual `E11000` via `isDuplicateKeyError`, re-throwing the same typed
41
+ domain error the pre-check emits. The prior check-then-save left a
42
+ TOCTOU window.
43
+ - **Owner reassignment is now atomic.** `assign()` on lead / account /
44
+ contact / opportunity / conversation uses a single `findOneAndUpdate`
45
+ CAS (`{ ownerId: { $ne } }` guard, `returnDocument: 'before'`) — no
46
+ read-modify-write lost-update window, and the emitted `previousOwnerId`
47
+ is the true pre-image under concurrency.
48
+ - **Cross-tenant leak in dashboard/feed reads closed.** `findRecentForFeed`,
49
+ `countCreatedSince`, `countOpen`, `sumWonSince`, `countOverduePlanned`
50
+ dropped raw `Model.find` / `countDocuments` with a manual
51
+ `if (ctx) organizationId` filter (which read ALL tenants when ctx was
52
+ absent) for the tenant-scoped `findAll` / `count` repository methods.
53
+
54
+ ### Build hygiene
55
+
56
+ - Sourcemaps and declaration maps are no longer emitted (`sourceMap` /
57
+ `declarationMap` off in tsconfig; `sourcemap: false` in tsdown) — both
58
+ leak source.
59
+ - Removed `@deprecated` markers from `ConvertResult` convenience id fields
60
+ (kept the fields; they were never deprecated, just mislabelled).
61
+ - Dropped the stale pnpm lockfile/workspace and the bundled `.tgz`;
62
+ installs are npm-from-registry only.
63
+
64
+ ### Docs
65
+
66
+ - Rewrote the README to the engine-factory API (`createCrm` + repository
67
+ domain verbs). The previous README documented the abandoned v0.1
68
+ port/adapter design (`AccountService`, `InMemory*Repository`,
69
+ `OutreachPort`) that no longer exists.
70
+
71
+ ### Tests
72
+
73
+ - Added `tests/integration/invariants.test.ts` (tenant probe, lead +
74
+ subscription dedup races, atomic reassignment) and
75
+ `tests/unit/event-catalog.test.ts` (no-drift + round-trip). 87 → 96
76
+ tests.
77
+
78
+ ## [0.4.0] — 2026-05-22
79
+
80
+ ### Changed — Subscription model renamed to `EmailSubscription`
81
+
82
+ **Breaking at the Mongo identity level.** The CRM's email opt-in resource
83
+ now registers as Mongoose model `EmailSubscription` (was `Subscription`)
84
+ and persists into the `email_subscriptions` collection (was
85
+ `subscriptions`).
86
+
87
+ **Why:** the previous name collided with `@classytic/revenue`'s
88
+ `Subscription` model when both packages were loaded on the same
89
+ Mongoose connection — `RevenueModelCollisionError` at boot. Renaming
90
+ gives the two concepts distinct identities at the DB level and lets
91
+ revenue + CRM coexist without the host having to split connections.
92
+
93
+ **TypeScript surface is unchanged.** `ISubscriptionDocument`,
94
+ `SubscriptionRepository`, `subscription.handlers.ts`,
95
+ `CrmModels.Subscription`, etc. all keep their existing names — only
96
+ the Mongoose model name string and the underlying collection name
97
+ changed. Consumers that import from `@classytic/crm` need no source
98
+ changes; the host only needs to migrate existing data (see below).
99
+
100
+ ### Migration
101
+
102
+ For DBs with existing CRM subscription rows in the legacy `subscriptions`
103
+ collection (distinguishable from revenue rows by `contactId` presence):
104
+
105
+ ```js
106
+ // MongoDB shell — one-time migration
107
+ db.subscriptions.aggregate([
108
+ { $match: { contactId: { $exists: true } } },
109
+ { $out: 'email_subscriptions' },
110
+ ]);
111
+ // Then remove the migrated docs from the legacy collection:
112
+ db.subscriptions.deleteMany({ contactId: { $exists: true } });
113
+ ```
114
+
115
+ Hosts using only CRM (no revenue) can also run this — leftover rows in
116
+ `subscriptions` won't cause boot errors because there's no collision
117
+ without revenue.
118
+
119
+ ## [0.3.0] — 2026-05-17
120
+
121
+ ### Added — `EmailEvent` domain
122
+
123
+ Closes the loop between outbound CRM sends and recipient engagement.
124
+ Hosts can now record opens, clicks, bounces, complaints, and unsubscribes
125
+ against a Contact / Subscription / Campaign tuple — and downstream
126
+ services (drip-sequence "if opened" branches, lead scoring, suppression
127
+ lists) consume the same events without polling the email transport.
128
+
129
+ - **Entity & enum** — `EmailEvent` (append-only) plus the
130
+ `EmailEventKind` enum (`open` / `click` / `bounce` / `complaint` /
131
+ `unsubscribe`) split into `ENGAGEMENT_KINDS` and `SUPPRESSION_KINDS`
132
+ helper sets for classification at call sites.
133
+ - **Port** — `EmailEventRepository` with filtered list, per-subject
134
+ timeline, per-campaign aggregates (`uniqueOpens`, `uniqueClicks`,
135
+ `bounces`, `complaints`, `unsubscribes`, `totalEvents`), and a
136
+ GDPR-style `deleteForSubject()` purge.
137
+ - **Service** — `EmailEventService.recordOpen()` /
138
+ `.recordClick({ url })` / `.recordBounce()` / `.recordComplaint()` /
139
+ `.recordUnsubscribe()` + read-side `.listForSubject()`,
140
+ `.aggregateForCampaign()`, and `.hasEngaged(subjectId, campaignId,
141
+ kind)` for drip-condition checks.
142
+ - **Events** — `crm:email_event.opened` / `.clicked` / `.bounced` /
143
+ `.complained` / `.unsubscribed` with Zod-validated payloads in
144
+ `event-catalog`, wired into `crmEventDefinitions[]`.
145
+ - **In-memory adapter** — `InMemoryEmailEventRepository` (tests +
146
+ dev). Hosts implement against Mongo / Postgres / etc.
147
+
148
+ ### Added — re-exports from `@classytic/primitives@0.6.0`
149
+
150
+ To eliminate duplicate type imports at consumer call sites:
151
+
152
+ ```ts
153
+ import {
154
+ appendStatus,
155
+ StatusChangeEntry,
156
+ StatusHistory,
157
+ timeInStatus,
158
+ lastTransitionTo,
159
+ } from '@classytic/crm';
160
+ ```
161
+
162
+ These pass through from `@classytic/primitives/status-history`. The
163
+ existing entity-specific `LeadStatusEntry` / `OpportunityStatusEntry` /
164
+ etc. names are still exported for back-compat — they remain structurally
165
+ compatible with `StatusChangeEntry<TStatus>`.
166
+
167
+ ### Changed
168
+
169
+ - Peer dep `@classytic/primitives` bumped from `>=0.5.0` to `>=0.6.0`
170
+ to access the new `status-history` subpath and value-typed primitives.
171
+ - Test harness `buildHarness()` now also exposes an
172
+ `emailEvents: EmailEventService` and `emailEventRepo` for integration
173
+ tests that exercise the engagement → drip path.
174
+
175
+ ### Dependencies
176
+
177
+ - `@classytic/primitives@>=0.6.0` (peer, was `>=0.5.0`)
178
+ - `zod@>=4.0.0` (peer, unchanged)
179
+
5
180
  ## [0.1.0] — 2026-04-18
6
181
 
7
182
  Initial release.
package/README.md CHANGED
@@ -1,37 +1,37 @@
1
1
  # @classytic/crm
2
2
 
3
- Framework-agnostic CRM primitive for Node.js + Bun — the base to build
4
- the next Pipedrive, HubSpot, Salesforce, or Buffer-style outreach
5
- automation on top of.
6
-
7
- - **Pure TypeScript.** No Mongo, no HTTP, no framework dep.
8
- - **Event-driven.** 19 lifecycle events out of the box — any
9
- subscribing automation layer plugs in without modifying the core.
10
- - **Port-based storage.** Plug in Mongoose / Prisma / your existing
11
- data store per entity (8 repo interfaces, 1 optional outreach
12
- bridge). In-memory reference implementations shipped for tests +
13
- standalone use.
14
- - **Atomic state transitions.** Every lead / opportunity / activity
15
- status change goes through a CAS against the expected status.
16
- - **Pluggable lead scoring.** `defaultLeadScorer` is replaceable with
17
- `composeScorers(...rules)` or any `(lead: Lead) => number`.
18
- - **Forecast + pipeline-conversion analytics.** Pure functions over
19
- opportunity lists — safe to run in any context.
20
- - **Polymorphic activities + notes.** Attach to Lead, Opportunity,
21
- Contact, or Account. Automation subscribes to `crm:activity.*` and
22
- does its thing — email, SMS, calendar booking, Slack ping, LLM
23
- summary.
24
-
25
- ## Why this matters for the Node / Bun community
26
-
27
- Most CRMs ship as monolithic apps. This package is the **headless
28
- engine** — it owns the entities, state machines, events, and
29
- analytics, and gets out of your way on everything else. Want a
30
- Buffer-style scheduled-outreach app? Subscribe to `crm:activity.planned`
31
- with `type: 'email'`, queue the send. Want an AI-written follow-up
32
- layer? Subscribe to `crm:opportunity.stage_advanced`, call an LLM,
33
- `activities.plan(...)` the generated task. None of that lives in this
34
- package; all of it is unblocked by it.
3
+ Engine-factory CRM package for the Classytic commerce stack — leads,
4
+ accounts, contacts, opportunities, pipelines, campaigns, double-opt-in
5
+ subscriptions, polymorphic activities + notes, email-engagement events,
6
+ and conversation/message threads. Built on **Mongoose + @classytic/mongokit
7
+ + @classytic/repo-core + @classytic/primitives**, PACKAGE_RULES compliant.
8
+
9
+ > **Architecture (0.4.0+):** this is an engine-factory mongokit package,
10
+ > NOT the pure-TS port-based design earlier docs described.
11
+ > `createCrm({ connection })` returns Mongoose models, `Repository`
12
+ > subclasses (domain verbs live on the repos), and an `EventTransport`.
13
+ > The ports / adapters / in-memory-repo layer is gone.
14
+
15
+ - **Repositories ARE the API.** Each repo `extends Repository<TDoc>` from
16
+ mongokit and inherits CRUD, pagination, query parsing, aggregation, and
17
+ transactions. Domain verbs (`qualify`, `convert`, `win`, `subscribe`,
18
+ `confirm`, `merge`, …) live on the same class.
19
+ - **Atomic state transitions.** Every lifecycle move goes through
20
+ `assertAndClaim` (state-machine legality check + `repo.claim()` CAS).
21
+ Concurrent qualifiers / stage-moves / activations can't both win;
22
+ race-losses surface as typed transition errors.
23
+ - **Index-backed dedup.** Lead email, campaign name, and live
24
+ `(contact, campaign)` subscriptions are guarded by partial-unique
25
+ indexes; the residual `E11000` is caught via `isDuplicateKeyError` and
26
+ re-thrown as the same typed domain error the pre-check emits — no
27
+ check-then-save TOCTOU window.
28
+ - **Multi-tenant by default.** `multiTenantPlugin` is wired on every repo
29
+ with `required: true`; tenant scope is injected at POLICY priority and
30
+ cannot be forgotten. A read with no `organizationId` throws.
31
+ - **Event-driven extension plane.** Every lifecycle change publishes a
32
+ `crm:<resource>.<verb>` event through an arc-compatible `EventTransport`.
33
+ Automation (outreach, AI follow-up, analytics) subscribes with globs;
34
+ none of it lives in this package.
35
35
 
36
36
  ## Install
37
37
 
@@ -39,283 +39,185 @@ package; all of it is unblocked by it.
39
39
  npm install @classytic/crm
40
40
  ```
41
41
 
42
- `@classytic/primitives` is the only peer (for `Money`).
42
+ Peers: `@classytic/mongokit >=3.16.0`, `@classytic/repo-core >=0.6.0`,
43
+ `@classytic/primitives >=0.7.1`, `mongoose >=9.4.1`, `zod >=4.0.0`.
43
44
 
44
- ## Full lead-to-customer flow
45
+ ## Quick start
45
46
 
46
47
  ```ts
47
- import {
48
- AccountService,
49
- ActivityService,
50
- ContactService,
51
- LeadService,
52
- OpportunityService,
53
- PipelineService,
54
- InMemoryAccountRepository,
55
- InMemoryActivityRepository,
56
- InMemoryContactRepository,
57
- InMemoryLeadRepository,
58
- InMemoryNoteRepository,
59
- InMemoryOpportunityRepository,
60
- InMemoryPipelineRepository,
61
- InMemoryEventTransport,
62
- } from '@classytic/crm';
63
-
64
- const events = new InMemoryEventTransport();
65
-
66
- const accounts = new AccountService({ repo: new InMemoryAccountRepository(), events });
67
- const contacts = new ContactService({ repo: new InMemoryContactRepository(), events });
68
- const pipelines = new PipelineService({ repo: new InMemoryPipelineRepository(), events });
69
- const opportunities = new OpportunityService({
70
- repo: new InMemoryOpportunityRepository(),
71
- pipelines,
72
- events,
73
- });
74
- const leads = new LeadService({
75
- repo: new InMemoryLeadRepository(),
76
- contacts,
77
- accounts,
78
- opportunities,
79
- events,
80
- });
81
- const activities = new ActivityService({
82
- repo: new InMemoryActivityRepository(),
83
- notes: new InMemoryNoteRepository(),
84
- events,
48
+ import mongoose from 'mongoose';
49
+ import { createCrm, LeadService } from '@classytic/crm';
50
+
51
+ const crm = await createCrm({
52
+ connection: mongoose.connection,
53
+ tenantFieldType: 'objectId', // matches Better Auth orgs; 'string' for UUID hosts
85
54
  });
86
55
 
56
+ const ctx = { organizationId: orgId };
57
+
87
58
  // Pipeline setup.
88
- const pipeline = await pipelines.create({
89
- name: 'New Business',
90
- stages: [
91
- { name: 'Prospecting', sequence: 1, defaultProbability: 0.1 },
92
- { name: 'Discovery', sequence: 2, defaultProbability: 0.3 },
93
- { name: 'Proposal', sequence: 3, defaultProbability: 0.6 },
94
- { name: 'Negotiation', sequence: 4, defaultProbability: 0.8 },
95
- ],
96
- });
59
+ const pipeline = await crm.repositories.pipeline.create(
60
+ {
61
+ name: 'New Business',
62
+ stages: [
63
+ { name: 'Prospecting', sequence: 1, defaultProbability: 0.1 },
64
+ { name: 'Discovery', sequence: 2, defaultProbability: 0.3 },
65
+ { name: 'Proposal', sequence: 3, defaultProbability: 0.6 },
66
+ { name: 'Negotiation', sequence: 4, defaultProbability: 0.8 },
67
+ ],
68
+ },
69
+ ctx,
70
+ );
97
71
 
98
- // Inbound lead.
99
- const lead = await leads.create({
100
- email: 'alex@acme.co',
101
- firstName: 'Alex',
102
- lastName: 'Rivera',
103
- companyName: 'Acme',
104
- jobTitle: 'Director',
105
- source: 'web-form',
106
- });
72
+ // Inbound lead — auto-scores + emits crm:lead.created.
73
+ const lead = await crm.repositories.lead.createLead(
74
+ {
75
+ email: 'alex@acme.co',
76
+ firstName: 'Alex',
77
+ lastName: 'Rivera',
78
+ companyName: 'Acme',
79
+ jobTitle: 'Director',
80
+ source: 'web-form',
81
+ },
82
+ ctx,
83
+ );
84
+ const leadId = String(lead._id);
107
85
 
108
- // Qualify + convert — spawns Contact + Account + Opportunity atomically.
109
- await leads.markContacted(lead.id);
110
- await leads.qualify(lead.id);
111
- const { opportunityId } = await leads.convert(lead.id, {
112
- pipelineId: pipeline.id,
113
- amount: { amount: 120_000, currency: 'USD' },
114
- expectedCloseAt: new Date('2026-06-30'),
115
- });
86
+ // Qualify, then convert — spawns Account + Contact + Opportunity.
87
+ await crm.repositories.lead.markContacted(leadId, {}, ctx);
88
+ await crm.repositories.lead.qualify(leadId, {}, ctx);
116
89
 
117
- // Move through pipeline.
118
- await opportunities.moveToStage(opportunityId, { stageId: pipeline.stages[1]!.id });
119
- await opportunities.moveToStage(opportunityId, { stageId: pipeline.stages[2]!.id });
120
- await opportunities.moveToStage(opportunityId, { stageId: pipeline.stages[3]!.id });
90
+ const leads = LeadService.fromEngine(crm);
91
+ const { opportunityId } = await leads.convert(
92
+ leadId,
93
+ { pipelineId: String(pipeline._id), amount: 120_000, currency: 'USD' },
94
+ ctx,
95
+ );
121
96
 
122
- // Close as won.
123
- await opportunities.win(opportunityId, { amount: { amount: 125_000, currency: 'USD' } });
97
+ // Move through the pipeline (atomic CAS on stageId).
98
+ const stages = pipeline.stages;
99
+ await crm.repositories.opportunity.moveToStage(opportunityId, { stageId: String(stages[1]._id) }, ctx);
100
+ await crm.repositories.opportunity.moveToStage(opportunityId, { stageId: String(stages[2]._id) }, ctx);
101
+
102
+ // Close as won — terminal, immutable.
103
+ await crm.repositories.opportunity.win(opportunityId, { amount: 125_000 }, ctx);
104
+
105
+ // React to events from anywhere.
106
+ crm.events.subscribe('crm:opportunity.*', (e) => console.log(e.type));
107
+ ```
108
+
109
+ ### Atomic conversion under a transaction
110
+
111
+ `LeadService.convert()` writes across four repositories (Account, Contact,
112
+ Opportunity, Lead). Wrap the call in `connection.transaction()` and thread
113
+ the session through `ctx.session` so all four writes commit or roll back
114
+ together (requires a replica set):
115
+
116
+ ```ts
117
+ await mongoose.connection.transaction(async (session) => {
118
+ await leads.convert(leadId, { pipelineId, amount: 120_000 }, { ...ctx, session });
119
+ });
124
120
  ```
125
121
 
126
122
  ## State machines
127
123
 
128
124
  ```
129
125
  Lead: new → contacted → qualified → converted
130
- ↘ ↘ ↘
131
- disqualified / nurturing ←──────┐
132
- │
133
- (nurturing → contacted/qualified)
126
+ new/contacted/qualified → disqualified | nurturing
127
+ nurturing → contacted | qualified
128
+
129
+ Opportunity: open → won | lost | abandoned (terminal; stage moves while open)
134
130
 
135
- Opportunity: open → won | lost | abandoned (terminal)
136
- (stage movement happens while open)
131
+ Campaign: draft → active ⇄ paused → completed → archived
132
+
133
+ Subscription: pending → confirmed → unsubscribed | bounced | complained
137
134
 
138
135
  Activity: planned → in_progress → completed
139
- ↘ ↘
140
- cancelled (from any non-terminal)
136
+ planned/in_progress → cancelled
141
137
  ```
142
138
 
143
- Each transition is CAS-guarded and appends to the record's
144
- `statusHistory`. Invalid transitions throw typed errors.
145
-
146
- ## Lead scoring
139
+ Every transition is CAS-guarded and appends to the record's
140
+ `statusHistory`. Invalid transitions throw typed errors;
141
+ `*_TRANSITIONS` maps are the canonical source of truth.
147
142
 
148
- `LeadService` runs the scorer on `create` and on-demand via
149
- `recomputeScore(id)`. Replace the default with your own:
143
+ ## Lead scoring, forecast, conversion analytics
150
144
 
151
145
  ```ts
152
- import { LeadService, composeScorers } from '@classytic/crm';
146
+ import { composeScorers, forecast, pipelineConversion } from '@classytic/crm';
153
147
 
148
+ // Custom scorer (replaces defaultLeadScorer at repo construction):
154
149
  const scorer = composeScorers(
155
150
  (l) => (l.email ? 20 : 0),
156
151
  (l) => (l.source === 'referral' ? 30 : 0),
157
- (l) => (l.tags?.includes('enterprise') ? 50 : 0),
158
152
  (l) => (/ceo|cto|founder/i.test(l.jobTitle ?? '') ? 40 : 0),
159
- // Plug in ML / enrichment predictions:
160
- (l) => myEnricher.scoreFor(l),
161
153
  );
162
154
 
163
- new LeadService({ repo, contacts, accounts, opportunities, scorer });
164
- ```
165
-
166
- ## Forecast + pipeline conversion
167
-
168
- ```ts
169
- import { forecast, pipelineConversion } from '@classytic/crm';
170
-
171
- const open = await opportunities.list({ status: 'open' });
172
- const rows = forecast(open); // default: ISO-month buckets
173
- // [{ bucket: '2026-06', committed: 120_000, weighted: 96_000, count: 1, currency: 'USD' }]
174
-
175
- // Custom buckets (quarters, fiscal years, per-team, …):
176
- forecast(open, { bucket: (o) => quarterOf(o.expectedCloseAt!) });
177
-
178
- // Funnel analysis: where do deals die?
179
- const all = await opportunities.list();
180
- const conv = pipelineConversion(all, pipeline.stages.map((s) => s.id));
155
+ // Pure analytics over opportunity lists (no I/O):
156
+ const open = await crm.repositories.opportunity.findAll({ status: 'open' }, ctx);
157
+ const rows = forecast(open); // ISO-month buckets: committed / weighted / count
158
+ const conv = pipelineConversion(open, pipeline.stages.map((s) => String(s._id)));
181
159
  ```
182
160
 
183
- ## Activities + notes (polymorphic)
161
+ ## Events catalog (rule 18)
184
162
 
185
- Attach to any entity via `subjectKind` + `subjectId`:
163
+ Every `CRM_EVENTS` constant has a Zod definition in `crmEventDefinitions`,
164
+ exported from the package root and the `./events` subpath:
186
165
 
187
166
  ```ts
188
- await activities.plan({
189
- type: 'email',
190
- subjectKind: 'lead',
191
- subjectId: lead.id,
192
- subject: 'Follow-up on demo',
193
- scheduledAt: new Date('2026-04-20T15:00:00Z'),
194
- ownerId: 'ae_1',
195
- });
196
-
197
- // Quick note on an opportunity:
198
- await activities.addNote({
199
- subjectKind: 'opportunity',
200
- subjectId: opp.id,
201
- body: 'Champion confirmed — legal review starts Monday.',
202
- authorId: 'ae_1',
203
- });
167
+ import { crmEventDefinitions, CRM_EVENTS } from '@classytic/crm/events';
204
168
 
205
- // Pull everything for an entity:
206
- const allActivities = await activities.listBySubject('opportunity', opp.id);
207
- const allNotes = await activities.listNotesBySubject('opportunity', opp.id);
169
+ for (const def of crmEventDefinitions) registry.register(def); // arc EventRegistry
208
170
  ```
209
171
 
210
- ## Events catalog
211
-
212
- Subscribe via `EventTransport` from `@classytic/primitives/events`
213
- (the same contract mirrored from Arc) — Arc's bus, Redis, in-memory,
214
- Kafka, or any transport implementing that shape works.
215
-
216
- | Event | When |
217
- |---|---|
218
- | `crm:lead.created` | new lead |
219
- | `crm:lead.status_changed` | any transition |
220
- | `crm:lead.scored` | score recomputed |
221
- | `crm:lead.converted` | spawns Contact + Account + Opportunity |
222
- | `crm:lead.disqualified` | with reason |
223
- | `crm:account.created` / `updated` | |
224
- | `crm:contact.created` / `updated` | |
225
- | `crm:opportunity.created` | |
226
- | `crm:opportunity.stage_advanced` | forward motion |
227
- | `crm:opportunity.stage_regressed` | backward motion (slip) |
228
- | `crm:opportunity.won` / `lost` / `abandoned` | |
229
- | `crm:pipeline.created` / `archived` | |
230
- | `crm:activity.planned` / `completed` / `cancelled` | |
231
- | `crm:note.added` | |
232
-
233
- This event surface is the extension plane — automation tools
234
- subscribe with globs (`crm:*`, `crm:lead.*`) and do their thing
235
- without touching the core package.
236
-
237
- ## Optional outreach bridge
238
-
239
- `OutreachPort` is a one-method interface for sending email / SMS /
240
- social. The package doesn't call it automatically — wire it in your
241
- automation layer:
172
+ Hosts wire these into arc's `EventRegistry` for publish-time validation and
173
+ OpenAPI introspection. Subscribe glob-style with any arc transport (Memory,
174
+ Redis, Kafka, BullMQ):
242
175
 
243
176
  ```ts
244
- import { CRM_EVENTS } from '@classytic/crm';
245
-
246
- events.subscribe(CRM_EVENTS.ACTIVITY_PLANNED, async (event) => {
247
- const { activityId } = event.payload as { activityId: string };
248
- const activity = await activities.findById(activityId);
249
- if (activity?.type !== 'email' || !activity.metadata?.toAddress) return;
250
- await outreach.send({
251
- channel: 'email',
252
- toAddress: activity.metadata.toAddress,
253
- subject: activity.subject,
254
- body: activity.body!,
255
- activityId: activity.id,
256
- });
257
- await activities.complete(activity.id);
258
- });
177
+ crm.events.subscribe('crm:lead.*', (e) => routeLead(e));
178
+ crm.events.subscribe('crm:opportunity.won', (e) => createOrder(e));
259
179
  ```
260
180
 
261
- `InMemoryOutreachPort` records sends for tests; `NullOutreachPort` is
262
- a no-op; real integrations wrap SendGrid, Postmark, Twilio, or an
263
- in-house provider.
181
+ Event families: `crm:lead.*`, `crm:account.*`, `crm:contact.*`,
182
+ `crm:opportunity.*`, `crm:pipeline.*`, `crm:campaign.*`,
183
+ `crm:subscription.*`, `crm:activity.*`, `crm:note.*`, `crm:email_event.*`,
184
+ `crm:conversation.*`, `crm:message.*`.
264
185
 
265
- ## What v0.1 does NOT include
186
+ ## Production boot
266
187
 
267
- Kept off v0.1 to ship a focused surface — each is small to bolt on
268
- via the event system:
188
+ ```ts
189
+ // Build indexes from a deploy script, not on cold boot (rule 35):
190
+ const crm = await createCrm({ connection, autoIndex: false });
191
+ // once per deploy:
192
+ await crm.syncIndexes();
193
+ ```
269
194
 
270
- - Campaigns / sequences (build via activity templates + cron).
271
- - Territory / team routing (ownership fields are there — use them).
272
- - Appointment booking (host integrates calendar).
273
- - Email thread parsing (host integrates inbox).
274
- - Custom-field schema validation (host owns the `metadata` map).
275
- - Role-based access control (host owns auth).
276
- - Audit log (host subscribes to events + persists).
277
- - Competitor tracking (free-form metadata for now).
278
- - Contract management (out of scope — use an invoicing package).
195
+ The engine asserts the backend's required capabilities at boot
196
+ (`upsert`, `getOrCreate`, `duplicateKeyError`, and `transactions` unless
197
+ `allowNonTransactional: true`) so a misconfigured deployment fails loud
198
+ instead of mid-convert.
279
199
 
280
- ## Architecture
200
+ ## What this package does NOT do
281
201
 
282
- ```
283
- src/
284
- ├── index.ts ← main barrel
285
- ├── domain/
286
- │ ├── entities/ ← Account, Contact, Lead, Opportunity, Pipeline,
287
- │ │ Activity, Note, LossReason
288
- │ ├── enums/ ← lead-status, opportunity-status, activity (+ subject-kinds)
289
- │ ├── ports/ ← 8 repo ports + OutreachPort + Clock + Ids
290
- │ └── errors.ts
291
- ├── adapters/in-memory/ ← reference repos + null/recording outreach + events + ids
292
- ├── services/
293
- │ ├── account.service.ts
294
- │ ├── activity.service.ts ← activities + notes
295
- │ ├── contact.service.ts
296
- │ ├── lead.service.ts ← includes convert() — crown-jewel verb
297
- │ ├── opportunity.service.ts ← stage movement + win/lose/abandon
298
- │ ├── pipeline.service.ts
299
- │ ├── scoring.ts ← pure lead scorer + composeScorers
300
- │ └── forecast.ts ← pure forecast() + pipelineConversion()
301
- └── events/
302
- └── event-constants.ts ← CRM_EVENTS
303
- ```
202
+ - No HTTP routes / auth / RBAC — the host (arc) owns those.
203
+ - No calendar / inbox integration — bridge at the host.
204
+ - No outreach sending — subscribe to `crm:activity.planned` and call your
205
+ own provider, then `activities.complete(id)`.
206
+ - No custom-field validation — `metadata` is `Record<string, unknown>`.
207
+ - Cross-entity re-pointing on contact merge is the host's job (subscribe to
208
+ `crm:contact.merged`).
304
209
 
305
210
  ## Tests
306
211
 
307
- 48 tests across unit + integration:
308
-
309
212
  ```bash
310
- npm test # unit + integration (default)
311
- npm run test:unit
312
- npm run test:integration
213
+ npm run test:unit # pure: scoring, forecast, state machines, event-catalog no-drift
214
+ npm test # unit + integration (mongodb-memory-server replica set)
313
215
  ```
314
216
 
315
- The integration scenario in [tests/integration/lead-to-customer.test.ts](tests/integration/lead-to-customer.test.ts)
316
- is the MOVEMENTS.md §7 gate: **lead → opportunity → customer end-to-end** —
317
- walks the full happy path, the lost-path, and the nurture-then-requalify
318
- path, asserting cross-entity links, event order, and forecast roll-up.
217
+ Integration coverage includes the lead → opportunity → customer scenario,
218
+ opportunity pipeline, subscription DOI, conversation/message threads,
219
+ agentic verbs, multi-tenant isolation, and `invariants.test.ts` (tenant
220
+ probe + lead/subscription dedup races + atomic owner reassignment).
319
221
 
320
222
  ## License
321
223