@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.
- package/CHANGELOG.md +363 -342
- package/dist/index.js +98 -93
- 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.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
(
|
|
51
|
-
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
- **`
|
|
92
|
-
(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
`
|
|
102
|
-
`
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
`
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
`
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
###
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
###
|
|
337
|
-
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
-
|
|
341
|
-
- `
|
|
342
|
-
-
|
|
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,
|
|
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
|
|
2477
|
+
const updated = await this.applyTransition(id, LEAD_MACHINE, {
|
|
2465
2478
|
from: existing.status,
|
|
2466
2479
|
to: target,
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
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
|
-
|
|
2742
|
+
return await this.applyTransition(id, OPPORTUNITY_MACHINE, {
|
|
2731
2743
|
from: existing.status,
|
|
2732
2744
|
to: target,
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
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
|
-
|
|
3095
|
+
return await this.applyTransition(id, CAMPAIGN_MACHINE, {
|
|
3086
3096
|
from: existing.status,
|
|
3087
3097
|
to: target,
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
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
|
-
|
|
3255
|
+
return await this.applyTransition(id, SUBSCRIPTION_MACHINE, {
|
|
3248
3256
|
from: existing.status,
|
|
3249
3257
|
to: target,
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3436
|
-
})
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
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
|
-
|
|
3750
|
+
return await this.applyTransition(id, SEQUENCE_MACHINE, {
|
|
3745
3751
|
from: existing.status,
|
|
3746
3752
|
to: target,
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
})
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
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
|
|
3849
|
+
const updated = await this.applyTransition(id, ENROLLMENT_MACHINE, {
|
|
3843
3850
|
from,
|
|
3844
3851
|
to: "exited",
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
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
|
-
|
|
3922
|
+
return await this.applyTransition(id, ENROLLMENT_MACHINE, {
|
|
3917
3923
|
from: existing.status,
|
|
3918
3924
|
to: target,
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
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.
|
|
4
|
-
"description": "Engine-factory CRM package
|
|
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.
|
|
55
|
-
"@classytic/primitives": ">=0.
|
|
56
|
-
"@classytic/repo-core": ">=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.
|
|
63
|
-
"@classytic/primitives": "^0.
|
|
64
|
-
"@classytic/repo-core": "^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
|
+
}
|