@classytic/crm 0.9.0 → 1.1.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,39 @@
1
- import { t as __exportAll } from "./chunk.js";
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { Repository, aggregateHelpersPlugin, appendOnlyPlugin, defineModels, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
2
3
  import { Schema } from "mongoose";
3
- import { Repository, aggregateHelpersPlugin, appendOnlyPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
4
- import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
4
+ import { EmailFormatError, normalizeEmail } from "@classytic/contact/email";
5
+ import { createEvent as createEvent$1 } from "@classytic/primitives/events";
5
6
  import { IllegalTransitionError, defineStateMachine } from "@classytic/primitives/state-machine";
6
7
  import { randomUUID } from "node:crypto";
8
+ import { InProcessEventBus } from "@classytic/primitives/event-infra";
9
+ import { assertNoLegacyTenantKeys, resolveTenantConfig } from "@classytic/repo-core/tenant";
7
10
  import { z } from "zod";
8
11
  import { civilDateOf } from "@classytic/primitives/timezone";
9
12
  import { CURRENCY_PATTERN } from "@classytic/primitives/currency";
10
13
  import { appendStatus, lastTransitionTo, latestEntry, timeInStatus } from "@classytic/primitives/status-history";
11
- //#region src/core/errors.ts
14
+ //#region src/engine/modules.ts
15
+ const ALL_CRM_MODULES = Object.freeze({
16
+ sales: true,
17
+ engagement: true,
18
+ automation: true
19
+ });
12
20
  /**
13
- * CRM error vocabulary. Hosts catch `CrmError` at API edges.
21
+ * Resolve module dependencies once at boot.
22
+ *
23
+ * Omitting `modules` preserves the pre-module package behaviour (everything
24
+ * enabled). Supplying it is opt-in: unspecified modules are disabled.
14
25
  */
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
- };
26
+ function resolveCrmModules(modules) {
27
+ if (modules === void 0) return ALL_CRM_MODULES;
28
+ const automation = modules.automation === true;
29
+ const engagement = modules.engagement === true || automation;
30
+ const sales = modules.sales === true || engagement;
31
+ return Object.freeze({
32
+ sales,
33
+ engagement,
34
+ automation
35
+ });
36
+ }
123
37
  //#endregion
124
38
  //#region src/models/tenant-field.ts
125
39
  /**
@@ -130,20 +44,22 @@ var AlreadyEnrolledError = class extends CrmError {
130
44
  * `.populate()`, QueryParser `?lookup=organizationId`); hosts with
131
45
  * UUID/slug auth pick `'string'`.
132
46
  */
47
+ function crmModelName(config, base) {
48
+ return `${config.modelPrefix ?? ""}${base}`;
49
+ }
133
50
  /**
134
51
  * Tenant-column schema definition. Use this anywhere a model has
135
52
  * `organizationId` — never write the raw type in a schema file.
136
53
  */
137
54
  function tenantFieldDef(config) {
138
- return config.tenantFieldType === "objectId" ? {
55
+ const { tenant } = config;
56
+ return tenant.fieldType === "objectId" ? {
139
57
  type: Schema.Types.ObjectId,
140
- required: true,
141
- ref: "organization",
142
- index: true
58
+ ...tenant.enabled && tenant.required ? { required: true } : {},
59
+ ...tenant.ref ? { ref: tenant.ref } : {}
143
60
  } : {
144
61
  type: String,
145
- required: true,
146
- index: true
62
+ ...tenant.enabled && tenant.required ? { required: true } : {}
147
63
  };
148
64
  }
149
65
  //#endregion
@@ -189,7 +105,7 @@ const stageSubSchema = new Schema({
189
105
  }, { _id: true });
190
106
  function buildPipelineSchema(config) {
191
107
  const schema = new Schema({
192
- organizationId: tenantFieldDef(config),
108
+ [config.tenant.tenantField]: tenantFieldDef(config),
193
109
  name: {
194
110
  type: String,
195
111
  required: true,
@@ -213,11 +129,11 @@ function buildPipelineSchema(config) {
213
129
  metadata: { type: Schema.Types.Mixed }
214
130
  }, { timestamps: true });
215
131
  schema.index({
216
- organizationId: 1,
132
+ [config.tenant.tenantField]: 1,
217
133
  isArchived: 1
218
134
  });
219
135
  schema.index({
220
- organizationId: 1,
136
+ [config.tenant.tenantField]: 1,
221
137
  name: 1
222
138
  });
223
139
  return schema;
@@ -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.
@@ -313,7 +539,7 @@ const engagementSchema = new Schema({
313
539
  }, { _id: false });
314
540
  function buildLeadSchema(config) {
315
541
  const schema = new Schema({
316
- organizationId: tenantFieldDef(config),
542
+ [config.tenant.tenantField]: tenantFieldDef(config),
317
543
  fullName: {
318
544
  type: String,
319
545
  required: true,
@@ -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,21 +630,22 @@ 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({
411
- organizationId: 1,
637
+ [config.tenant.tenantField]: 1,
412
638
  email: 1
413
639
  }, {
414
640
  unique: true,
415
641
  partialFilterExpression: { email: { $type: "string" } }
416
642
  });
417
643
  schema.index({
418
- organizationId: 1,
644
+ [config.tenant.tenantField]: 1,
419
645
  createdAt: -1
420
646
  });
421
647
  schema.index({
422
- organizationId: 1,
648
+ [config.tenant.tenantField]: 1,
423
649
  score: -1
424
650
  });
425
651
  return schema;
@@ -479,7 +705,7 @@ const statusEntrySchema$4 = new Schema({
479
705
  }, { _id: false });
480
706
  function buildOpportunitySchema(config) {
481
707
  const schema = new Schema({
482
- organizationId: tenantFieldDef(config),
708
+ [config.tenant.tenantField]: tenantFieldDef(config),
483
709
  name: {
484
710
  type: String,
485
711
  required: true,
@@ -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],
@@ -549,17 +775,17 @@ function buildOpportunitySchema(config) {
549
775
  metadata: { type: Schema.Types.Mixed }
550
776
  }, { timestamps: true });
551
777
  schema.index({
552
- organizationId: 1,
778
+ [config.tenant.tenantField]: 1,
553
779
  pipelineId: 1,
554
780
  stageId: 1
555
781
  });
556
782
  schema.index({
557
- organizationId: 1,
783
+ [config.tenant.tenantField]: 1,
558
784
  status: 1,
559
785
  expectedCloseAt: 1
560
786
  });
561
787
  schema.index({
562
- organizationId: 1,
788
+ [config.tenant.tenantField]: 1,
563
789
  ownerId: 1,
564
790
  status: 1
565
791
  });
@@ -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: {
@@ -660,7 +884,7 @@ const contactInfoSchema$1 = new Schema({
660
884
  }, { _id: false });
661
885
  function buildContactSchema(config) {
662
886
  const schema = new Schema({
663
- organizationId: tenantFieldDef(config),
887
+ [config.tenant.tenantField]: tenantFieldDef(config),
664
888
  name: {
665
889
  type: nameSchema,
666
890
  required: true
@@ -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,26 +910,30 @@ 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 },
693
918
  toObject: { virtuals: true }
694
919
  });
695
920
  schema.index({
696
- organizationId: 1,
921
+ [config.tenant.tenantField]: 1,
697
922
  "contact.email": 1
923
+ }, {
924
+ unique: true,
925
+ partialFilterExpression: { "contact.email": { $type: "string" } }
698
926
  });
699
927
  schema.index({
700
- organizationId: 1,
928
+ [config.tenant.tenantField]: 1,
701
929
  accountId: 1
702
930
  });
703
931
  schema.index({
704
- organizationId: 1,
932
+ [config.tenant.tenantField]: 1,
705
933
  createdAt: -1
706
934
  });
707
935
  schema.index({
708
- organizationId: 1,
936
+ [config.tenant.tenantField]: 1,
709
937
  tags: 1
710
938
  });
711
939
  schema.virtual("fullName").get(function() {
@@ -726,7 +954,7 @@ function createContactModel(connection, config) {
726
954
  */
727
955
  function buildAccountSchema(config) {
728
956
  const schema = new Schema({
729
- organizationId: tenantFieldDef(config),
957
+ [config.tenant.tenantField]: tenantFieldDef(config),
730
958
  name: {
731
959
  type: String,
732
960
  required: true,
@@ -765,15 +993,15 @@ function buildAccountSchema(config) {
765
993
  metadata: { type: Schema.Types.Mixed }
766
994
  }, { timestamps: true });
767
995
  schema.index({
768
- organizationId: 1,
996
+ [config.tenant.tenantField]: 1,
769
997
  domain: 1
770
998
  });
771
999
  schema.index({
772
- organizationId: 1,
1000
+ [config.tenant.tenantField]: 1,
773
1001
  createdAt: -1
774
1002
  });
775
1003
  schema.index({
776
- organizationId: 1,
1004
+ [config.tenant.tenantField]: 1,
777
1005
  tags: 1
778
1006
  });
779
1007
  return schema;
@@ -839,7 +1067,7 @@ const statusEntrySchema$3 = new Schema({
839
1067
  }, { _id: false });
840
1068
  function buildCampaignSchema(config) {
841
1069
  const schema = new Schema({
842
- organizationId: tenantFieldDef(config),
1070
+ [config.tenant.tenantField]: tenantFieldDef(config),
843
1071
  name: {
844
1072
  type: String,
845
1073
  required: true,
@@ -896,23 +1124,23 @@ function buildCampaignSchema(config) {
896
1124
  metadata: { type: Schema.Types.Mixed }
897
1125
  }, { timestamps: true });
898
1126
  schema.index({
899
- organizationId: 1,
1127
+ [config.tenant.tenantField]: 1,
900
1128
  name: 1
901
1129
  }, { unique: true });
902
1130
  schema.index({
903
- organizationId: 1,
1131
+ [config.tenant.tenantField]: 1,
904
1132
  status: 1
905
1133
  });
906
1134
  schema.index({
907
- organizationId: 1,
1135
+ [config.tenant.tenantField]: 1,
908
1136
  startDate: 1
909
1137
  });
910
1138
  schema.index({
911
- organizationId: 1,
1139
+ [config.tenant.tenantField]: 1,
912
1140
  createdAt: -1
913
1141
  });
914
1142
  schema.index({
915
- organizationId: 1,
1143
+ [config.tenant.tenantField]: 1,
916
1144
  tags: 1
917
1145
  });
918
1146
  return schema;
@@ -988,15 +1216,15 @@ const statusEntrySchema$2 = new Schema({
988
1216
  }, { _id: false });
989
1217
  function buildSubscriptionSchema(config) {
990
1218
  const schema = new Schema({
991
- organizationId: tenantFieldDef(config),
1219
+ [config.tenant.tenantField]: 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: {
@@ -1057,12 +1285,12 @@ function buildSubscriptionSchema(config) {
1057
1285
  collection: "email_subscriptions"
1058
1286
  });
1059
1287
  schema.index({
1060
- organizationId: 1,
1288
+ [config.tenant.tenantField]: 1,
1061
1289
  campaignId: 1,
1062
1290
  status: 1
1063
1291
  });
1064
1292
  schema.index({
1065
- organizationId: 1,
1293
+ [config.tenant.tenantField]: 1,
1066
1294
  contactId: 1,
1067
1295
  campaignId: 1
1068
1296
  }, {
@@ -1070,7 +1298,7 @@ function buildSubscriptionSchema(config) {
1070
1298
  partialFilterExpression: { status: { $in: ["pending", "confirmed"] } }
1071
1299
  });
1072
1300
  schema.index({
1073
- organizationId: 1,
1301
+ [config.tenant.tenantField]: 1,
1074
1302
  createdAt: -1
1075
1303
  });
1076
1304
  return schema;
@@ -1107,7 +1335,7 @@ const SUPPRESSION_REASONS = [...EVENT_DERIVED_SUPPRESSION_REASONS, "manual"];
1107
1335
  */
1108
1336
  function buildSuppressionSchema(config) {
1109
1337
  const schema = new Schema({
1110
- organizationId: tenantFieldDef(config),
1338
+ [config.tenant.tenantField]: tenantFieldDef(config),
1111
1339
  email: {
1112
1340
  type: String,
1113
1341
  required: true,
@@ -1143,16 +1371,16 @@ function buildSuppressionSchema(config) {
1143
1371
  metadata: { type: Schema.Types.Mixed }
1144
1372
  }, { timestamps: true });
1145
1373
  schema.index({
1146
- organizationId: 1,
1374
+ [config.tenant.tenantField]: 1,
1147
1375
  email: 1
1148
1376
  }, { unique: true });
1149
1377
  schema.index({
1150
- organizationId: 1,
1378
+ [config.tenant.tenantField]: 1,
1151
1379
  reason: 1,
1152
1380
  suppressedAt: -1
1153
1381
  });
1154
1382
  schema.index({
1155
- organizationId: 1,
1383
+ [config.tenant.tenantField]: 1,
1156
1384
  suppressedAt: -1
1157
1385
  });
1158
1386
  return schema;
@@ -1213,7 +1441,7 @@ const SUBJECT_KINDS = [
1213
1441
  */
1214
1442
  function buildActivitySchema(config) {
1215
1443
  const schema = new Schema({
1216
- organizationId: tenantFieldDef(config),
1444
+ [config.tenant.tenantField]: tenantFieldDef(config),
1217
1445
  type: {
1218
1446
  type: String,
1219
1447
  enum: ACTIVITY_TYPES,
@@ -1268,26 +1496,26 @@ function buildActivitySchema(config) {
1268
1496
  metadata: { type: Schema.Types.Mixed }
1269
1497
  }, { timestamps: true });
1270
1498
  schema.index({
1271
- organizationId: 1,
1499
+ [config.tenant.tenantField]: 1,
1272
1500
  subjectKind: 1,
1273
1501
  subjectId: 1
1274
1502
  });
1275
1503
  schema.index({
1276
- organizationId: 1,
1504
+ [config.tenant.tenantField]: 1,
1277
1505
  status: 1,
1278
1506
  dueDate: 1
1279
1507
  });
1280
1508
  schema.index({
1281
- organizationId: 1,
1509
+ [config.tenant.tenantField]: 1,
1282
1510
  ownerId: 1,
1283
1511
  status: 1
1284
1512
  });
1285
1513
  schema.index({
1286
- organizationId: 1,
1514
+ [config.tenant.tenantField]: 1,
1287
1515
  type: 1
1288
1516
  });
1289
1517
  schema.index({
1290
- organizationId: 1,
1518
+ [config.tenant.tenantField]: 1,
1291
1519
  createdAt: -1
1292
1520
  });
1293
1521
  return schema;
@@ -1305,7 +1533,7 @@ function createActivityModel(connection, config) {
1305
1533
  */
1306
1534
  function buildNoteSchema(config) {
1307
1535
  const schema = new Schema({
1308
- organizationId: tenantFieldDef(config),
1536
+ [config.tenant.tenantField]: tenantFieldDef(config),
1309
1537
  subjectKind: {
1310
1538
  type: String,
1311
1539
  enum: SUBJECT_KINDS,
@@ -1334,7 +1562,7 @@ function buildNoteSchema(config) {
1334
1562
  metadata: { type: Schema.Types.Mixed }
1335
1563
  }, { timestamps: true });
1336
1564
  schema.index({
1337
- organizationId: 1,
1565
+ [config.tenant.tenantField]: 1,
1338
1566
  subjectKind: 1,
1339
1567
  subjectId: 1,
1340
1568
  pinned: -1,
@@ -1355,7 +1583,7 @@ function createNoteModel(connection, config) {
1355
1583
  */
1356
1584
  function buildLossReasonSchema(config) {
1357
1585
  const schema = new Schema({
1358
- organizationId: tenantFieldDef(config),
1586
+ [config.tenant.tenantField]: tenantFieldDef(config),
1359
1587
  name: {
1360
1588
  type: String,
1361
1589
  required: true,
@@ -1379,7 +1607,7 @@ function buildLossReasonSchema(config) {
1379
1607
  metadata: { type: Schema.Types.Mixed }
1380
1608
  }, { timestamps: true });
1381
1609
  schema.index({
1382
- organizationId: 1,
1610
+ [config.tenant.tenantField]: 1,
1383
1611
  isActive: 1,
1384
1612
  sequence: 1
1385
1613
  });
@@ -1436,7 +1664,7 @@ const EMAIL_EVENT_KINDS = [
1436
1664
  const EMAIL_EVENT_SUBJECT_KINDS = ["contact", "subscription"];
1437
1665
  function buildEmailEventSchema(config) {
1438
1666
  const schema = new Schema({
1439
- organizationId: tenantFieldDef(config),
1667
+ [config.tenant.tenantField]: tenantFieldDef(config),
1440
1668
  kind: {
1441
1669
  type: String,
1442
1670
  enum: EMAIL_EVENT_KINDS,
@@ -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: {
@@ -1484,23 +1712,23 @@ function buildEmailEventSchema(config) {
1484
1712
  updatedAt: false
1485
1713
  } });
1486
1714
  schema.index({
1487
- organizationId: 1,
1715
+ [config.tenant.tenantField]: 1,
1488
1716
  createdAt: -1
1489
1717
  });
1490
1718
  schema.index({
1491
- organizationId: 1,
1719
+ [config.tenant.tenantField]: 1,
1492
1720
  subjectKind: 1,
1493
1721
  subjectId: 1,
1494
1722
  createdAt: -1
1495
1723
  });
1496
1724
  schema.index({
1497
- organizationId: 1,
1725
+ [config.tenant.tenantField]: 1,
1498
1726
  campaignId: 1,
1499
1727
  kind: 1,
1500
1728
  createdAt: -1
1501
1729
  });
1502
1730
  schema.index({
1503
- organizationId: 1,
1731
+ [config.tenant.tenantField]: 1,
1504
1732
  messageId: 1
1505
1733
  });
1506
1734
  return schema;
@@ -1555,7 +1783,7 @@ const MESSAGE_DELIVERY_STATUSES = [
1555
1783
  */
1556
1784
  function buildConversationSchema(config) {
1557
1785
  const schema = new Schema({
1558
- organizationId: tenantFieldDef(config),
1786
+ [config.tenant.tenantField]: tenantFieldDef(config),
1559
1787
  channel: {
1560
1788
  type: String,
1561
1789
  enum: CONVERSATION_CHANNELS,
@@ -1599,22 +1827,22 @@ function buildConversationSchema(config) {
1599
1827
  metadata: { type: Schema.Types.Mixed }
1600
1828
  }, { timestamps: true });
1601
1829
  schema.index({
1602
- organizationId: 1,
1830
+ [config.tenant.tenantField]: 1,
1603
1831
  participantKind: 1,
1604
1832
  participantId: 1
1605
1833
  });
1606
1834
  schema.index({
1607
- organizationId: 1,
1835
+ [config.tenant.tenantField]: 1,
1608
1836
  status: 1,
1609
1837
  lastMessageAt: -1
1610
1838
  });
1611
1839
  schema.index({
1612
- organizationId: 1,
1840
+ [config.tenant.tenantField]: 1,
1613
1841
  assigneeId: 1,
1614
1842
  status: 1
1615
1843
  });
1616
1844
  schema.index({
1617
- organizationId: 1,
1845
+ [config.tenant.tenantField]: 1,
1618
1846
  channel: 1,
1619
1847
  channelUserId: 1
1620
1848
  });
@@ -1662,10 +1890,10 @@ const attachmentSchema = new Schema({
1662
1890
  }, { _id: false });
1663
1891
  function buildMessageSchema(config) {
1664
1892
  const schema = new Schema({
1665
- organizationId: tenantFieldDef(config),
1893
+ [config.tenant.tenantField]: 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
  },
@@ -1707,19 +1935,19 @@ function buildMessageSchema(config) {
1707
1935
  metadata: { type: Schema.Types.Mixed }
1708
1936
  }, { timestamps: true });
1709
1937
  schema.index({
1710
- organizationId: 1,
1938
+ [config.tenant.tenantField]: 1,
1711
1939
  conversationId: 1,
1712
1940
  createdAt: -1
1713
1941
  });
1714
1942
  schema.index({
1715
- organizationId: 1,
1943
+ [config.tenant.tenantField]: 1,
1716
1944
  externalMessageId: 1
1717
1945
  }, {
1718
1946
  unique: true,
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
  });
@@ -1777,7 +2005,7 @@ const statusEntrySchema$1 = new Schema({
1777
2005
  }, { _id: false });
1778
2006
  function buildSequenceSchema(config) {
1779
2007
  const schema = new Schema({
1780
- organizationId: tenantFieldDef(config),
2008
+ [config.tenant.tenantField]: tenantFieldDef(config),
1781
2009
  name: {
1782
2010
  type: String,
1783
2011
  required: true,
@@ -1825,11 +2053,11 @@ function buildSequenceSchema(config) {
1825
2053
  }
1826
2054
  }, { timestamps: true });
1827
2055
  schema.index({
1828
- organizationId: 1,
2056
+ [config.tenant.tenantField]: 1,
1829
2057
  status: 1
1830
2058
  });
1831
2059
  schema.index({
1832
- organizationId: 1,
2060
+ [config.tenant.tenantField]: 1,
1833
2061
  createdAt: -1
1834
2062
  });
1835
2063
  return schema;
@@ -1909,11 +2137,11 @@ const statusEntrySchema = new Schema({
1909
2137
  }, { _id: false });
1910
2138
  function buildEnrollmentSchema(config) {
1911
2139
  const schema = new Schema({
1912
- organizationId: tenantFieldDef(config),
2140
+ [config.tenant.tenantField]: tenantFieldDef(config),
1913
2141
  sequenceId: {
1914
- type: config.tenantFieldType === "objectId" ? Schema.Types.ObjectId : String,
2142
+ type: config.tenant.fieldType === "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,
@@ -1921,7 +2149,7 @@ function buildEnrollmentSchema(config) {
1921
2149
  required: true
1922
2150
  },
1923
2151
  subjectId: {
1924
- type: config.tenantFieldType === "objectId" ? Schema.Types.ObjectId : String,
2152
+ type: config.tenant.fieldType === "objectId" ? Schema.Types.ObjectId : String,
1925
2153
  required: true
1926
2154
  },
1927
2155
  status: {
@@ -1965,30 +2193,43 @@ function buildEnrollmentSchema(config) {
1965
2193
  }
1966
2194
  }, { timestamps: true });
1967
2195
  schema.index({
1968
- organizationId: 1,
2196
+ [config.tenant.tenantField]: 1,
1969
2197
  sequenceId: 1,
1970
2198
  subjectId: 1,
1971
2199
  status: 1
1972
2200
  }, { unique: false });
1973
2201
  schema.index({
1974
- organizationId: 1,
2202
+ [config.tenant.tenantField]: 1,
1975
2203
  sequenceId: 1,
1976
2204
  status: 1
1977
2205
  });
1978
2206
  schema.index({
1979
- organizationId: 1,
2207
+ [config.tenant.tenantField]: 1,
1980
2208
  subjectKind: 1,
1981
2209
  subjectId: 1
1982
2210
  });
1983
2211
  schema.index({
1984
- organizationId: 1,
2212
+ [config.tenant.tenantField]: 1,
1985
2213
  createdAt: -1
1986
2214
  });
1987
2215
  return schema;
1988
2216
  }
1989
2217
  //#endregion
1990
2218
  //#region src/models/create-models.ts
1991
- /** Mongoose model names this package registers — drives the §21 collision check. */
2219
+ /**
2220
+ * CRM model specification — the ONE authoritative place the CRM model set is
2221
+ * described (Kernel Construction Standard §5/§6).
2222
+ *
2223
+ * `defineCrmModels(config)` returns a connection-free mongokit `ModelBlueprint`;
2224
+ * `defineCrm` binds it during engine construction. Optional model families
2225
+ * (sales / engagement / automation) become CONDITIONAL `ModelSpec` entries — a
2226
+ * disabled module contributes no spec, so it registers no model (invariant §11.10).
2227
+ *
2228
+ * Collision handling, auto-index and the tenant-column shape live here once.
2229
+ * A name collision surfaces as mongokit's typed `ModelCollisionError` — there is
2230
+ * no CRM-specific collision error.
2231
+ */
2232
+ /** Mongoose model names this package can register (before `modelPrefix`). Reference/documentation. */
1992
2233
  const CRM_MODEL_NAMES = [
1993
2234
  "Pipeline",
1994
2235
  "Lead",
@@ -2007,34 +2248,132 @@ const CRM_MODEL_NAMES = [
2007
2248
  "Sequence",
2008
2249
  "Enrollment"
2009
2250
  ];
2010
- function createCrmModels(connection, config) {
2011
- const forceRecreate = config.forceRecreate ?? false;
2251
+ /**
2252
+ * Describe the CRM model set as a connection-free blueprint. Pure — builds no
2253
+ * schema and registers nothing until `.bind(connection)`. Only the enabled
2254
+ * module families contribute `ModelSpec` entries.
2255
+ */
2256
+ function defineCrmModels(config) {
2012
2257
  const autoIndex = config.autoIndex ?? true;
2013
- const namesToCheck = [...CRM_MODEL_NAMES, "Subscription"];
2014
- if (forceRecreate) {
2015
- for (const name of namesToCheck) if (connection.models[name]) connection.deleteModel(name);
2016
- } else for (const name of CRM_MODEL_NAMES) if (connection.models[name]) throw new CrmModelCollisionError(name);
2017
- const register = (name, schema) => {
2018
- schema.set("autoIndex", autoIndex);
2019
- return connection.model(name, schema);
2020
- };
2021
- return Object.freeze({
2022
- Pipeline: register("Pipeline", buildPipelineSchema(config)),
2023
- Lead: register("Lead", buildLeadSchema(config)),
2024
- Opportunity: register("Opportunity", buildOpportunitySchema(config)),
2025
- Contact: register("Contact", buildContactSchema(config)),
2026
- Account: register("Account", buildAccountSchema(config)),
2027
- Campaign: register("Campaign", buildCampaignSchema(config)),
2028
- Subscription: register("EmailSubscription", buildSubscriptionSchema(config)),
2029
- Suppression: register("Suppression", buildSuppressionSchema(config)),
2030
- Activity: register("Activity", buildActivitySchema(config)),
2031
- Note: register("Note", buildNoteSchema(config)),
2032
- LossReason: register("LossReason", buildLossReasonSchema(config)),
2033
- EmailEvent: register("EmailEvent", buildEmailEventSchema(config)),
2034
- Conversation: register("Conversation", buildConversationSchema(config)),
2035
- Message: register("Message", buildMessageSchema(config)),
2036
- Sequence: register("Sequence", buildSequenceSchema(config)),
2037
- Enrollment: register("Enrollment", buildEnrollmentSchema(config))
2258
+ const existing = config.forceRecreate ? {
2259
+ mode: "replace",
2260
+ environment: "test"
2261
+ } : { mode: "throw" };
2262
+ const salesEntries = [
2263
+ {
2264
+ key: "Pipeline",
2265
+ baseName: "Pipeline",
2266
+ build: buildPipelineSchema
2267
+ },
2268
+ {
2269
+ key: "Lead",
2270
+ baseName: "Lead",
2271
+ build: buildLeadSchema
2272
+ },
2273
+ {
2274
+ key: "Opportunity",
2275
+ baseName: "Opportunity",
2276
+ build: buildOpportunitySchema
2277
+ },
2278
+ {
2279
+ key: "Contact",
2280
+ baseName: "Contact",
2281
+ build: buildContactSchema
2282
+ },
2283
+ {
2284
+ key: "Account",
2285
+ baseName: "Account",
2286
+ build: buildAccountSchema
2287
+ },
2288
+ {
2289
+ key: "Activity",
2290
+ baseName: "Activity",
2291
+ build: buildActivitySchema
2292
+ },
2293
+ {
2294
+ key: "Note",
2295
+ baseName: "Note",
2296
+ build: buildNoteSchema
2297
+ },
2298
+ {
2299
+ key: "LossReason",
2300
+ baseName: "LossReason",
2301
+ build: buildLossReasonSchema
2302
+ }
2303
+ ];
2304
+ const engagementEntries = [
2305
+ {
2306
+ key: "Subscription",
2307
+ baseName: "EmailSubscription",
2308
+ build: buildSubscriptionSchema
2309
+ },
2310
+ {
2311
+ key: "Suppression",
2312
+ baseName: "Suppression",
2313
+ build: buildSuppressionSchema
2314
+ },
2315
+ {
2316
+ key: "EmailEvent",
2317
+ baseName: "EmailEvent",
2318
+ build: buildEmailEventSchema
2319
+ },
2320
+ {
2321
+ key: "Conversation",
2322
+ baseName: "Conversation",
2323
+ build: buildConversationSchema
2324
+ },
2325
+ {
2326
+ key: "Message",
2327
+ baseName: "Message",
2328
+ build: buildMessageSchema
2329
+ }
2330
+ ];
2331
+ const automationEntries = [
2332
+ {
2333
+ key: "Campaign",
2334
+ baseName: "Campaign",
2335
+ build: buildCampaignSchema
2336
+ },
2337
+ {
2338
+ key: "Sequence",
2339
+ baseName: "Sequence",
2340
+ build: buildSequenceSchema
2341
+ },
2342
+ {
2343
+ key: "Enrollment",
2344
+ baseName: "Enrollment",
2345
+ build: buildEnrollmentSchema
2346
+ }
2347
+ ];
2348
+ const entries = [
2349
+ ...config.modules.sales ? salesEntries : [],
2350
+ ...config.modules.engagement ? engagementEntries : [],
2351
+ ...config.modules.automation ? automationEntries : []
2352
+ ];
2353
+ const keyByRegistryName = /* @__PURE__ */ new Map();
2354
+ const specs = entries.map((entry) => {
2355
+ const name = crmModelName(config, entry.baseName);
2356
+ keyByRegistryName.set(name, entry.key);
2357
+ return {
2358
+ name,
2359
+ schema: () => {
2360
+ const schema = entry.build(config);
2361
+ schema.set("autoIndex", autoIndex);
2362
+ return schema;
2363
+ },
2364
+ existing
2365
+ };
2366
+ });
2367
+ return defineModels({
2368
+ models: specs,
2369
+ assemble: (models) => {
2370
+ const bag = {};
2371
+ for (const [name, key] of keyByRegistryName) {
2372
+ const model = models.get(name);
2373
+ if (model) bag[key] = model;
2374
+ }
2375
+ return Object.freeze(bag);
2376
+ }
2038
2377
  });
2039
2378
  }
2040
2379
  //#endregion
@@ -2090,9 +2429,11 @@ const CRM_EVENTS = {
2090
2429
  EMAIL_EVENT_UNSUBSCRIBED: "crm:email_event.unsubscribed",
2091
2430
  LEAD_ENRICHED: "crm:lead.enriched",
2092
2431
  LEAD_ASSIGNED: "crm:lead.assigned",
2432
+ LEAD_ANONYMIZED: "crm:lead.anonymized",
2093
2433
  CONTACT_ENRICHED: "crm:contact.enriched",
2094
2434
  CONTACT_ASSIGNED: "crm:contact.assigned",
2095
2435
  CONTACT_MERGED: "crm:contact.merged",
2436
+ CONTACT_ANONYMIZED: "crm:contact.anonymized",
2096
2437
  ACCOUNT_ENRICHED: "crm:account.enriched",
2097
2438
  ACCOUNT_ASSIGNED: "crm:account.assigned",
2098
2439
  ACCOUNT_MERGED: "crm:account.merged",
@@ -2328,15 +2669,22 @@ var LeadRepository = class extends Repository {
2328
2669
  * Create a Lead. Auto-scores on insert so hosts can route immediately.
2329
2670
  * Rejects duplicate emails inside the same tenant unless
2330
2671
  * `rejectDuplicates: false` was set at engine boot.
2672
+ *
2673
+ * Throws `LeadEmailFormatError` (400) on an email that cannot be canonicalised
2674
+ * — nothing is written. The reason lives beside the helper in
2675
+ * `core/email-identity.ts`, which also explains why the SCHEMA setter degrades
2676
+ * where this verb refuses.
2331
2677
  */
2332
2678
  async createLead(input, ctx) {
2333
- if (this.rejectDuplicates && input.email) {
2334
- if (await this.getOne({ email: input.email }, ctx)) throw new DuplicateLeadError({ email: input.email });
2679
+ const email = input.email === void 0 ? void 0 : requireLeadEmailKey(input.email);
2680
+ if (this.rejectDuplicates && email) {
2681
+ if (await this.getOne({ email }, ctx)) throw new DuplicateLeadError({ email });
2335
2682
  }
2336
2683
  const now = /* @__PURE__ */ new Date();
2337
- const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || input.email || input.phone);
2684
+ const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || email || input.phone);
2338
2685
  const draft = {
2339
2686
  ...input,
2687
+ ...email !== void 0 ? { email } : {},
2340
2688
  status: input.status ?? "new",
2341
2689
  score: 0,
2342
2690
  statusHistory: [{
@@ -2349,7 +2697,7 @@ var LeadRepository = class extends Repository {
2349
2697
  try {
2350
2698
  created = await this.create(draft, ctx);
2351
2699
  } catch (err) {
2352
- if (this.rejectDuplicates && input.email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email: input.email });
2700
+ if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email });
2353
2701
  throw err;
2354
2702
  }
2355
2703
  const score = this.scorer(created);
@@ -2464,6 +2812,67 @@ var LeadRepository = class extends Repository {
2464
2812
  }, ctx));
2465
2813
  return updated;
2466
2814
  }
2815
+ /**
2816
+ * The `converted` hop, owned by `LeadService.convert()` (the only caller
2817
+ * — the four cross-repo writes make it a service concern). Kept on the
2818
+ * repo so the atomic state write goes through `applyTransition` — a
2819
+ * machine-guarded CAS that `$push`es the history entry instead of
2820
+ * read-then-replacing the whole `statusHistory` array (no lost-update
2821
+ * window). Deliberately emits NO `lead.status_changed`: `lead.converted`
2822
+ * is the canonical signal for this transition and is dispatched by the
2823
+ * service alongside the cross-repo refs.
2824
+ */
2825
+ async markConverted(id, from, patch, entry, ctx) {
2826
+ return await this.applyTransition(id, LEAD_MACHINE, {
2827
+ from,
2828
+ to: "converted",
2829
+ set: patch,
2830
+ push: { statusHistory: entry },
2831
+ history: false
2832
+ }, ctx).catch(async (err) => {
2833
+ if (!isTransitionRace(err)) throw err;
2834
+ throw new InvalidLeadTransitionError({
2835
+ from: (await this.getById(id, ctx))?.status ?? from,
2836
+ to: "converted",
2837
+ leadId: id
2838
+ });
2839
+ });
2840
+ }
2841
+ /**
2842
+ * GDPR right-to-erasure for a Lead. Scrubs the PII fields
2843
+ * (`fullName`/`firstName`/`lastName`/`email`/`phone`) in place while
2844
+ * preserving non-PII aggregates (`score`, `status`, `statusHistory`,
2845
+ * `engagement`, firmographic `companyName`/`source`) and the id. Sets
2846
+ * `anonymizedAt` and emits `crm:lead.anonymized`.
2847
+ *
2848
+ * `fullName` is `required`, so it becomes the `'[redacted]'` sentinel;
2849
+ * the optional PII scalars are nulled (dropping `email` out of the
2850
+ * partial-unique email index). Idempotent.
2851
+ */
2852
+ async anonymize(id, ctx) {
2853
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Lead", id);
2854
+ const patch = {
2855
+ fullName: "[redacted]",
2856
+ firstName: null,
2857
+ lastName: null,
2858
+ email: null,
2859
+ phone: null,
2860
+ anonymizedAt: /* @__PURE__ */ new Date()
2861
+ };
2862
+ const updated = await this.update(id, patch, ctx);
2863
+ if (!updated) throw new NotFoundError("Lead", id);
2864
+ await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_ANONYMIZED, {
2865
+ leadId: id,
2866
+ fields: [
2867
+ "fullName",
2868
+ "firstName",
2869
+ "lastName",
2870
+ "email",
2871
+ "phone"
2872
+ ]
2873
+ }, ctx));
2874
+ return updated;
2875
+ }
2467
2876
  async _transition(id, target, extras, ctx) {
2468
2877
  const existing = await this.getById(id, ctx);
2469
2878
  if (!existing) throw new NotFoundError("Lead", id);
@@ -2680,8 +3089,33 @@ var OpportunityRepository = class extends Repository {
2680
3089
  opportunityId: id
2681
3090
  });
2682
3091
  if (amount === existing.amount) return existing;
2683
- const updated = await this.update(id, { amount }, ctx);
2684
- if (!updated) throw new NotFoundError("Opportunity", id);
3092
+ /**
3093
+ * `claim` with from === to — the status predicate rides the WRITE's filter.
3094
+ *
3095
+ * The `status === 'open'` check above is read-then-write: an opportunity closed
3096
+ * between the two still had its amount changed, so the figure that fed closed-won
3097
+ * reporting depended on scheduling. Not money movement, but the forecast is what
3098
+ * pipeline decisions are made against, and a closed deal whose amount silently
3099
+ * moves after the close is unauditable. The miss disambiguates below: a genuine
3100
+ * status change gets the SAME transition error the pre-check throws, so callers
3101
+ * see one contract whether they lost a race or arrived late.
3102
+ */
3103
+ const updated = await this.claim(id, {
3104
+ from: "open",
3105
+ to: "open"
3106
+ }, { $set: { amount } }, ctx);
3107
+ if (!updated) {
3108
+ const current = await this.getById(id, {
3109
+ ...ctx,
3110
+ throwOnNotFound: false
3111
+ });
3112
+ if (!current) throw new NotFoundError("Opportunity", id);
3113
+ throw new InvalidOpportunityTransitionError({
3114
+ from: current.status,
3115
+ to: current.status,
3116
+ opportunityId: id
3117
+ });
3118
+ }
2685
3119
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_AMOUNT_CHANGED, {
2686
3120
  opportunityId: id,
2687
3121
  amount,
@@ -2759,38 +3193,32 @@ var OpportunityRepository = class extends Repository {
2759
3193
  return this.count({ status: "open" }, ctx);
2760
3194
  }
2761
3195
  /**
2762
- * Sum `amount` on won opportunities updated within `since`.
2763
- * Goes through the tenant-scoped `findAll()` — the multiTenant plugin
2764
- * injects `organizationId`, so a missing ctx can't leak other tenants'
2765
- * revenue (a manual `if (ctx)` filter would).
3196
+ * Sum `amount` on won opportunities updated on/after `since`, grouped by
3197
+ * currency. Returns one row per currency (descending by total) — a deal
3198
+ * book is multi-currency, so collapsing to a single total would silently
3199
+ * drop every non-top currency's revenue.
3200
+ *
3201
+ * Uses `aggregatePipeline` (a DB-side `$group`) instead of loading every
3202
+ * won opp into memory: the multiTenant + soft-delete policy hooks prepend
3203
+ * their `$match` so a missing ctx can't leak other tenants' revenue, and
3204
+ * the row count is bounded by the number of distinct currencies, not the
3205
+ * number of deals.
2766
3206
  */
2767
3207
  async sumWonSince(since, ctx) {
2768
- const rows = await this.findAll({
2769
- status: "won",
2770
- updatedAt: { $gte: since }
2771
- }, {
2772
- ...ctx,
2773
- select: {
2774
- amount: 1,
2775
- currency: 1
2776
- }
2777
- });
2778
- if (rows.length === 0) return null;
2779
- const byCurrency = /* @__PURE__ */ new Map();
2780
- for (const r of rows) {
2781
- const cur = r.currency ?? "UNKNOWN";
2782
- byCurrency.set(cur, (byCurrency.get(cur) ?? 0) + (r.amount ?? 0));
2783
- }
2784
- let topCurrency = "";
2785
- let topTotal = -Infinity;
2786
- for (const [cur, total] of byCurrency) if (total > topTotal) {
2787
- topTotal = total;
2788
- topCurrency = cur;
2789
- }
2790
- return {
2791
- total: topTotal,
2792
- currency: topCurrency
2793
- };
3208
+ return (await this.aggregatePipeline([
3209
+ { $match: {
3210
+ status: "won",
3211
+ updatedAt: { $gte: since }
3212
+ } },
3213
+ { $group: {
3214
+ _id: { $ifNull: ["$currency", "UNKNOWN"] },
3215
+ total: { $sum: { $ifNull: ["$amount", 0] } }
3216
+ } },
3217
+ { $sort: { total: -1 } }
3218
+ ], ctx)).map((r) => ({
3219
+ currency: r._id ?? "UNKNOWN",
3220
+ total: r.total
3221
+ }));
2794
3222
  }
2795
3223
  /** Latest `limit` opportunities — lightweight projection for the activity feed. */
2796
3224
  async findRecentForFeed(limit, ctx) {
@@ -2811,16 +3239,45 @@ var OpportunityRepository = class extends Repository {
2811
3239
  //#region src/repositories/contact.repository.ts
2812
3240
  var ContactRepository = class extends Repository {
2813
3241
  events;
3242
+ rejectDuplicates;
2814
3243
  constructor(model, plugins, deps, pagination) {
2815
3244
  super(model, plugins, pagination);
2816
3245
  this.events = deps.events;
3246
+ this.rejectDuplicates = deps.rejectDuplicates ?? true;
2817
3247
  }
2818
3248
  /**
2819
3249
  * Create + emit `crm:contact.created`. Hosts call this instead of
2820
3250
  * `create()` when they want the event side-effect (e.g. webhook fan-out).
3251
+ *
3252
+ * Rejects duplicate emails inside the same tenant unless
3253
+ * `rejectDuplicates: false` was set — mirrors `LeadRepository.createLead`.
3254
+ * The partial-unique `(organizationId, contact.email)` index is the
3255
+ * authoritative guard; the pre-check only closes the fast path.
3256
+ *
3257
+ * Throws `ContactEmailFormatError` (400) on an email that cannot be
3258
+ * canonicalised — nothing is written. See `core/email-identity.ts` for the
3259
+ * reason, including what it means for `LeadService.convert()`.
2821
3260
  */
2822
3261
  async createContact(input, ctx) {
2823
- const created = await this.create(input, ctx);
3262
+ const raw = input.contact?.email;
3263
+ const email = raw === void 0 ? void 0 : requireContactEmailKey(raw);
3264
+ if (this.rejectDuplicates && email) {
3265
+ if (await this.getOne({ "contact.email": email }, ctx)) throw new DuplicateContactError({ email });
3266
+ }
3267
+ const draft = email === void 0 ? input : {
3268
+ ...input,
3269
+ contact: {
3270
+ ...input.contact ?? {},
3271
+ email
3272
+ }
3273
+ };
3274
+ let created;
3275
+ try {
3276
+ created = await this.create(draft, ctx);
3277
+ } catch (err) {
3278
+ if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateContactError({ email });
3279
+ throw err;
3280
+ }
2824
3281
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_CREATED, {
2825
3282
  contactId: String(created._id),
2826
3283
  email: created.contact?.email
@@ -2899,14 +3356,19 @@ var ContactRepository = class extends Repository {
2899
3356
  ...stripEmpty(winnerName)
2900
3357
  },
2901
3358
  contact: mergedContactInfo,
2902
- tags: Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
3359
+ tags: Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
2903
3360
  ...winner.jobTitle !== void 0 ? { jobTitle: winner.jobTitle } : loser.jobTitle !== void 0 ? { jobTitle: loser.jobTitle } : {},
2904
3361
  ...winner.accountId !== void 0 ? { accountId: winner.accountId } : loser.accountId !== void 0 ? { accountId: loser.accountId } : {},
2905
3362
  ...winner.ownerId !== void 0 ? { ownerId: winner.ownerId } : loser.ownerId !== void 0 ? { ownerId: loser.ownerId } : {}
2906
3363
  };
3364
+ if (input.repoint) await input.repoint({
3365
+ primaryId,
3366
+ secondaryId,
3367
+ ...ctx !== void 0 ? { ctx } : {}
3368
+ });
3369
+ await this.delete(secondaryId, ctx);
2907
3370
  const updated = await this.update(primaryId, patch, ctx);
2908
3371
  if (!updated) throw new NotFoundError("Contact", primaryId);
2909
- await this.delete(secondaryId, ctx);
2910
3372
  await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_MERGED, {
2911
3373
  primaryId,
2912
3374
  secondaryId,
@@ -2914,7 +3376,54 @@ var ContactRepository = class extends Repository {
2914
3376
  }, ctx));
2915
3377
  return updated;
2916
3378
  }
3379
+ /**
3380
+ * GDPR right-to-erasure. Scrubs every PII field on the contact in place —
3381
+ * the structured `name` and ALL `contact.*` channels (email,
3382
+ * personalEmail, phone, alternatePhone, whatsapp, telegram, linkedin,
3383
+ * instagram) — while preserving the id and non-PII aggregates
3384
+ * (`accountId`, `ownerId`, `tags`, timestamps) so historical rollups and
3385
+ * relationships survive. Sets `anonymizedAt` as a tombstone and emits
3386
+ * `crm:contact.anonymized`. Idempotent: a second call re-scrubs to the
3387
+ * same sentinel.
3388
+ *
3389
+ * `name.given` is `required`, so it becomes the `'[redacted]'` sentinel
3390
+ * rather than empty; every other name part and the whole `contact`
3391
+ * subdocument are cleared. Unsetting `contact.email` also drops the row
3392
+ * out of the partial-unique email index, freeing the address for reuse.
3393
+ */
3394
+ async anonymize(id, ctx) {
3395
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Contact", id);
3396
+ const patch = {
3397
+ name: {
3398
+ given: REDACTED,
3399
+ family: ""
3400
+ },
3401
+ contact: {},
3402
+ anonymizedAt: /* @__PURE__ */ new Date()
3403
+ };
3404
+ const updated = await this.update(id, patch, ctx);
3405
+ if (!updated) throw new NotFoundError("Contact", id);
3406
+ await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_ANONYMIZED, {
3407
+ contactId: id,
3408
+ fields: PII_CONTACT_FIELDS
3409
+ }, ctx));
3410
+ return updated;
3411
+ }
2917
3412
  };
3413
+ /** Sentinel written to `required` PII fields that can't be cleared to empty. */
3414
+ const REDACTED = "[redacted]";
3415
+ /** PII field set scrubbed by `anonymize()` — surfaced in the event payload. */
3416
+ const PII_CONTACT_FIELDS = [
3417
+ "name",
3418
+ "contact.email",
3419
+ "contact.personalEmail",
3420
+ "contact.phone",
3421
+ "contact.alternatePhone",
3422
+ "contact.whatsapp",
3423
+ "contact.telegram",
3424
+ "contact.linkedin",
3425
+ "contact.instagram"
3426
+ ];
2918
3427
  function stripEmpty(obj) {
2919
3428
  const out = {};
2920
3429
  for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null && v !== "") out[k] = v;
@@ -2985,7 +3494,7 @@ var AccountRepository = class extends Repository {
2985
3494
  if (!secondary) throw new NotFoundError("Account", secondaryId);
2986
3495
  const winner = strategy === "preferPrimary" ? primary : secondary;
2987
3496
  const loser = strategy === "preferPrimary" ? secondary : primary;
2988
- const mergedTags = Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
3497
+ const mergedTags = Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
2989
3498
  const patch = {
2990
3499
  name: winner.name || loser.name,
2991
3500
  tags: mergedTags,
@@ -3270,8 +3779,36 @@ var SubscriptionRepository = class extends Repository {
3270
3779
  };
3271
3780
  //#endregion
3272
3781
  //#region src/repositories/suppression.repository.ts
3273
- function normalizeEmail(email) {
3274
- return email.trim().toLowerCase();
3782
+ /**
3783
+ * The suppression key for `email`, or `null` when it cannot be canonicalised.
3784
+ *
3785
+ * Exported because it is the ONLY way a caller can distinguish "this address is on
3786
+ * the list" from "this address is not an address" — the read verbs collapse both
3787
+ * to `true` on purpose (see the fail-closed rule in the module docblock), and a
3788
+ * caller that needs the distinction should not re-derive the rule to get it.
3789
+ */
3790
+ function suppressionEmailKey(email) {
3791
+ try {
3792
+ return normalizeEmail(email);
3793
+ } catch (err) {
3794
+ if (err instanceof EmailFormatError) return null;
3795
+ throw err;
3796
+ }
3797
+ }
3798
+ /**
3799
+ * The suppression key for `email`, or a 400. Used by the WRITE verbs only —
3800
+ * storing an address that can never match is a suppression that suppresses nothing.
3801
+ */
3802
+ function requireSuppressionEmailKey(email) {
3803
+ try {
3804
+ return normalizeEmail(email);
3805
+ } catch (err) {
3806
+ if (err instanceof EmailFormatError) throw new SuppressionEmailFormatError({
3807
+ email,
3808
+ reason: err.code
3809
+ });
3810
+ throw err;
3811
+ }
3275
3812
  }
3276
3813
  var SuppressionRepository = class extends Repository {
3277
3814
  events;
@@ -3284,9 +3821,12 @@ var SuppressionRepository = class extends Repository {
3284
3821
  * `getOrCreate` on the unique (org, email) index — of N concurrent
3285
3822
  * calls exactly one inserts. Emits `crm:suppression.added` only for
3286
3823
  * the inserting call; an existing row is returned unchanged, no event.
3824
+ *
3825
+ * Throws `SuppressionEmailFormatError` (400) on an address that cannot be
3826
+ * canonicalised — nothing is written.
3287
3827
  */
3288
3828
  async suppress(email, input, ctx) {
3289
- const normalized = normalizeEmail(email);
3829
+ const normalized = requireSuppressionEmailKey(email);
3290
3830
  const draft = {
3291
3831
  email: normalized,
3292
3832
  reason: input.reason,
@@ -3304,37 +3844,68 @@ var SuppressionRepository = class extends Repository {
3304
3844
  }, ctx));
3305
3845
  return doc;
3306
3846
  }
3307
- /** True when `email` is on the org's suppression list. */
3847
+ /**
3848
+ * True when `email` is on the org's suppression list — **or when it cannot be
3849
+ * canonicalised at all**, which is the fail-closed half of the module rule
3850
+ * above. Never throws on a malformed address: the safe answer is "do not send",
3851
+ * and this verb's job is to produce that answer, not to police the input.
3852
+ */
3308
3853
  async isSuppressed(email, ctx) {
3309
- return await this.exists({ email: normalizeEmail(email) }, ctx) !== null;
3854
+ const key = suppressionEmailKey(email);
3855
+ if (key === null) return true;
3856
+ return await this.exists({ email: key }, ctx) !== null;
3310
3857
  }
3311
3858
  /**
3312
- * Return the suppressed subset of `emails` (normalized) as a Set —
3313
- * ONE `$in` query, built for the campaign-fanout hot path:
3859
+ * Return the suppressed subset of `emails` as a Set — ONE `$in` query, built for
3860
+ * the campaign-fanout hot path:
3314
3861
  *
3315
3862
  * ```ts
3316
3863
  * const blocked = await suppression.filterSuppressed(recipients, ctx);
3317
- * const sendable = recipients.filter((e) => !blocked.has(e.toLowerCase()));
3864
+ * const sendable = recipients.filter((e) => !blocked.has(suppressionEmailKey(e) ?? e));
3318
3865
  * ```
3866
+ *
3867
+ * The Set is keyed CANONICALLY, so a caller must canonicalise its own recipient
3868
+ * before testing membership — matching on the raw spelling is how the punycode
3869
+ * miss came back. `suppressionEmailKey` is exported for exactly that, and returns
3870
+ * `null` for an address with no canonical form.
3871
+ *
3872
+ * An address that cannot be canonicalised is reported as BLOCKED, keyed by the
3873
+ * caller's original spelling (there is no canonical form to key it by, and the
3874
+ * caller has to be able to find it). It is not thrown: one malformed recipient
3875
+ * must not abort a fanout to thousands of valid ones.
3319
3876
  */
3320
3877
  async filterSuppressed(emails, ctx) {
3321
3878
  if (emails.length === 0) return /* @__PURE__ */ new Set();
3322
- const normalized = [...new Set(emails.map(normalizeEmail))];
3323
- const rows = await this.findAll({ email: { $in: normalized } }, {
3324
- ...ctx ?? {},
3325
- select: "email",
3326
- lean: true
3327
- });
3328
- return new Set(rows.map((row) => row.email));
3879
+ const blocked = /* @__PURE__ */ new Set();
3880
+ const keys = /* @__PURE__ */ new Set();
3881
+ for (const email of emails) {
3882
+ const key = suppressionEmailKey(email);
3883
+ if (key === null) blocked.add(email);
3884
+ else keys.add(key);
3885
+ }
3886
+ if (keys.size > 0) {
3887
+ const rows = await this.findAll({ email: { $in: [...keys] } }, {
3888
+ ...ctx ?? {},
3889
+ select: "email",
3890
+ lean: true
3891
+ });
3892
+ for (const row of rows) blocked.add(row.email);
3893
+ }
3894
+ return blocked;
3329
3895
  }
3330
3896
  /**
3331
3897
  * Remove `email` from the org's suppression list. Single atomic
3332
3898
  * `deleteMany` on the (unique) email key — emits
3333
3899
  * `crm:suppression.removed` only when a row was actually removed.
3334
3900
  * Returns whether a row was removed.
3901
+ *
3902
+ * Throws `SuppressionEmailFormatError` (400) on an address that cannot be
3903
+ * canonicalised, rather than answering `false`. This is the verb that re-enables
3904
+ * sending, and `false` would read as "already removed" when the truth is "that
3905
+ * was never an address" — an operator would close the ticket either way.
3335
3906
  */
3336
3907
  async unsuppress(email, ctx) {
3337
- const normalized = normalizeEmail(email);
3908
+ const normalized = requireSuppressionEmailKey(email);
3338
3909
  const removed = (await this.deleteMany({ email: normalized }, ctx ?? {})).deletedCount > 0;
3339
3910
  if (removed) await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUPPRESSION_REMOVED, { email: normalized }, ctx));
3340
3911
  return removed;
@@ -3951,57 +4522,56 @@ function createCrmRepositories(models, deps) {
3951
4522
  const { events, config } = deps;
3952
4523
  const pagination = config.pagination;
3953
4524
  const plugins = [methodRegistryPlugin()];
3954
- if (config.multiTenant) plugins.push(multiTenantPlugin({
3955
- tenantField: "organizationId",
3956
- contextKey: "organizationId",
3957
- required: true,
3958
- fieldType: config.tenantFieldType
4525
+ if (config.tenant.enabled) plugins.push(multiTenantPlugin({
4526
+ tenantField: config.tenant.tenantField,
4527
+ contextKey: config.tenant.contextKey,
4528
+ required: config.tenant.required,
4529
+ fieldType: config.tenant.fieldType
3959
4530
  }));
3960
4531
  plugins.push(aggregateHelpersPlugin());
3961
4532
  if (config.plugins.length > 0) plugins.push(...config.plugins);
3962
- const pipeline = new PipelineRepository(models.Pipeline, plugins, { events }, pagination);
3963
- const lead = new LeadRepository(models.Lead, plugins, { events }, pagination);
3964
- const opportunity = new OpportunityRepository(models.Opportunity, plugins, {
3965
- events,
3966
- pipelines: pipeline
3967
- }, pagination);
3968
- const contact = new ContactRepository(models.Contact, plugins, { events }, pagination);
3969
- const account = new AccountRepository(models.Account, plugins, { events }, pagination);
3970
- const campaign = new CampaignRepository(models.Campaign, plugins, { events }, pagination);
3971
- const subscription = new SubscriptionRepository(models.Subscription, plugins, { events }, pagination);
3972
- const suppression = new SuppressionRepository(models.Suppression, plugins, { events }, pagination);
3973
- const activity = new ActivityRepository(models.Activity, plugins, { events }, pagination);
3974
- const note = new NoteRepository(models.Note, plugins, { events }, pagination);
3975
- const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
3976
- const emailEvent = new EmailEventRepository(models.EmailEvent, plugins, { events }, pagination);
3977
- const conversation = new ConversationRepository(models.Conversation, plugins, { events }, pagination);
3978
- const message = new MessageRepository(models.Message, plugins, {
3979
- events,
3980
- conversations: conversation
3981
- }, pagination);
3982
- const sequence = new SequenceRepository(models.Sequence, plugins, { events }, pagination);
3983
- const enrollment = new EnrollmentRepository(models.Enrollment, plugins, {
3984
- events,
3985
- sequences: sequence
3986
- }, pagination);
3987
- return Object.freeze({
3988
- pipeline,
3989
- lead,
3990
- opportunity,
3991
- contact,
3992
- account,
3993
- campaign,
3994
- subscription,
3995
- suppression,
3996
- activity,
3997
- note,
3998
- lossReason,
3999
- emailEvent,
4000
- conversation,
4001
- message,
4002
- sequence,
4003
- enrollment
4004
- });
4533
+ const repositories = {};
4534
+ if (config.modules.sales) {
4535
+ const { Pipeline, Lead, Opportunity, Contact, Account, Activity, Note, LossReason } = models;
4536
+ if (!Pipeline || !Lead || !Opportunity || !Contact || !Account || !Activity || !Note || !LossReason) throw new Error("[crm] sales module model graph is incomplete");
4537
+ const pipeline = new PipelineRepository(Pipeline, plugins, { events }, pagination);
4538
+ repositories.pipeline = pipeline;
4539
+ repositories.lead = new LeadRepository(Lead, plugins, { events }, pagination);
4540
+ repositories.opportunity = new OpportunityRepository(Opportunity, plugins, {
4541
+ events,
4542
+ pipelines: pipeline
4543
+ }, pagination);
4544
+ repositories.contact = new ContactRepository(Contact, plugins, { events }, pagination);
4545
+ repositories.account = new AccountRepository(Account, plugins, { events }, pagination);
4546
+ repositories.activity = new ActivityRepository(Activity, plugins, { events }, pagination);
4547
+ repositories.note = new NoteRepository(Note, plugins, { events }, pagination);
4548
+ repositories.lossReason = new LossReasonRepository(LossReason, plugins, { events }, pagination);
4549
+ }
4550
+ if (config.modules.engagement) {
4551
+ const { Subscription, Suppression, EmailEvent, Conversation, Message } = models;
4552
+ if (!Subscription || !Suppression || !EmailEvent || !Conversation || !Message) throw new Error("[crm] engagement module model graph is incomplete");
4553
+ const conversation = new ConversationRepository(Conversation, plugins, { events }, pagination);
4554
+ repositories.subscription = new SubscriptionRepository(Subscription, plugins, { events }, pagination);
4555
+ repositories.suppression = new SuppressionRepository(Suppression, plugins, { events }, pagination);
4556
+ repositories.emailEvent = new EmailEventRepository(EmailEvent, plugins, { events }, pagination);
4557
+ repositories.conversation = conversation;
4558
+ repositories.message = new MessageRepository(Message, plugins, {
4559
+ events,
4560
+ conversations: conversation
4561
+ }, pagination);
4562
+ }
4563
+ if (config.modules.automation) {
4564
+ const { Campaign, Sequence, Enrollment } = models;
4565
+ if (!Campaign || !Sequence || !Enrollment) throw new Error("[crm] automation module model graph is incomplete");
4566
+ const sequence = new SequenceRepository(Sequence, plugins, { events }, pagination);
4567
+ repositories.campaign = new CampaignRepository(Campaign, plugins, { events }, pagination);
4568
+ repositories.sequence = sequence;
4569
+ repositories.enrollment = new EnrollmentRepository(Enrollment, plugins, {
4570
+ events,
4571
+ sequences: sequence
4572
+ }, pagination);
4573
+ }
4574
+ return Object.freeze(repositories);
4005
4575
  }
4006
4576
  //#endregion
4007
4577
  //#region src/events/dispatch.ts
@@ -4033,7 +4603,9 @@ var CrmEventDispatcher = class {
4033
4603
  return this.transport.publish(event);
4034
4604
  }
4035
4605
  subscribe(pattern, handler) {
4036
- return this.transport.subscribe(pattern, handler);
4606
+ const subscribe = this.transport.subscribe?.bind(this.transport);
4607
+ if (!subscribe) throw new Error("crm: configured event transport does not support subscribe");
4608
+ return subscribe(pattern, handler);
4037
4609
  }
4038
4610
  async close() {
4039
4611
  await this.transport.close?.();
@@ -4079,37 +4651,24 @@ async function flushPending(transport, events, logger) {
4079
4651
  //#region src/events/in-process-bus.ts
4080
4652
  /**
4081
4653
  * In-process event bus — default fallback when no `eventTransport` is
4082
- * provided to `createCrm`. Structurally identical to arc's
4083
- * `MemoryEventTransport`; pattern matching is delegated to primitives.
4084
- * Handler errors are swallowed — event emission never blocks writes.
4654
+ * provided to `blueprint.bind(connection, runtime)`.
4655
+ *
4656
+ * A thin subclass of `@classytic/primitives/event-infra`'s shared
4657
+ * `InProcessEventBus`. Prior semantics preserved: glob matching via
4658
+ * `matchEventPattern`, per-handler error isolation, idempotent `close()`.
4659
+ * CRM historically SWALLOWED handler errors when no logger was supplied —
4660
+ * that default is preserved by passing `logger: null` unless the host
4661
+ * injects a `CrmLogger`, whose structured `error(msg, meta)` slot is
4662
+ * bridged onto the shared error-logger contract.
4085
4663
  */
4086
- var InProcessCrmBus = class {
4087
- name = "in-process-crm";
4088
- subs = [];
4089
- logger;
4664
+ var InProcessCrmBus = class extends InProcessEventBus {
4090
4665
  constructor(options) {
4091
- if (options?.logger) this.logger = options.logger;
4092
- }
4093
- async publish(event) {
4094
- const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
4095
- for (const sub of matching) try {
4096
- await sub.handler(event);
4097
- } catch (err) {
4098
- this.logger?.error?.(`[crm] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
4099
- }
4100
- }
4101
- async subscribe(pattern, handler) {
4102
- const sub = {
4103
- pattern,
4104
- handler
4105
- };
4106
- this.subs.push(sub);
4107
- return () => {
4108
- this.subs = this.subs.filter((s) => s !== sub);
4109
- };
4110
- }
4111
- async close() {
4112
- this.subs = [];
4666
+ const crmLogger = options?.logger;
4667
+ super({
4668
+ name: "in-process-crm",
4669
+ logLabel: "crm",
4670
+ logger: crmLogger?.error ? { error: (message, ...args) => crmLogger.error?.(message, { args }) } : null
4671
+ });
4113
4672
  }
4114
4673
  };
4115
4674
  //#endregion
@@ -4167,53 +4726,194 @@ async function runUnit(config, ctx, body) {
4167
4726
  return result;
4168
4727
  }
4169
4728
  //#endregion
4170
- //#region src/engine/create-crm.ts
4171
- async function createCrm(config) {
4172
- if (!config.connection) throw new Error("[crm] CrmConfig.connection is required");
4173
- const resolved = {
4174
- connection: config.connection,
4175
- tenantFieldType: config.tenantFieldType ?? "objectId",
4176
- multiTenant: config.multiTenant ?? true,
4177
- forceRecreate: config.forceRecreate ?? false,
4178
- autoIndex: config.autoIndex ?? true,
4179
- ...config.logger !== void 0 ? { logger: config.logger } : {},
4180
- ...config.pagination !== void 0 ? { pagination: config.pagination } : {},
4181
- plugins: config.plugins ?? []
4182
- };
4183
- const events = new CrmEventDispatcher({
4184
- transport: config.eventTransport ?? new InProcessCrmBus(config.logger ? { logger: config.logger } : void 0),
4185
- outbox: config.outbox,
4186
- logger: config.logger
4187
- });
4188
- const models = createCrmModels(config.connection, resolved);
4189
- const repositories = createCrmRepositories(models, {
4190
- events,
4191
- config: resolved
4192
- });
4193
- assertCrmCapabilities(repositories.lead, {
4194
- ...config.allowNonTransactional !== void 0 ? { allowNonTransactional: config.allowNonTransactional } : {},
4195
- ...config.logger?.warn !== void 0 ? { logger: { warn: (m) => config.logger?.warn?.(m) } } : {}
4729
+ //#region src/engine/define-crm.ts
4730
+ function defineCrm(shape = {}) {
4731
+ /**
4732
+ * The pre-consolidation tenant keys are REFUSED, never absorbed.
4733
+ *
4734
+ * This entry reads `shape.tenant`, so a leftover `multiTenant` is invisible and
4735
+ * the `?? true` default applies repo-core's defaults instead. Both outcomes
4736
+ * return plausible data:
4737
+ * - `multiTenant: false` -> tenancy ON, and the host loses its company-wide reads;
4738
+ * - `multiTenant: { tenantField: 'business' }` -> scoping on `organizationId`,
4739
+ * a field those documents do not carry.
4740
+ *
4741
+ * tsc cannot cover this: excess-property checking does not fire for a typed
4742
+ * variable, a spread, or a shape handed over from a dependency's dist — which is
4743
+ * how every real host composes one. Hence a describe-time check, shared from
4744
+ * repo-core so the nine kernels making this same rename cannot each forget it.
4745
+ */
4746
+ assertNoLegacyTenantKeys(shape, "defineCrm", [["tenantFieldType", "tenant.fieldType"]]);
4747
+ const tenant = resolveTenantConfig(shape.tenant ?? true);
4748
+ if (tenant.strategy === "custom") throw new Error("[crm] tenant.strategy 'custom' is unsupported: CRM repositories require one concrete tenant field");
4749
+ const forceRecreate = shape.forceRecreate ?? false;
4750
+ const autoIndex = shape.autoIndex ?? true;
4751
+ const modules = resolveCrmModules(shape.modules);
4752
+ const modelBlueprint = defineCrmModels({
4753
+ tenant,
4754
+ ...shape.modelPrefix !== void 0 ? { modelPrefix: shape.modelPrefix } : {},
4755
+ modules,
4756
+ forceRecreate,
4757
+ autoIndex
4196
4758
  });
4197
- return Object.freeze({
4198
- models,
4199
- repositories,
4200
- events,
4201
- config: Object.freeze(resolved),
4202
- withTransaction(ctx, body) {
4203
- return runUnit({
4204
- connection: config.connection,
4759
+ return {
4760
+ id: "crm",
4761
+ modelNames: modelBlueprint.modelNames,
4762
+ bind(connection, runtime = {}) {
4763
+ /**
4764
+ * VERIFY (boot gate) — an injected outbox MUST enlist `ctx.session`.
4765
+ *
4766
+ * Checked FIRST, before the model blueprint binds, for two reasons: a cheaper error
4767
+ * should report before a costlier one (a model collision would otherwise surface first
4768
+ * and hide this), and a bind that throws here must not have mutated the connection's
4769
+ * model registry.
4770
+ *
4771
+ * WHY CRM. `OutboxWriteOptions.session` is best-effort BY CONTRACT, so a store that
4772
+ * ignores it persists events for mutations that later roll back. CRM is the only kernel
4773
+ * in the batch reviewed alongside it with a LIVE, WIRED consumer whose side effect is not
4774
+ * re-derivable: be-prod's `crmCustomerProjectionHandlers` sits on the crm module's arc
4775
+ * `eventHandlers` arm and, on `crm:lead.converted` / `crm:opportunity.won`, WRITES
4776
+ * `crm.stage` and `crm.convertedFromLeadId` onto the Customer document in another
4777
+ * collection. Nothing recomputes those fields and no reconciler exists, so a ghost event
4778
+ * marks a customer converted/won permanently and silently — the exact bug class AGENTS.md
4779
+ * FAIL LOUD is about (a plausible-looking result instead of an error).
4780
+ *
4781
+ * The rollback window is package-owned, not hypothetical: `LeadService.convert()` writes
4782
+ * Account + Contact + Opportunity + the lead transition inside `runUnit`'s transaction and
4783
+ * dispatches `crm:lead.converted` from within it, and spine-crm's convert route calls it
4784
+ * through `engine.withTransaction`.
4785
+ *
4786
+ * `!== true` and not `=== false`: a hand-rolled store has the flag ABSENT, not `false`,
4787
+ * and absence of an answer is not a positive answer (FAIL LOUD rule 3).
4788
+ *
4789
+ * `allowNonTransactional` does NOT waive this. That flag downgrades the BACKEND's
4790
+ * `transactions` capability for hosts that never convert under a session (see
4791
+ * `assertCrmCapabilities`). This gate is about the injected STORE, which is wrong on a
4792
+ * replica set too — letting a dev-convenience flag disable it would be a general default
4793
+ * overriding a specific correctness check (FAIL LOUD rule 1).
4794
+ */
4795
+ 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.");
4796
+ const resolved = {
4797
+ connection,
4798
+ tenant,
4799
+ ...shape.modelPrefix !== void 0 ? { modelPrefix: shape.modelPrefix } : {},
4800
+ forceRecreate,
4801
+ autoIndex,
4802
+ modules,
4803
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {},
4804
+ ...runtime.pagination !== void 0 ? { pagination: runtime.pagination } : {},
4805
+ plugins: runtime.plugins ?? []
4806
+ };
4807
+ const ownsTransport = runtime.eventTransport === void 0;
4808
+ const events = new CrmEventDispatcher({
4809
+ transport: runtime.eventTransport ?? new InProcessCrmBus(runtime.logger ? { logger: runtime.logger } : void 0),
4810
+ ...runtime.outbox !== void 0 ? { outbox: runtime.outbox } : {},
4811
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {}
4812
+ });
4813
+ const models = modelBlueprint.bind(connection);
4814
+ const repositories = createCrmRepositories(models, {
4205
4815
  events,
4206
- logger: config.logger,
4207
- allowNonTransactional: config.allowNonTransactional ?? false
4208
- }, ctx, body);
4209
- },
4210
- async syncIndexes() {
4211
- await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
4212
- },
4213
- async dispose() {
4214
- await events.close();
4816
+ config: resolved
4817
+ });
4818
+ const allowNonTransactional = runtime.allowNonTransactional ?? false;
4819
+ if (repositories.lead) assertCrmCapabilities(repositories.lead, {
4820
+ allowNonTransactional,
4821
+ ...runtime.logger?.warn !== void 0 ? { logger: { warn: (m) => runtime.logger?.warn?.(m) } } : {}
4822
+ });
4823
+ const moduleViews = {};
4824
+ if (modules.sales) {
4825
+ const { Pipeline, Lead, Opportunity, Contact, Account, Activity, Note, LossReason } = models;
4826
+ const { pipeline, lead, opportunity, contact, account, activity, note, lossReason } = repositories;
4827
+ 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");
4828
+ moduleViews.sales = Object.freeze({
4829
+ models: Object.freeze({
4830
+ Pipeline,
4831
+ Lead,
4832
+ Opportunity,
4833
+ Contact,
4834
+ Account,
4835
+ Activity,
4836
+ Note,
4837
+ LossReason
4838
+ }),
4839
+ repositories: Object.freeze({
4840
+ pipeline,
4841
+ lead,
4842
+ opportunity,
4843
+ contact,
4844
+ account,
4845
+ activity,
4846
+ note,
4847
+ lossReason
4848
+ })
4849
+ });
4850
+ }
4851
+ if (modules.engagement) {
4852
+ const { Subscription, Suppression, EmailEvent, Conversation, Message } = models;
4853
+ const { subscription, suppression, emailEvent, conversation, message } = repositories;
4854
+ if (!Subscription || !Suppression || !EmailEvent || !Conversation || !Message || !subscription || !suppression || !emailEvent || !conversation || !message) throw new Error("[crm] engagement module failed to initialize");
4855
+ moduleViews.engagement = Object.freeze({
4856
+ models: Object.freeze({
4857
+ Subscription,
4858
+ Suppression,
4859
+ EmailEvent,
4860
+ Conversation,
4861
+ Message
4862
+ }),
4863
+ repositories: Object.freeze({
4864
+ subscription,
4865
+ suppression,
4866
+ emailEvent,
4867
+ conversation,
4868
+ message
4869
+ })
4870
+ });
4871
+ }
4872
+ if (modules.automation) {
4873
+ const { Campaign, Sequence, Enrollment } = models;
4874
+ const { campaign, sequence, enrollment } = repositories;
4875
+ if (!Campaign || !Sequence || !Enrollment || !campaign || !sequence || !enrollment) throw new Error("[crm] automation module failed to initialize");
4876
+ moduleViews.automation = Object.freeze({
4877
+ models: Object.freeze({
4878
+ Campaign,
4879
+ Sequence,
4880
+ Enrollment
4881
+ }),
4882
+ repositories: Object.freeze({
4883
+ campaign,
4884
+ sequence,
4885
+ enrollment
4886
+ })
4887
+ });
4888
+ }
4889
+ Object.freeze(moduleViews);
4890
+ let closed = false;
4891
+ const close = async () => {
4892
+ if (closed) return;
4893
+ closed = true;
4894
+ if (ownsTransport) await events.close();
4895
+ };
4896
+ return Object.freeze({
4897
+ models,
4898
+ repositories,
4899
+ events,
4900
+ config: Object.freeze(resolved),
4901
+ modules: moduleViews,
4902
+ withTransaction(ctx, body) {
4903
+ return runUnit({
4904
+ connection,
4905
+ events,
4906
+ ...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
4907
+ allowNonTransactional
4908
+ }, ctx, body);
4909
+ },
4910
+ async syncIndexes() {
4911
+ await Promise.all(Object.values(models).map((model) => model.syncIndexes()));
4912
+ },
4913
+ close
4914
+ });
4215
4915
  }
4216
- });
4916
+ };
4217
4917
  }
4218
4918
  //#endregion
4219
4919
  //#region src/services/lead.service.ts
@@ -4259,7 +4959,9 @@ var LeadService = class LeadService {
4259
4959
  const lead = await this.leads.getById(leadId, ctx);
4260
4960
  if (!lead) throw new NotFoundError("Lead", leadId);
4261
4961
  if (lead.status === "converted") throw new LeadAlreadyConvertedError(leadId);
4262
- if (!LEAD_MACHINE.canTransition(lead.status, "converted")) throw new InvalidLeadTransitionError({
4962
+ const canQualifyHop = LEAD_MACHINE.canTransition(lead.status, "qualified");
4963
+ const canConvertDirect = LEAD_MACHINE.canTransition(lead.status, "converted");
4964
+ if (!canQualifyHop && !canConvertDirect) throw new InvalidLeadTransitionError({
4263
4965
  from: lead.status,
4264
4966
  to: "converted",
4265
4967
  leadId
@@ -4352,17 +5054,7 @@ var LeadService = class LeadService {
4352
5054
  occurredAt: /* @__PURE__ */ new Date(),
4353
5055
  ...crossRefs.by !== void 0 ? { by: crossRefs.by } : {}
4354
5056
  };
4355
- const current = await this.leads.getById(leadId, ctx);
4356
- const updated = await this.leads.claim(leadId, {
4357
- field: "status",
4358
- from: fromStatus,
4359
- to: "converted"
4360
- }, {
4361
- ...patch,
4362
- statusHistory: [...current?.statusHistory ?? [], entry]
4363
- }, ctx);
4364
- if (!updated) throw new NotFoundError("Lead", leadId);
4365
- return updated;
5057
+ return this.leads.markConverted(leadId, fromStatus, patch, entry, ctx);
4366
5058
  }
4367
5059
  };
4368
5060
  /**
@@ -4507,6 +5199,15 @@ const LeadAssigned = defineCrmEvent({
4507
5199
  description: "assign() (re)assigned the lead owner.",
4508
5200
  zodSchema: z.object({ leadId: z.string() }).extend(ownerChangeSchema.shape)
4509
5201
  });
5202
+ const LeadAnonymized = defineCrmEvent({
5203
+ name: CRM_EVENTS.LEAD_ANONYMIZED,
5204
+ resource: "lead",
5205
+ 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.",
5206
+ zodSchema: z.object({
5207
+ leadId: z.string(),
5208
+ fields: z.array(z.string())
5209
+ })
5210
+ });
4510
5211
  const AccountCreated = defineCrmEvent({
4511
5212
  name: CRM_EVENTS.ACCOUNT_CREATED,
4512
5213
  resource: "account",
@@ -4573,6 +5274,15 @@ const ContactMerged = defineCrmEvent({
4573
5274
  description: "merge() folded a secondary contact into the primary; secondary deleted.",
4574
5275
  zodSchema: mergeSchema
4575
5276
  });
5277
+ const ContactAnonymized = defineCrmEvent({
5278
+ name: CRM_EVENTS.CONTACT_ANONYMIZED,
5279
+ resource: "contact",
5280
+ 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.",
5281
+ zodSchema: z.object({
5282
+ contactId: z.string(),
5283
+ fields: z.array(z.string())
5284
+ })
5285
+ });
4576
5286
  const OpportunityCreated = defineCrmEvent({
4577
5287
  name: CRM_EVENTS.OPPORTUNITY_CREATED,
4578
5288
  resource: "opportunity",
@@ -5028,6 +5738,7 @@ const crmEventDefinitions = [
5028
5738
  LeadDisqualified,
5029
5739
  LeadEnriched,
5030
5740
  LeadAssigned,
5741
+ LeadAnonymized,
5031
5742
  AccountCreated,
5032
5743
  AccountUpdated,
5033
5744
  AccountEnriched,
@@ -5038,6 +5749,7 @@ const crmEventDefinitions = [
5038
5749
  ContactEnriched,
5039
5750
  ContactAssigned,
5040
5751
  ContactMerged,
5752
+ ContactAnonymized,
5041
5753
  OpportunityCreated,
5042
5754
  OpportunityStageAdvanced,
5043
5755
  OpportunityStageRegressed,
@@ -5154,8 +5866,10 @@ function pipelineConversion(opportunities, stageIds) {
5154
5866
  for (const o of opportunities) {
5155
5867
  if (o.stageId === stageId && o.status === "open") currentCount++;
5156
5868
  if (o.status === "won" || o.status === "lost") {
5157
- if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) if (o.status === "won") wonFromHere++;
5158
- else lostFromHere++;
5869
+ if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) {
5870
+ if (o.status === "won") wonFromHere++;
5871
+ else lostFromHere++;
5872
+ }
5159
5873
  }
5160
5874
  }
5161
5875
  const closed = wonFromHere + lostFromHere;
@@ -5582,4 +6296,4 @@ var validators_exports = /* @__PURE__ */ __exportAll({
5582
6296
  updateSequenceSchema: () => updateSequenceSchema
5583
6297
  });
5584
6298
  //#endregion
5585
- export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, CrmEventDispatcher, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, EnrollmentCompleted, EnrollmentCreated, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentResumed, EnrollmentStepSent, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, SuppressionAdded, SuppressionRemoved, SuppressionRepository, UnmanagedSessionError, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, flushPending, forecast, lastTransitionTo, latestEntry, pipelineConversion, runUnit, tenantFieldDef, timeInStatus, validators_exports as validators };
6299
+ 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 };