@classytic/crm 0.8.0 → 0.9.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +363 -342
  2. package/dist/index.js +98 -93
  3. package/package.json +73 -73
package/CHANGELOG.md CHANGED
@@ -1,342 +1,363 @@
1
- # Changelog
2
-
3
- All notable changes to `@classytic/crm` are documented here.
4
-
5
- ## [0.8.0] — 2026-07-11
6
-
7
- ### Added — engagement-based lead scoring
8
- - **`ILeadEngagement` on the Lead** (`{ opens, clicks, replies, activities, lastEngagedAt? }`, nested default `{}` so old rows read as zeros). Behavioural counters, distinct from firmographic fields.
9
- - **`LeadRepository.recordEngagement(id, signal, ctx)`** where `signal ∈ 'open'|'click'|'reply'|'activity'` (`LeadEngagementSignal`). Atomic `$inc` on the matching counter + `lastEngagedAt` via `findOneAndUpdate`, then `recomputeScore`. Hosts feed it off `crm:message.received` (reply), `crm:activity.*` (activity), and email open/click tracking.
10
- - **`defaultLeadScorer` now weighs engagement** on top of firmographic fit: reply ×25 · click ×8 · open ×2 · activity ×5, capped at +80. `LeadInput` picks up `engagement`. Pure/deterministic — no wall-clock. Absent engagement scores as zero (score unchanged for pre-existing leads until a signal lands).
11
-
12
- ## [0.7.0] — 2026-07-05
13
-
14
- ### Added — org-wide email suppression list
15
-
16
- - **`Suppression` entity** (`crm.repositories.suppression`, model
17
- `Suppression`): global per-org "never email this address again" list —
18
- the gate hosts consult before EVERY outbound email. Unsubscribes on
19
- `Subscription` stay per-campaign; suppression is org-wide.
20
- - `suppress(email, { reason, source?, subjectKind?, subjectId?, metadata? }, ctx?)`
21
- — idempotent `getOrCreate` on the unique `(organizationId, email)`
22
- index (no check-then-save); emits `crm:suppression.added` only when
23
- THIS call inserted the row.
24
- - `isSuppressed(email, ctx?)` → boolean.
25
- - `filterSuppressed(emails, ctx?)` → `Set<string>` of the suppressed
26
- subset — ONE `$in` query for the campaign-fanout hot path.
27
- - `unsuppress(email, ctx?)` — atomic delete; emits
28
- `crm:suppression.removed` only when a row was actually removed.
29
- - All verbs normalize emails (trim + lowercase; schema stores lowercase).
30
- - **`SUPPRESSION_REASONS`** (`'bounce' | 'complaint' | 'unsubscribe' |
31
- 'manual'`) — the first three `satisfies readonly EmailEventKind[]` (P6),
32
- so a rename in `EMAIL_EVENT_KINDS` fails compilation here.
33
- - **Events** `crm:suppression.added` / `crm:suppression.removed` — Zod
34
- catalog definitions included (no-drift invariant stays green);
35
- `organizationId` rides event meta as usual.
36
- - **Validators** `suppressSchema` / `unsuppressSchema` /
37
- `suppressionFilterSchema`.
38
-
39
- ### Added — reply-detection exit verb
40
-
41
- - **`EnrollmentRepository.exitActiveOnReply(subjects, ctx?)`** — the ONE
42
- method a host's `crm:message.received` subscriber calls: finds ACTIVE
43
- enrollments for the given `{ subjectKind, subjectId }` list, batch-loads
44
- their sequences in a single `$in` query, skips those with
45
- `replyDetectionEnabled === false`, and exits the rest via `exit(id,
46
- 'reply')` (each exit emits `crm:enrollment.exited`). Lost CAS races
47
- (concurrent pause/complete/exit) are skipped, not failed. Returns the
48
- enrollments this call exited.
49
- - `EnrollmentRepositoryDeps` now carries `sequences: SequenceRepository`
50
- (same cross-repo deps idiom as `OpportunityRepository.pipelines` /
51
- `MessageRepository.conversations`) — wired automatically by
52
- `createCrm()`; only hosts constructing `EnrollmentRepository` by hand
53
- need to pass it.
54
-
55
- ## [0.6.0] — 2026-07-02
56
-
57
- Breaking-in-behavior release (ships as MINOR per house convention):
58
- outbox-first, session-safe event dispatch — the previously deferred
59
- PACKAGE_RULES §P8/§P8.1 work.
60
-
61
- ### Added — unit-of-work contract (P8.1, shared with refit/review/cart/invoice)
62
-
63
- - **`engine.withTransaction(ctx, body)`** — runs `body` inside a MongoDB
64
- transaction with post-commit event discipline: events dispatched through
65
- `txCtx` are queued under `ctx[PENDING_EVENTS]` and flushed to the
66
- transport only AFTER commit; the queue resets on retry attempts; an abort
67
- leaks nothing to in-process subscribers. Reuses the caller's session (and
68
- queue) when `ctx.session` is already set. Standalone-Mongo dev boots opt
69
- in via the existing `allowNonTransactional: true`.
70
- - **`PENDING_EVENTS`** (`Symbol.for('@classytic/crm:pendingEvents')`) +
71
- **`flushPending(transport, queue, logger?)`** + **`runUnit`** exported
72
- from the root for hosts composing their own outer transaction.
73
- - **`CrmConfig.outbox?: OutboxStore`** (from `@classytic/primitives/outbox`)
74
- — when wired, every domain event is persisted via
75
- `outbox.save(event, { session })` in the SAME session as the business
76
- write; the host's relay publishes committed rows. Outbox save failures
77
- PROPAGATE (P8: a swallowed save means the write persists while its event
78
- vanishes).
79
- - **`CrmEventDispatcher`** — EventTransport-compatible wrapper repositories
80
- route every event through (`engine.events` is now this wrapper; the host
81
- `publish`/`subscribe` surface is unchanged).
82
- - **`UnmanagedSessionError`** (`code: 'UNMANAGED_SESSION'`) — a host-owned
83
- `ctx.session` arriving with neither an outbox nor a `PENDING_EVENTS`
84
- queue now throws instead of ghost-publishing mid-transaction.
85
- **Behavioral break:** callers that previously threaded a bare session and
86
- tolerated ghost events must switch to `engine.withTransaction` (or wire
87
- an outbox / attach a queue).
88
-
89
- ### Added — timezone-aware forecasting
90
-
91
- - **`ForecastOptions.timeZone`** — IANA zone for the default month bucket
92
- (e.g. `'Asia/Dhaka'`). Without it, months cut at UTC boundaries: a deal
93
- expected to close July 1, 02:00 Dhaka time (June 30, 20:00 UTC) files
94
- under JUNE. Uses `civilDateOf` from `@classytic/primitives/timezone`.
95
- Peer floor bumped: `@classytic/primitives >=0.9.0`.
96
-
97
- ### Fixed — sequence/enrollment (completes the 0.6.0 WIP)
98
-
99
- - **Event catalog no-drift restored**: 10 missing Zod definitions added
100
- (`crm:sequence.{created,active,paused,archived}`,
101
- `crm:enrollment.{created,step_sent,paused,resumed,completed,exited}`) —
102
- `crm:sequence.created` and `crm:enrollment.step_sent` are host-emitted
103
- (create via `def.create()`), the rest publish in-repo.
104
- - **`EnrollmentRepository.setWorkflowRunId` / `advanceStep` no longer
105
- bypass the repository**: both used raw `model.findOneAndUpdate` with a
106
- manual tenant filter — escaping `multiTenantPlugin` scoping AND
107
- `ctx.session` (a call inside `withTransaction` would commit outside the
108
- transaction). `advanceStep` is now a `claim()` CAS on `currentStepIndex`
109
- with `where: { status: 'active' }`; `setWorkflowRunId` goes through
110
- `repo.update`.
111
-
112
- ### Tests
113
-
114
- - 14 new: `tests/unit/dispatch.test.ts` (6 — outbox propagation/session
115
- routing/guard/swallow), `tests/integration/unit-of-work.test.ts` (3 —
116
- convert() on a replica set: zero mid-tx events, post-commit flush, abort
117
- leaks nothing, bare-session guard), `tests/unit/forecast-timezone.test.ts`
118
- (5 — the +06:00 month-boundary trap). 110/110 total.
119
-
120
- ## [0.5.1] — 2026-06-14
121
-
122
- ### Fixed — declaration bundle
123
-
124
- - **fix: declaration bundle exported mangled aliases; consumer named imports
125
- now resolve.** The published `dist/index.d.ts` re-exported every public
126
- symbol under a minified alias (`export { AccountRepository as A, CRM_EVENTS
127
- as Rr, … }`), so downstream `import { AccountRepository, CRM_EVENTS } from
128
- '@classytic/crm'` failed with TS2305 / TS2460. Root cause: the `./events`
129
- entry re-exports a strict subset of the `index` barrel, which made
130
- `rolldown-plugin-dts` alias every name on the superset (`index`) entry when
131
- both were emitted in a single pass. Build now runs `index` and `events` as
132
- two independent tsdown passes (array config) so each `.d.ts` exports real
133
- names. `tsdown` bumped `^0.14.2` → `^0.21.10` to match sibling packages.
134
- No runtime/API change.
135
-
136
- ## [0.5.0] — 2026-06-14
137
-
138
- Stack upgrade + verified correctness fixes + PACKAGE_RULES compliance. No
139
- new features. Clean break — no deprecation aliases.
140
-
141
- ### Changed — stack currency
142
-
143
- - Peers bumped: `@classytic/mongokit` `>=3.16.0`, `@classytic/repo-core`
144
- `>=0.6.0`, `@classytic/primitives` `>=0.7.1`, `mongoose` `>=9.4.1`.
145
- Clean-installed from npm and verified resolved (3.16.0 / 0.6.0 / 0.7.2).
146
-
147
- ### Added — PACKAGE_RULES compliance
148
-
149
- - **Zod event catalog (rule 18).** New `crmEventDefinitions[]` (56 events,
150
- one Zod schema each) exported from the package root and a new `./events`
151
- subpath. Timestamps are `z.iso.datetime()`. A no-drift invariant test
152
- asserts every `CRM_EVENTS` constant has a definition. Hosts wire these
153
- into arc's `EventRegistry` for publish-time validation + OpenAPI.
154
- - **Model-collision policy (rule 21).** `createCrm()` now throws
155
- `CrmModelCollisionError` when a model name is already registered; pass
156
- `forceRecreate: true` (test/hot-reload) to delete + re-register.
157
- - **Index management (rule 35).** New `autoIndex` config flag (default
158
- `true`) and `engine.syncIndexes()` for `autoIndex: false` production
159
- boots.
160
- - **Capability boot gate.** `createCrm()` asserts the backend supports
161
- `upsert` / `getOrCreate` / `duplicateKeyError` (and `transactions`,
162
- unless `allowNonTransactional: true`) so misconfigured deployments fail
163
- loud at boot instead of mid-convert.
164
-
165
- ### Fixed — domain correctness
166
-
167
- - **Lead / Subscription / Campaign dedup are now race-safe.** Added
168
- authoritative partial-unique indexes — lead `(organizationId, email)`,
169
- campaign `(organizationId, name)`, subscription `(organizationId,
170
- contactId, campaignId)` scoped to live statuses — and catch the
171
- residual `E11000` via `isDuplicateKeyError`, re-throwing the same typed
172
- domain error the pre-check emits. The prior check-then-save left a
173
- TOCTOU window.
174
- - **Owner reassignment is now atomic.** `assign()` on lead / account /
175
- contact / opportunity / conversation uses a single `findOneAndUpdate`
176
- CAS (`{ ownerId: { $ne } }` guard, `returnDocument: 'before'`) — no
177
- read-modify-write lost-update window, and the emitted `previousOwnerId`
178
- is the true pre-image under concurrency.
179
- - **Cross-tenant leak in dashboard/feed reads closed.** `findRecentForFeed`,
180
- `countCreatedSince`, `countOpen`, `sumWonSince`, `countOverduePlanned`
181
- dropped raw `Model.find` / `countDocuments` with a manual
182
- `if (ctx) organizationId` filter (which read ALL tenants when ctx was
183
- absent) for the tenant-scoped `findAll` / `count` repository methods.
184
-
185
- ### Build hygiene
186
-
187
- - Sourcemaps and declaration maps are no longer emitted (`sourceMap` /
188
- `declarationMap` off in tsconfig; `sourcemap: false` in tsdown) — both
189
- leak source.
190
- - Removed `@deprecated` markers from `ConvertResult` convenience id fields
191
- (kept the fields; they were never deprecated, just mislabelled).
192
- - Dropped the stale pnpm lockfile/workspace and the bundled `.tgz`;
193
- installs are npm-from-registry only.
194
-
195
- ### Docs
196
-
197
- - Rewrote the README to the engine-factory API (`createCrm` + repository
198
- domain verbs). The previous README documented the abandoned v0.1
199
- port/adapter design (`AccountService`, `InMemory*Repository`,
200
- `OutreachPort`) that no longer exists.
201
-
202
- ### Tests
203
-
204
- - Added `tests/integration/invariants.test.ts` (tenant probe, lead +
205
- subscription dedup races, atomic reassignment) and
206
- `tests/unit/event-catalog.test.ts` (no-drift + round-trip). 87 → 96
207
- tests.
208
-
209
- ## [0.4.0] — 2026-05-22
210
-
211
- ### Changed — Subscription model renamed to `EmailSubscription`
212
-
213
- **Breaking at the Mongo identity level.** The CRM's email opt-in resource
214
- now registers as Mongoose model `EmailSubscription` (was `Subscription`)
215
- and persists into the `email_subscriptions` collection (was
216
- `subscriptions`).
217
-
218
- **Why:** the previous name collided with `@classytic/revenue`'s
219
- `Subscription` model when both packages were loaded on the same
220
- Mongoose connection — `RevenueModelCollisionError` at boot. Renaming
221
- gives the two concepts distinct identities at the DB level and lets
222
- revenue + CRM coexist without the host having to split connections.
223
-
224
- **TypeScript surface is unchanged.** `ISubscriptionDocument`,
225
- `SubscriptionRepository`, `subscription.handlers.ts`,
226
- `CrmModels.Subscription`, etc. all keep their existing names — only
227
- the Mongoose model name string and the underlying collection name
228
- changed. Consumers that import from `@classytic/crm` need no source
229
- changes; the host only needs to migrate existing data (see below).
230
-
231
- ### Migration
232
-
233
- For DBs with existing CRM subscription rows in the legacy `subscriptions`
234
- collection (distinguishable from revenue rows by `contactId` presence):
235
-
236
- ```js
237
- // MongoDB shell — one-time migration
238
- db.subscriptions.aggregate([
239
- { $match: { contactId: { $exists: true } } },
240
- { $out: 'email_subscriptions' },
241
- ]);
242
- // Then remove the migrated docs from the legacy collection:
243
- db.subscriptions.deleteMany({ contactId: { $exists: true } });
244
- ```
245
-
246
- Hosts using only CRM (no revenue) can also run this — leftover rows in
247
- `subscriptions` won't cause boot errors because there's no collision
248
- without revenue.
249
-
250
- ## [0.3.0] — 2026-05-17
251
-
252
- ### Added — `EmailEvent` domain
253
-
254
- Closes the loop between outbound CRM sends and recipient engagement.
255
- Hosts can now record opens, clicks, bounces, complaints, and unsubscribes
256
- against a Contact / Subscription / Campaign tuple — and downstream
257
- services (drip-sequence "if opened" branches, lead scoring, suppression
258
- lists) consume the same events without polling the email transport.
259
-
260
- - **Entity & enum** — `EmailEvent` (append-only) plus the
261
- `EmailEventKind` enum (`open` / `click` / `bounce` / `complaint` /
262
- `unsubscribe`) split into `ENGAGEMENT_KINDS` and `SUPPRESSION_KINDS`
263
- helper sets for classification at call sites.
264
- - **Port** — `EmailEventRepository` with filtered list, per-subject
265
- timeline, per-campaign aggregates (`uniqueOpens`, `uniqueClicks`,
266
- `bounces`, `complaints`, `unsubscribes`, `totalEvents`), and a
267
- GDPR-style `deleteForSubject()` purge.
268
- - **Service** — `EmailEventService.recordOpen()` /
269
- `.recordClick({ url })` / `.recordBounce()` / `.recordComplaint()` /
270
- `.recordUnsubscribe()` + read-side `.listForSubject()`,
271
- `.aggregateForCampaign()`, and `.hasEngaged(subjectId, campaignId,
272
- kind)` for drip-condition checks.
273
- - **Events** — `crm:email_event.opened` / `.clicked` / `.bounced` /
274
- `.complained` / `.unsubscribed` with Zod-validated payloads in
275
- `event-catalog`, wired into `crmEventDefinitions[]`.
276
- - **In-memory adapter** — `InMemoryEmailEventRepository` (tests +
277
- dev). Hosts implement against Mongo / Postgres / etc.
278
-
279
- ### Added — re-exports from `@classytic/primitives@0.6.0`
280
-
281
- To eliminate duplicate type imports at consumer call sites:
282
-
283
- ```ts
284
- import {
285
- appendStatus,
286
- StatusChangeEntry,
287
- StatusHistory,
288
- timeInStatus,
289
- lastTransitionTo,
290
- } from '@classytic/crm';
291
- ```
292
-
293
- These pass through from `@classytic/primitives/status-history`. The
294
- existing entity-specific `LeadStatusEntry` / `OpportunityStatusEntry` /
295
- etc. names are still exported for back-compat — they remain structurally
296
- compatible with `StatusChangeEntry<TStatus>`.
297
-
298
- ### Changed
299
-
300
- - Peer dep `@classytic/primitives` bumped from `>=0.5.0` to `>=0.6.0`
301
- to access the new `status-history` subpath and value-typed primitives.
302
- - Test harness `buildHarness()` now also exposes an
303
- `emailEvents: EmailEventService` and `emailEventRepo` for integration
304
- tests that exercise the engagement → drip path.
305
-
306
- ### Dependencies
307
-
308
- - `@classytic/primitives@>=0.6.0` (peer, was `>=0.5.0`)
309
- - `zod@>=4.0.0` (peer, unchanged)
310
-
311
- ## [0.1.0] — 2026-04-18
312
-
313
- Initial release.
314
-
315
- ### Added
316
-
317
- - **Eight domain entities.** `Contact`, `Account`, `Lead`, `Opportunity`, `Pipeline` (+ nested `Stage`), `Activity`, `Note`, `LossReason`.
318
- - **Three state machines.** Lead (`new` → `contacted` → `qualified` → `converted`, + `disqualified` / `nurturing` branches), Opportunity (`open` → `won` / `lost` / `abandoned` with in-pipeline Stage movement), Activity (`planned` → `in_progress` → `completed` / `cancelled`). All transitions CAS-guarded with explicit `*_TRANSITIONS` maps and `canTransition*` guards.
319
- - **Six services.**
320
- - `ContactService` — CRUD + tag filtering + email lookup.
321
- - `AccountService` — CRUD + domain lookup.
322
- - `PipelineService` — create, archive, stage helpers (`getStage`, `getFirstStage`, `getNextStage`).
323
- - `OpportunityService` — create at first stage (or specified), `moveToStage` with advance/regress event disambiguation, `win` / `lose` (with `lostReasonId`) / `abandon`.
324
- - `LeadService` — CRUD + full lifecycle (`markContacted`, `qualify`, `disqualify`, `nurture`) + `convert()` — the crown-jewel verb that atomically creates Contact + Account + Opportunity and transitions the lead to `converted`.
325
- - `ActivityService` — polymorphic activities (`plan`, `start`, `complete`, `cancel`) + notes (`addNote`, `listNotesBySubject`).
326
- - **Pure calculators.**
327
- - `defaultLeadScorer` + `composeScorers(...rules)` — host injects whatever rubric (firmographics, in-app signals, ML prediction, enrichment) via a `(lead: Lead) => number` function.
328
- - `forecast(opportunities, options?)` — committed + weighted pipeline by bucket (default ISO month), with custom bucket function support. Currency-mismatch detection.
329
- - `pipelineConversion(opportunities, stageIds)` — where do deals die? returns per-stage current count + won/lost counts + conversion rate.
330
- - **Nine repository ports** — one per entity (plus `EventTransport`, `IdGenerator`, `Clock`, optional `OutreachPort`). Full in-memory reference implementations shipped.
331
- - **Typed error hierarchy** rooted at `CrmError`: `NotFoundError`, `InvalidLeadTransitionError`, `InvalidOpportunityTransitionError`, `InvalidActivityTransitionError`, `StageNotInPipelineError`, `LeadAlreadyConvertedError`, `DuplicateLeadError`, `EmptyPipelineError`.
332
- - **Nineteen lifecycle events** (`CRM_EVENTS`): covering lead/account/contact/opportunity/pipeline/activity/note verbs, all following `crm:<resource>.<verb>` per PACKAGE_RULES rule 12.
333
- - **Optional `OutreachPort`** — one-method (`send`) + `NullOutreachPort` + `InMemoryOutreachPort` (recording). Host subscribes to `crm:activity.*` events and drives the port from their automation layer (keeps pacing / retry / bounce handling out of the core).
334
- - **Testing.** `vitest.config.ts` projects split `unit` + `integration` per [testing-infrastructure.md](../../testing-infrastructure.md). 48 tests (7 files) — all green in <1.5s.
335
-
336
- ### Design choices
337
-
338
- - Pure-TS port-based pattern — consistent with `@classytic/manufacturing`, `@classytic/assets`, `@classytic/fleet`, `@classytic/carrier-*`.
339
- - Separate Lead + Opportunity models (erpnext style) rather than unified (odoo style) — cleaner semantics for pre-qualification vs in-pipeline data and enables distinct analytics.
340
- - Polymorphic Activity / Note via `subjectKind` + `subjectId` so hosts can extend without schema changes.
341
- - `LeadService.convert()` is atomic in intent and sequenced for auditability — it creates Account, then Contact, then Opportunity, then transitions the lead. If a downstream repo fails, the ones that succeeded persist (hosts using Mongoose sessions get atomicity at their layer).
342
- - Campaigns, appointment booking, competitors, contracts, team routing, RBAC, custom-field validation, email-thread parsing deferred to v0.2+ to keep the surface focused.
1
+ # Changelog
2
+
3
+ All notable changes to `@classytic/crm` are documented here.
4
+
5
+ ## [0.9.0] — unpublished (2026-07-15)
6
+
7
+ ### Changed — mongokit 3.22 adopted (peer floors: mongokit >=3.22.0, repo-core >=0.13.0, primitives >=0.11.0)
8
+
9
+ - **Seven transition verbs ride `applyTransition`** (activity, campaign,
10
+ enrollment exit + generic, lead, sequence, subscription, opportunity):
11
+ machine pre-flight + CAS + accurate race diagnosis via the machines'
12
+ existing errorFactories. All `this as any` casts at those sites are gone
13
+ (native method).
14
+ - **FIXED a real concurrency defect**: five sites built `statusHistory` by
15
+ read-modify-write (`[...(existing.statusHistory ?? []), entry]`) inside
16
+ the CAS patch — concurrent writers could drop history entries. Entries
17
+ now ride `$push` via the `push` arg, byte-identical shapes preserved.
18
+ - **`EmailEvent` log fence enforced** — "append-only" was a doc comment;
19
+ `appendOnlyPlugin` now refuses every mutation path (405
20
+ `APPEND_ONLY_VIOLATION`), with `deleteMany` allowed for host-gated GDPR
21
+ erasure of a recipient's rows.
22
+ - DELIBERATELY unchanged: `enrollment.advanceStep` (non-status
23
+ `currentStepIndex` CAS with null semantics) and opportunity's stage-move
24
+ claim — correct raw `claim` usage, not transition boilerplate.
25
+
26
+ ## [0.8.0] — 2026-07-11
27
+
28
+ ### Added — engagement-based lead scoring
29
+ - **`ILeadEngagement` on the Lead** (`{ opens, clicks, replies, activities, lastEngagedAt? }`, nested default `{}` so old rows read as zeros). Behavioural counters, distinct from firmographic fields.
30
+ - **`LeadRepository.recordEngagement(id, signal, ctx)`** where `signal ∈ 'open'|'click'|'reply'|'activity'` (`LeadEngagementSignal`). Atomic `$inc` on the matching counter + `lastEngagedAt` via `findOneAndUpdate`, then `recomputeScore`. Hosts feed it off `crm:message.received` (reply), `crm:activity.*` (activity), and email open/click tracking.
31
+ - **`defaultLeadScorer` now weighs engagement** on top of firmographic fit: reply ×25 · click ×8 · open ×2 · activity ×5, capped at +80. `LeadInput` picks up `engagement`. Pure/deterministic — no wall-clock. Absent engagement scores as zero (score unchanged for pre-existing leads until a signal lands).
32
+
33
+ ## [0.7.0] — 2026-07-05
34
+
35
+ ### Added — org-wide email suppression list
36
+
37
+ - **`Suppression` entity** (`crm.repositories.suppression`, model
38
+ `Suppression`): global per-org "never email this address again" list —
39
+ the gate hosts consult before EVERY outbound email. Unsubscribes on
40
+ `Subscription` stay per-campaign; suppression is org-wide.
41
+ - `suppress(email, { reason, source?, subjectKind?, subjectId?, metadata? }, ctx?)`
42
+ — idempotent `getOrCreate` on the unique `(organizationId, email)`
43
+ index (no check-then-save); emits `crm:suppression.added` only when
44
+ THIS call inserted the row.
45
+ - `isSuppressed(email, ctx?)` → boolean.
46
+ - `filterSuppressed(emails, ctx?)` → `Set<string>` of the suppressed
47
+ subset — ONE `$in` query for the campaign-fanout hot path.
48
+ - `unsuppress(email, ctx?)` — atomic delete; emits
49
+ `crm:suppression.removed` only when a row was actually removed.
50
+ - All verbs normalize emails (trim + lowercase; schema stores lowercase).
51
+ - **`SUPPRESSION_REASONS`** (`'bounce' | 'complaint' | 'unsubscribe' |
52
+ 'manual'`) — the first three `satisfies readonly EmailEventKind[]` (P6),
53
+ so a rename in `EMAIL_EVENT_KINDS` fails compilation here.
54
+ - **Events** `crm:suppression.added` / `crm:suppression.removed` — Zod
55
+ catalog definitions included (no-drift invariant stays green);
56
+ `organizationId` rides event meta as usual.
57
+ - **Validators** `suppressSchema` / `unsuppressSchema` /
58
+ `suppressionFilterSchema`.
59
+
60
+ ### Added — reply-detection exit verb
61
+
62
+ - **`EnrollmentRepository.exitActiveOnReply(subjects, ctx?)`** — the ONE
63
+ method a host's `crm:message.received` subscriber calls: finds ACTIVE
64
+ enrollments for the given `{ subjectKind, subjectId }` list, batch-loads
65
+ their sequences in a single `$in` query, skips those with
66
+ `replyDetectionEnabled === false`, and exits the rest via `exit(id,
67
+ 'reply')` (each exit emits `crm:enrollment.exited`). Lost CAS races
68
+ (concurrent pause/complete/exit) are skipped, not failed. Returns the
69
+ enrollments this call exited.
70
+ - `EnrollmentRepositoryDeps` now carries `sequences: SequenceRepository`
71
+ (same cross-repo deps idiom as `OpportunityRepository.pipelines` /
72
+ `MessageRepository.conversations`) — wired automatically by
73
+ `createCrm()`; only hosts constructing `EnrollmentRepository` by hand
74
+ need to pass it.
75
+
76
+ ## [0.6.0] — 2026-07-02
77
+
78
+ Breaking-in-behavior release (ships as MINOR per house convention):
79
+ outbox-first, session-safe event dispatch — the previously deferred
80
+ PACKAGE_RULES §P8/§P8.1 work.
81
+
82
+ ### Added — unit-of-work contract (P8.1, shared with refit/review/cart/invoice)
83
+
84
+ - **`engine.withTransaction(ctx, body)`** — runs `body` inside a MongoDB
85
+ transaction with post-commit event discipline: events dispatched through
86
+ `txCtx` are queued under `ctx[PENDING_EVENTS]` and flushed to the
87
+ transport only AFTER commit; the queue resets on retry attempts; an abort
88
+ leaks nothing to in-process subscribers. Reuses the caller's session (and
89
+ queue) when `ctx.session` is already set. Standalone-Mongo dev boots opt
90
+ in via the existing `allowNonTransactional: true`.
91
+ - **`PENDING_EVENTS`** (`Symbol.for('@classytic/crm:pendingEvents')`) +
92
+ **`flushPending(transport, queue, logger?)`** + **`runUnit`** exported
93
+ from the root for hosts composing their own outer transaction.
94
+ - **`CrmConfig.outbox?: OutboxStore`** (from `@classytic/primitives/outbox`)
95
+ — when wired, every domain event is persisted via
96
+ `outbox.save(event, { session })` in the SAME session as the business
97
+ write; the host's relay publishes committed rows. Outbox save failures
98
+ PROPAGATE (P8: a swallowed save means the write persists while its event
99
+ vanishes).
100
+ - **`CrmEventDispatcher`** — EventTransport-compatible wrapper repositories
101
+ route every event through (`engine.events` is now this wrapper; the host
102
+ `publish`/`subscribe` surface is unchanged).
103
+ - **`UnmanagedSessionError`** (`code: 'UNMANAGED_SESSION'`) — a host-owned
104
+ `ctx.session` arriving with neither an outbox nor a `PENDING_EVENTS`
105
+ queue now throws instead of ghost-publishing mid-transaction.
106
+ **Behavioral break:** callers that previously threaded a bare session and
107
+ tolerated ghost events must switch to `engine.withTransaction` (or wire
108
+ an outbox / attach a queue).
109
+
110
+ ### Added — timezone-aware forecasting
111
+
112
+ - **`ForecastOptions.timeZone`** — IANA zone for the default month bucket
113
+ (e.g. `'Asia/Dhaka'`). Without it, months cut at UTC boundaries: a deal
114
+ expected to close July 1, 02:00 Dhaka time (June 30, 20:00 UTC) files
115
+ under JUNE. Uses `civilDateOf` from `@classytic/primitives/timezone`.
116
+ Peer floor bumped: `@classytic/primitives >=0.9.0`.
117
+
118
+ ### Fixed — sequence/enrollment (completes the 0.6.0 WIP)
119
+
120
+ - **Event catalog no-drift restored**: 10 missing Zod definitions added
121
+ (`crm:sequence.{created,active,paused,archived}`,
122
+ `crm:enrollment.{created,step_sent,paused,resumed,completed,exited}`) —
123
+ `crm:sequence.created` and `crm:enrollment.step_sent` are host-emitted
124
+ (create via `def.create()`), the rest publish in-repo.
125
+ - **`EnrollmentRepository.setWorkflowRunId` / `advanceStep` no longer
126
+ bypass the repository**: both used raw `model.findOneAndUpdate` with a
127
+ manual tenant filter — escaping `multiTenantPlugin` scoping AND
128
+ `ctx.session` (a call inside `withTransaction` would commit outside the
129
+ transaction). `advanceStep` is now a `claim()` CAS on `currentStepIndex`
130
+ with `where: { status: 'active' }`; `setWorkflowRunId` goes through
131
+ `repo.update`.
132
+
133
+ ### Tests
134
+
135
+ - 14 new: `tests/unit/dispatch.test.ts` (6 — outbox propagation/session
136
+ routing/guard/swallow), `tests/integration/unit-of-work.test.ts` (3 —
137
+ convert() on a replica set: zero mid-tx events, post-commit flush, abort
138
+ leaks nothing, bare-session guard), `tests/unit/forecast-timezone.test.ts`
139
+ (5 — the +06:00 month-boundary trap). 110/110 total.
140
+
141
+ ## [0.5.1] — 2026-06-14
142
+
143
+ ### Fixed — declaration bundle
144
+
145
+ - **fix: declaration bundle exported mangled aliases; consumer named imports
146
+ now resolve.** The published `dist/index.d.ts` re-exported every public
147
+ symbol under a minified alias (`export { AccountRepository as A, CRM_EVENTS
148
+ as Rr, … }`), so downstream `import { AccountRepository, CRM_EVENTS } from
149
+ '@classytic/crm'` failed with TS2305 / TS2460. Root cause: the `./events`
150
+ entry re-exports a strict subset of the `index` barrel, which made
151
+ `rolldown-plugin-dts` alias every name on the superset (`index`) entry when
152
+ both were emitted in a single pass. Build now runs `index` and `events` as
153
+ two independent tsdown passes (array config) so each `.d.ts` exports real
154
+ names. `tsdown` bumped `^0.14.2` → `^0.21.10` to match sibling packages.
155
+ No runtime/API change.
156
+
157
+ ## [0.5.0] — 2026-06-14
158
+
159
+ Stack upgrade + verified correctness fixes + PACKAGE_RULES compliance. No
160
+ new features. Clean break — no deprecation aliases.
161
+
162
+ ### Changed — stack currency
163
+
164
+ - Peers bumped: `@classytic/mongokit` `>=3.16.0`, `@classytic/repo-core`
165
+ `>=0.6.0`, `@classytic/primitives` `>=0.7.1`, `mongoose` `>=9.4.1`.
166
+ Clean-installed from npm and verified resolved (3.16.0 / 0.6.0 / 0.7.2).
167
+
168
+ ### Added — PACKAGE_RULES compliance
169
+
170
+ - **Zod event catalog (rule 18).** New `crmEventDefinitions[]` (56 events,
171
+ one Zod schema each) exported from the package root and a new `./events`
172
+ subpath. Timestamps are `z.iso.datetime()`. A no-drift invariant test
173
+ asserts every `CRM_EVENTS` constant has a definition. Hosts wire these
174
+ into arc's `EventRegistry` for publish-time validation + OpenAPI.
175
+ - **Model-collision policy (rule 21).** `createCrm()` now throws
176
+ `CrmModelCollisionError` when a model name is already registered; pass
177
+ `forceRecreate: true` (test/hot-reload) to delete + re-register.
178
+ - **Index management (rule 35).** New `autoIndex` config flag (default
179
+ `true`) and `engine.syncIndexes()` for `autoIndex: false` production
180
+ boots.
181
+ - **Capability boot gate.** `createCrm()` asserts the backend supports
182
+ `upsert` / `getOrCreate` / `duplicateKeyError` (and `transactions`,
183
+ unless `allowNonTransactional: true`) so misconfigured deployments fail
184
+ loud at boot instead of mid-convert.
185
+
186
+ ### Fixed — domain correctness
187
+
188
+ - **Lead / Subscription / Campaign dedup are now race-safe.** Added
189
+ authoritative partial-unique indexes — lead `(organizationId, email)`,
190
+ campaign `(organizationId, name)`, subscription `(organizationId,
191
+ contactId, campaignId)` scoped to live statuses — and catch the
192
+ residual `E11000` via `isDuplicateKeyError`, re-throwing the same typed
193
+ domain error the pre-check emits. The prior check-then-save left a
194
+ TOCTOU window.
195
+ - **Owner reassignment is now atomic.** `assign()` on lead / account /
196
+ contact / opportunity / conversation uses a single `findOneAndUpdate`
197
+ CAS (`{ ownerId: { $ne } }` guard, `returnDocument: 'before'`) — no
198
+ read-modify-write lost-update window, and the emitted `previousOwnerId`
199
+ is the true pre-image under concurrency.
200
+ - **Cross-tenant leak in dashboard/feed reads closed.** `findRecentForFeed`,
201
+ `countCreatedSince`, `countOpen`, `sumWonSince`, `countOverduePlanned`
202
+ dropped raw `Model.find` / `countDocuments` with a manual
203
+ `if (ctx) organizationId` filter (which read ALL tenants when ctx was
204
+ absent) for the tenant-scoped `findAll` / `count` repository methods.
205
+
206
+ ### Build hygiene
207
+
208
+ - Sourcemaps and declaration maps are no longer emitted (`sourceMap` /
209
+ `declarationMap` off in tsconfig; `sourcemap: false` in tsdown) — both
210
+ leak source.
211
+ - Removed `@deprecated` markers from `ConvertResult` convenience id fields
212
+ (kept the fields; they were never deprecated, just mislabelled).
213
+ - Dropped the stale pnpm lockfile/workspace and the bundled `.tgz`;
214
+ installs are npm-from-registry only.
215
+
216
+ ### Docs
217
+
218
+ - Rewrote the README to the engine-factory API (`createCrm` + repository
219
+ domain verbs). The previous README documented the abandoned v0.1
220
+ port/adapter design (`AccountService`, `InMemory*Repository`,
221
+ `OutreachPort`) that no longer exists.
222
+
223
+ ### Tests
224
+
225
+ - Added `tests/integration/invariants.test.ts` (tenant probe, lead +
226
+ subscription dedup races, atomic reassignment) and
227
+ `tests/unit/event-catalog.test.ts` (no-drift + round-trip). 87 → 96
228
+ tests.
229
+
230
+ ## [0.4.0] — 2026-05-22
231
+
232
+ ### Changed — Subscription model renamed to `EmailSubscription`
233
+
234
+ **Breaking at the Mongo identity level.** The CRM's email opt-in resource
235
+ now registers as Mongoose model `EmailSubscription` (was `Subscription`)
236
+ and persists into the `email_subscriptions` collection (was
237
+ `subscriptions`).
238
+
239
+ **Why:** the previous name collided with `@classytic/revenue`'s
240
+ `Subscription` model when both packages were loaded on the same
241
+ Mongoose connection — `RevenueModelCollisionError` at boot. Renaming
242
+ gives the two concepts distinct identities at the DB level and lets
243
+ revenue + CRM coexist without the host having to split connections.
244
+
245
+ **TypeScript surface is unchanged.** `ISubscriptionDocument`,
246
+ `SubscriptionRepository`, `subscription.handlers.ts`,
247
+ `CrmModels.Subscription`, etc. all keep their existing names — only
248
+ the Mongoose model name string and the underlying collection name
249
+ changed. Consumers that import from `@classytic/crm` need no source
250
+ changes; the host only needs to migrate existing data (see below).
251
+
252
+ ### Migration
253
+
254
+ For DBs with existing CRM subscription rows in the legacy `subscriptions`
255
+ collection (distinguishable from revenue rows by `contactId` presence):
256
+
257
+ ```js
258
+ // MongoDB shell — one-time migration
259
+ db.subscriptions.aggregate([
260
+ { $match: { contactId: { $exists: true } } },
261
+ { $out: 'email_subscriptions' },
262
+ ]);
263
+ // Then remove the migrated docs from the legacy collection:
264
+ db.subscriptions.deleteMany({ contactId: { $exists: true } });
265
+ ```
266
+
267
+ Hosts using only CRM (no revenue) can also run this — leftover rows in
268
+ `subscriptions` won't cause boot errors because there's no collision
269
+ without revenue.
270
+
271
+ ## [0.3.0] — 2026-05-17
272
+
273
+ ### Added — `EmailEvent` domain
274
+
275
+ Closes the loop between outbound CRM sends and recipient engagement.
276
+ Hosts can now record opens, clicks, bounces, complaints, and unsubscribes
277
+ against a Contact / Subscription / Campaign tuple — and downstream
278
+ services (drip-sequence "if opened" branches, lead scoring, suppression
279
+ lists) consume the same events without polling the email transport.
280
+
281
+ - **Entity & enum** — `EmailEvent` (append-only) plus the
282
+ `EmailEventKind` enum (`open` / `click` / `bounce` / `complaint` /
283
+ `unsubscribe`) split into `ENGAGEMENT_KINDS` and `SUPPRESSION_KINDS`
284
+ helper sets for classification at call sites.
285
+ - **Port** — `EmailEventRepository` with filtered list, per-subject
286
+ timeline, per-campaign aggregates (`uniqueOpens`, `uniqueClicks`,
287
+ `bounces`, `complaints`, `unsubscribes`, `totalEvents`), and a
288
+ GDPR-style `deleteForSubject()` purge.
289
+ - **Service** — `EmailEventService.recordOpen()` /
290
+ `.recordClick({ url })` / `.recordBounce()` / `.recordComplaint()` /
291
+ `.recordUnsubscribe()` + read-side `.listForSubject()`,
292
+ `.aggregateForCampaign()`, and `.hasEngaged(subjectId, campaignId,
293
+ kind)` for drip-condition checks.
294
+ - **Events** — `crm:email_event.opened` / `.clicked` / `.bounced` /
295
+ `.complained` / `.unsubscribed` with Zod-validated payloads in
296
+ `event-catalog`, wired into `crmEventDefinitions[]`.
297
+ - **In-memory adapter** — `InMemoryEmailEventRepository` (tests +
298
+ dev). Hosts implement against Mongo / Postgres / etc.
299
+
300
+ ### Added — re-exports from `@classytic/primitives@0.6.0`
301
+
302
+ To eliminate duplicate type imports at consumer call sites:
303
+
304
+ ```ts
305
+ import {
306
+ appendStatus,
307
+ StatusChangeEntry,
308
+ StatusHistory,
309
+ timeInStatus,
310
+ lastTransitionTo,
311
+ } from '@classytic/crm';
312
+ ```
313
+
314
+ These pass through from `@classytic/primitives/status-history`. The
315
+ existing entity-specific `LeadStatusEntry` / `OpportunityStatusEntry` /
316
+ etc. names are still exported for back-compat — they remain structurally
317
+ compatible with `StatusChangeEntry<TStatus>`.
318
+
319
+ ### Changed
320
+
321
+ - Peer dep `@classytic/primitives` bumped from `>=0.5.0` to `>=0.6.0`
322
+ to access the new `status-history` subpath and value-typed primitives.
323
+ - Test harness `buildHarness()` now also exposes an
324
+ `emailEvents: EmailEventService` and `emailEventRepo` for integration
325
+ tests that exercise the engagement → drip path.
326
+
327
+ ### Dependencies
328
+
329
+ - `@classytic/primitives@>=0.6.0` (peer, was `>=0.5.0`)
330
+ - `zod@>=4.0.0` (peer, unchanged)
331
+
332
+ ## [0.1.0] — 2026-04-18
333
+
334
+ Initial release.
335
+
336
+ ### Added
337
+
338
+ - **Eight domain entities.** `Contact`, `Account`, `Lead`, `Opportunity`, `Pipeline` (+ nested `Stage`), `Activity`, `Note`, `LossReason`.
339
+ - **Three state machines.** Lead (`new` → `contacted` → `qualified` → `converted`, + `disqualified` / `nurturing` branches), Opportunity (`open` → `won` / `lost` / `abandoned` with in-pipeline Stage movement), Activity (`planned` → `in_progress` → `completed` / `cancelled`). All transitions CAS-guarded with explicit `*_TRANSITIONS` maps and `canTransition*` guards.
340
+ - **Six services.**
341
+ - `ContactService` — CRUD + tag filtering + email lookup.
342
+ - `AccountService` — CRUD + domain lookup.
343
+ - `PipelineService` — create, archive, stage helpers (`getStage`, `getFirstStage`, `getNextStage`).
344
+ - `OpportunityService` — create at first stage (or specified), `moveToStage` with advance/regress event disambiguation, `win` / `lose` (with `lostReasonId`) / `abandon`.
345
+ - `LeadService` — CRUD + full lifecycle (`markContacted`, `qualify`, `disqualify`, `nurture`) + `convert()` — the crown-jewel verb that atomically creates Contact + Account + Opportunity and transitions the lead to `converted`.
346
+ - `ActivityService` — polymorphic activities (`plan`, `start`, `complete`, `cancel`) + notes (`addNote`, `listNotesBySubject`).
347
+ - **Pure calculators.**
348
+ - `defaultLeadScorer` + `composeScorers(...rules)` — host injects whatever rubric (firmographics, in-app signals, ML prediction, enrichment) via a `(lead: Lead) => number` function.
349
+ - `forecast(opportunities, options?)` — committed + weighted pipeline by bucket (default ISO month), with custom bucket function support. Currency-mismatch detection.
350
+ - `pipelineConversion(opportunities, stageIds)` — where do deals die? returns per-stage current count + won/lost counts + conversion rate.
351
+ - **Nine repository ports** — one per entity (plus `EventTransport`, `IdGenerator`, `Clock`, optional `OutreachPort`). Full in-memory reference implementations shipped.
352
+ - **Typed error hierarchy** rooted at `CrmError`: `NotFoundError`, `InvalidLeadTransitionError`, `InvalidOpportunityTransitionError`, `InvalidActivityTransitionError`, `StageNotInPipelineError`, `LeadAlreadyConvertedError`, `DuplicateLeadError`, `EmptyPipelineError`.
353
+ - **Nineteen lifecycle events** (`CRM_EVENTS`): covering lead/account/contact/opportunity/pipeline/activity/note verbs, all following `crm:<resource>.<verb>` per PACKAGE_RULES rule 12.
354
+ - **Optional `OutreachPort`** — one-method (`send`) + `NullOutreachPort` + `InMemoryOutreachPort` (recording). Host subscribes to `crm:activity.*` events and drives the port from their automation layer (keeps pacing / retry / bounce handling out of the core).
355
+ - **Testing.** `vitest.config.ts` projects split `unit` + `integration` per [testing-infrastructure.md](../../testing-infrastructure.md). 48 tests (7 files) — all green in <1.5s.
356
+
357
+ ### Design choices
358
+
359
+ - Pure-TS port-based pattern — consistent with `@classytic/manufacturing`, `@classytic/assets`, `@classytic/fleet`, `@classytic/carrier-*`.
360
+ - Separate Lead + Opportunity models (erpnext style) rather than unified (odoo style) — cleaner semantics for pre-qualification vs in-pipeline data and enables distinct analytics.
361
+ - Polymorphic Activity / Note via `subjectKind` + `subjectId` so hosts can extend without schema changes.
362
+ - `LeadService.convert()` is atomic in intent and sequenced for auditability — it creates Account, then Contact, then Opportunity, then transitions the lead. If a downstream repo fails, the ones that succeeded persist (hosts using Mongoose sessions get atomicity at their layer).
363
+ - Campaigns, appointment booking, competitors, contracts, team routing, RBAC, custom-field validation, email-thread parsing deferred to v0.2+ to keep the surface focused.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./chunk.js";
2
2
  import { Schema } from "mongoose";
