@classytic/crm 0.9.0 → 1.0.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/dist/index.js CHANGED
@@ -1,125 +1,38 @@
1
- import { t as __exportAll } from "./chunk.js";
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { Repository, aggregateHelpersPlugin, appendOnlyPlugin, defineModels, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
2
3
  import { Schema } from "mongoose";
3
- import { Repository, aggregateHelpersPlugin, appendOnlyPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
4
- import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
4
+ import { EmailFormatError, normalizeEmail } from "@classytic/contact/email";
5
+ import { createEvent as createEvent$1 } from "@classytic/primitives/events";
5
6
  import { IllegalTransitionError, defineStateMachine } from "@classytic/primitives/state-machine";
6
7
  import { randomUUID } from "node:crypto";
8
+ import { InProcessEventBus } from "@classytic/primitives/event-infra";
7
9
  import { z } from "zod";
8
10
  import { civilDateOf } from "@classytic/primitives/timezone";
9
11
  import { CURRENCY_PATTERN } from "@classytic/primitives/currency";
10
12
  import { appendStatus, lastTransitionTo, latestEntry, timeInStatus } from "@classytic/primitives/status-history";
11
- //#region src/core/errors.ts
13
+ //#region src/engine/modules.ts
14
+ const ALL_CRM_MODULES = Object.freeze({
15
+ sales: true,
16
+ engagement: true,
17
+ automation: true
18
+ });
12
19
  /**
13
- * CRM error vocabulary. Hosts catch `CrmError` at API edges.
20
+ * Resolve module dependencies once at boot.
21
+ *
22
+ * Omitting `modules` preserves the pre-module package behaviour (everything
23
+ * enabled). Supplying it is opt-in: unspecified modules are disabled.
14
24
  */
15
- var CrmError = class extends Error {
16
- code;
17
- details;
18
- constructor(code, message, details) {
19
- super(message);
20
- this.name = "CrmError";
21
- this.code = code;
22
- if (details !== void 0) this.details = details;
23
- }
24
- };
25
- var NotFoundError = class extends CrmError {
26
- constructor(kind, id) {
27
- super(`${kind.toUpperCase()}_NOT_FOUND`, `${kind} '${id}' not found`, {
28
- kind,
29
- id
30
- });
31
- }
32
- };
33
- var InvalidLeadTransitionError = class extends CrmError {
34
- constructor(details) {
35
- super("INVALID_LEAD_TRANSITION", `Lead ${details.leadId} cannot move from '${details.from}' to '${details.to}'`, details);
36
- }
37
- };
38
- var InvalidOpportunityTransitionError = class extends CrmError {
39
- constructor(details) {
40
- super("INVALID_OPPORTUNITY_TRANSITION", `Opportunity ${details.opportunityId} cannot move from '${details.from}' to '${details.to}'`, details);
41
- }
42
- };
43
- var InvalidActivityTransitionError = class extends CrmError {
44
- constructor(details) {
45
- super("INVALID_ACTIVITY_TRANSITION", `Activity ${details.activityId} cannot move from '${details.from}' to '${details.to}'`, details);
46
- }
47
- };
48
- var StageNotInPipelineError = class extends CrmError {
49
- constructor(details) {
50
- super("STAGE_NOT_IN_PIPELINE", `Stage '${details.stageId}' is not defined on pipeline '${details.pipelineId}'`, details);
51
- }
52
- };
53
- var LeadAlreadyConvertedError = class extends CrmError {
54
- constructor(leadId) {
55
- super("LEAD_ALREADY_CONVERTED", `Lead '${leadId}' is already converted`, { leadId });
56
- }
57
- };
58
- var DuplicateLeadError = class extends CrmError {
59
- constructor(details) {
60
- super("DUPLICATE_LEAD", `A lead with this email / phone already exists`, details);
61
- }
62
- };
63
- var UnmanagedSessionError = class extends CrmError {
64
- constructor(eventType) {
65
- super("UNMANAGED_SESSION", "ctx.session was provided but no event delivery path exists — inside a host-owned transaction the event would either leak to subscribers before commit or be lost on rollback. Use engine.withTransaction(ctx, fn), wire CrmConfig.outbox, or attach a ctx[PENDING_EVENTS] queue and call flushPending() after commit (PACKAGE_RULES §P8.1).", eventType !== void 0 ? { eventType } : void 0);
66
- }
67
- };
68
- var CrmModelCollisionError = class extends CrmError {
69
- constructor(modelName) {
70
- super("CRM_MODEL_COLLISION", `Mongoose model '${modelName}' is already registered on this connection. Another engine (or a hot reload) owns it. Pass { forceRecreate: true } to createCrm() for test/hot-reload fixtures, or use a separate mongoose connection per engine in production (PACKAGE_RULES §21).`, { modelName });
71
- }
72
- };
73
- var EmptyPipelineError = class extends CrmError {
74
- constructor(pipelineId) {
75
- super("EMPTY_PIPELINE", `Pipeline '${pipelineId}' has no stages`, { pipelineId });
76
- }
77
- };
78
- var InvalidCampaignTransitionError = class extends CrmError {
79
- constructor(details) {
80
- super("INVALID_CAMPAIGN_TRANSITION", `Campaign ${details.campaignId} cannot move from '${details.from}' to '${details.to}'`, details);
81
- }
82
- };
83
- var DuplicateCampaignError = class extends CrmError {
84
- constructor(name) {
85
- super("DUPLICATE_CAMPAIGN", `A campaign named '${name}' already exists`, { name });
86
- }
87
- };
88
- var InvalidSubscriptionTransitionError = class extends CrmError {
89
- constructor(details) {
90
- super("INVALID_SUBSCRIPTION_TRANSITION", `Subscription ${details.subscriptionId} cannot move from '${details.from}' to '${details.to}'`, details);
91
- }
92
- };
93
- var DuplicateSubscriptionError = class extends CrmError {
94
- constructor(details) {
95
- super("DUPLICATE_SUBSCRIPTION", `Contact '${details.contactId}' already has a live subscription on campaign '${details.campaignId}'`, details);
96
- }
97
- };
98
- var InvalidConfirmationTokenError = class extends CrmError {
99
- constructor() {
100
- super("INVALID_CONFIRMATION_TOKEN", "No subscription matches the supplied confirmation token, or it has already been used");
101
- }
102
- };
103
- var InvalidUnsubscribeTokenError = class extends CrmError {
104
- constructor() {
105
- super("INVALID_UNSUBSCRIBE_TOKEN", "No subscription matches the supplied unsubscribe token");
106
- }
107
- };
108
- var InvalidSequenceTransitionError = class extends CrmError {
109
- constructor(details) {
110
- super("INVALID_SEQUENCE_TRANSITION", `Sequence ${details.sequenceId} cannot move from '${details.from}' to '${details.to}'`, details);
111
- }
112
- };
113
- var InvalidEnrollmentTransitionError = class extends CrmError {
114
- constructor(details) {
115
- super("INVALID_ENROLLMENT_TRANSITION", `Enrollment ${details.enrollmentId} cannot move from '${details.from}' to '${details.to}'`, details);
116
- }
117
- };
118
- var AlreadyEnrolledError = class extends CrmError {
119
- constructor(details) {
120
- super("ALREADY_ENROLLED", `Subject '${details.subjectId}' already has an active enrollment in sequence '${details.sequenceId}'`, details);
121
- }
122
- };
25
+ function resolveCrmModules(modules) {
26
+ if (modules === void 0) return ALL_CRM_MODULES;
27
+ const automation = modules.automation === true;
28
+ const engagement = modules.engagement === true || automation;
29
+ const sales = modules.sales === true || engagement;
30
+ return Object.freeze({
31
+ sales,
32
+ engagement,
33
+ automation
34
+ });
35
+ }
123
36
  //#endregion
124
37
  //#region src/models/tenant-field.ts
125
38
  /**
@@ -130,6 +43,9 @@ var AlreadyEnrolledError = class extends CrmError {
130
43
  * `.populate()`, QueryParser `?lookup=organizationId`); hosts with
131
44
  * UUID/slug auth pick `'string'`.
132
45
  */
46
+ function crmModelName(config, base) {
47
+ return `${config.modelPrefix ?? ""}${base}`;
48
+ }
133
49
  /**
134
50
  * Tenant-column schema definition. Use this anywhere a model has
135
51
  * `organizationId` — never write the raw type in a schema file.
@@ -275,6 +191,316 @@ function canTransitionLead(from, to) {
275
191
  return LEAD_TRANSITIONS[from].includes(to);
276
192
  }
277
193
  //#endregion
194
+ //#region src/core/errors.ts
195
+ /**
196
+ * `status` makes every CRM throwable satisfy `HttpError` from
197
+ * `@classytic/repo-core/errors` (PACKAGE_RULES "Errors and wire envelopes"),
198
+ * so arc's global handler serializes it natively with the right code AND the
199
+ * right status — no per-host `errorMapper`. Without it every CRM error
200
+ * reached the client as `arc.internal_error` / 500: `NotFoundError` answered
201
+ * 500 instead of 404, which tells a caller "the server broke" when the truth
202
+ * was "that lead doesn't exist".
203
+ *
204
+ * Defaults to 422 (well-formed request, violates a domain invariant) —
205
+ * the common case here. Subclasses override with `readonly status = N`;
206
+ * the subclass field initializer runs after this constructor, so it wins.
207
+ */
208
+ var CrmError = class extends Error {
209
+ code;
210
+ status = 422;
211
+ details;
212
+ constructor(code, message, details) {
213
+ super(message);
214
+ this.name = "CrmError";
215
+ this.code = code;
216
+ if (details !== void 0) this.details = details;
217
+ }
218
+ };
219
+ var NotFoundError = class extends CrmError {
220
+ status = 404;
221
+ constructor(kind, id) {
222
+ super(`${kind.toUpperCase()}_NOT_FOUND`, `${kind} '${id}' not found`, {
223
+ kind,
224
+ id
225
+ });
226
+ }
227
+ };
228
+ var InvalidLeadTransitionError = class extends CrmError {
229
+ status = 422;
230
+ constructor(details) {
231
+ super("INVALID_LEAD_TRANSITION", `Lead ${details.leadId} cannot move from '${details.from}' to '${details.to}'`, details);
232
+ }
233
+ };
234
+ var InvalidOpportunityTransitionError = class extends CrmError {
235
+ status = 422;
236
+ constructor(details) {
237
+ super("INVALID_OPPORTUNITY_TRANSITION", `Opportunity ${details.opportunityId} cannot move from '${details.from}' to '${details.to}'`, details);
238
+ }
239
+ };
240
+ var InvalidActivityTransitionError = class extends CrmError {
241
+ status = 422;
242
+ constructor(details) {
243
+ super("INVALID_ACTIVITY_TRANSITION", `Activity ${details.activityId} cannot move from '${details.from}' to '${details.to}'`, details);
244
+ }
245
+ };
246
+ var StageNotInPipelineError = class extends CrmError {
247
+ status = 422;
248
+ constructor(details) {
249
+ super("STAGE_NOT_IN_PIPELINE", `Stage '${details.stageId}' is not defined on pipeline '${details.pipelineId}'`, details);
250
+ }
251
+ };
252
+ var LeadAlreadyConvertedError = class extends CrmError {
253
+ status = 409;
254
+ constructor(leadId) {
255
+ super("LEAD_ALREADY_CONVERTED", `Lead '${leadId}' is already converted`, { leadId });
256
+ }
257
+ };
258
+ var DuplicateLeadError = class extends CrmError {
259
+ status = 409;
260
+ constructor(details) {
261
+ super("DUPLICATE_LEAD", `A lead with this email / phone already exists`, details);
262
+ }
263
+ };
264
+ var DuplicateContactError = class extends CrmError {
265
+ status = 409;
266
+ constructor(details) {
267
+ super("DUPLICATE_CONTACT", `A contact with email '${details.email}' already exists in this organization`, details);
268
+ }
269
+ };
270
+ var UnmanagedSessionError = class extends CrmError {
271
+ status = 500;
272
+ constructor(eventType) {
273
+ super("UNMANAGED_SESSION", "ctx.session was provided but no event delivery path exists — inside a host-owned transaction the event would either leak to subscribers before commit or be lost on rollback. Use engine.withTransaction(ctx, fn), wire the `outbox` runtime option, or attach a ctx[PENDING_EVENTS] queue and call flushPending() after commit (PACKAGE_RULES §P8.1).", eventType !== void 0 ? { eventType } : void 0);
274
+ }
275
+ };
276
+ var EmptyPipelineError = class extends CrmError {
277
+ status = 422;
278
+ constructor(pipelineId) {
279
+ super("EMPTY_PIPELINE", `Pipeline '${pipelineId}' has no stages`, { pipelineId });
280
+ }
281
+ };
282
+ var InvalidCampaignTransitionError = class extends CrmError {
283
+ status = 422;
284
+ constructor(details) {
285
+ super("INVALID_CAMPAIGN_TRANSITION", `Campaign ${details.campaignId} cannot move from '${details.from}' to '${details.to}'`, details);
286
+ }
287
+ };
288
+ var DuplicateCampaignError = class extends CrmError {
289
+ status = 409;
290
+ constructor(name) {
291
+ super("DUPLICATE_CAMPAIGN", `A campaign named '${name}' already exists`, { name });
292
+ }
293
+ };
294
+ var InvalidSubscriptionTransitionError = class extends CrmError {
295
+ status = 422;
296
+ constructor(details) {
297
+ super("INVALID_SUBSCRIPTION_TRANSITION", `Subscription ${details.subscriptionId} cannot move from '${details.from}' to '${details.to}'`, details);
298
+ }
299
+ };
300
+ var DuplicateSubscriptionError = class extends CrmError {
301
+ status = 409;
302
+ constructor(details) {
303
+ super("DUPLICATE_SUBSCRIPTION", `Contact '${details.contactId}' already has a live subscription on campaign '${details.campaignId}'`, details);
304
+ }
305
+ };
306
+ var InvalidConfirmationTokenError = class extends CrmError {
307
+ status = 422;
308
+ constructor() {
309
+ super("INVALID_CONFIRMATION_TOKEN", "No subscription matches the supplied confirmation token, or it has already been used");
310
+ }
311
+ };
312
+ var InvalidUnsubscribeTokenError = class extends CrmError {
313
+ status = 422;
314
+ constructor() {
315
+ super("INVALID_UNSUBSCRIBE_TOKEN", "No subscription matches the supplied unsubscribe token");
316
+ }
317
+ };
318
+ var InvalidSequenceTransitionError = class extends CrmError {
319
+ status = 422;
320
+ constructor(details) {
321
+ super("INVALID_SEQUENCE_TRANSITION", `Sequence ${details.sequenceId} cannot move from '${details.from}' to '${details.to}'`, details);
322
+ }
323
+ };
324
+ var InvalidEnrollmentTransitionError = class extends CrmError {
325
+ status = 422;
326
+ constructor(details) {
327
+ super("INVALID_ENROLLMENT_TRANSITION", `Enrollment ${details.enrollmentId} cannot move from '${details.from}' to '${details.to}'`, details);
328
+ }
329
+ };
330
+ var AlreadyEnrolledError = class extends CrmError {
331
+ status = 409;
332
+ constructor(details) {
333
+ super("ALREADY_ENROLLED", `Subject '${details.subjectId}' already has an active enrollment in sequence '${details.sequenceId}'`, details);
334
+ }
335
+ };
336
+ /**
337
+ * An address handed to a suppression-list WRITE could not be canonicalised.
338
+ *
339
+ * 400, not 422 — the request itself is malformed at the field, no domain
340
+ * invariant was consulted. This is `@classytic/contact`'s `EmailFormatError`
341
+ * re-thrown as a CRM error so arc serializes it natively; the same shape party
342
+ * uses for `PartyPhoneFormatError`. `contact` sits below every domain kernel
343
+ * and knows nothing about HTTP, so the mapping has to happen here.
344
+ *
345
+ * Only WRITES throw. Reads fail closed instead — see
346
+ * `suppression.repository.ts`, which writes the asymmetry down.
347
+ */
348
+ var SuppressionEmailFormatError = class extends CrmError {
349
+ status = 400;
350
+ constructor(details) {
351
+ super("SUPPRESSION_EMAIL_INVALID", `'${details.email}' cannot be canonicalised as an email address (${details.reason}), so it is NOT written to the suppression list. A malformed entry stored verbatim is a do-not-contact record that can never match a send — it suppresses nothing and reports success.`, details);
352
+ }
353
+ };
354
+ /**
355
+ * An address handed to `LeadRepository.createLead` could not be canonicalised.
356
+ *
357
+ * 400, same shape and same reason as `SuppressionEmailFormatError`: the field is
358
+ * malformed, no domain invariant was consulted. Thrown from
359
+ * `core/email-identity.ts`, which owns the write-throws / setter-degrades
360
+ * asymmetry and writes down why each side goes the way it does.
361
+ */
362
+ var LeadEmailFormatError = class extends CrmError {
363
+ status = 400;
364
+ constructor(details) {
365
+ super("LEAD_EMAIL_INVALID", `'${details.email}' cannot be canonicalised as an email address (${details.reason}), so no lead is created. Stored verbatim it would defeat the (organizationId, email) unique index it is supposed to arm — uncanonicalisable spellings never collide, so the same broken address inserts without limit while every call reports success.`, details);
366
+ }
367
+ };
368
+ /** As {@link LeadEmailFormatError}, for `ContactRepository.createContact`. */
369
+ var ContactEmailFormatError = class extends CrmError {
370
+ status = 400;
371
+ constructor(details) {
372
+ super("CONTACT_EMAIL_INVALID", `'${details.email}' cannot be canonicalised as an email address (${details.reason}), so no contact is created. A contact whose only identity field is unusable cannot be deduped, contacted, or matched against a suppression list.`, details);
373
+ }
374
+ };
375
+ //#endregion
376
+ //#region src/core/email-identity.ts
377
+ /**
378
+ * WHERE the email key rule is applied inside CRM. **Not a second copy of the
379
+ * rule** — the rule is `normalizeEmail` from `@classytic/contact/email`, imported
380
+ * directly here as it is in `suppression.repository.ts`. What this module owns is
381
+ * the two things a shared kernel cannot decide for us: which mongoose fields carry
382
+ * the key, and what happens when the kernel refuses an input.
383
+ *
384
+ * It exists as a module rather than a copy-paste because Lead and Contact both
385
+ * carry an email identity. Two schemas hand-rolling the same setter is the drift
386
+ * this package already paid for once.
387
+ *
388
+ * ## Why the SETTER, and not only the repository verbs
389
+ *
390
+ * `LeadRepository.createLead` / `ContactRepository.createContact` are NOT on the
391
+ * HTTP path. `@spinekit/crm` mounts both resources through arc's mongoose CRUD
392
+ * adapter, so `POST /crm/leads` calls the inherited `Repository.create()` and the
393
+ * verbs' pre-check never runs. A fix that lived only on the verbs would leave the
394
+ * live route forking one mailbox into two rows, and the tests for the verbs would
395
+ * be green while it did.
396
+ *
397
+ * The narrowest boundary EVERY write must cross is the schema field, so the key
398
+ * rule is applied there. That is also what makes the partial-unique
399
+ * `(organizationId, email)` index authoritative: an index can only dedup what the
400
+ * key already folded, and `lowercase: true` does not IDNA-fold. `ada@bücher.de`
401
+ * and `ada@xn--bcher-kva.de` are one host in two encodings — the index saw two
402
+ * strings and admitted both, no error, one mailbox as two leads.
403
+ *
404
+ * Mongoose runs string setters on QUERY FILTERS as well as on writes (this is why
405
+ * `lowercase: true` already made `getOne({ email: 'ADA@x.com' })` match a stored
406
+ * `ada@x.com`), so the setter fixes the read side in the same stroke. The
407
+ * repository verbs still canonicalise EXPLICITLY before querying — see
408
+ * `requireLeadEmailKey`'s call sites. That is not redundancy for its own sake:
409
+ * a pre-check that depends on an invisible schema option silently WIDENS the day
410
+ * someone edits the field, and a widened filter does not fail, it matches nothing
411
+ * and reports "no duplicate".
412
+ *
413
+ * ## The setter must NEVER throw, and it does not
414
+ *
415
+ * A throw inside a setter surfaces during query casting as an opaque `CastError`,
416
+ * which would turn a malformed filter into a 500 on a READ. So the setter degrades
417
+ * to the LEGACY folding (`trim().toLowerCase()`) for anything the kernel refuses —
418
+ * exactly the behaviour those values have today, so nothing already stored is
419
+ * re-keyed by a rule that cannot explain itself.
420
+ *
421
+ * Refusing is the WRITE VERBS' job, where a typed 400 can be thrown and the caller
422
+ * is a deliberate domain call rather than a filter. Same asymmetry, same reason as
423
+ * `suppression.repository.ts`: the safe direction differs per side.
424
+ */
425
+ /**
426
+ * The canonical key for `email`, or `null` when it has none.
427
+ *
428
+ * Exported so a host can tell "not on file" apart from "not an address" without
429
+ * re-deriving the rule — the same reason `suppressionEmailKey` is exported.
430
+ */
431
+ function crmEmailKey(email) {
432
+ try {
433
+ return normalizeEmail(email);
434
+ } catch (err) {
435
+ if (err instanceof EmailFormatError) return null;
436
+ throw err;
437
+ }
438
+ }
439
+ /**
440
+ * Mongoose setter for every field holding an email IDENTITY (one that a unique
441
+ * index or a dedup pre-check reads). Canonicalises what it can; falls back to the
442
+ * legacy fold for what it cannot. Never throws — see the module docblock.
443
+ *
444
+ * Non-strings pass through untouched: `anonymize()` writes `null` to clear the
445
+ * field, and a canonicaliser that coerced that to `''` would put the row back in
446
+ * the partial-unique index it was meant to leave.
447
+ */
448
+ function canonicalEmailSetter(value) {
449
+ if (typeof value !== "string") return value;
450
+ return crmEmailKey(value) ?? value.trim().toLowerCase();
451
+ }
452
+ /**
453
+ * The canonical key for a Lead's email, or a 400.
454
+ *
455
+ * `createLead` is a WRITE, and a lead keyed by an address that is not an address
456
+ * defeats the guard it is supposed to arm: uncanonicalisable spellings never
457
+ * collide with each other, so the `(organizationId, email)` unique index admits
458
+ * the same broken input without limit while reporting success on every insert.
459
+ * The row also cannot be contacted, matched against a suppression list, or merged
460
+ * later — it is a duplicate factory that looks like a lead.
461
+ *
462
+ * Reachability, checked rather than assumed: be-prod gates the CRM write
463
+ * permission with `requireAuth()` (`be-prod/src/modules/sales/crm.module.ts`), and
464
+ * there is no public lead-capture route in this repo — so this 400 reaches an
465
+ * authenticated operator or an importer, never an anonymous form visitor. If a
466
+ * deployment ever puts a public form in front of `POST /crm/leads`, note that the
467
+ * arc-CRUD route does NOT reach this function at all (see the module docblock);
468
+ * that route's leniency is deliberate and separate.
469
+ */
470
+ function requireLeadEmailKey(email) {
471
+ try {
472
+ return normalizeEmail(email);
473
+ } catch (err) {
474
+ if (err instanceof EmailFormatError) throw new LeadEmailFormatError({
475
+ email,
476
+ reason: err.code
477
+ });
478
+ throw err;
479
+ }
480
+ }
481
+ /**
482
+ * The canonical key for a Contact's email, or a 400. Same rationale as
483
+ * {@link requireLeadEmailKey}.
484
+ *
485
+ * One extra caller worth naming: `LeadService.convert()` passes the lead's stored
486
+ * email straight into `createContact`, so converting a lead whose address cannot
487
+ * be canonicalised now FAILS instead of minting a Contact whose only identity
488
+ * field is unusable. That is a deliberate behaviour change — the alternative
489
+ * (dropping the address during convert) creates a Contact the operator believes
490
+ * carries the lead's email and which silently does not.
491
+ */
492
+ function requireContactEmailKey(email) {
493
+ try {
494
+ return normalizeEmail(email);
495
+ } catch (err) {
496
+ if (err instanceof EmailFormatError) throw new ContactEmailFormatError({
497
+ email,
498
+ reason: err.code
499
+ });
500
+ throw err;
501
+ }
502
+ }
503
+ //#endregion
278
504
  //#region src/models/lead.schema.ts
279
505
  /**
280
506
  * Lead — top-of-funnel inquiry, pre-qualification.
@@ -332,8 +558,7 @@ function buildLeadSchema(config) {
332
558
  },
333
559
  email: {
334
560
  type: String,
335
- trim: true,
336
- lowercase: true,
561
+ set: canonicalEmailSetter,
337
562
  maxlength: 255
338
563
  },
339
564
  phone: {
@@ -389,15 +614,15 @@ function buildLeadSchema(config) {
389
614
  },
390
615
  convertedContactId: {
391
616
  type: Schema.Types.ObjectId,
392
- ref: "Contact"
617
+ ref: crmModelName(config, "Contact")
393
618
  },
394
619
  convertedAccountId: {
395
620
  type: Schema.Types.ObjectId,
396
- ref: "Account"
621
+ ref: crmModelName(config, "Account")
397
622
  },
398
623
  convertedOpportunityId: {
399
624
  type: Schema.Types.ObjectId,
400
- ref: "Opportunity"
625
+ ref: crmModelName(config, "Opportunity")
401
626
  },
402
627
  convertedAt: { type: Date },
403
628
  disqualifyReason: {
@@ -405,6 +630,7 @@ function buildLeadSchema(config) {
405
630
  trim: true,
406
631
  maxlength: 500
407
632
  },
633
+ anonymizedAt: { type: Date },
408
634
  metadata: { type: Schema.Types.Mixed }
409
635
  }, { timestamps: true });
410
636
  schema.index({
@@ -488,15 +714,15 @@ function buildOpportunitySchema(config) {
488
714
  },
489
715
  accountId: {
490
716
  type: Schema.Types.ObjectId,
491
- ref: "Account"
717
+ ref: crmModelName(config, "Account")
492
718
  },
493
719
  primaryContactId: {
494
720
  type: Schema.Types.ObjectId,
495
- ref: "Contact"
721
+ ref: crmModelName(config, "Contact")
496
722
  },
497
723
  pipelineId: {
498
724
  type: Schema.Types.ObjectId,
499
- ref: "Pipeline",
725
+ ref: crmModelName(config, "Pipeline"),
500
726
  required: true
501
727
  },
502
728
  stageId: {
@@ -532,7 +758,7 @@ function buildOpportunitySchema(config) {
532
758
  closedAt: { type: Date },
533
759
  lostReasonId: {
534
760
  type: Schema.Types.ObjectId,
535
- ref: "LossReason"
761
+ ref: crmModelName(config, "LossReason")
536
762
  },
537
763
  ownerId: {
538
764
  type: String,
@@ -540,7 +766,7 @@ function buildOpportunitySchema(config) {
540
766
  },
541
767
  sourceLeadId: {
542
768
  type: Schema.Types.ObjectId,
543
- ref: "Lead"
769
+ ref: crmModelName(config, "Lead")
544
770
  },
545
771
  tags: {
546
772
  type: [String],
@@ -617,14 +843,12 @@ const nameSchema = new Schema({
617
843
  const contactInfoSchema$1 = new Schema({
618
844
  email: {
619
845
  type: String,
620
- trim: true,
621
- lowercase: true,
846
+ set: canonicalEmailSetter,
622
847
  maxlength: 255
623
848
  },
624
849
  personalEmail: {
625
850
  type: String,
626
- trim: true,
627
- lowercase: true,
851
+ set: canonicalEmailSetter,
628
852
  maxlength: 255
629
853
  },
630
854
  phone: {
@@ -676,7 +900,7 @@ function buildContactSchema(config) {
676
900
  },
677
901
  accountId: {
678
902
  type: Schema.Types.ObjectId,
679
- ref: "Account"
903
+ ref: crmModelName(config, "Account")
680
904
  },
681
905
  ownerId: {
682
906
  type: String,
@@ -686,7 +910,8 @@ function buildContactSchema(config) {
686
910
  type: [String],
687
911
  default: []
688
912
  },
689
- metadata: { type: Schema.Types.Mixed }
913
+ metadata: { type: Schema.Types.Mixed },
914
+ anonymizedAt: { type: Date }
690
915
  }, {
691
916
  timestamps: true,
692
917
  toJSON: { virtuals: true },
@@ -695,6 +920,9 @@ function buildContactSchema(config) {
695
920
  schema.index({
696
921
  organizationId: 1,
697
922
  "contact.email": 1
923
+ }, {
924
+ unique: true,
925
+ partialFilterExpression: { "contact.email": { $type: "string" } }
698
926
  });
699
927
  schema.index({
700
928
  organizationId: 1,
@@ -991,12 +1219,12 @@ function buildSubscriptionSchema(config) {
991
1219
  organizationId: tenantFieldDef(config),
992
1220
  contactId: {
993
1221
  type: Schema.Types.ObjectId,
994
- ref: "Contact",
1222
+ ref: crmModelName(config, "Contact"),
995
1223
  required: true
996
1224
  },
997
1225
  campaignId: {
998
1226
  type: Schema.Types.ObjectId,
999
- ref: "Campaign",
1227
+ ref: crmModelName(config, "Campaign"),
1000
1228
  required: true
1001
1229
  },
1002
1230
  status: {
@@ -1455,7 +1683,7 @@ function buildEmailEventSchema(config) {
1455
1683
  },
1456
1684
  campaignId: {
1457
1685
  type: Schema.Types.ObjectId,
1458
- ref: "Campaign",
1686
+ ref: crmModelName(config, "Campaign"),
1459
1687
  index: true
1460
1688
  },
1461
1689
  messageId: {
@@ -1665,7 +1893,7 @@ function buildMessageSchema(config) {
1665
1893
  organizationId: tenantFieldDef(config),
1666
1894
  conversationId: {
1667
1895
  type: Schema.Types.ObjectId,
1668
- ref: "Conversation",
1896
+ ref: crmModelName(config, "Conversation"),
1669
1897
  required: true,
1670
1898
  index: true
1671
1899
  },
@@ -1719,7 +1947,7 @@ function buildMessageSchema(config) {
1719
1947
  partialFilterExpression: { externalMessageId: { $type: "string" } }
1720
1948
  });
1721
1949
  schema.index({ sentAt: 1 }, {
1722
- expireAfterSeconds: 365 * 24 * 60 * 60,
1950
+ expireAfterSeconds: 31536e3,
1723
1951
  sparse: true,
1724
1952
  name: "sentAt_ttl"
1725
1953
  });
@@ -1913,7 +2141,7 @@ function buildEnrollmentSchema(config) {
1913
2141
  sequenceId: {
1914
2142
  type: config.tenantFieldType === "objectId" ? Schema.Types.ObjectId : String,
1915
2143
  required: true,
1916
- ref: "Sequence"
2144
+ ref: crmModelName(config, "Sequence")
1917
2145
  },
1918
2146
  subjectKind: {
1919
2147
  type: String,
@@ -1988,7 +2216,20 @@ function buildEnrollmentSchema(config) {
1988
2216
  }
1989
2217
  //#endregion
1990
2218
  //#region src/models/create-models.ts
1991
- /** Mongoose model names this package registers — drives the §21 collision check. */
2219
+ /**
2220
+ * CRM model specification — the ONE authoritative place the CRM model set is
2221
+ * described (Kernel Construction Standard §5/§6).
2222
+ *
2223
+ * `defineCrmModels(config)` returns a connection-free mongokit `ModelBlueprint`;
2224
+ * `defineCrm` binds it during engine construction. Optional model families
2225
+ * (sales / engagement / automation) become CONDITIONAL `ModelSpec` entries — a
2226
+ * disabled module contributes no spec, so it registers no model (invariant §11.10).
2227
+ *
2228
+ * Collision handling, auto-index and the tenant-column shape live here once.
2229
+ * A name collision surfaces as mongokit's typed `ModelCollisionError` — there is
2230
+ * no CRM-specific collision error.
2231
+ */
2232
+ /** Mongoose model names this package can register (before `modelPrefix`). Reference/documentation. */
1992
2233
  const CRM_MODEL_NAMES = [
1993
2234
  "Pipeline",
1994
2235
  "Lead",
@@ -2007,34 +2248,132 @@ const CRM_MODEL_NAMES = [
2007
2248
  "Sequence",
2008
2249
  "Enrollment"
2009
2250
  ];
2010
- function createCrmModels(connection, config) {
2011
- const forceRecreate = config.forceRecreate ?? false;
2251
+ /**
2252
+ * Describe the CRM model set as a connection-free blueprint. Pure — builds no
2253
+ * schema and registers nothing until `.bind(connection)`. Only the enabled
2254
+ * module families contribute `ModelSpec` entries.
2255
+ */
2256
+ function defineCrmModels(config) {
2012
2257
  const autoIndex = config.autoIndex ?? true;
2013
- const namesToCheck = [...CRM_MODEL_NAMES, "Subscription"];
2014
- if (forceRecreate) {
2015
- for (const name of namesToCheck) if (connection.models[name]) connection.deleteModel(name);
2016
- } else for (const name of CRM_MODEL_NAMES) if (connection.models[name]) throw new CrmModelCollisionError(name);
2017
- const register = (name, schema) => {
2018
- schema.set("autoIndex", autoIndex);
2019
- return connection.model(name, schema);
2020
- };
2021
- return Object.freeze({
2022
- Pipeline: register("Pipeline", buildPipelineSchema(config)),
2023
- Lead: register("Lead", buildLeadSchema(config)),
2024
- Opportunity: register("Opportunity", buildOpportunitySchema(config)),
2025
- Contact: register("Contact", buildContactSchema(config)),
2026
- Account: register("Account", buildAccountSchema(config)),
2027
- Campaign: register("Campaign", buildCampaignSchema(config)),
2028
- Subscription: register("EmailSubscription", buildSubscriptionSchema(config)),
2029
- Suppression: register("Suppression", buildSuppressionSchema(config)),
2030
- Activity: register("Activity", buildActivitySchema(config)),
2031
- Note: register("Note", buildNoteSchema(config)),
2032
- LossReason: register("LossReason", buildLossReasonSchema(config)),
2033
- EmailEvent: register("EmailEvent", buildEmailEventSchema(config)),
2034
- Conversation: register("Conversation", buildConversationSchema(config)),
2035
- Message: register("Message", buildMessageSchema(config)),
2036
- Sequence: register("Sequence", buildSequenceSchema(config)),
2037
- Enrollment: register("Enrollment", buildEnrollmentSchema(config))
2258
+ const existing = config.forceRecreate ? {
2259
+ mode: "replace",
2260
+ environment: "test"
2261
+ } : { mode: "throw" };
2262
+ const salesEntries = [
2263
+ {
2264
+ key: "Pipeline",
2265
+ baseName: "Pipeline",
2266
+ build: buildPipelineSchema
2267
+ },
2268
+ {
2269
+ key: "Lead",
2270
+ baseName: "Lead",
2271
+ build: buildLeadSchema
2272
+ },
2273
+ {
2274
+ key: "Opportunity",
2275
+ baseName: "Opportunity",
2276
+ build: buildOpportunitySchema
2277
+ },
2278
+ {
2279
+ key: "Contact",
2280
+ baseName: "Contact",
2281
+ build: buildContactSchema
2282
+ },
2283
+ {
2284
+ key: "Account",
2285
+ baseName: "Account",
2286
+ build: buildAccountSchema
2287
+ },
2288
+ {
2289
+ key: "Activity",
2290
+ baseName: "Activity",
2291
+ build: buildActivitySchema
2292
+ },
2293
+ {
2294
+ key: "Note",
2295
+ baseName: "Note",
2296
+ build: buildNoteSchema
2297
+ },
2298
+ {
2299
+ key: "LossReason",
2300
+ baseName: "LossReason",
2301
+ build: buildLossReasonSchema
2302
+ }
2303
+ ];
2304
+ const engagementEntries = [
2305
+ {
2306
+ key: "Subscription",
2307
+ baseName: "EmailSubscription",
2308
+ build: buildSubscriptionSchema
2309
+ },
2310
+ {
2311
+ key: "Suppression",
2312
+ baseName: "Suppression",
2313
+ build: buildSuppressionSchema
2314
+ },
2315
+ {
2316
+ key: "EmailEvent",
2317
+ baseName: "EmailEvent",
2318
+ build: buildEmailEventSchema
2319
+ },
2320
+ {
2321
+ key: "Conversation",
2322
+ baseName: "Conversation",
2323
+ build: buildConversationSchema
2324
+ },
2325
+ {
2326
+ key: "Message",
2327
+ baseName: "Message",
2328
+ build: buildMessageSchema
2329
+ }
2330
+ ];
2331
+ const automationEntries = [
2332
+ {
2333
+ key: "Campaign",
2334
+ baseName: "Campaign",
2335
+ build: buildCampaignSchema
2336
+ },
2337
+ {
2338
+ key: "Sequence",
2339
+ baseName: "Sequence",
2340
+ build: buildSequenceSchema
2341
+ },
2342
+ {
2343
+ key: "Enrollment",
2344
+ baseName: "Enrollment",
2345
+ build: buildEnrollmentSchema
2346
+ }
2347
+ ];
2348
+ const entries = [
2349
+ ...config.modules.sales ? salesEntries : [],
2350
+ ...config.modules.engagement ? engagementEntries : [],
2351
+ ...config.modules.automation ? automationEntries : []
2352
+ ];
2353
+ const keyByRegistryName = /* @__PURE__ */ new Map();
2354
+ const specs = entries.map((entry) => {
2355
+ const name = crmModelName(config, entry.baseName);
2356
+ keyByRegistryName.set(name, entry.key);
2357
+ return {
2358
+ name,
2359
+ schema: () => {
2360
+ const schema = entry.build(config);
2361
+ schema.set("autoIndex", autoIndex);
2362
+ return schema;
2363
+ },
2364
+ existing
2365
+ };
2366
+ });
2367
+ return defineModels({
2368
+ models: specs,
2369
+ assemble: (models) => {
2370
+ const bag = {};
2371
+ for (const [name, key] of keyByRegistryName) {
2372
+ const model = models.get(name);
2373
+ if (model) bag[key] = model;
2374
+ }
2375
+ return Object.freeze(bag);
2376
+ }
2038
2377
  });
2039
2378
  }
2040
2379
  //#endregion
@@ -2090,9 +2429,11 @@ const CRM_EVENTS = {
2090
2429
  EMAIL_EVENT_UNSUBSCRIBED: "crm:email_event.unsubscribed",
2091
2430
  LEAD_ENRICHED: "crm:lead.enriched",
2092
2431
  LEAD_ASSIGNED: "crm:lead.assigned",
2432
+ LEAD_ANONYMIZED: "crm:lead.anonymized",
2093
2433
  CONTACT_ENRICHED: "crm:contact.enriched",
2094
2434
  CONTACT_ASSIGNED: "crm:contact.assigned",
2095
2435
  CONTACT_MERGED: "crm:contact.merged",
2436
+ CONTACT_ANONYMIZED: "crm:contact.anonymized",
2096
2437
  ACCOUNT_ENRICHED: "crm:account.enriched",
2097
2438
  ACCOUNT_ASSIGNED: "crm:account.assigned",
2098
2439
  ACCOUNT_MERGED: "crm:account.merged",
@@ -2328,15 +2669,22 @@ var LeadRepository = class extends Repository {
2328
2669
  * Create a Lead. Auto-scores on insert so hosts can route immediately.
2329
2670
  * Rejects duplicate emails inside the same tenant unless
2330
2671
  * `rejectDuplicates: false` was set at engine boot.
2672
+ *
2673
+ * Throws `LeadEmailFormatError` (400) on an email that cannot be canonicalised
2674
+ * — nothing is written. The reason lives beside the helper in
2675
+ * `core/email-identity.ts`, which also explains why the SCHEMA setter degrades
2676
+ * where this verb refuses.
2331
2677
  */
2332
2678
  async createLead(input, ctx) {
2333
- if (this.rejectDuplicates && input.email) {
2334
- if (await this.getOne({ email: input.email }, ctx)) throw new DuplicateLeadError({ email: input.email });
2679
+ const email = input.email === void 0 ? void 0 : requireLeadEmailKey(input.email);
2680
+ if (this.rejectDuplicates && email) {
2681
+ if (await this.getOne({ email }, ctx)) throw new DuplicateLeadError({ email });
2335
2682
  }
2336
2683
  const now = /* @__PURE__ */ new Date();
2337
- const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || input.email || input.phone);
2684
+ const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || email || input.phone);
2338
2685
  const draft = {
2339
2686
  ...input,
2687
+ ...email !== void 0 ? { email } : {},
2340
2688
  status: input.status ?? "new",
2341
2689
  score: 0,
2342
2690
  statusHistory: [{
@@ -2349,7 +2697,7 @@ var LeadRepository = class extends Repository {
2349
2697
  try {
2350
2698
  created = await this.create(draft, ctx);
2351
2699
  } catch (err) {
2352
- if (this.rejectDuplicates && input.email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email: input.email });
2700
+ if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email });
2353
2701
  throw err;
2354
2702
  }
2355
2703
  const score = this.scorer(created);
@@ -2464,6 +2812,67 @@ var LeadRepository = class extends Repository {
2464
2812
  }, ctx));
2465
2813
  return updated;
2466
2814
  }
2815
+ /**
2816
+ * The `converted` hop, owned by `LeadService.convert()` (the only caller
2817
+ * — the four cross-repo writes make it a service concern). Kept on the
2818
+ * repo so the atomic state write goes through `applyTransition` — a
2819
+ * machine-guarded CAS that `$push`es the history entry instead of
2820
+ * read-then-replacing the whole `statusHistory` array (no lost-update
2821
+ * window). Deliberately emits NO `lead.status_changed`: `lead.converted`
2822
+ * is the canonical signal for this transition and is dispatched by the
2823
+ * service alongside the cross-repo refs.
2824
+ */
2825
+ async markConverted(id, from, patch, entry, ctx) {
2826
+ return await this.applyTransition(id, LEAD_MACHINE, {
2827
+ from,
2828
+ to: "converted",
2829
+ set: patch,
2830
+ push: { statusHistory: entry },
2831
+ history: false
2832
+ }, ctx).catch(async (err) => {
2833
+ if (!isTransitionRace(err)) throw err;
2834
+ throw new InvalidLeadTransitionError({
2835
+ from: (await this.getById(id, ctx))?.status ?? from,
2836
+ to: "converted",
2837
+ leadId: id
2838
+ });
2839
+ });
2840
+ }
2841
+ /**
2842
+ * GDPR right-to-erasure for a Lead. Scrubs the PII fields
2843
+ * (`fullName`/`firstName`/`lastName`/`email`/`phone`) in place while
2844
+ * preserving non-PII aggregates (`score`, `status`, `statusHistory`,
2845
+ * `engagement`, firmographic `companyName`/`source`) and the id. Sets
2846
+ * `anonymizedAt` and emits `crm:lead.anonymized`.
2847
+ *
2848
+ * `fullName` is `required`, so it becomes the `'[redacted]'` sentinel;
2849
+ * the optional PII scalars are nulled (dropping `email` out of the
2850
+ * partial-unique email index). Idempotent.
2851
+ */
2852
+ async anonymize(id, ctx) {
2853
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Lead", id);
2854
+ const patch = {
2855
+ fullName: "[redacted]",
2856
+ firstName: null,
2857
+ lastName: null,
2858
+ email: null,
2859
+ phone: null,
2860
+ anonymizedAt: /* @__PURE__ */ new Date()
2861
+ };
2862
+ const updated = await this.update(id, patch, ctx);
2863
+ if (!updated) throw new NotFoundError("Lead", id);
2864
+ await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_ANONYMIZED, {
2865
+ leadId: id,
2866
+ fields: [
2867
+ "fullName",
2868
+ "firstName",
2869
+ "lastName",
2870
+ "email",
2871
+ "phone"
2872
+ ]
2873
+ }, ctx));
2874
+ return updated;
2875
+ }
2467
2876
  async _transition(id, target, extras, ctx) {
2468
2877
  const existing = await this.getById(id, ctx);
2469
2878
  if (!existing) throw new NotFoundError("Lead", id);
@@ -2759,38 +3168,32 @@ var OpportunityRepository = class extends Repository {
2759
3168
  return this.count({ status: "open" }, ctx);
2760
3169
  }
2761
3170
  /**
2762
- * Sum `amount` on won opportunities updated within `since`.
2763
- * Goes through the tenant-scoped `findAll()` — the multiTenant plugin
2764
- * injects `organizationId`, so a missing ctx can't leak other tenants'
2765
- * revenue (a manual `if (ctx)` filter would).
3171
+ * Sum `amount` on won opportunities updated on/after `since`, grouped by
3172
+ * currency. Returns one row per currency (descending by total) — a deal
3173
+ * book is multi-currency, so collapsing to a single total would silently
3174
+ * drop every non-top currency's revenue.
3175
+ *
3176
+ * Uses `aggregatePipeline` (a DB-side `$group`) instead of loading every
3177
+ * won opp into memory: the multiTenant + soft-delete policy hooks prepend
3178
+ * their `$match` so a missing ctx can't leak other tenants' revenue, and
3179
+ * the row count is bounded by the number of distinct currencies, not the
3180
+ * number of deals.
2766
3181
  */
2767
3182
  async sumWonSince(since, ctx) {
2768
- const rows = await this.findAll({
2769
- status: "won",
2770
- updatedAt: { $gte: since }
2771
- }, {
2772
- ...ctx,
2773
- select: {
2774
- amount: 1,
2775
- currency: 1
2776
- }
2777
- });
2778
- if (rows.length === 0) return null;
2779
- const byCurrency = /* @__PURE__ */ new Map();
2780
- for (const r of rows) {
2781
- const cur = r.currency ?? "UNKNOWN";
2782
- byCurrency.set(cur, (byCurrency.get(cur) ?? 0) + (r.amount ?? 0));
2783
- }
2784
- let topCurrency = "";
2785
- let topTotal = -Infinity;
2786
- for (const [cur, total] of byCurrency) if (total > topTotal) {
2787
- topTotal = total;
2788
- topCurrency = cur;
2789
- }
2790
- return {
2791
- total: topTotal,
2792
- currency: topCurrency
2793
- };
3183
+ return (await this.aggregatePipeline([
3184
+ { $match: {
3185
+ status: "won",
3186
+ updatedAt: { $gte: since }
3187
+ } },
3188
+ { $group: {
3189
+ _id: { $ifNull: ["$currency", "UNKNOWN"] },
3190
+ total: { $sum: { $ifNull: ["$amount", 0] } }
3191
+ } },
3192
+ { $sort: { total: -1 } }
3193
+ ], ctx)).map((r) => ({
3194
+ currency: r._id ?? "UNKNOWN",
3195
+ total: r.total
3196
+ }));
2794
3197
  }
2795
3198
  /** Latest `limit` opportunities — lightweight projection for the activity feed. */
2796
3199
  async findRecentForFeed(limit, ctx) {
@@ -2811,16 +3214,45 @@ var OpportunityRepository = class extends Repository {
2811
3214
  //#region src/repositories/contact.repository.ts
2812
3215
  var ContactRepository = class extends Repository {
2813
3216
  events;
3217
+ rejectDuplicates;
2814
3218
  constructor(model, plugins, deps, pagination) {
2815
3219
  super(model, plugins, pagination);
2816
3220
  this.events = deps.events;
3221
+ this.rejectDuplicates = deps.rejectDuplicates ?? true;
2817
3222
  }
2818
3223
  /**
2819
3224
  * Create + emit `crm:contact.created`. Hosts call this instead of
2820
3225
  * `create()` when they want the event side-effect (e.g. webhook fan-out).
3226
+ *
3227
+ * Rejects duplicate emails inside the same tenant unless
3228
+ * `rejectDuplicates: false` was set — mirrors `LeadRepository.createLead`.
3229
+ * The partial-unique `(organizationId, contact.email)` index is the
3230
+ * authoritative guard; the pre-check only closes the fast path.
3231
+ *
3232
+ * Throws `ContactEmailFormatError` (400) on an email that cannot be
3233
+ * canonicalised — nothing is written. See `core/email-identity.ts` for the
3234
+ * reason, including what it means for `LeadService.convert()`.
2821
3235
  */
2822
3236
  async createContact(input, ctx) {
2823
- const created = await this.create(input, ctx);
3237
+ const raw = input.contact?.email;
3238
+ const email = raw === void 0 ? void 0 : requireContactEmailKey(raw);
3239
+ if (this.rejectDuplicates && email) {
3240
+ if (await this.getOne({ "contact.email": email }, ctx)) throw new DuplicateContactError({ email });
3241
+ }
3242
+ const draft = email === void 0 ? input : {
3243
+ ...input,
3244
+ contact: {
3245
+ ...input.contact ?? {},
3246
+ email
3247
+ }
3248
+ };
3249
+ let created;
3250
+ try {
3251
+ created = await this.create(draft, ctx);
3252
+ } catch (err) {
3253
+ if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateContactError({ email });
3254
+ throw err;
3255
+ }
2824
3256
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_CREATED, {
2825
3257
  contactId: String(created._id),
2826
3258
  email: created.contact?.email
@@ -2899,14 +3331,19 @@ var ContactRepository = class extends Repository {
2899
3331
  ...stripEmpty(winnerName)
2900
3332
  },
2901
3333
  contact: mergedContactInfo,
2902
- tags: Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
3334
+ tags: Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
2903
3335
  ...winner.jobTitle !== void 0 ? { jobTitle: winner.jobTitle } : loser.jobTitle !== void 0 ? { jobTitle: loser.jobTitle } : {},
2904
3336
  ...winner.accountId !== void 0 ? { accountId: winner.accountId } : loser.accountId !== void 0 ? { accountId: loser.accountId } : {},
2905
3337
  ...winner.ownerId !== void 0 ? { ownerId: winner.ownerId } : loser.ownerId !== void 0 ? { ownerId: loser.ownerId } : {}
2906
3338
  };
3339
+ if (input.repoint) await input.repoint({
3340
+ primaryId,
3341
+ secondaryId,
3342
+ ...ctx !== void 0 ? { ctx } : {}
3343
+ });
3344
+ await this.delete(secondaryId, ctx);
2907
3345
  const updated = await this.update(primaryId, patch, ctx);
2908
3346
  if (!updated) throw new NotFoundError("Contact", primaryId);
2909
- await this.delete(secondaryId, ctx);
2910
3347
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_MERGED, {
2911
3348
  primaryId,
2912
3349
  secondaryId,
@@ -2914,7 +3351,54 @@ var ContactRepository = class extends Repository {
2914
3351
  }, ctx));
2915
3352
  return updated;
2916
3353
  }
3354
+ /**
3355
+ * GDPR right-to-erasure. Scrubs every PII field on the contact in place —
3356
+ * the structured `name` and ALL `contact.*` channels (email,
3357
+ * personalEmail, phone, alternatePhone, whatsapp, telegram, linkedin,
3358
+ * instagram) — while preserving the id and non-PII aggregates
3359
+ * (`accountId`, `ownerId`, `tags`, timestamps) so historical rollups and
3360
+ * relationships survive. Sets `anonymizedAt` as a tombstone and emits
3361
+ * `crm:contact.anonymized`. Idempotent: a second call re-scrubs to the
3362
+ * same sentinel.
3363
+ *
3364
+ * `name.given` is `required`, so it becomes the `'[redacted]'` sentinel
3365
+ * rather than empty; every other name part and the whole `contact`
3366
+ * subdocument are cleared. Unsetting `contact.email` also drops the row
3367
+ * out of the partial-unique email index, freeing the address for reuse.
3368
+ */
3369
+ async anonymize(id, ctx) {
3370
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Contact", id);
3371
+ const patch = {
3372
+ name: {
3373
+ given: REDACTED,
3374
+ family: ""
3375
+ },
3376
+ contact: {},
3377
+ anonymizedAt: /* @__PURE__ */ new Date()
3378
+ };
3379
+ const updated = await this.update(id, patch, ctx);
3380
+ if (!updated) throw new NotFoundError("Contact", id);
3381
+ await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_ANONYMIZED, {
3382
+ contactId: id,
3383
+ fields: PII_CONTACT_FIELDS
3384
+ }, ctx));
3385
+ return updated;
3386
+ }
2917
3387
  };
3388
+ /** Sentinel written to `required` PII fields that can't be cleared to empty. */
3389
+ const REDACTED = "[redacted]";
3390
+ /** PII field set scrubbed by `anonymize()` — surfaced in the event payload. */
3391
+ const PII_CONTACT_FIELDS = [
3392
+ "name",
3393
+ "contact.email",
3394
+ "contact.personalEmail",
3395
+ "contact.phone",
3396
+ "contact.alternatePhone",
3397
+ "contact.whatsapp",
3398
+ "contact.telegram",
3399
+ "contact.linkedin",
3400
+ "contact.instagram"
3401
+ ];
2918
3402
  function stripEmpty(obj) {
2919
3403
  const out = {};
2920
3404
  for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null && v !== "") out[k] = v;
@@ -2985,7 +3469,7 @@ var AccountRepository = class extends Repository {
2985
3469
  if (!secondary) throw new NotFoundError("Account", secondaryId);
2986
3470
  const winner = strategy === "preferPrimary" ? primary : secondary;
2987
3471
  const loser = strategy === "preferPrimary" ? secondary : primary;
2988
- const mergedTags = Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
3472
+ const mergedTags = Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
2989
3473
  const patch = {
2990
3474
  name: winner.name || loser.name,
2991
3475
  tags: mergedTags,
@@ -3270,8 +3754,36 @@ var SubscriptionRepository = class extends Repository {
3270
3754
  };
3271
3755
  //#endregion
3272
3756
  //#region src/repositories/suppression.repository.ts
3273
- function normalizeEmail(email) {
3274
- return email.trim().toLowerCase();
3757
+ /**
3758
+ * The suppression key for `email`, or `null` when it cannot be canonicalised.
3759
+ *
3760
+ * Exported because it is the ONLY way a caller can distinguish "this address is on
3761
+ * the list" from "this address is not an address" — the read verbs collapse both
3762
+ * to `true` on purpose (see the fail-closed rule in the module docblock), and a
3763
+ * caller that needs the distinction should not re-derive the rule to get it.
3764
+ */
3765
+ function suppressionEmailKey(email) {
3766
+ try {
3767
+ return normalizeEmail(email);
3768
+ } catch (err) {
3769
+ if (err instanceof EmailFormatError) return null;
3770
+ throw err;
3771
+ }
3772
+ }
3773
+ /**
3774
+ * The suppression key for `email`, or a 400. Used by the WRITE verbs only —
3775
+ * storing an address that can never match is a suppression that suppresses nothing.
3776
+ */
3777
+ function requireSuppressionEmailKey(email) {
3778
+ try {
3779
+ return normalizeEmail(email);
3780
+ } catch (err) {
3781
+ if (err instanceof EmailFormatError) throw new SuppressionEmailFormatError({
3782
+ email,
3783
+ reason: err.code
3784
+ });
3785
+ throw err;
3786
+ }
3275
3787
  }
3276
3788
  var SuppressionRepository = class extends Repository {
3277
3789
  events;
@@ -3284,9 +3796,12 @@ var SuppressionRepository = class extends Repository {
3284
3796
  * `getOrCreate` on the unique (org, email) index — of N concurrent
3285
3797
  * calls exactly one inserts. Emits `crm:suppression.added` only for
3286
3798
  * the inserting call; an existing row is returned unchanged, no event.
3799
+ *
3800
+ * Throws `SuppressionEmailFormatError` (400) on an address that cannot be
3801
+ * canonicalised — nothing is written.
3287
3802
  */
3288
3803
  async suppress(email, input, ctx) {
3289
- const normalized = normalizeEmail(email);
3804
+ const normalized = requireSuppressionEmailKey(email);
3290
3805
  const draft = {
3291
3806
  email: normalized,
3292
3807
  reason: input.reason,
@@ -3304,37 +3819,68 @@ var SuppressionRepository = class extends Repository {
3304
3819
  }, ctx));
3305
3820
  return doc;
3306
3821
  }
3307
- /** True when `email` is on the org's suppression list. */
3822
+ /**
3823
+ * True when `email` is on the org's suppression list — **or when it cannot be
3824
+ * canonicalised at all**, which is the fail-closed half of the module rule
3825
+ * above. Never throws on a malformed address: the safe answer is "do not send",
3826
+ * and this verb's job is to produce that answer, not to police the input.
3827
+ */
3308
3828
  async isSuppressed(email, ctx) {
3309
- return await this.exists({ email: normalizeEmail(email) }, ctx) !== null;
3829
+ const key = suppressionEmailKey(email);
3830
+ if (key === null) return true;
3831
+ return await this.exists({ email: key }, ctx) !== null;
3310
3832
  }
3311
3833
  /**
3312
- * Return the suppressed subset of `emails` (normalized) as a Set —
3313
- * ONE `$in` query, built for the campaign-fanout hot path:
3834
+ * Return the suppressed subset of `emails` as a Set — ONE `$in` query, built for
3835
+ * the campaign-fanout hot path:
3314
3836
  *
3315
3837
  * ```ts
3316
3838
  * const blocked = await suppression.filterSuppressed(recipients, ctx);
3317
- * const sendable = recipients.filter((e) => !blocked.has(e.toLowerCase()));
3839
+ * const sendable = recipients.filter((e) => !blocked.has(suppressionEmailKey(e) ?? e));
3318
3840
  * ```
3841
+ *
3842
+ * The Set is keyed CANONICALLY, so a caller must canonicalise its own recipient
3843
+ * before testing membership — matching on the raw spelling is how the punycode
3844
+ * miss came back. `suppressionEmailKey` is exported for exactly that, and returns
3845
+ * `null` for an address with no canonical form.
3846
+ *
3847
+ * An address that cannot be canonicalised is reported as BLOCKED, keyed by the
3848
+ * caller's original spelling (there is no canonical form to key it by, and the
3849
+ * caller has to be able to find it). It is not thrown: one malformed recipient
3850
+ * must not abort a fanout to thousands of valid ones.
3319
3851
  */
3320
3852
  async filterSuppressed(emails, ctx) {
3321
3853
  if (emails.length === 0) return /* @__PURE__ */ new Set();
3322
- const normalized = [...new Set(emails.map(normalizeEmail))];
3323
- const rows = await this.findAll({ email: { $in: normalized } }, {
3324
- ...ctx ?? {},
3325
- select: "email",
3326
- lean: true
3327
- });
3328
- return new Set(rows.map((row) => row.email));
3854
+ const blocked = /* @__PURE__ */ new Set();
3855
+ const keys = /* @__PURE__ */ new Set();
3856
+ for (const email of emails) {
3857
+ const key = suppressionEmailKey(email);
3858
+ if (key === null) blocked.add(email);
3859
+ else keys.add(key);
3860
+ }
3861
+ if (keys.size > 0) {
3862
+ const rows = await this.findAll({ email: { $in: [...keys] } }, {
3863
+ ...ctx ?? {},
3864
+ select: "email",
3865
+ lean: true
3866
+ });
3867
+ for (const row of rows) blocked.add(row.email);
3868
+ }
3869
+ return blocked;
3329
3870
  }
3330
3871
  /**
3331
3872
  * Remove `email` from the org's suppression list. Single atomic
3332
3873
  * `deleteMany` on the (unique) email key — emits
3333
3874
  * `crm:suppression.removed` only when a row was actually removed.
3334
3875
  * Returns whether a row was removed.
3876
+ *
3877
+ * Throws `SuppressionEmailFormatError` (400) on an address that cannot be
3878
+ * canonicalised, rather than answering `false`. This is the verb that re-enables
3879
+ * sending, and `false` would read as "already removed" when the truth is "that
3880
+ * was never an address" — an operator would close the ticket either way.
3335
3881
  */
3336
3882
  async unsuppress(email, ctx) {
3337
- const normalized = normalizeEmail(email);
3883
+ const normalized = requireSuppressionEmailKey(email);
3338
3884
  const removed = (await this.deleteMany({ email: normalized }, ctx ?? {})).deletedCount > 0;
3339
3885
  if (removed) await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUPPRESSION_REMOVED, { email: normalized }, ctx));
3340
3886
  return removed;
@@ -3959,49 +4505,48 @@ function createCrmRepositories(models, deps) {
3959
4505
  }));
3960
4506
  plugins.push(aggregateHelpersPlugin());
3961
4507
  if (config.plugins.length > 0) plugins.push(...config.plugins);
3962
- const pipeline = new PipelineRepository(models.Pipeline, plugins, { events }, pagination);
3963
- const lead = new LeadRepository(models.Lead, plugins, { events }, pagination);
3964
- const opportunity = new OpportunityRepository(models.Opportunity, plugins, {
3965
- events,
3966
- pipelines: pipeline
3967
- }, pagination);
3968
- const contact = new ContactRepository(models.Contact, plugins, { events }, pagination);
3969
- const account = new AccountRepository(models.Account, plugins, { events }, pagination);
3970
- const campaign = new CampaignRepository(models.Campaign, plugins, { events }, pagination);
3971
- const subscription = new SubscriptionRepository(models.Subscription, plugins, { events }, pagination);
3972
- const suppression = new SuppressionRepository(models.Suppression, plugins, { events }, pagination);
3973
- const activity = new ActivityRepository(models.Activity, plugins, { events }, pagination);
3974
- const note = new NoteRepository(models.Note, plugins, { events }, pagination);
3975
- const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
3976
- const emailEvent = new EmailEventRepository(models.EmailEvent, plugins, { events }, pagination);
3977
- const conversation = new ConversationRepository(models.Conversation, plugins, { events }, pagination);
3978
- const message = new MessageRepository(models.Message, plugins, {
3979
- events,
3980
- conversations: conversation
3981
- }, pagination);
3982
- const sequence = new SequenceRepository(models.Sequence, plugins, { events }, pagination);
3983
- const enrollment = new EnrollmentRepository(models.Enrollment, plugins, {
3984
- events,
3985
- sequences: sequence
3986
- }, pagination);
3987
- return Object.freeze({
3988
- pipeline,
3989
- lead,
3990
- opportunity,
3991
- contact,
3992
- account,
3993
- campaign,
3994
- subscription,
3995
- suppression,
3996
- activity,
3997
- note,
3998
- lossReason,
3999
- emailEvent,
4000
- conversation,
4001
- message,
4002
- sequence,
4003
- enrollment
4004
- });
4508
+ const repositories = {};
4509
+ if (config.modules.sales) {
4510
+ const { Pipeline, Lead, Opportunity, Contact, Account, Activity, Note, LossReason } = models;
4511
+ if (!Pipeline || !Lead || !Opportunity || !Contact || !Account || !Activity || !Note || !LossReason) throw new Error("[crm] sales module model graph is incomplete");
4512
+ const pipeline = new PipelineRepository(Pipeline, plugins, { events }, pagination);
4513
+ repositories.pipeline = pipeline;
4514
+ repositories.lead = new LeadRepository(Lead, plugins, { events }, pagination);
4515
+ repositories.opportunity = new OpportunityRepository(Opportunity, plugins, {
4516
+ events,
4517
+ pipelines: pipeline
4518
+ }, pagination);
4519
+ repositories.contact = new ContactRepository(Contact, plugins, { events }, pagination);
4520
+ repositories.account = new AccountRepository(Account, plugins, { events }, pagination);
4521
+ repositories.activity = new ActivityRepository(Activity, plugins, { events }, pagination);
4522
+ repositories.note = new NoteRepository(Note, plugins, { events }, pagination);
4523
+ repositories.lossReason = new LossReasonRepository(LossReason, plugins, { events }, pagination);
4524
+ }
4525
+ if (config.modules.engagement) {
4526
+ const { Subscription, Suppression, EmailEvent, Conversation, Message } = models;
4527
+ if (!Subscription || !Suppression || !EmailEvent || !Conversation || !Message) throw new Error("[crm] engagement module model graph is incomplete");
4528
+ const conversation = new ConversationRepository(Conversation, plugins, { events }, pagination);
4529
+ repositories.subscription = new SubscriptionRepository(Subscription, plugins, { events }, pagination);
4530
+ repositories.suppression = new SuppressionRepository(Suppression, plugins, { events }, pagination);
4531
+ repositories.emailEvent = new EmailEventRepository(EmailEvent, plugins, { events }, pagination);
4532
+ repositories.conversation = conversation;
4533
+ repositories.message = new MessageRepository(Message, plugins, {
4534
+ events,
4535
+ conversations: conversation
4536
+ }, pagination);
4537
+ }
4538
+ if (config.modules.automation) {
4539
+ const { Campaign, Sequence, Enrollment } = models;
4540
+ if (!Campaign || !Sequence || !Enrollment) throw new Error("[crm] automation module model graph is incomplete");
4541
+ const sequence = new SequenceRepository(Sequence, plugins, { events }, pagination);
4542
+ repositories.campaign = new CampaignRepository(Campaign, plugins, { events }, pagination);
4543
+ repositories.sequence = sequence;
4544
+ repositories.enrollment = new EnrollmentRepository(Enrollment, plugins, {
4545
+ events,
4546
+ sequences: sequence
4547
+ }, pagination);
4548
+ }
4549
+ return Object.freeze(repositories);
4005
4550
  }
4006
4551
  //#endregion
4007
4552
  //#region src/events/dispatch.ts
@@ -4033,7 +4578,9 @@ var CrmEventDispatcher = class {
4033
4578
  return this.transport.publish(event);
4034
4579
  }
4035
4580
  subscribe(pattern, handler) {
4036
- return this.transport.subscribe(pattern, handler);
4581
+ const subscribe = this.transport.subscribe?.bind(this.transport);
4582
+ if (!subscribe) throw new Error("crm: configured event transport does not support subscribe");
4583
+ return subscribe(pattern, handler);
4037
4584
  }
4038
4585
  async close() {
4039
4586
  await this.transport.close?.();
@@ -4079,37 +4626,24 @@ async function flushPending(transport, events, logger) {
4079
4626
  //#region src/events/in-process-bus.ts
4080
4627
  /**
4081
4628
  * In-process event bus — default fallback when no `eventTransport` is
4082
- * provided to `createCrm`. Structurally identical to arc's
4083
- * `MemoryEventTransport`; pattern matching is delegated to primitives.
4084
- * Handler errors are swallowed — event emission never blocks writes.
4629
+ * provided to `blueprint.bind(connection, runtime)`.
4630
+ *
4631
+ * A thin subclass of `@classytic/primitives/event-infra`'s shared
4632
+ * `InProcessEventBus`. Prior semantics preserved: glob matching via
4633
+ * `matchEventPattern`, per-handler error isolation, idempotent `close()`.
4634
+ * CRM historically SWALLOWED handler errors when no logger was supplied —
4635
+ * that default is preserved by passing `logger: null` unless the host
4636
+ * injects a `CrmLogger`, whose structured `error(msg, meta)` slot is
4637
+ * bridged onto the shared error-logger contract.
4085
4638
  */
4086
- var InProcessCrmBus = class {
4087
- name = "in-process-crm";
4088
- subs = [];
4089
- logger;
4639
+ var InProcessCrmBus = class extends InProcessEventBus {
4090
4640
  constructor(options) {
4091
- if (options?.logger) this.logger = options.logger;
4092
- }
4093
- async publish(event) {
4094
- const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
4095
- for (const sub of matching) try {
4096
- await sub.handler(event);
4097
- } catch (err) {
4098
- this.logger?.error?.(`[crm] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
4099
- }
4100
- }
4101
- async subscribe(pattern, handler) {
4102
- const sub = {
4103
- pattern,
4104
- handler
4105
- };
4106
- this.subs.push(sub);
4107
- return () => {
4108
- this.subs = this.subs.filter((s) => s !== sub);
4109
- };
4110
- }
4111
- async close() {
4112
- this.subs = [];
4641
+ const crmLogger = options?.logger;
4642
+ super({
4643
+ name: "in-process-crm",
4644
+ logLabel: "crm",
4645
+ logger: crmLogger?.error ? { error: (message, ...args) => crmLogger.error?.(message, { args }) } : null
4646
+ });
4113
4647
  }
4114
4648
  };
4115
4649
  //#endregion
@@ -4167,53 +4701,179 @@ async function runUnit(config, ctx, body) {
4167
4701
  return result;
4168
4702
  }
4169
4703
  //#endregion
4170
- //#region src/engine/create-crm.ts
4171
- async function createCrm(config) {
4172
- if (!config.connection) throw new Error("[crm] CrmConfig.connection is required");
4173
- const resolved = {
4174
- connection: config.connection,
4175
- tenantFieldType: config.tenantFieldType ?? "objectId",
4176
- multiTenant: config.multiTenant ?? true,
4177
- forceRecreate: config.forceRecreate ?? false,
4178
- autoIndex: config.autoIndex ?? true,
4179
- ...config.logger !== void 0 ? { logger: config.logger } : {},
4180
- ...config.pagination !== void 0 ? { pagination: config.pagination } : {},
4181
- plugins: config.plugins ?? []
4182
- };
4183
- const events = new CrmEventDispatcher({
4184
- transport: config.eventTransport ?? new InProcessCrmBus(config.logger ? { logger: config.logger } : void 0),
4185
- outbox: config.outbox,
4186
- logger: config.logger
4187
- });
4188
- const models = createCrmModels(config.connection, resolved);
4189
- const repositories = createCrmRepositories(models, {
4190
- events,
4191
- config: resolved
4192
- });
4193
- assertCrmCapabilities(repositories.lead, {
4194
- ...config.allowNonTransactional !== void 0 ? { allowNonTransactional: config.allowNonTransactional } : {},
4195
- ...config.logger?.warn !== void 0 ? { logger: { warn: (m) => config.logger?.warn?.(m) } } : {}
4704
+ //#region src/engine/define-crm.ts
4705
+ function defineCrm(shape = {}) {
4706
+ const tenantFieldType = shape.tenantFieldType ?? "objectId";
4707
+ const multiTenant = shape.multiTenant ?? true;
4708
+ const forceRecreate = shape.forceRecreate ?? false;
4709
+ const autoIndex = shape.autoIndex ?? true;
4710
+ const modules = resolveCrmModules(shape.modules);
4711
+ const modelBlueprint = defineCrmModels({
4712
+ tenantFieldType,
4713
+ ...shape.modelPrefix !== void 0 ? { modelPrefix: shape.modelPrefix } : {},
4714
+ modules,
4715
+ forceRecreate,
4716
+ autoIndex
4196
4717
  });
4197
- return Object.freeze({
4198
- models,
4199
- repositories,
4200
- events,
4201
- config: Object.freeze(resolved),
4202
- withTransaction(ctx, body) {
4203
- return runUnit({
4204
- connection: config.connection,
4718
+ return {
4719
+ id: "crm",
4720
+ modelNames: modelBlueprint.modelNames,
4721
+ bind(connection, runtime = {}) {
4722
+ /**
4723
+ * VERIFY (boot gate) — an injected outbox MUST enlist `ctx.session`.
4724
+ *
4725
+ * Checked FIRST, before the model blueprint binds, for two reasons: a cheaper error
4726
+ * should report before a costlier one (a model collision would otherwise surface first
4727
+ * and hide this), and a bind that throws here must not have mutated the connection's
4728
+ * model registry.
4729
+ *
4730
+ * WHY CRM. `OutboxWriteOptions.session` is best-effort BY CONTRACT, so a store that
4731
+ * ignores it persists events for mutations that later roll back. CRM is the only kernel
4732
+ * in the batch reviewed alongside it with a LIVE, WIRED consumer whose side effect is not
4733
+ * re-derivable: be-prod's `crmCustomerProjectionHandlers` sits on the crm module's arc
4734
+ * `eventHandlers` arm and, on `crm:lead.converted` / `crm:opportunity.won`, WRITES
4735
+ * `crm.stage` and `crm.convertedFromLeadId` onto the Customer document in another
4736
+ * collection. Nothing recomputes those fields and no reconciler exists, so a ghost event
4737
+ * marks a customer converted/won permanently and silently — the exact bug class AGENTS.md
4738
+ * FAIL LOUD is about (a plausible-looking result instead of an error).
4739
+ *
4740
+ * The rollback window is package-owned, not hypothetical: `LeadService.convert()` writes
4741
+ * Account + Contact + Opportunity + the lead transition inside `runUnit`'s transaction and
4742
+ * dispatches `crm:lead.converted` from within it, and spine-crm's convert route calls it
4743
+ * through `engine.withTransaction`.
4744
+ *
4745
+ * `!== true` and not `=== false`: a hand-rolled store has the flag ABSENT, not `false`,
4746
+ * and absence of an answer is not a positive answer (FAIL LOUD rule 3).
4747
+ *
4748
+ * `allowNonTransactional` does NOT waive this. That flag downgrades the BACKEND's
4749
+ * `transactions` capability for hosts that never convert under a session (see
4750
+ * `assertCrmCapabilities`). This gate is about the injected STORE, which is wrong on a
4751
+ * replica set too — letting a dev-convenience flag disable it would be a general default
4752
+ * overriding a specific correctness check (FAIL LOUD rule 1).
4753
+ */
4754
+ if (runtime.outbox && runtime.outbox.transactionalSave !== true) throw new Error("[crm] the configured OutboxStore does not declare `outbox.transactionalSave` — crm events would not commit atomically with the writes that emit them. See OutboxStore.transactionalSave in @classytic/primitives/outbox; arc's `repositoryAsOutboxStore` declares it. This is a property of the STORE, not of the database, and `allowNonTransactional` does not waive it.");
4755
+ const resolved = {
4756
+ connection,
4757
+ tenantFieldType,
4758
+ ...shape.modelPrefix !== void 0 ? { modelPrefix: shape.modelPrefix } : {},
4759
+ multiTenant,
4760
+ forceRecreate,
4761
+ autoIndex,
4762
+ modules,
4763
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {},
4764
+ ...runtime.pagination !== void 0 ? { pagination: runtime.pagination } : {},
4765
+ plugins: runtime.plugins ?? []
4766
+ };
4767
+ const ownsTransport = runtime.eventTransport === void 0;
4768
+ const events = new CrmEventDispatcher({
4769
+ transport: runtime.eventTransport ?? new InProcessCrmBus(runtime.logger ? { logger: runtime.logger } : void 0),
4770
+ ...runtime.outbox !== void 0 ? { outbox: runtime.outbox } : {},
4771
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {}
4772
+ });
4773
+ const models = modelBlueprint.bind(connection);
4774
+ const repositories = createCrmRepositories(models, {
4205
4775
  events,
4206
- logger: config.logger,
4207
- allowNonTransactional: config.allowNonTransactional ?? false
4208
- }, ctx, body);
4209
- },
4210
- async syncIndexes() {
4211
- await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
4212
- },
4213
- async dispose() {
4214
- await events.close();
4776
+ config: resolved
4777
+ });
4778
+ const allowNonTransactional = runtime.allowNonTransactional ?? false;
4779
+ if (repositories.lead) assertCrmCapabilities(repositories.lead, {
4780
+ allowNonTransactional,
4781
+ ...runtime.logger?.warn !== void 0 ? { logger: { warn: (m) => runtime.logger?.warn?.(m) } } : {}
4782
+ });
4783
+ const moduleViews = {};
4784
+ if (modules.sales) {
4785
+ const { Pipeline, Lead, Opportunity, Contact, Account, Activity, Note, LossReason } = models;
4786
+ const { pipeline, lead, opportunity, contact, account, activity, note, lossReason } = repositories;
4787
+ if (!Pipeline || !Lead || !Opportunity || !Contact || !Account || !Activity || !Note || !LossReason || !pipeline || !lead || !opportunity || !contact || !account || !activity || !note || !lossReason) throw new Error("[crm] sales module failed to initialize");
4788
+ moduleViews.sales = Object.freeze({
4789
+ models: Object.freeze({
4790
+ Pipeline,
4791
+ Lead,
4792
+ Opportunity,
4793
+ Contact,
4794
+ Account,
4795
+ Activity,
4796
+ Note,
4797
+ LossReason
4798
+ }),
4799
+ repositories: Object.freeze({
4800
+ pipeline,
4801
+ lead,
4802
+ opportunity,
4803
+ contact,
4804
+ account,
4805
+ activity,
4806
+ note,
4807
+ lossReason
4808
+ })
4809
+ });
4810
+ }
4811
+ if (modules.engagement) {
4812
+ const { Subscription, Suppression, EmailEvent, Conversation, Message } = models;
4813
+ const { subscription, suppression, emailEvent, conversation, message } = repositories;
4814
+ if (!Subscription || !Suppression || !EmailEvent || !Conversation || !Message || !subscription || !suppression || !emailEvent || !conversation || !message) throw new Error("[crm] engagement module failed to initialize");
4815
+ moduleViews.engagement = Object.freeze({
4816
+ models: Object.freeze({
4817
+ Subscription,
4818
+ Suppression,
4819
+ EmailEvent,
4820
+ Conversation,
4821
+ Message
4822
+ }),
4823
+ repositories: Object.freeze({
4824
+ subscription,
4825
+ suppression,
4826
+ emailEvent,
4827
+ conversation,
4828
+ message
4829
+ })
4830
+ });
4831
+ }
4832
+ if (modules.automation) {
4833
+ const { Campaign, Sequence, Enrollment } = models;
4834
+ const { campaign, sequence, enrollment } = repositories;
4835
+ if (!Campaign || !Sequence || !Enrollment || !campaign || !sequence || !enrollment) throw new Error("[crm] automation module failed to initialize");
4836
+ moduleViews.automation = Object.freeze({
4837
+ models: Object.freeze({
4838
+ Campaign,
4839
+ Sequence,
4840
+ Enrollment
4841
+ }),
4842
+ repositories: Object.freeze({
4843
+ campaign,
4844
+ sequence,
4845
+ enrollment
4846
+ })
4847
+ });
4848
+ }
4849
+ Object.freeze(moduleViews);
4850
+ let closed = false;
4851
+ const close = async () => {
4852
+ if (closed) return;
4853
+ closed = true;
4854
+ if (ownsTransport) await events.close();
4855
+ };
4856
+ return Object.freeze({
4857
+ models,
4858
+ repositories,
4859
+ events,
4860
+ config: Object.freeze(resolved),
4861
+ modules: moduleViews,
4862
+ withTransaction(ctx, body) {
4863
+ return runUnit({
4864
+ connection,
4865
+ events,
4866
+ ...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
4867
+ allowNonTransactional
4868
+ }, ctx, body);
4869
+ },
4870
+ async syncIndexes() {
4871
+ await Promise.all(Object.values(models).map((model) => model.syncIndexes()));
4872
+ },
4873
+ close
4874
+ });
4215
4875
  }
4216
- });
4876
+ };
4217
4877
  }
4218
4878
  //#endregion
4219
4879
  //#region src/services/lead.service.ts
@@ -4259,7 +4919,9 @@ var LeadService = class LeadService {
4259
4919
  const lead = await this.leads.getById(leadId, ctx);
4260
4920
  if (!lead) throw new NotFoundError("Lead", leadId);
4261
4921
  if (lead.status === "converted") throw new LeadAlreadyConvertedError(leadId);
4262
- if (!LEAD_MACHINE.canTransition(lead.status, "converted")) throw new InvalidLeadTransitionError({
4922
+ const canQualifyHop = LEAD_MACHINE.canTransition(lead.status, "qualified");
4923
+ const canConvertDirect = LEAD_MACHINE.canTransition(lead.status, "converted");
4924
+ if (!canQualifyHop && !canConvertDirect) throw new InvalidLeadTransitionError({
4263
4925
  from: lead.status,
4264
4926
  to: "converted",
4265
4927
  leadId
@@ -4352,17 +5014,7 @@ var LeadService = class LeadService {
4352
5014
  occurredAt: /* @__PURE__ */ new Date(),
4353
5015
  ...crossRefs.by !== void 0 ? { by: crossRefs.by } : {}
4354
5016
  };
4355
- const current = await this.leads.getById(leadId, ctx);
4356
- const updated = await this.leads.claim(leadId, {
4357
- field: "status",
4358
- from: fromStatus,
4359
- to: "converted"
4360
- }, {
4361
- ...patch,
4362
- statusHistory: [...current?.statusHistory ?? [], entry]
4363
- }, ctx);
4364
- if (!updated) throw new NotFoundError("Lead", leadId);
4365
- return updated;
5017
+ return this.leads.markConverted(leadId, fromStatus, patch, entry, ctx);
4366
5018
  }
4367
5019
  };
4368
5020
  /**
@@ -4507,6 +5159,15 @@ const LeadAssigned = defineCrmEvent({
4507
5159
  description: "assign() (re)assigned the lead owner.",
4508
5160
  zodSchema: z.object({ leadId: z.string() }).extend(ownerChangeSchema.shape)
4509
5161
  });
5162
+ const LeadAnonymized = defineCrmEvent({
5163
+ name: CRM_EVENTS.LEAD_ANONYMIZED,
5164
+ resource: "lead",
5165
+ description: "anonymize() scrubbed a lead's PII (name/email/phone) in place — GDPR right-to-erasure. Non-PII aggregates (score/status/engagement) and the id are preserved; `anonymizedAt` tombstones the row.",
5166
+ zodSchema: z.object({
5167
+ leadId: z.string(),
5168
+ fields: z.array(z.string())
5169
+ })
5170
+ });
4510
5171
  const AccountCreated = defineCrmEvent({
4511
5172
  name: CRM_EVENTS.ACCOUNT_CREATED,
4512
5173
  resource: "account",
@@ -4573,6 +5234,15 @@ const ContactMerged = defineCrmEvent({
4573
5234
  description: "merge() folded a secondary contact into the primary; secondary deleted.",
4574
5235
  zodSchema: mergeSchema
4575
5236
  });
5237
+ const ContactAnonymized = defineCrmEvent({
5238
+ name: CRM_EVENTS.CONTACT_ANONYMIZED,
5239
+ resource: "contact",
5240
+ description: "anonymize() scrubbed a contact's PII (name + every contact channel) in place — GDPR right-to-erasure. Non-PII aggregates (accountId, ownerId, tags) and the id are preserved; `anonymizedAt` tombstones the row.",
5241
+ zodSchema: z.object({
5242
+ contactId: z.string(),
5243
+ fields: z.array(z.string())
5244
+ })
5245
+ });
4576
5246
  const OpportunityCreated = defineCrmEvent({
4577
5247
  name: CRM_EVENTS.OPPORTUNITY_CREATED,
4578
5248
  resource: "opportunity",
@@ -5028,6 +5698,7 @@ const crmEventDefinitions = [
5028
5698
  LeadDisqualified,
5029
5699
  LeadEnriched,
5030
5700
  LeadAssigned,
5701
+ LeadAnonymized,
5031
5702
  AccountCreated,
5032
5703
  AccountUpdated,
5033
5704
  AccountEnriched,
@@ -5038,6 +5709,7 @@ const crmEventDefinitions = [
5038
5709
  ContactEnriched,
5039
5710
  ContactAssigned,
5040
5711
  ContactMerged,
5712
+ ContactAnonymized,
5041
5713
  OpportunityCreated,
5042
5714
  OpportunityStageAdvanced,
5043
5715
  OpportunityStageRegressed,
@@ -5154,8 +5826,10 @@ function pipelineConversion(opportunities, stageIds) {
5154
5826
  for (const o of opportunities) {
5155
5827
  if (o.stageId === stageId && o.status === "open") currentCount++;
5156
5828
  if (o.status === "won" || o.status === "lost") {
5157
- if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) if (o.status === "won") wonFromHere++;
5158
- else lostFromHere++;
5829
+ if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) {
5830
+ if (o.status === "won") wonFromHere++;
5831
+ else lostFromHere++;
5832
+ }
5159
5833
  }
5160
5834
  }
5161
5835
  const closed = wonFromHere + lostFromHere;
@@ -5582,4 +6256,4 @@ var validators_exports = /* @__PURE__ */ __exportAll({
5582
6256
  updateSequenceSchema: () => updateSequenceSchema
5583
6257
  });
5584
6258
  //#endregion
5585
- export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, CrmEventDispatcher, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, EnrollmentCompleted, EnrollmentCreated, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentResumed, EnrollmentStepSent, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, SuppressionAdded, SuppressionRemoved, SuppressionRepository, UnmanagedSessionError, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, flushPending, forecast, lastTransitionTo, latestEntry, pipelineConversion, runUnit, tenantFieldDef, timeInStatus, validators_exports as validators };
6259
+ export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, ALL_CRM_MODULES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAnonymized, ContactAssigned, ContactCreated, ContactEmailFormatError, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, CrmEventDispatcher, DuplicateCampaignError, DuplicateContactError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, EnrollmentCompleted, EnrollmentCreated, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentResumed, EnrollmentStepSent, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAnonymized, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEmailFormatError, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, SuppressionAdded, SuppressionEmailFormatError, SuppressionRemoved, SuppressionRepository, UnmanagedSessionError, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEmailKey, crmEventDefinitions, crmModelName, defaultLeadScorer, defineCrm, defineCrmModels, flushPending, forecast, lastTransitionTo, latestEntry, pipelineConversion, resolveCrmModules, runUnit, suppressionEmailKey, tenantFieldDef, timeInStatus, validators_exports as validators };