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