@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 +175 -0
- package/README.md +157 -255
- package/dist/events.d.ts +2 -0
- package/dist/events.js +716 -0
- package/dist/index.d.ts +1332 -1265
- package/dist/index.js +3476 -1606
- package/dist/index2.d.ts +746 -0
- package/dist/rolldown-runtime.js +13 -0
- package/package.json +19 -5
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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/
|
|
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
|
-
##
|
|
45
|
+
## Quick start
|
|
45
46
|
|
|
46
47
|
```ts
|
|
47
|
-
import
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
await
|
|
110
|
-
await
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
123
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
`recomputeScore(id)`. Replace the default with your own:
|
|
143
|
+
## Lead scoring, forecast, conversion analytics
|
|
150
144
|
|
|
151
145
|
```ts
|
|
152
|
-
import {
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
##
|
|
161
|
+
## Events catalog (rule 18)
|
|
184
162
|
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
##
|
|
186
|
+
## Production boot
|
|
266
187
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
##
|
|
200
|
+
## What this package does NOT do
|
|
281
201
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
311
|
-
npm
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|