@classytic/crm 0.8.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, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
4
- import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
5
- import { IllegalTransitionError, assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
4
+ import { EmailFormatError, normalizeEmail } from "@classytic/contact/email";
5
+ import { createEvent as createEvent$1 } from "@classytic/primitives/events";
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",
@@ -2293,6 +2634,19 @@ function composeScorers(...rules) {
2293
2634
  return (lead) => rules.reduce((acc, r) => acc + r(lead), 0);
2294
2635
  }
2295
2636
  //#endregion
2637
+ //#region src/repositories/transition-support.ts
2638
+ /**
2639
+ * mongokit `applyTransition()` race-outcome detection — pure races /
2640
+ * vanished rows remap to each verb's historical error (typed transition
2641
+ * error built from a fresh read, or NotFound where that was the
2642
+ * contract); now-illegal races throw the machine's errorFactory error
2643
+ * with the on-disk status natively.
2644
+ */
2645
+ function isTransitionRace(err) {
2646
+ const code = err?.code;
2647
+ return code === "TRANSITION_RACE_LOST" || code === "TRANSITION_TARGET_MISSING";
2648
+ }
2649
+ //#endregion
2296
2650
  //#region src/repositories/lead.repository.ts
2297
2651
  /** signal → engagement counter field. */
2298
2652
  const ENGAGEMENT_COUNTER = {
@@ -2315,15 +2669,22 @@ var LeadRepository = class extends Repository {
2315
2669
  * Create a Lead. Auto-scores on insert so hosts can route immediately.
2316
2670
  * Rejects duplicate emails inside the same tenant unless
2317
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.
2318
2677
  */
2319
2678
  async createLead(input, ctx) {
2320
- if (this.rejectDuplicates && input.email) {
2321
- 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 });
2322
2682
  }
2323
2683
  const now = /* @__PURE__ */ new Date();
2324
- 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);
2325
2685
  const draft = {
2326
2686
  ...input,
2687
+ ...email !== void 0 ? { email } : {},
2327
2688
  status: input.status ?? "new",
2328
2689
  score: 0,
2329
2690
  statusHistory: [{
@@ -2336,7 +2697,7 @@ var LeadRepository = class extends Repository {
2336
2697
  try {
2337
2698
  created = await this.create(draft, ctx);
2338
2699
  } catch (err) {
2339
- if (this.rejectDuplicates && input.email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email: input.email });
2700
+ if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email });
2340
2701
  throw err;
2341
2702
  }
2342
2703
  const score = this.scorer(created);
@@ -2451,6 +2812,67 @@ var LeadRepository = class extends Repository {
2451
2812
  }, ctx));
2452
2813
  return updated;