3
- import { Repository, aggregateHelpersPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
3
+ import { Repository, aggregateHelpersPlugin, appendOnlyPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
4
4
  import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
5
- import { IllegalTransitionError, assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
5
+ import { IllegalTransitionError, defineStateMachine } from "@classytic/primitives/state-machine";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { z } from "zod";
8
8
  import { civilDateOf } from "@classytic/primitives/timezone";
@@ -2293,6 +2293,19 @@ function composeScorers(...rules) {
2293
2293
  return (lead) => rules.reduce((acc, r) => acc + r(lead), 0);
2294
2294
  }
2295
2295
  //#endregion
2296
+ //#region src/repositories/transition-support.ts
2297
+ /**
2298
+ * mongokit `applyTransition()` race-outcome detection — pure races /
2299
+ * vanished rows remap to each verb's historical error (typed transition
2300
+ * error built from a fresh read, or NotFound where that was the
2301
+ * contract); now-illegal races throw the machine's errorFactory error
2302
+ * with the on-disk status natively.
2303
+ */
2304
+ function isTransitionRace(err) {
2305
+ const code = err?.code;
2306
+ return code === "TRANSITION_RACE_LOST" || code === "TRANSITION_TARGET_MISSING";
2307
+ }
2308
+ //#endregion
2296
2309
  //#region src/repositories/lead.repository.ts
2297
2310
  /** signal → engagement counter field. */
2298
2311
  const ENGAGEMENT_COUNTER = {
@@ -2461,19 +2474,19 @@ var LeadRepository = class extends Repository {
2461
2474
  ...extras.by !== void 0 ? { by: extras.by } : {},
2462
2475
  ...extras.note !== void 0 ? { note: extras.note } : {}
2463
2476
  };
2464
- const updated = await assertAndClaim(LEAD_MACHINE, this, id, {
2477
+ const updated = await this.applyTransition(id, LEAD_MACHINE, {
2465
2478
  from: existing.status,
2466
2479
  to: target,
2467
- patch: {
2468
- ...extras.patch ?? {},
2469
- statusHistory: [...existing.statusHistory ?? [], entry]
2470
- },
2471
- options: ctx
2472
- });
2473
- if (!updated) throw new InvalidLeadTransitionError({
2474
- from: (await this.getById(id, ctx))?.status ?? existing.status,
2475
- to: target,
2476
- leadId: id
2480
+ set: extras.patch ?? {},
2481
+ push: { statusHistory: entry },
2482
+ history: false
2483
+ }, ctx).catch(async (err) => {
2484
+ if (!isTransitionRace(err)) throw err;
2485
+ throw new InvalidLeadTransitionError({
2486
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
2487
+ to: target,
2488
+ leadId: id
2489
+ });
2477
2490
  });
2478
2491
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_STATUS_CHANGED, {
2479
2492
  leadId: id,
@@ -2719,7 +2732,6 @@ var OpportunityRepository = class extends Repository {
2719
2732
  async _closeStatus(id, target, patch, extras, ctx) {
2720
2733
  const existing = await this.getById(id, ctx);
2721
2734
  if (!existing) throw new NotFoundError("Opportunity", id);
2722
- OPPORTUNITY_MACHINE.assertTransition(id, existing.status, target);
2723
2735
  const entry = {
2724
2736
  status: target,
2725
2737
  stageId: existing.stageId,
@@ -2727,21 +2739,20 @@ var OpportunityRepository = class extends Repository {
2727
2739
  ...extras.by !== void 0 ? { by: extras.by } : {},
2728
2740
  ...extras.note !== void 0 ? { note: extras.note } : {}
2729
2741
  };
2730
- const updated = await assertAndClaim(OPPORTUNITY_MACHINE, this, id, {
2742
+ return await this.applyTransition(id, OPPORTUNITY_MACHINE, {
2731
2743
  from: existing.status,
2732
2744
  to: target,
2733
- patch: {
2734
- ...patch,
2735
- statusHistory: [...existing.statusHistory ?? [], entry]
2736
- },
2737
- options: ctx
2738
- });
2739
- if (!updated) throw new InvalidOpportunityTransitionError({
2740
- from: (await this.getById(id, ctx))?.status ?? existing.status,
2741
- to: target,
2742
- opportunityId: id
2745
+ set: patch,
2746
+ push: { statusHistory: entry },
2747
+ history: false
2748
+ }, ctx).catch(async (err) => {
2749
+ if (!isTransitionRace(err)) throw err;
2750
+ throw new InvalidOpportunityTransitionError({
2751
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
2752
+ to: target,
2753
+ opportunityId: id
2754
+ });
2743
2755
  });
2744
- return updated;
2745
2756
  }
2746
2757
  /** Count opportunities with status `open`. Used by dashboard KPI tiles. */
2747
2758
  async countOpen(ctx) {
@@ -3075,28 +3086,26 @@ var CampaignRepository = class extends Repository {
3075
3086
  async _transition(id, target, extras, ctx, patch = {}) {
3076
3087
  const existing = await this.getById(id, ctx);
3077
3088
  if (!existing) throw new NotFoundError("Campaign", id);
3078
- CAMPAIGN_MACHINE.assertTransition(id, existing.status, target);
3079
3089
  const entry = {
3080
3090
  status: target,
3081
3091
  occurredAt: /* @__PURE__ */ new Date(),
3082
3092
  ...extras.by !== void 0 ? { by: extras.by } : {},
3083
3093
  ...extras.note !== void 0 ? { note: extras.note } : {}
3084
3094
  };
3085
- const updated = await assertAndClaim(CAMPAIGN_MACHINE, this, id, {
3095
+ return await this.applyTransition(id, CAMPAIGN_MACHINE, {
3086
3096
  from: existing.status,
3087
3097
  to: target,
3088
- patch: {
3089
- ...patch,
3090
- statusHistory: [...existing.statusHistory ?? [], entry]
3091
- },
3092
- options: ctx
3093
- });
3094
- if (!updated) throw new InvalidCampaignTransitionError({
3095
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3096
- to: target,
3097
- campaignId: id
3098
+ set: patch,
3099
+ push: { statusHistory: entry },
3100
+ history: false
3101
+ }, ctx).catch(async (err) => {
3102
+ if (!isTransitionRace(err)) throw err;
3103
+ throw new InvalidCampaignTransitionError({
3104
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3105
+ to: target,
3106
+ campaignId: id
3107
+ });
3098
3108
  });
3099
- return updated;
3100
3109
  }
3101
3110
  };
3102
3111
  //#endregion
@@ -3237,28 +3246,26 @@ var SubscriptionRepository = class extends Repository {
3237
3246
  async _transition(id, target, opts, ctx) {
3238
3247
  const existing = await this.getById(id, ctx);
3239
3248
  if (!existing) throw new NotFoundError("Subscription", id);
3240
- SUBSCRIPTION_MACHINE.assertTransition(id, existing.status, target);
3241
3249
  const entry = {
3242
3250
  status: target,
3243
3251
  occurredAt: /* @__PURE__ */ new Date(),
3244
3252
  ...opts.by !== void 0 ? { by: opts.by } : {},
3245
3253
  ...opts.note !== void 0 ? { note: opts.note } : {}
3246
3254
  };
3247
- const updated = await assertAndClaim(SUBSCRIPTION_MACHINE, this, id, {
3255
+ return await this.applyTransition(id, SUBSCRIPTION_MACHINE, {
3248
3256
  from: existing.status,
3249
3257
  to: target,
3250
- patch: {
3251
- ...opts.patch ?? {},
3252
- statusHistory: [...existing.statusHistory ?? [], entry]
3253
- },
3254
- options: ctx
3255
- });
3256
- if (!updated) throw new InvalidSubscriptionTransitionError({
3257
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3258
- to: target,
3259
- subscriptionId: id
3258
+ set: opts.patch ?? {},
3259
+ push: { statusHistory: entry },
3260
+ history: false
3261
+ }, ctx).catch(async (err) => {
3262
+ if (!isTransitionRace(err)) throw err;
3263
+ throw new InvalidSubscriptionTransitionError({
3264
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3265
+ to: target,
3266
+ subscriptionId: id
3267
+ });
3260
3268
  });
3261
- return updated;
3262
3269
  }
3263
3270
  };
3264
3271
  //#endregion
@@ -3427,19 +3434,19 @@ var ActivityRepository = class extends Repository {
3427
3434
  async _transition(id, target, patch, ctx) {
3428
3435
  const existing = await this.getById(id, ctx);
3429
3436
  if (!existing) throw new NotFoundError("Activity", id);
3430
- ACTIVITY_MACHINE.assertTransition(id, existing.status, target);
3431
- const updated = await assertAndClaim(ACTIVITY_MACHINE, this, id, {
3437
+ return await this.applyTransition(id, ACTIVITY_MACHINE, {
3432
3438
  from: existing.status,
3433
3439
  to: target,
3434
- patch,
3435
- options: ctx
3436
- });
3437
- if (!updated) throw new InvalidActivityTransitionError({
3438
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3439
- to: target,
3440
- activityId: id
3440
+ set: patch,
3441
+ history: false
3442
+ }, ctx).catch(async (err) => {
3443
+ if (!isTransitionRace(err)) throw err;
3444
+ throw new InvalidActivityTransitionError({
3445
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3446
+ to: target,
3447
+ activityId: id
3448
+ });
3441
3449
  });
3442
- return updated;
3443
3450
  }
3444
3451
  /** Count `planned` activities with `dueDate` before `asOf`. Dashboard KPI. */
3445
3452
  async countOverduePlanned(asOf, ctx) {
@@ -3510,7 +3517,7 @@ const KIND_TO_EVENT = {
3510
3517
  var EmailEventRepository = class extends Repository {
3511
3518
  events;
3512
3519
  constructor(model, plugins, deps, pagination) {
3513
- super(model, plugins, pagination);
3520
+ super(model, [...plugins, appendOnlyPlugin({ allow: ["deleteMany"] })], pagination);
3514
3521
  this.events = deps.events;
3515
3522
  }
3516
3523
  async recordOpen(input, ctx) {
@@ -3735,24 +3742,24 @@ var SequenceRepository = class extends Repository {
3735
3742
  async _transition(id, target, ctx) {
3736
3743
  const existing = await this.getById(id, ctx);
3737
3744
  if (!existing) throw new NotFoundError("Sequence", id);
3738
- SEQUENCE_MACHINE.assertTransition(id, existing.status, target);
3739
3745
  const entry = {
3740
3746
  status: target,
3741
3747
  occurredAt: /* @__PURE__ */ new Date(),
3742
3748
  ...ctx?.actorId !== void 0 ? { by: String(ctx.actorId) } : {}
3743
3749
  };
3744
- const updated = await assertAndClaim(SEQUENCE_MACHINE, this, id, {
3750
+ return await this.applyTransition(id, SEQUENCE_MACHINE, {
3745
3751
  from: existing.status,
3746
3752
  to: target,
3747
- patch: { statusHistory: [...existing.statusHistory ?? [], entry] },
3748
- options: ctx
3749
- });
3750
- if (!updated) throw new InvalidSequenceTransitionError({
3751
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3752
- to: target,
3753
- sequenceId: id
3753
+ push: { statusHistory: entry },
3754
+ history: false
3755
+ }, ctx).catch(async (err) => {
3756
+ if (!isTransitionRace(err)) throw err;
3757
+ throw new InvalidSequenceTransitionError({
3758
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3759
+ to: target,
3760
+ sequenceId: id
3761
+ });
3754
3762
  });
3755
- return updated;
3756
3763
  }
3757
3764
  };
3758
3765
  //#endregion
@@ -3839,16 +3846,16 @@ var EnrollmentRepository = class extends Repository {
3839
3846
  occurredAt: /* @__PURE__ */ new Date(),
3840
3847
  ...ctx?.actorId ? { by: String(ctx.actorId) } : {}
3841
3848
  };
3842
- const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
3849
+ const updated = await this.applyTransition(id, ENROLLMENT_MACHINE, {
3843
3850
  from,
3844
3851
  to: "exited",
3845
- patch: {
3846
- exitReason,
3847
- statusHistory: [...existing.statusHistory ?? [], entry]
3848
- },
3849
- options: ctx
3852
+ set: { exitReason },
3853
+ push: { statusHistory: entry },
3854
+ history: false
3855
+ }, ctx).catch((err) => {
3856
+ if (isTransitionRace(err)) throw new NotFoundError("Enrollment", id);
3857
+ throw err;
3850
3858
  });
3851
- if (!updated) throw new NotFoundError("Enrollment", id);
3852
3859
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_EXITED, {
3853
3860
  enrollmentId: id,
3854
3861
  exitReason
@@ -3907,27 +3914,25 @@ var EnrollmentRepository = class extends Repository {
3907
3914
  async _transition(id, target, ctx, patch = {}) {
3908
3915
  const existing = await this.getById(id, ctx);
3909
3916
  if (!existing) throw new NotFoundError("Enrollment", id);
3910
- ENROLLMENT_MACHINE.assertTransition(id, existing.status, target);
3911
3917
  const entry = {
3912
3918
  status: target,
3913
3919
  occurredAt: /* @__PURE__ */ new Date(),
3914
3920
  ...ctx?.actorId ? { by: String(ctx.actorId) } : {}
3915
3921
  };
3916
- const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
3922
+ return await this.applyTransition(id, ENROLLMENT_MACHINE, {
3917
3923
  from: existing.status,
3918
3924
  to: target,
3919
- patch: {
3920
- ...patch,
3921
- statusHistory: [...existing.statusHistory ?? [], entry]
3922
- },
3923
- options: ctx
3924
- });
3925
- if (!updated) throw new InvalidEnrollmentTransitionError({
3926
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3927
- to: target,
3928
- enrollmentId: id
3925
+ set: patch,
3926
+ push: { statusHistory: entry },
3927
+ history: false
3928
+ }, ctx).catch(async (err) => {
3929
+ if (!isTransitionRace(err)) throw err;
3930
+ throw new InvalidEnrollmentTransitionError({
3931
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3932
+ to: target,
3933
+ enrollmentId: id
3934
+ });
3929
3935
  });
3930
- return updated;
3931
3936
  }
3932
3937
  };
3933
3938
  //#endregion
package/package.json CHANGED
@@ -1,73 +1,73 @@
1
- {
2
- "name": "@classytic/crm",
3
- "version": "0.8.0",
4
- "description": "Engine-factory CRM package — leads, accounts, contacts, opportunities, pipelines, campaigns, subscriptions, activities, notes, email engagement. PACKAGE_RULES compliant; built on Mongoose + mongokit + arc primitives.",
5
- "author": "Classytic",
6
- "license": "MIT",
7
- "type": "module",
8
- "sideEffects": false,
9
- "engines": {
10
- "node": ">=22"
11
- },
12
- "publishConfig": {
13
- "access": "public"
14
- },
15
- "exports": {
16
- ".": {
17
- "types": "./dist/index.d.ts",
18
- "default": "./dist/index.js"
19
- },
20
- "./events": {
21
- "types": "./dist/events.d.ts",
22
- "default": "./dist/events.js"
23
- },
24
- "./package.json": "./package.json"
25
- },
26
- "main": "./dist/index.js",
27
- "types": "./dist/index.d.ts",
28
- "files": [
29
- "dist",
30
- "README.md",
31
- "CHANGELOG.md",
32
- "LICENSE"
33
- ],
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/classytic/crm.git"
37
- },
38
- "bugs": {
39
- "url": "https://github.com/classytic/crm/issues"
40
- },
41
- "homepage": "https://github.com/classytic/crm#readme",
42
- "scripts": {
43
- "build": "tsdown",
44
- "typecheck": "tsc --noEmit",
45
- "test": "vitest run --project unit --project integration",
46
- "test:unit": "vitest run --project unit",
47
- "test:integration": "vitest run --project integration",
48
- "test:e2e": "vitest run --project e2e",
49
- "test:all": "vitest run",
50
- "test:watch": "vitest --project unit --project integration",
51
- "push": "classytic-push"
52
- },
53
- "peerDependencies": {
54
- "@classytic/mongokit": ">=3.18.0",
55
- "@classytic/primitives": ">=0.9.1",
56
- "@classytic/repo-core": ">=0.7.0",
57
- "mongoose": ">=9.4.1",
58
- "zod": ">=4.0.0"
59
- },
60
- "devDependencies": {
61
- "@classytic/dev-tools": "^0.2.0",
62
- "@classytic/mongokit": "^3.18.0",
63
- "@classytic/primitives": "^0.9.0",
64
- "@classytic/repo-core": "^0.7.0",
65
- "@types/node": "^25.5.0",
66
- "mongodb-memory-server": "^10.1.4",
67
- "mongoose": "^9.4.1",
68
- "tsdown": "^0.21.10",
69
- "typescript": "^5.8.2",
70
- "vitest": "^3.2.4",
71
- "zod": "^4.3.6"
72
- }
73
- }
1
+ {
2
+ "name": "@classytic/crm",
3
+ "version": "0.9.0",
4
+ "description": "Engine-factory CRM package \u2014 leads, accounts, contacts, opportunities, pipelines, campaigns, subscriptions, activities, notes, email engagement. PACKAGE_RULES compliant; built on Mongoose + mongokit + arc primitives.",
5
+ "author": "Classytic",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./events": {
21
+ "types": "./dist/events.d.ts",
22
+ "default": "./dist/events.js"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/classytic/crm.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/classytic/crm/issues"
40
+ },
41
+ "homepage": "https://github.com/classytic/crm#readme",
42
+ "scripts": {
43
+ "build": "tsdown",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "vitest run --project unit --project integration",
46
+ "test:unit": "vitest run --project unit",
47
+ "test:integration": "vitest run --project integration",
48
+ "test:e2e": "vitest run --project e2e",
49
+ "test:all": "vitest run",
50
+ "test:watch": "vitest --project unit --project integration",
51
+ "push": "classytic-push"
52
+ },
53
+ "peerDependencies": {
54
+ "@classytic/mongokit": ">=3.22.0",
55
+ "@classytic/primitives": ">=0.11.0",
56
+ "@classytic/repo-core": ">=0.13.0",
57
+ "mongoose": ">=9.4.1",
58
+ "zod": ">=4.0.0"
59
+ },
60
+ "devDependencies": {
61
+ "@classytic/dev-tools": "^0.2.0",
62
+ "@classytic/mongokit": "^3.22.0",
63
+ "@classytic/primitives": "^0.11.0",
64
+ "@classytic/repo-core": "^0.13.0",
65
+ "@types/node": "^25.5.0",
66
+ "mongodb-memory-server": "^10.1.4",
67
+ "mongoose": "^9.4.1",
68
+ "tsdown": "^0.21.10",
69
+ "typescript": "^5.8.2",
70
+ "vitest": "^3.2.4",
71
+ "zod": "^4.3.6"
72
+ }
73
+ }