2453
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
+ }
2454
2876
  async _transition(id, target, extras, ctx) {
2455
2877
  const existing = await this.getById(id, ctx);
2456
2878
  if (!existing) throw new NotFoundError("Lead", id);
@@ -2461,19 +2883,19 @@ var LeadRepository = class extends Repository {
2461
2883
  ...extras.by !== void 0 ? { by: extras.by } : {},
2462
2884
  ...extras.note !== void 0 ? { note: extras.note } : {}
2463
2885
  };
2464
- const updated = await assertAndClaim(LEAD_MACHINE, this, id, {
2886
+ const updated = await this.applyTransition(id, LEAD_MACHINE, {
2465
2887
  from: existing.status,
2466
2888
  to: target,
2467
- patch: {
2468
- ...extras.patch ?? {},
2469
- statusHistory: [...existing.statusHistory ?? [], entry]
2470
- },
2471
- options: ctx
2472
- });
2473
- if (!updated) throw new InvalidLeadTransitionError({
2474
- from: (await this.getById(id, ctx))?.status ?? existing.status,
2475
- to: target,
2476
- leadId: id
2889
+ set: extras.patch ?? {},
2890
+ push: { statusHistory: entry },
2891
+ history: false
2892
+ }, ctx).catch(async (err) => {
2893
+ if (!isTransitionRace(err)) throw err;
2894
+ throw new InvalidLeadTransitionError({
2895
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
2896
+ to: target,
2897
+ leadId: id
2898
+ });
2477
2899
  });
2478
2900
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_STATUS_CHANGED, {
2479
2901
  leadId: id,
@@ -2719,7 +3141,6 @@ var OpportunityRepository = class extends Repository {
2719
3141
  async _closeStatus(id, target, patch, extras, ctx) {
2720
3142
  const existing = await this.getById(id, ctx);
2721
3143
  if (!existing) throw new NotFoundError("Opportunity", id);
2722
- OPPORTUNITY_MACHINE.assertTransition(id, existing.status, target);
2723
3144
  const entry = {
2724
3145
  status: target,
2725
3146
  stageId: existing.stageId,
@@ -2727,59 +3148,52 @@ var OpportunityRepository = class extends Repository {
2727
3148
  ...extras.by !== void 0 ? { by: extras.by } : {},
2728
3149
  ...extras.note !== void 0 ? { note: extras.note } : {}
2729
3150
  };
2730
- const updated = await assertAndClaim(OPPORTUNITY_MACHINE, this, id, {
3151
+ return await this.applyTransition(id, OPPORTUNITY_MACHINE, {
2731
3152
  from: existing.status,
2732
3153
  to: target,
2733
- patch: {
2734
- ...patch,
2735
- statusHistory: [...existing.statusHistory ?? [], entry]
2736
- },
2737
- options: ctx
2738
- });
2739
- if (!updated) throw new InvalidOpportunityTransitionError({
2740
- from: (await this.getById(id, ctx))?.status ?? existing.status,
2741
- to: target,
2742
- opportunityId: id
3154
+ set: patch,
3155
+ push: { statusHistory: entry },
3156
+ history: false
3157
+ }, ctx).catch(async (err) => {
3158
+ if (!isTransitionRace(err)) throw err;
3159
+ throw new InvalidOpportunityTransitionError({
3160
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3161
+ to: target,
3162
+ opportunityId: id
3163
+ });
2743
3164
  });
2744
- return updated;
2745
3165
  }
2746
3166
  /** Count opportunities with status `open`. Used by dashboard KPI tiles. */
2747
3167
  async countOpen(ctx) {
2748
3168
  return this.count({ status: "open" }, ctx);
2749
3169
  }
2750
3170
  /**
2751
- * Sum `amount` on won opportunities updated within `since`.
2752
- * Goes through the tenant-scoped `findAll()` — the multiTenant plugin
2753
- * injects `organizationId`, so a missing ctx can't leak other tenants'
2754
- * 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.
2755
3181
  */
2756
3182
  async sumWonSince(since, ctx) {
2757
- const rows = await this.findAll({
2758
- status: "won",
2759
- updatedAt: { $gte: since }
2760
- }, {
2761
- ...ctx,
2762
- select: {
2763
- amount: 1,
2764
- currency: 1
2765
- }
2766
- });
2767
- if (rows.length === 0) return null;
2768
- const byCurrency = /* @__PURE__ */ new Map();
2769
- for (const r of rows) {
2770
- const cur = r.currency ?? "UNKNOWN";
2771
- byCurrency.set(cur, (byCurrency.get(cur) ?? 0) + (r.amount ?? 0));
2772
- }
2773
- let topCurrency = "";
2774
- let topTotal = -Infinity;
2775
- for (const [cur, total] of byCurrency) if (total > topTotal) {
2776
- topTotal = total;
2777
- topCurrency = cur;
2778
- }
2779
- return {
2780
- total: topTotal,
2781
- currency: topCurrency
2782
- };
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
+ }));
2783
3197
  }
2784
3198
  /** Latest `limit` opportunities — lightweight projection for the activity feed. */
2785
3199
  async findRecentForFeed(limit, ctx) {
@@ -2800,16 +3214,45 @@ var OpportunityRepository = class extends Repository {
2800
3214
  //#region src/repositories/contact.repository.ts
2801
3215
  var ContactRepository = class extends Repository {
2802
3216
  events;
3217
+ rejectDuplicates;
2803
3218
  constructor(model, plugins, deps, pagination) {
2804
3219
  super(model, plugins, pagination);
2805
3220
  this.events = deps.events;
3221
+ this.rejectDuplicates = deps.rejectDuplicates ?? true;
2806
3222
  }
2807
3223
  /**
2808
3224
  * Create + emit `crm:contact.created`. Hosts call this instead of
2809
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()`.
2810
3235
  */
2811
3236
  async createContact(input, ctx) {
2812
- 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
+ }
2813
3256
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_CREATED, {
2814
3257
  contactId: String(created._id),
2815
3258
  email: created.contact?.email
@@ -2888,14 +3331,19 @@ var ContactRepository = class extends Repository {
2888
3331
  ...stripEmpty(winnerName)
2889
3332
  },
2890
3333
  contact: mergedContactInfo,
2891
- tags: Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
3334
+ tags: Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
2892
3335
  ...winner.jobTitle !== void 0 ? { jobTitle: winner.jobTitle } : loser.jobTitle !== void 0 ? { jobTitle: loser.jobTitle } : {},
2893
3336
  ...winner.accountId !== void 0 ? { accountId: winner.accountId } : loser.accountId !== void 0 ? { accountId: loser.accountId } : {},
2894
3337
  ...winner.ownerId !== void 0 ? { ownerId: winner.ownerId } : loser.ownerId !== void 0 ? { ownerId: loser.ownerId } : {}
2895
3338
  };
3339
+ if (input.repoint) await input.repoint({
3340
+ primaryId,
3341
+ secondaryId,
3342
+ ...ctx !== void 0 ? { ctx } : {}
3343
+ });
3344
+ await this.delete(secondaryId, ctx);
2896
3345
  const updated = await this.update(primaryId, patch, ctx);
2897
3346
  if (!updated) throw new NotFoundError("Contact", primaryId);
2898
- await this.delete(secondaryId, ctx);
2899
3347
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_MERGED, {
2900
3348
  primaryId,
2901
3349
  secondaryId,
@@ -2903,7 +3351,54 @@ var ContactRepository = class extends Repository {
2903
3351
  }, ctx));
2904
3352
  return updated;
2905
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
+ }
2906
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
+ ];
2907
3402
  function stripEmpty(obj) {
2908
3403
  const out = {};
2909
3404
  for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null && v !== "") out[k] = v;
@@ -2974,7 +3469,7 @@ var AccountRepository = class extends Repository {
2974
3469
  if (!secondary) throw new NotFoundError("Account", secondaryId);
2975
3470
  const winner = strategy === "preferPrimary" ? primary : secondary;
2976
3471
  const loser = strategy === "preferPrimary" ? secondary : primary;
2977
- const mergedTags = Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
3472
+ const mergedTags = Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
2978
3473
  const patch = {
2979
3474
  name: winner.name || loser.name,
2980
3475
  tags: mergedTags,
@@ -3075,28 +3570,26 @@ var CampaignRepository = class extends Repository {
3075
3570
  async _transition(id, target, extras, ctx, patch = {}) {
3076
3571
  const existing = await this.getById(id, ctx);
3077
3572
  if (!existing) throw new NotFoundError("Campaign", id);
3078
- CAMPAIGN_MACHINE.assertTransition(id, existing.status, target);
3079
3573
  const entry = {
3080
3574
  status: target,
3081
3575
  occurredAt: /* @__PURE__ */ new Date(),
3082
3576
  ...extras.by !== void 0 ? { by: extras.by } : {},
3083
3577
  ...extras.note !== void 0 ? { note: extras.note } : {}
3084
3578
  };
3085
- const updated = await assertAndClaim(CAMPAIGN_MACHINE, this, id, {
3579
+ return await this.applyTransition(id, CAMPAIGN_MACHINE, {
3086
3580
  from: existing.status,
3087
3581
  to: target,
3088
- patch: {
3089
- ...patch,
3090
- statusHistory: [...existing.statusHistory ?? [], entry]
3091
- },
3092
- options: ctx
3093
- });
3094
- if (!updated) throw new InvalidCampaignTransitionError({
3095
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3096
- to: target,
3097
- campaignId: id
3582
+ set: patch,
3583
+ push: { statusHistory: entry },
3584
+ history: false
3585
+ }, ctx).catch(async (err) => {
3586
+ if (!isTransitionRace(err)) throw err;
3587
+ throw new InvalidCampaignTransitionError({
3588
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3589
+ to: target,
3590
+ campaignId: id
3591
+ });
3098
3592
  });
3099
- return updated;
3100
3593
  }
3101
3594
  };
3102
3595
  //#endregion
@@ -3237,34 +3730,60 @@ var SubscriptionRepository = class extends Repository {
3237
3730
  async _transition(id, target, opts, ctx) {
3238
3731
  const existing = await this.getById(id, ctx);
3239
3732
  if (!existing) throw new NotFoundError("Subscription", id);
3240
- SUBSCRIPTION_MACHINE.assertTransition(id, existing.status, target);
3241
3733
  const entry = {
3242
3734
  status: target,
3243
3735
  occurredAt: /* @__PURE__ */ new Date(),
3244
3736
  ...opts.by !== void 0 ? { by: opts.by } : {},
3245
3737
  ...opts.note !== void 0 ? { note: opts.note } : {}
3246
3738
  };
3247
- const updated = await assertAndClaim(SUBSCRIPTION_MACHINE, this, id, {
3739
+ return await this.applyTransition(id, SUBSCRIPTION_MACHINE, {
3248
3740
  from: existing.status,
3249
3741
  to: target,
3250
- patch: {
3251
- ...opts.patch ?? {},
3252
- statusHistory: [...existing.statusHistory ?? [], entry]
3253
- },
3254
- options: ctx
3255
- });
3256
- if (!updated) throw new InvalidSubscriptionTransitionError({
3257
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3258
- to: target,
3259
- subscriptionId: id
3742
+ set: opts.patch ?? {},
3743
+ push: { statusHistory: entry },
3744
+ history: false
3745
+ }, ctx).catch(async (err) => {
3746
+ if (!isTransitionRace(err)) throw err;
3747
+ throw new InvalidSubscriptionTransitionError({
3748
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3749
+ to: target,
3750
+ subscriptionId: id
3751
+ });
3260
3752
  });
3261
- return updated;
3262
3753
  }
3263
3754
  };
3264
3755
  //#endregion
3265
3756
  //#region src/repositories/suppression.repository.ts
3266
- function normalizeEmail(email) {
3267
- 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
+ }
3268
3787
  }
3269
3788
  var SuppressionRepository = class extends Repository {
3270
3789
  events;
@@ -3277,9 +3796,12 @@ var SuppressionRepository = class extends Repository {
3277
3796
  * `getOrCreate` on the unique (org, email) index — of N concurrent
3278
3797
  * calls exactly one inserts. Emits `crm:suppression.added` only for
3279
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.
3280
3802
  */
3281
3803
  async suppress(email, input, ctx) {
3282
- const normalized = normalizeEmail(email);
3804
+ const normalized = requireSuppressionEmailKey(email);
3283
3805
  const draft = {
3284
3806
  email: normalized,
3285
3807
  reason: input.reason,
@@ -3297,37 +3819,68 @@ var SuppressionRepository = class extends Repository {
3297
3819
  }, ctx));
3298
3820
  return doc;
3299
3821
  }
3300
- /** 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
+ */
3301
3828
  async isSuppressed(email, ctx) {
3302
- 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;
3303
3832
  }
3304
3833
  /**
3305
- * Return the suppressed subset of `emails` (normalized) as a Set —
3306
- * 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:
3307
3836
  *
3308
3837
  * ```ts
3309
3838
  * const blocked = await suppression.filterSuppressed(recipients, ctx);
3310
- * const sendable = recipients.filter((e) => !blocked.has(e.toLowerCase()));
3839
+ * const sendable = recipients.filter((e) => !blocked.has(suppressionEmailKey(e) ?? e));
3311
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.
3312
3851
  */
3313
3852
  async filterSuppressed(emails, ctx) {
3314
3853
  if (emails.length === 0) return /* @__PURE__ */ new Set();
3315
- const normalized = [...new Set(emails.map(normalizeEmail))];
3316
- const rows = await this.findAll({ email: { $in: normalized } }, {
3317
- ...ctx ?? {},
3318
- select: "email",
3319
- lean: true
3320
- });
3321
- 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;
3322
3870
  }
3323
3871
  /**
3324
3872
  * Remove `email` from the org's suppression list. Single atomic
3325
3873
  * `deleteMany` on the (unique) email key — emits
3326
3874
  * `crm:suppression.removed` only when a row was actually removed.
3327
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.
3328
3881
  */
3329
3882
  async unsuppress(email, ctx) {
3330
- const normalized = normalizeEmail(email);
3883
+ const normalized = requireSuppressionEmailKey(email);
3331
3884
  const removed = (await this.deleteMany({ email: normalized }, ctx ?? {})).deletedCount > 0;
3332
3885
  if (removed) await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUPPRESSION_REMOVED, { email: normalized }, ctx));
3333
3886
  return removed;
@@ -3427,19 +3980,19 @@ var ActivityRepository = class extends Repository {
3427
3980
  async _transition(id, target, patch, ctx) {
3428
3981
  const existing = await this.getById(id, ctx);
3429
3982
  if (!existing) throw new NotFoundError("Activity", id);
3430
- ACTIVITY_MACHINE.assertTransition(id, existing.status, target);
3431
- const updated = await assertAndClaim(ACTIVITY_MACHINE, this, id, {
3983
+ return await this.applyTransition(id, ACTIVITY_MACHINE, {
3432
3984
  from: existing.status,
3433
3985
  to: target,
3434
- patch,
3435
- options: ctx
3436
- });
3437
- if (!updated) throw new InvalidActivityTransitionError({
3438
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3439
- to: target,
3440
- activityId: id
3986
+ set: patch,
3987
+ history: false
3988
+ }, ctx).catch(async (err) => {
3989
+ if (!isTransitionRace(err)) throw err;
3990
+ throw new InvalidActivityTransitionError({
3991
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
3992
+ to: target,
3993
+ activityId: id
3994
+ });
3441
3995
  });
3442
- return updated;
3443
3996
  }
3444
3997
  /** Count `planned` activities with `dueDate` before `asOf`. Dashboard KPI. */
3445
3998
  async countOverduePlanned(asOf, ctx) {
@@ -3510,7 +4063,7 @@ const KIND_TO_EVENT = {
3510
4063
  var EmailEventRepository = class extends Repository {
3511
4064
  events;
3512
4065
  constructor(model, plugins, deps, pagination) {
3513
- super(model, plugins, pagination);
4066
+ super(model, [...plugins, appendOnlyPlugin({ allow: ["deleteMany"] })], pagination);
3514
4067
  this.events = deps.events;
3515
4068
  }
3516
4069
  async recordOpen(input, ctx) {
@@ -3735,24 +4288,24 @@ var SequenceRepository = class extends Repository {
3735
4288
  async _transition(id, target, ctx) {
3736
4289
  const existing = await this.getById(id, ctx);
3737
4290
  if (!existing) throw new NotFoundError("Sequence", id);
3738
- SEQUENCE_MACHINE.assertTransition(id, existing.status, target);
3739
4291
  const entry = {
3740
4292
  status: target,
3741
4293
  occurredAt: /* @__PURE__ */ new Date(),
3742
4294
  ...ctx?.actorId !== void 0 ? { by: String(ctx.actorId) } : {}
3743
4295
  };
3744
- const updated = await assertAndClaim(SEQUENCE_MACHINE, this, id, {
4296
+ return await this.applyTransition(id, SEQUENCE_MACHINE, {
3745
4297
  from: existing.status,
3746
4298
  to: target,
3747
- patch: { statusHistory: [...existing.statusHistory ?? [], entry] },
3748
- options: ctx
3749
- });
3750
- if (!updated) throw new InvalidSequenceTransitionError({
3751
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3752
- to: target,
3753
- sequenceId: id
4299
+ push: { statusHistory: entry },
4300
+ history: false
4301
+ }, ctx).catch(async (err) => {
4302
+ if (!isTransitionRace(err)) throw err;
4303
+ throw new InvalidSequenceTransitionError({
4304
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
4305
+ to: target,
4306
+ sequenceId: id
4307
+ });
3754
4308
  });
3755
- return updated;
3756
4309
  }
3757
4310
  };
3758
4311
  //#endregion
@@ -3839,16 +4392,16 @@ var EnrollmentRepository = class extends Repository {
3839
4392
  occurredAt: /* @__PURE__ */ new Date(),
3840
4393
  ...ctx?.actorId ? { by: String(ctx.actorId) } : {}
3841
4394
  };
3842
- const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
4395
+ const updated = await this.applyTransition(id, ENROLLMENT_MACHINE, {
3843
4396
  from,
3844
4397
  to: "exited",
3845
- patch: {
3846
- exitReason,
3847
- statusHistory: [...existing.statusHistory ?? [], entry]
3848
- },
3849
- options: ctx
4398
+ set: { exitReason },
4399
+ push: { statusHistory: entry },
4400
+ history: false
4401
+ }, ctx).catch((err) => {
4402
+ if (isTransitionRace(err)) throw new NotFoundError("Enrollment", id);
4403
+ throw err;
3850
4404
  });
3851
- if (!updated) throw new NotFoundError("Enrollment", id);
3852
4405
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_EXITED, {
3853
4406
  enrollmentId: id,
3854
4407
  exitReason
@@ -3907,27 +4460,25 @@ var EnrollmentRepository = class extends Repository {
3907
4460
  async _transition(id, target, ctx, patch = {}) {
3908
4461
  const existing = await this.getById(id, ctx);
3909
4462
  if (!existing) throw new NotFoundError("Enrollment", id);
3910
- ENROLLMENT_MACHINE.assertTransition(id, existing.status, target);
3911
4463
  const entry = {
3912
4464
  status: target,
3913
4465
  occurredAt: /* @__PURE__ */ new Date(),
3914
4466
  ...ctx?.actorId ? { by: String(ctx.actorId) } : {}
3915
4467
  };
3916
- const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
4468
+ return await this.applyTransition(id, ENROLLMENT_MACHINE, {
3917
4469
  from: existing.status,
3918
4470
  to: target,
3919
- patch: {
3920
- ...patch,
3921
- statusHistory: [...existing.statusHistory ?? [], entry]
3922
- },
3923
- options: ctx
3924
- });
3925
- if (!updated) throw new InvalidEnrollmentTransitionError({
3926
- from: (await this.getById(id, ctx))?.status ?? existing.status,
3927
- to: target,
3928
- enrollmentId: id
4471
+ set: patch,
4472
+ push: { statusHistory: entry },
4473
+ history: false
4474
+ }, ctx).catch(async (err) => {
4475
+ if (!isTransitionRace(err)) throw err;
4476
+ throw new InvalidEnrollmentTransitionError({
4477
+ from: (await this.getById(id, ctx))?.status ?? existing.status,
4478
+ to: target,
4479
+ enrollmentId: id
4480
+ });
3929
4481
  });
3930
- return updated;
3931
4482
  }
3932
4483
  };
3933
4484
  //#endregion
@@ -3954,49 +4505,48 @@ function createCrmRepositories(models, deps) {
3954
4505
  }));
3955
4506
  plugins.push(aggregateHelpersPlugin());
3956
4507
  if (config.plugins.length > 0) plugins.push(...config.plugins);
3957
- const pipeline = new PipelineRepository(models.Pipeline, plugins, { events }, pagination);
3958
- const lead = new LeadRepository(models.Lead, plugins, { events }, pagination);
3959
- const opportunity = new OpportunityRepository(models.Opportunity, plugins, {
3960
- events,
3961
- pipelines: pipeline
3962
- }, pagination);
3963
- const contact = new ContactRepository(models.Contact, plugins, { events }, pagination);
3964
- const account = new AccountRepository(models.Account, plugins, { events }, pagination);
3965
- const campaign = new CampaignRepository(models.Campaign, plugins, { events }, pagination);
3966
- const subscription = new SubscriptionRepository(models.Subscription, plugins, { events }, pagination);
3967
- const suppression = new SuppressionRepository(models.Suppression, plugins, { events }, pagination);
3968
- const activity = new ActivityRepository(models.Activity, plugins, { events }, pagination);
3969
- const note = new NoteRepository(models.Note, plugins, { events }, pagination);
3970
- const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
3971
- const emailEvent = new EmailEventRepository(models.EmailEvent, plugins, { events }, pagination);
3972
- const conversation = new ConversationRepository(models.Conversation, plugins, { events }, pagination);
3973
- const message = new MessageRepository(models.Message, plugins, {
3974
- events,
3975
- conversations: conversation
3976
- }, pagination);
3977
- const sequence = new SequenceRepository(models.Sequence, plugins, { events }, pagination);
3978
- const enrollment = new EnrollmentRepository(models.Enrollment, plugins, {
3979
- events,
3980
- sequences: sequence
3981
- }, pagination);
3982
- return Object.freeze({
3983
- pipeline,
3984
- lead,
3985
- opportunity,
3986
- contact,
3987
- account,
3988
- campaign,
3989
- subscription,
3990
- suppression,
3991
- activity,
3992
- note,
3993
- lossReason,
3994
- emailEvent,
3995
- conversation,
3996
- message,
3997
- sequence,
3998
- enrollment
3999
- });
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);
4000
4550
  }
4001
4551
  //#endregion
4002
4552
  //#region src/events/dispatch.ts
@@ -4028,7 +4578,9 @@ var CrmEventDispatcher = class {
4028
4578
  return this.transport.publish(event);
4029
4579
  }
4030
4580
  subscribe(pattern, handler) {
4031
- 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);
4032
4584
  }
4033
4585
  async close() {
4034
4586
  await this.transport.close?.();
@@ -4074,37 +4626,24 @@ async function flushPending(transport, events, logger) {
4074
4626
  //#region src/events/in-process-bus.ts
4075
4627
  /**
4076
4628
  * In-process event bus — default fallback when no `eventTransport` is
4077
- * provided to `createCrm`. Structurally identical to arc's
4078
- * `MemoryEventTransport`; pattern matching is delegated to primitives.
4079
- * 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.
4080
4638
  */
4081
- var InProcessCrmBus = class {
4082
- name = "in-process-crm";
4083
- subs = [];
4084
- logger;
4639
+ var InProcessCrmBus = class extends InProcessEventBus {
4085
4640
  constructor(options) {
4086
- if (options?.logger) this.logger = options.logger;
4087
- }
4088
- async publish(event) {
4089
- const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
4090
- for (const sub of matching) try {
4091
- await sub.handler(event);
4092
- } catch (err) {
4093
- this.logger?.error?.(`[crm] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
4094
- }
4095
- }
4096
- async subscribe(pattern, handler) {
4097
- const sub = {
4098
- pattern,
4099
- handler
4100
- };
4101
- this.subs.push(sub);
4102
- return () => {
4103
- this.subs = this.subs.filter((s) => s !== sub);
4104
- };
4105
- }
4106
- async close() {
4107
- 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
+ });
4108
4647
  }
4109
4648
  };
4110
4649
  //#endregion
@@ -4162,53 +4701,179 @@ async function runUnit(config, ctx, body) {
4162
4701
  return result;
4163
4702
  }
4164
4703
  //#endregion
4165
- //#region src/engine/create-crm.ts
4166
- async function createCrm(config) {
4167
- if (!config.connection) throw new Error("[crm] CrmConfig.connection is required");
4168
- const resolved = {
4169
- connection: config.connection,
4170
- tenantFieldType: config.tenantFieldType ?? "objectId",
4171
- multiTenant: config.multiTenant ?? true,
4172
- forceRecreate: config.forceRecreate ?? false,
4173
- autoIndex: config.autoIndex ?? true,
4174
- ...config.logger !== void 0 ? { logger: config.logger } : {},
4175
- ...config.pagination !== void 0 ? { pagination: config.pagination } : {},
4176
- plugins: config.plugins ?? []
4177
- };
4178
- const events = new CrmEventDispatcher({
4179
- transport: config.eventTransport ?? new InProcessCrmBus(config.logger ? { logger: config.logger } : void 0),
4180
- outbox: config.outbox,
4181
- logger: config.logger
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
4182
4717
  });
4183
- const models = createCrmModels(config.connection, resolved);
4184
- const repositories = createCrmRepositories(models, {
4185
- events,
4186
- config: resolved
4187
- });
4188
- assertCrmCapabilities(repositories.lead, {
4189
- ...config.allowNonTransactional !== void 0 ? { allowNonTransactional: config.allowNonTransactional } : {},
4190
- ...config.logger?.warn !== void 0 ? { logger: { warn: (m) => config.logger?.warn?.(m) } } : {}
4191
- });
4192
- return Object.freeze({
4193
- models,
4194
- repositories,
4195
- events,
4196
- config: Object.freeze(resolved),
4197
- withTransaction(ctx, body) {
4198
- return runUnit({
4199
- 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, {
4200
4775
  events,
4201
- logger: config.logger,
4202
- allowNonTransactional: config.allowNonTransactional ?? false
4203
- }, ctx, body);
4204
- },
4205
- async syncIndexes() {
4206
- await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
4207
- },
4208
- async dispose() {
4209
- 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
+ });
4210
4875
  }
4211
- });
4876
+ };
4212
4877
  }
4213
4878
  //#endregion
4214
4879
  //#region src/services/lead.service.ts
@@ -4254,7 +4919,9 @@ var LeadService = class LeadService {
4254
4919
  const lead = await this.leads.getById(leadId, ctx);
4255
4920
  if (!lead) throw new NotFoundError("Lead", leadId);
4256
4921
  if (lead.status === "converted") throw new LeadAlreadyConvertedError(leadId);
4257
- 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({
4258
4925
  from: lead.status,
4259
4926
  to: "converted",
4260
4927
  leadId
@@ -4347,17 +5014,7 @@ var LeadService = class LeadService {
4347
5014
  occurredAt: /* @__PURE__ */ new Date(),
4348
5015
  ...crossRefs.by !== void 0 ? { by: crossRefs.by } : {}
4349
5016
  };
4350
- const current = await this.leads.getById(leadId, ctx);
4351
- const updated = await this.leads.claim(leadId, {
4352
- field: "status",
4353
- from: fromStatus,
4354
- to: "converted"
4355
- }, {
4356
- ...patch,
4357
- statusHistory: [...current?.statusHistory ?? [], entry]
4358
- }, ctx);
4359
- if (!updated) throw new NotFoundError("Lead", leadId);
4360
- return updated;
5017
+ return this.leads.markConverted(leadId, fromStatus, patch, entry, ctx);
4361
5018
  }
4362
5019
  };
4363
5020
  /**
@@ -4502,6 +5159,15 @@ const LeadAssigned = defineCrmEvent({
4502
5159
  description: "assign() (re)assigned the lead owner.",
4503
5160
  zodSchema: z.object({ leadId: z.string() }).extend(ownerChangeSchema.shape)
4504
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
+ });
4505
5171
  const AccountCreated = defineCrmEvent({
4506
5172
  name: CRM_EVENTS.ACCOUNT_CREATED,
4507
5173
  resource: "account",
@@ -4568,6 +5234,15 @@ const ContactMerged = defineCrmEvent({
4568
5234
  description: "merge() folded a secondary contact into the primary; secondary deleted.",
4569
5235
  zodSchema: mergeSchema
4570
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
+ });
4571
5246
  const OpportunityCreated = defineCrmEvent({
4572
5247
  name: CRM_EVENTS.OPPORTUNITY_CREATED,
4573
5248
  resource: "opportunity",
@@ -5023,6 +5698,7 @@ const crmEventDefinitions = [
5023
5698
  LeadDisqualified,
5024
5699
  LeadEnriched,
5025
5700
  LeadAssigned,
5701
+ LeadAnonymized,
5026
5702
  AccountCreated,
5027
5703
  AccountUpdated,
5028
5704
  AccountEnriched,
@@ -5033,6 +5709,7 @@ const crmEventDefinitions = [
5033
5709
  ContactEnriched,
5034
5710
  ContactAssigned,
5035
5711
  ContactMerged,
5712
+ ContactAnonymized,
5036
5713
  OpportunityCreated,
5037
5714
  OpportunityStageAdvanced,
5038
5715
  OpportunityStageRegressed,
@@ -5149,8 +5826,10 @@ function pipelineConversion(opportunities, stageIds) {
5149
5826
  for (const o of opportunities) {
5150
5827
  if (o.stageId === stageId && o.status === "open") currentCount++;
5151
5828
  if (o.status === "won" || o.status === "lost") {
5152
- if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) if (o.status === "won") wonFromHere++;
5153
- else lostFromHere++;
5829
+ if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) {
5830
+ if (o.status === "won") wonFromHere++;
5831
+ else lostFromHere++;
5832
+ }
5154
5833
  }
5155
5834
  }
5156
5835
  const closed = wonFromHere + lostFromHere;
@@ -5577,4 +6256,4 @@ var validators_exports = /* @__PURE__ */ __exportAll({
5577
6256
  updateSequenceSchema: () => updateSequenceSchema
5578
6257
  });
5579
6258
  //#endregion
5580
- 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 };