@classytic/crm 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- 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 +1120 -441
- package/package.json +15 -9
- 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
|
|
5
|
-
import { IllegalTransitionError,
|
|
4
|
+
import { EmailFormatError, normalizeEmail } from "@classytic/contact/email";
|
|
5
|
+
import { createEvent as createEvent$1 } from "@classytic/primitives/events";
|
|
6
|
+
import { IllegalTransitionError, defineStateMachine } from "@classytic/primitives/state-machine";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { InProcessEventBus } from "@classytic/primitives/event-infra";
|
|
7
9
|
import { z } from "zod";
|
|
8
10
|
import { civilDateOf } from "@classytic/primitives/timezone";
|
|
9
11
|
import { CURRENCY_PATTERN } from "@classytic/primitives/currency";
|
|
10
12
|
import { appendStatus, lastTransitionTo, latestEntry, timeInStatus } from "@classytic/primitives/status-history";
|
|
11
|
-
//#region src/
|
|
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",
|
|
@@ -2293,6 +2634,19 @@ function composeScorers(...rules) {
|
|
|
2293
2634
|
return (lead) => rules.reduce((acc, r) => acc + r(lead), 0);
|
|
2294
2635
|
}
|
|
2295
2636
|
//#endregion
|
|
2637
|
+
//#region src/repositories/transition-support.ts
|
|
2638
|
+
/**
|
|
2639
|
+
* mongokit `applyTransition()` race-outcome detection — pure races /
|
|
2640
|
+
* vanished rows remap to each verb's historical error (typed transition
|
|
2641
|
+
* error built from a fresh read, or NotFound where that was the
|
|
2642
|
+
* contract); now-illegal races throw the machine's errorFactory error
|
|
2643
|
+
* with the on-disk status natively.
|
|
2644
|
+
*/
|
|
2645
|
+
function isTransitionRace(err) {
|
|
2646
|
+
const code = err?.code;
|
|
2647
|
+
return code === "TRANSITION_RACE_LOST" || code === "TRANSITION_TARGET_MISSING";
|
|
2648
|
+
}
|
|
2649
|
+
//#endregion
|
|
2296
2650
|
//#region src/repositories/lead.repository.ts
|
|
2297
2651
|
/** signal → engagement counter field. */
|
|
2298
2652
|
const ENGAGEMENT_COUNTER = {
|
|
@@ -2315,15 +2669,22 @@ var LeadRepository = class extends Repository {
|
|
|
2315
2669
|
* Create a Lead. Auto-scores on insert so hosts can route immediately.
|
|
2316
2670
|
* Rejects duplicate emails inside the same tenant unless
|
|
2317
2671
|
* `rejectDuplicates: false` was set at engine boot.
|
|
2672
|
+
*
|
|
2673
|
+
* Throws `LeadEmailFormatError` (400) on an email that cannot be canonicalised
|
|
2674
|
+
* — nothing is written. The reason lives beside the helper in
|
|
2675
|
+
* `core/email-identity.ts`, which also explains why the SCHEMA setter degrades
|
|
2676
|
+
* where this verb refuses.
|
|
2318
2677
|
*/
|
|
2319
2678
|
async createLead(input, ctx) {
|
|
2320
|
-
|
|
2321
|
-
|
|
2679
|
+
const email = input.email === void 0 ? void 0 : requireLeadEmailKey(input.email);
|
|
2680
|
+
if (this.rejectDuplicates && email) {
|
|
2681
|
+
if (await this.getOne({ email }, ctx)) throw new DuplicateLeadError({ email });
|
|
2322
2682
|
}
|
|
2323
2683
|
const now = /* @__PURE__ */ new Date();
|
|
2324
|
-
const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() ||
|
|
2684
|
+
const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || email || input.phone);
|
|
2325
2685
|
const draft = {
|
|
2326
2686
|
...input,
|
|
2687
|
+
...email !== void 0 ? { email } : {},
|
|
2327
2688
|
status: input.status ?? "new",
|
|
2328
2689
|
score: 0,
|
|
2329
2690
|
statusHistory: [{
|
|
@@ -2336,7 +2697,7 @@ var LeadRepository = class extends Repository {
|
|
|
2336
2697
|
try {
|
|
2337
2698
|
created = await this.create(draft, ctx);
|
|
2338
2699
|
} catch (err) {
|
|
2339
|
-
if (this.rejectDuplicates &&
|
|
2700
|
+
if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email });
|
|
2340
2701
|
throw err;
|
|
2341
2702
|
}
|
|
2342
2703
|
const score = this.scorer(created);
|
|
@@ -2451,6 +2812,67 @@ var LeadRepository = class extends Repository {
|
|
|
2451
2812
|
}, ctx));
|
|
2452
2813
|
return updated;
|
|
2453
2814
|
}
|
|
2815
|
+
/**
|
|
2816
|
+
* The `converted` hop, owned by `LeadService.convert()` (the only caller
|
|
2817
|
+
* — the four cross-repo writes make it a service concern). Kept on the
|
|
2818
|
+
* repo so the atomic state write goes through `applyTransition` — a
|
|
2819
|
+
* machine-guarded CAS that `$push`es the history entry instead of
|
|
2820
|
+
* read-then-replacing the whole `statusHistory` array (no lost-update
|
|
2821
|
+
* window). Deliberately emits NO `lead.status_changed`: `lead.converted`
|
|
2822
|
+
* is the canonical signal for this transition and is dispatched by the
|
|
2823
|
+
* service alongside the cross-repo refs.
|
|
2824
|
+
*/
|
|
2825
|
+
async markConverted(id, from, patch, entry, ctx) {
|
|
2826
|
+
return await this.applyTransition(id, LEAD_MACHINE, {
|
|
2827
|
+
from,
|
|
2828
|
+
to: "converted",
|
|
2829
|
+
set: patch,
|
|
2830
|
+
push: { statusHistory: entry },
|
|
2831
|
+
history: false
|
|
2832
|
+
}, ctx).catch(async (err) => {
|
|
2833
|
+
if (!isTransitionRace(err)) throw err;
|
|
2834
|
+
throw new InvalidLeadTransitionError({
|
|
2835
|
+
from: (await this.getById(id, ctx))?.status ?? from,
|
|
2836
|
+
to: "converted",
|
|
2837
|
+
leadId: id
|
|
2838
|
+
});
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
/**
|
|
2842
|
+
* GDPR right-to-erasure for a Lead. Scrubs the PII fields
|
|
2843
|
+
* (`fullName`/`firstName`/`lastName`/`email`/`phone`) in place while
|
|
2844
|
+
* preserving non-PII aggregates (`score`, `status`, `statusHistory`,
|
|
2845
|
+
* `engagement`, firmographic `companyName`/`source`) and the id. Sets
|
|
2846
|
+
* `anonymizedAt` and emits `crm:lead.anonymized`.
|
|
2847
|
+
*
|
|
2848
|
+
* `fullName` is `required`, so it becomes the `'[redacted]'` sentinel;
|
|
2849
|
+
* the optional PII scalars are nulled (dropping `email` out of the
|
|
2850
|
+
* partial-unique email index). Idempotent.
|
|
2851
|
+
*/
|
|
2852
|
+
async anonymize(id, ctx) {
|
|
2853
|
+
if (!await this.getById(id, ctx)) throw new NotFoundError("Lead", id);
|
|
2854
|
+
const patch = {
|
|
2855
|
+
fullName: "[redacted]",
|
|
2856
|
+
firstName: null,
|
|
2857
|
+
lastName: null,
|
|
2858
|
+
email: null,
|
|
2859
|
+
phone: null,
|
|
2860
|
+
anonymizedAt: /* @__PURE__ */ new Date()
|
|
2861
|
+
};
|
|
2862
|
+
const updated = await this.update(id, patch, ctx);
|
|
2863
|
+
if (!updated) throw new NotFoundError("Lead", id);
|
|
2864
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_ANONYMIZED, {
|
|
2865
|
+
leadId: id,
|
|
2866
|
+
fields: [
|
|
2867
|
+
"fullName",
|
|
2868
|
+
"firstName",
|
|
2869
|
+
"lastName",
|
|
2870
|
+
"email",
|
|
2871
|
+
"phone"
|
|
2872
|
+
]
|
|
2873
|
+
}, ctx));
|
|
2874
|
+
return updated;
|
|
2875
|
+
}
|
|
2454
2876
|
async _transition(id, target, extras, ctx) {
|
|
2455
2877
|
const existing = await this.getById(id, ctx);
|
|
2456
2878
|
if (!existing) throw new NotFoundError("Lead", id);
|
|
@@ -2461,19 +2883,19 @@ var LeadRepository = class extends Repository {
|
|
|
2461
2883
|
...extras.by !== void 0 ? { by: extras.by } : {},
|
|
2462
2884
|
...extras.note !== void 0 ? { note: extras.note } : {}
|
|
2463
2885
|
};
|
|
2464
|
-
const updated = await
|
|
2886
|
+
const updated = await this.applyTransition(id, LEAD_MACHINE, {
|
|
2465
2887
|
from: existing.status,
|
|
2466
2888
|
to: target,
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2889
|
+
set: extras.patch ?? {},
|
|
2890
|
+
push: { statusHistory: entry },
|
|
2891
|
+
history: false
|
|
2892
|
+
}, ctx).catch(async (err) => {
|
|
2893
|
+
if (!isTransitionRace(err)) throw err;
|
|
2894
|
+
throw new InvalidLeadTransitionError({
|
|
2895
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
2896
|
+
to: target,
|
|
2897
|
+
leadId: id
|
|
2898
|
+
});
|
|
2477
2899
|
});
|
|
2478
2900
|
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_STATUS_CHANGED, {
|
|
2479
2901
|
leadId: id,
|
|
@@ -2719,7 +3141,6 @@ var OpportunityRepository = class extends Repository {
|
|
|
2719
3141
|
async _closeStatus(id, target, patch, extras, ctx) {
|
|
2720
3142
|
const existing = await this.getById(id, ctx);
|
|
2721
3143
|
if (!existing) throw new NotFoundError("Opportunity", id);
|
|
2722
|
-
OPPORTUNITY_MACHINE.assertTransition(id, existing.status, target);
|
|
2723
3144
|
const entry = {
|
|
2724
3145
|
status: target,
|
|
2725
3146
|
stageId: existing.stageId,
|
|
@@ -2727,59 +3148,52 @@ var OpportunityRepository = class extends Repository {
|
|
|
2727
3148
|
...extras.by !== void 0 ? { by: extras.by } : {},
|
|
2728
3149
|
...extras.note !== void 0 ? { note: extras.note } : {}
|
|
2729
3150
|
};
|
|
2730
|
-
|
|
3151
|
+
return await this.applyTransition(id, OPPORTUNITY_MACHINE, {
|
|
2731
3152
|
from: existing.status,
|
|
2732
3153
|
to: target,
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
3154
|
+
set: patch,
|
|
3155
|
+
push: { statusHistory: entry },
|
|
3156
|
+
history: false
|
|
3157
|
+
}, ctx).catch(async (err) => {
|
|
3158
|
+
if (!isTransitionRace(err)) throw err;
|
|
3159
|
+
throw new InvalidOpportunityTransitionError({
|
|
3160
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
3161
|
+
to: target,
|
|
3162
|
+
opportunityId: id
|
|
3163
|
+
});
|
|
2743
3164
|
});
|
|
2744
|
-
return updated;
|
|
2745
3165
|
}
|
|
2746
3166
|
/** Count opportunities with status `open`. Used by dashboard KPI tiles. */
|
|
2747
3167
|
async countOpen(ctx) {
|
|
2748
3168
|
return this.count({ status: "open" }, ctx);
|
|
2749
3169
|
}
|
|
2750
3170
|
/**
|
|
2751
|
-
* Sum `amount` on won opportunities updated
|
|
2752
|
-
*
|
|
2753
|
-
*
|
|
2754
|
-
*
|
|
3171
|
+
* Sum `amount` on won opportunities updated on/after `since`, grouped by
|
|
3172
|
+
* currency. Returns one row per currency (descending by total) — a deal
|
|
3173
|
+
* book is multi-currency, so collapsing to a single total would silently
|
|
3174
|
+
* drop every non-top currency's revenue.
|
|
3175
|
+
*
|
|
3176
|
+
* Uses `aggregatePipeline` (a DB-side `$group`) instead of loading every
|
|
3177
|
+
* won opp into memory: the multiTenant + soft-delete policy hooks prepend
|
|
3178
|
+
* their `$match` so a missing ctx can't leak other tenants' revenue, and
|
|
3179
|
+
* the row count is bounded by the number of distinct currencies, not the
|
|
3180
|
+
* number of deals.
|
|
2755
3181
|
*/
|
|
2756
3182
|
async sumWonSince(since, ctx) {
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
}
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
byCurrency.set(cur, (byCurrency.get(cur) ?? 0) + (r.amount ?? 0));
|
|
2772
|
-
}
|
|
2773
|
-
let topCurrency = "";
|
|
2774
|
-
let topTotal = -Infinity;
|
|
2775
|
-
for (const [cur, total] of byCurrency) if (total > topTotal) {
|
|
2776
|
-
topTotal = total;
|
|
2777
|
-
topCurrency = cur;
|
|
2778
|
-
}
|
|
2779
|
-
return {
|
|
2780
|
-
total: topTotal,
|
|
2781
|
-
currency: topCurrency
|
|
2782
|
-
};
|
|
3183
|
+
return (await this.aggregatePipeline([
|
|
3184
|
+
{ $match: {
|
|
3185
|
+
status: "won",
|
|
3186
|
+
updatedAt: { $gte: since }
|
|
3187
|
+
} },
|
|
3188
|
+
{ $group: {
|
|
3189
|
+
_id: { $ifNull: ["$currency", "UNKNOWN"] },
|
|
3190
|
+
total: { $sum: { $ifNull: ["$amount", 0] } }
|
|
3191
|
+
} },
|
|
3192
|
+
{ $sort: { total: -1 } }
|
|
3193
|
+
], ctx)).map((r) => ({
|
|
3194
|
+
currency: r._id ?? "UNKNOWN",
|
|
3195
|
+
total: r.total
|
|
3196
|
+
}));
|
|
2783
3197
|
}
|
|
2784
3198
|
/** Latest `limit` opportunities — lightweight projection for the activity feed. */
|
|
2785
3199
|
async findRecentForFeed(limit, ctx) {
|
|
@@ -2800,16 +3214,45 @@ var OpportunityRepository = class extends Repository {
|
|
|
2800
3214
|
//#region src/repositories/contact.repository.ts
|
|
2801
3215
|
var ContactRepository = class extends Repository {
|
|
2802
3216
|
events;
|
|
3217
|
+
rejectDuplicates;
|
|
2803
3218
|
constructor(model, plugins, deps, pagination) {
|
|
2804
3219
|
super(model, plugins, pagination);
|
|
2805
3220
|
this.events = deps.events;
|
|
3221
|
+
this.rejectDuplicates = deps.rejectDuplicates ?? true;
|
|
2806
3222
|
}
|
|
2807
3223
|
/**
|
|
2808
3224
|
* Create + emit `crm:contact.created`. Hosts call this instead of
|
|
2809
3225
|
* `create()` when they want the event side-effect (e.g. webhook fan-out).
|
|
3226
|
+
*
|
|
3227
|
+
* Rejects duplicate emails inside the same tenant unless
|
|
3228
|
+
* `rejectDuplicates: false` was set — mirrors `LeadRepository.createLead`.
|
|
3229
|
+
* The partial-unique `(organizationId, contact.email)` index is the
|
|
3230
|
+
* authoritative guard; the pre-check only closes the fast path.
|
|
3231
|
+
*
|
|
3232
|
+
* Throws `ContactEmailFormatError` (400) on an email that cannot be
|
|
3233
|
+
* canonicalised — nothing is written. See `core/email-identity.ts` for the
|
|
3234
|
+
* reason, including what it means for `LeadService.convert()`.
|
|
2810
3235
|
*/
|
|
2811
3236
|
async createContact(input, ctx) {
|
|
2812
|
-
const
|
|
3237
|
+
const raw = input.contact?.email;
|
|
3238
|
+
const email = raw === void 0 ? void 0 : requireContactEmailKey(raw);
|
|
3239
|
+
if (this.rejectDuplicates && email) {
|
|
3240
|
+
if (await this.getOne({ "contact.email": email }, ctx)) throw new DuplicateContactError({ email });
|
|
3241
|
+
}
|
|
3242
|
+
const draft = email === void 0 ? input : {
|
|
3243
|
+
...input,
|
|
3244
|
+
contact: {
|
|
3245
|
+
...input.contact ?? {},
|
|
3246
|
+
email
|
|
3247
|
+
}
|
|
3248
|
+
};
|
|
3249
|
+
let created;
|
|
3250
|
+
try {
|
|
3251
|
+
created = await this.create(draft, ctx);
|
|
3252
|
+
} catch (err) {
|
|
3253
|
+
if (this.rejectDuplicates && email && isDuplicateKeyError(err)) throw new DuplicateContactError({ email });
|
|
3254
|
+
throw err;
|
|
3255
|
+
}
|
|
2813
3256
|
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_CREATED, {
|
|
2814
3257
|
contactId: String(created._id),
|
|
2815
3258
|
email: created.contact?.email
|
|
@@ -2888,14 +3331,19 @@ var ContactRepository = class extends Repository {
|
|
|
2888
3331
|
...stripEmpty(winnerName)
|
|
2889
3332
|
},
|
|
2890
3333
|
contact: mergedContactInfo,
|
|
2891
|
-
tags: Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
|
|
3334
|
+
tags: Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
|
|
2892
3335
|
...winner.jobTitle !== void 0 ? { jobTitle: winner.jobTitle } : loser.jobTitle !== void 0 ? { jobTitle: loser.jobTitle } : {},
|
|
2893
3336
|
...winner.accountId !== void 0 ? { accountId: winner.accountId } : loser.accountId !== void 0 ? { accountId: loser.accountId } : {},
|
|
2894
3337
|
...winner.ownerId !== void 0 ? { ownerId: winner.ownerId } : loser.ownerId !== void 0 ? { ownerId: loser.ownerId } : {}
|
|
2895
3338
|
};
|
|
3339
|
+
if (input.repoint) await input.repoint({
|
|
3340
|
+
primaryId,
|
|
3341
|
+
secondaryId,
|
|
3342
|
+
...ctx !== void 0 ? { ctx } : {}
|
|
3343
|
+
});
|
|
3344
|
+
await this.delete(secondaryId, ctx);
|
|
2896
3345
|
const updated = await this.update(primaryId, patch, ctx);
|
|
2897
3346
|
if (!updated) throw new NotFoundError("Contact", primaryId);
|
|
2898
|
-
await this.delete(secondaryId, ctx);
|
|
2899
3347
|
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_MERGED, {
|
|
2900
3348
|
primaryId,
|
|
2901
3349
|
secondaryId,
|
|
@@ -2903,7 +3351,54 @@ var ContactRepository = class extends Repository {
|
|
|
2903
3351
|
}, ctx));
|
|
2904
3352
|
return updated;
|
|
2905
3353
|
}
|
|
3354
|
+
/**
|
|
3355
|
+
* GDPR right-to-erasure. Scrubs every PII field on the contact in place —
|
|
3356
|
+
* the structured `name` and ALL `contact.*` channels (email,
|
|
3357
|
+
* personalEmail, phone, alternatePhone, whatsapp, telegram, linkedin,
|
|
3358
|
+
* instagram) — while preserving the id and non-PII aggregates
|
|
3359
|
+
* (`accountId`, `ownerId`, `tags`, timestamps) so historical rollups and
|
|
3360
|
+
* relationships survive. Sets `anonymizedAt` as a tombstone and emits
|
|
3361
|
+
* `crm:contact.anonymized`. Idempotent: a second call re-scrubs to the
|
|
3362
|
+
* same sentinel.
|
|
3363
|
+
*
|
|
3364
|
+
* `name.given` is `required`, so it becomes the `'[redacted]'` sentinel
|
|
3365
|
+
* rather than empty; every other name part and the whole `contact`
|
|
3366
|
+
* subdocument are cleared. Unsetting `contact.email` also drops the row
|
|
3367
|
+
* out of the partial-unique email index, freeing the address for reuse.
|
|
3368
|
+
*/
|
|
3369
|
+
async anonymize(id, ctx) {
|
|
3370
|
+
if (!await this.getById(id, ctx)) throw new NotFoundError("Contact", id);
|
|
3371
|
+
const patch = {
|
|
3372
|
+
name: {
|
|
3373
|
+
given: REDACTED,
|
|
3374
|
+
family: ""
|
|
3375
|
+
},
|
|
3376
|
+
contact: {},
|
|
3377
|
+
anonymizedAt: /* @__PURE__ */ new Date()
|
|
3378
|
+
};
|
|
3379
|
+
const updated = await this.update(id, patch, ctx);
|
|
3380
|
+
if (!updated) throw new NotFoundError("Contact", id);
|
|
3381
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_ANONYMIZED, {
|
|
3382
|
+
contactId: id,
|
|
3383
|
+
fields: PII_CONTACT_FIELDS
|
|
3384
|
+
}, ctx));
|
|
3385
|
+
return updated;
|
|
3386
|
+
}
|
|
2906
3387
|
};
|
|
3388
|
+
/** Sentinel written to `required` PII fields that can't be cleared to empty. */
|
|
3389
|
+
const REDACTED = "[redacted]";
|
|
3390
|
+
/** PII field set scrubbed by `anonymize()` — surfaced in the event payload. */
|
|
3391
|
+
const PII_CONTACT_FIELDS = [
|
|
3392
|
+
"name",
|
|
3393
|
+
"contact.email",
|
|
3394
|
+
"contact.personalEmail",
|
|
3395
|
+
"contact.phone",
|
|
3396
|
+
"contact.alternatePhone",
|
|
3397
|
+
"contact.whatsapp",
|
|
3398
|
+
"contact.telegram",
|
|
3399
|
+
"contact.linkedin",
|
|
3400
|
+
"contact.instagram"
|
|
3401
|
+
];
|
|
2907
3402
|
function stripEmpty(obj) {
|
|
2908
3403
|
const out = {};
|
|
2909
3404
|
for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null && v !== "") out[k] = v;
|
|
@@ -2974,7 +3469,7 @@ var AccountRepository = class extends Repository {
|
|
|
2974
3469
|
if (!secondary) throw new NotFoundError("Account", secondaryId);
|
|
2975
3470
|
const winner = strategy === "preferPrimary" ? primary : secondary;
|
|
2976
3471
|
const loser = strategy === "preferPrimary" ? secondary : primary;
|
|
2977
|
-
const mergedTags = Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
|
|
3472
|
+
const mergedTags = Array.from(/* @__PURE__ */ new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
|
|
2978
3473
|
const patch = {
|
|
2979
3474
|
name: winner.name || loser.name,
|
|
2980
3475
|
tags: mergedTags,
|
|
@@ -3075,28 +3570,26 @@ var CampaignRepository = class extends Repository {
|
|
|
3075
3570
|
async _transition(id, target, extras, ctx, patch = {}) {
|
|
3076
3571
|
const existing = await this.getById(id, ctx);
|
|
3077
3572
|
if (!existing) throw new NotFoundError("Campaign", id);
|
|
3078
|
-
CAMPAIGN_MACHINE.assertTransition(id, existing.status, target);
|
|
3079
3573
|
const entry = {
|
|
3080
3574
|
status: target,
|
|
3081
3575
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
3082
3576
|
...extras.by !== void 0 ? { by: extras.by } : {},
|
|
3083
3577
|
...extras.note !== void 0 ? { note: extras.note } : {}
|
|
3084
3578
|
};
|
|
3085
|
-
|
|
3579
|
+
return await this.applyTransition(id, CAMPAIGN_MACHINE, {
|
|
3086
3580
|
from: existing.status,
|
|
3087
3581
|
to: target,
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3582
|
+
set: patch,
|
|
3583
|
+
push: { statusHistory: entry },
|
|
3584
|
+
history: false
|
|
3585
|
+
}, ctx).catch(async (err) => {
|
|
3586
|
+
if (!isTransitionRace(err)) throw err;
|
|
3587
|
+
throw new InvalidCampaignTransitionError({
|
|
3588
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
3589
|
+
to: target,
|
|
3590
|
+
campaignId: id
|
|
3591
|
+
});
|
|
3098
3592
|
});
|
|
3099
|
-
return updated;
|
|
3100
3593
|
}
|
|
3101
3594
|
};
|
|
3102
3595
|
//#endregion
|
|
@@ -3237,34 +3730,60 @@ var SubscriptionRepository = class extends Repository {
|
|
|
3237
3730
|
async _transition(id, target, opts, ctx) {
|
|
3238
3731
|
const existing = await this.getById(id, ctx);
|
|
3239
3732
|
if (!existing) throw new NotFoundError("Subscription", id);
|
|
3240
|
-
SUBSCRIPTION_MACHINE.assertTransition(id, existing.status, target);
|
|
3241
3733
|
const entry = {
|
|
3242
3734
|
status: target,
|
|
3243
3735
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
3244
3736
|
...opts.by !== void 0 ? { by: opts.by } : {},
|
|
3245
3737
|
...opts.note !== void 0 ? { note: opts.note } : {}
|
|
3246
3738
|
};
|
|
3247
|
-
|
|
3739
|
+
return await this.applyTransition(id, SUBSCRIPTION_MACHINE, {
|
|
3248
3740
|
from: existing.status,
|
|
3249
3741
|
to: target,
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3742
|
+
set: opts.patch ?? {},
|
|
3743
|
+
push: { statusHistory: entry },
|
|
3744
|
+
history: false
|
|
3745
|
+
}, ctx).catch(async (err) => {
|
|
3746
|
+
if (!isTransitionRace(err)) throw err;
|
|
3747
|
+
throw new InvalidSubscriptionTransitionError({
|
|
3748
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
3749
|
+
to: target,
|
|
3750
|
+
subscriptionId: id
|
|
3751
|
+
});
|
|
3260
3752
|
});
|
|
3261
|
-
return updated;
|
|
3262
3753
|
}
|
|
3263
3754
|
};
|
|
3264
3755
|
//#endregion
|
|
3265
3756
|
//#region src/repositories/suppression.repository.ts
|
|
3266
|
-
|
|
3267
|
-
|
|
3757
|
+
/**
|
|
3758
|
+
* The suppression key for `email`, or `null` when it cannot be canonicalised.
|
|
3759
|
+
*
|
|
3760
|
+
* Exported because it is the ONLY way a caller can distinguish "this address is on
|
|
3761
|
+
* the list" from "this address is not an address" — the read verbs collapse both
|
|
3762
|
+
* to `true` on purpose (see the fail-closed rule in the module docblock), and a
|
|
3763
|
+
* caller that needs the distinction should not re-derive the rule to get it.
|
|
3764
|
+
*/
|
|
3765
|
+
function suppressionEmailKey(email) {
|
|
3766
|
+
try {
|
|
3767
|
+
return normalizeEmail(email);
|
|
3768
|
+
} catch (err) {
|
|
3769
|
+
if (err instanceof EmailFormatError) return null;
|
|
3770
|
+
throw err;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* The suppression key for `email`, or a 400. Used by the WRITE verbs only —
|
|
3775
|
+
* storing an address that can never match is a suppression that suppresses nothing.
|
|
3776
|
+
*/
|
|
3777
|
+
function requireSuppressionEmailKey(email) {
|
|
3778
|
+
try {
|
|
3779
|
+
return normalizeEmail(email);
|
|
3780
|
+
} catch (err) {
|
|
3781
|
+
if (err instanceof EmailFormatError) throw new SuppressionEmailFormatError({
|
|
3782
|
+
email,
|
|
3783
|
+
reason: err.code
|
|
3784
|
+
});
|
|
3785
|
+
throw err;
|
|
3786
|
+
}
|
|
3268
3787
|
}
|
|
3269
3788
|
var SuppressionRepository = class extends Repository {
|
|
3270
3789
|
events;
|
|
@@ -3277,9 +3796,12 @@ var SuppressionRepository = class extends Repository {
|
|
|
3277
3796
|
* `getOrCreate` on the unique (org, email) index — of N concurrent
|
|
3278
3797
|
* calls exactly one inserts. Emits `crm:suppression.added` only for
|
|
3279
3798
|
* the inserting call; an existing row is returned unchanged, no event.
|
|
3799
|
+
*
|
|
3800
|
+
* Throws `SuppressionEmailFormatError` (400) on an address that cannot be
|
|
3801
|
+
* canonicalised — nothing is written.
|
|
3280
3802
|
*/
|
|
3281
3803
|
async suppress(email, input, ctx) {
|
|
3282
|
-
const normalized =
|
|
3804
|
+
const normalized = requireSuppressionEmailKey(email);
|
|
3283
3805
|
const draft = {
|
|
3284
3806
|
email: normalized,
|
|
3285
3807
|
reason: input.reason,
|
|
@@ -3297,37 +3819,68 @@ var SuppressionRepository = class extends Repository {
|
|
|
3297
3819
|
}, ctx));
|
|
3298
3820
|
return doc;
|
|
3299
3821
|
}
|
|
3300
|
-
/**
|
|
3822
|
+
/**
|
|
3823
|
+
* True when `email` is on the org's suppression list — **or when it cannot be
|
|
3824
|
+
* canonicalised at all**, which is the fail-closed half of the module rule
|
|
3825
|
+
* above. Never throws on a malformed address: the safe answer is "do not send",
|
|
3826
|
+
* and this verb's job is to produce that answer, not to police the input.
|
|
3827
|
+
*/
|
|
3301
3828
|
async isSuppressed(email, ctx) {
|
|
3302
|
-
|
|
3829
|
+
const key = suppressionEmailKey(email);
|
|
3830
|
+
if (key === null) return true;
|
|
3831
|
+
return await this.exists({ email: key }, ctx) !== null;
|
|
3303
3832
|
}
|
|
3304
3833
|
/**
|
|
3305
|
-
* Return the suppressed subset of `emails`
|
|
3306
|
-
*
|
|
3834
|
+
* Return the suppressed subset of `emails` as a Set — ONE `$in` query, built for
|
|
3835
|
+
* the campaign-fanout hot path:
|
|
3307
3836
|
*
|
|
3308
3837
|
* ```ts
|
|
3309
3838
|
* const blocked = await suppression.filterSuppressed(recipients, ctx);
|
|
3310
|
-
* const sendable = recipients.filter((e) => !blocked.has(e
|
|
3839
|
+
* const sendable = recipients.filter((e) => !blocked.has(suppressionEmailKey(e) ?? e));
|
|
3311
3840
|
* ```
|
|
3841
|
+
*
|
|
3842
|
+
* The Set is keyed CANONICALLY, so a caller must canonicalise its own recipient
|
|
3843
|
+
* before testing membership — matching on the raw spelling is how the punycode
|
|
3844
|
+
* miss came back. `suppressionEmailKey` is exported for exactly that, and returns
|
|
3845
|
+
* `null` for an address with no canonical form.
|
|
3846
|
+
*
|
|
3847
|
+
* An address that cannot be canonicalised is reported as BLOCKED, keyed by the
|
|
3848
|
+
* caller's original spelling (there is no canonical form to key it by, and the
|
|
3849
|
+
* caller has to be able to find it). It is not thrown: one malformed recipient
|
|
3850
|
+
* must not abort a fanout to thousands of valid ones.
|
|
3312
3851
|
*/
|
|
3313
3852
|
async filterSuppressed(emails, ctx) {
|
|
3314
3853
|
if (emails.length === 0) return /* @__PURE__ */ new Set();
|
|
3315
|
-
const
|
|
3316
|
-
const
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3854
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
3855
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3856
|
+
for (const email of emails) {
|
|
3857
|
+
const key = suppressionEmailKey(email);
|
|
3858
|
+
if (key === null) blocked.add(email);
|
|
3859
|
+
else keys.add(key);
|
|
3860
|
+
}
|
|
3861
|
+
if (keys.size > 0) {
|
|
3862
|
+
const rows = await this.findAll({ email: { $in: [...keys] } }, {
|
|
3863
|
+
...ctx ?? {},
|
|
3864
|
+
select: "email",
|
|
3865
|
+
lean: true
|
|
3866
|
+
});
|
|
3867
|
+
for (const row of rows) blocked.add(row.email);
|
|
3868
|
+
}
|
|
3869
|
+
return blocked;
|
|
3322
3870
|
}
|
|
3323
3871
|
/**
|
|
3324
3872
|
* Remove `email` from the org's suppression list. Single atomic
|
|
3325
3873
|
* `deleteMany` on the (unique) email key — emits
|
|
3326
3874
|
* `crm:suppression.removed` only when a row was actually removed.
|
|
3327
3875
|
* Returns whether a row was removed.
|
|
3876
|
+
*
|
|
3877
|
+
* Throws `SuppressionEmailFormatError` (400) on an address that cannot be
|
|
3878
|
+
* canonicalised, rather than answering `false`. This is the verb that re-enables
|
|
3879
|
+
* sending, and `false` would read as "already removed" when the truth is "that
|
|
3880
|
+
* was never an address" — an operator would close the ticket either way.
|
|
3328
3881
|
*/
|
|
3329
3882
|
async unsuppress(email, ctx) {
|
|
3330
|
-
const normalized =
|
|
3883
|
+
const normalized = requireSuppressionEmailKey(email);
|
|
3331
3884
|
const removed = (await this.deleteMany({ email: normalized }, ctx ?? {})).deletedCount > 0;
|
|
3332
3885
|
if (removed) await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUPPRESSION_REMOVED, { email: normalized }, ctx));
|
|
3333
3886
|
return removed;
|
|
@@ -3427,19 +3980,19 @@ var ActivityRepository = class extends Repository {
|
|
|
3427
3980
|
async _transition(id, target, patch, ctx) {
|
|
3428
3981
|
const existing = await this.getById(id, ctx);
|
|
3429
3982
|
if (!existing) throw new NotFoundError("Activity", id);
|
|
3430
|
-
|
|
3431
|
-
const updated = await assertAndClaim(ACTIVITY_MACHINE, this, id, {
|
|
3983
|
+
return await this.applyTransition(id, ACTIVITY_MACHINE, {
|
|
3432
3984
|
from: existing.status,
|
|
3433
3985
|
to: target,
|
|
3434
|
-
patch,
|
|
3435
|
-
|
|
3436
|
-
})
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3986
|
+
set: patch,
|
|
3987
|
+
history: false
|
|
3988
|
+
}, ctx).catch(async (err) => {
|
|
3989
|
+
if (!isTransitionRace(err)) throw err;
|
|
3990
|
+
throw new InvalidActivityTransitionError({
|
|
3991
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
3992
|
+
to: target,
|
|
3993
|
+
activityId: id
|
|
3994
|
+
});
|
|
3441
3995
|
});
|
|
3442
|
-
return updated;
|
|
3443
3996
|
}
|
|
3444
3997
|
/** Count `planned` activities with `dueDate` before `asOf`. Dashboard KPI. */
|
|
3445
3998
|
async countOverduePlanned(asOf, ctx) {
|
|
@@ -3510,7 +4063,7 @@ const KIND_TO_EVENT = {
|
|
|
3510
4063
|
var EmailEventRepository = class extends Repository {
|
|
3511
4064
|
events;
|
|
3512
4065
|
constructor(model, plugins, deps, pagination) {
|
|
3513
|
-
super(model, plugins, pagination);
|
|
4066
|
+
super(model, [...plugins, appendOnlyPlugin({ allow: ["deleteMany"] })], pagination);
|
|
3514
4067
|
this.events = deps.events;
|
|
3515
4068
|
}
|
|
3516
4069
|
async recordOpen(input, ctx) {
|
|
@@ -3735,24 +4288,24 @@ var SequenceRepository = class extends Repository {
|
|
|
3735
4288
|
async _transition(id, target, ctx) {
|
|
3736
4289
|
const existing = await this.getById(id, ctx);
|
|
3737
4290
|
if (!existing) throw new NotFoundError("Sequence", id);
|
|
3738
|
-
SEQUENCE_MACHINE.assertTransition(id, existing.status, target);
|
|
3739
4291
|
const entry = {
|
|
3740
4292
|
status: target,
|
|
3741
4293
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
3742
4294
|
...ctx?.actorId !== void 0 ? { by: String(ctx.actorId) } : {}
|
|
3743
4295
|
};
|
|
3744
|
-
|
|
4296
|
+
return await this.applyTransition(id, SEQUENCE_MACHINE, {
|
|
3745
4297
|
from: existing.status,
|
|
3746
4298
|
to: target,
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
})
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
4299
|
+
push: { statusHistory: entry },
|
|
4300
|
+
history: false
|
|
4301
|
+
}, ctx).catch(async (err) => {
|
|
4302
|
+
if (!isTransitionRace(err)) throw err;
|
|
4303
|
+
throw new InvalidSequenceTransitionError({
|
|
4304
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
4305
|
+
to: target,
|
|
4306
|
+
sequenceId: id
|
|
4307
|
+
});
|
|
3754
4308
|
});
|
|
3755
|
-
return updated;
|
|
3756
4309
|
}
|
|
3757
4310
|
};
|
|
3758
4311
|
//#endregion
|
|
@@ -3839,16 +4392,16 @@ var EnrollmentRepository = class extends Repository {
|
|
|
3839
4392
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
3840
4393
|
...ctx?.actorId ? { by: String(ctx.actorId) } : {}
|
|
3841
4394
|
};
|
|
3842
|
-
const updated = await
|
|
4395
|
+
const updated = await this.applyTransition(id, ENROLLMENT_MACHINE, {
|
|
3843
4396
|
from,
|
|
3844
4397
|
to: "exited",
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
4398
|
+
set: { exitReason },
|
|
4399
|
+
push: { statusHistory: entry },
|
|
4400
|
+
history: false
|
|
4401
|
+
}, ctx).catch((err) => {
|
|
4402
|
+
if (isTransitionRace(err)) throw new NotFoundError("Enrollment", id);
|
|
4403
|
+
throw err;
|
|
3850
4404
|
});
|
|
3851
|
-
if (!updated) throw new NotFoundError("Enrollment", id);
|
|
3852
4405
|
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_EXITED, {
|
|
3853
4406
|
enrollmentId: id,
|
|
3854
4407
|
exitReason
|
|
@@ -3907,27 +4460,25 @@ var EnrollmentRepository = class extends Repository {
|
|
|
3907
4460
|
async _transition(id, target, ctx, patch = {}) {
|
|
3908
4461
|
const existing = await this.getById(id, ctx);
|
|
3909
4462
|
if (!existing) throw new NotFoundError("Enrollment", id);
|
|
3910
|
-
ENROLLMENT_MACHINE.assertTransition(id, existing.status, target);
|
|
3911
4463
|
const entry = {
|
|
3912
4464
|
status: target,
|
|
3913
4465
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
3914
4466
|
...ctx?.actorId ? { by: String(ctx.actorId) } : {}
|
|
3915
4467
|
};
|
|
3916
|
-
|
|
4468
|
+
return await this.applyTransition(id, ENROLLMENT_MACHINE, {
|
|
3917
4469
|
from: existing.status,
|
|
3918
4470
|
to: target,
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
4471
|
+
set: patch,
|
|
4472
|
+
push: { statusHistory: entry },
|
|
4473
|
+
history: false
|
|
4474
|
+
}, ctx).catch(async (err) => {
|
|
4475
|
+
if (!isTransitionRace(err)) throw err;
|
|
4476
|
+
throw new InvalidEnrollmentTransitionError({
|
|
4477
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
4478
|
+
to: target,
|
|
4479
|
+
enrollmentId: id
|
|
4480
|
+
});
|
|
3929
4481
|
});
|
|
3930
|
-
return updated;
|
|
3931
4482
|
}
|
|
3932
4483
|
};
|
|
3933
4484
|
//#endregion
|
|
@@ -3954,49 +4505,48 @@ function createCrmRepositories(models, deps) {
|
|
|
3954
4505
|
}));
|
|
3955
4506
|
plugins.push(aggregateHelpersPlugin());
|
|
3956
4507
|
if (config.plugins.length > 0) plugins.push(...config.plugins);
|
|
3957
|
-
const
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
events,
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
});
|
|
4508
|
+
const repositories = {};
|
|
4509
|
+
if (config.modules.sales) {
|
|
4510
|
+
const { Pipeline, Lead, Opportunity, Contact, Account, Activity, Note, LossReason } = models;
|
|
4511
|
+
if (!Pipeline || !Lead || !Opportunity || !Contact || !Account || !Activity || !Note || !LossReason) throw new Error("[crm] sales module model graph is incomplete");
|
|
4512
|
+
const pipeline = new PipelineRepository(Pipeline, plugins, { events }, pagination);
|
|
4513
|
+
repositories.pipeline = pipeline;
|
|
4514
|
+
repositories.lead = new LeadRepository(Lead, plugins, { events }, pagination);
|
|
4515
|
+
repositories.opportunity = new OpportunityRepository(Opportunity, plugins, {
|
|
4516
|
+
events,
|
|
4517
|
+
pipelines: pipeline
|
|
4518
|
+
}, pagination);
|
|
4519
|
+
repositories.contact = new ContactRepository(Contact, plugins, { events }, pagination);
|
|
4520
|
+
repositories.account = new AccountRepository(Account, plugins, { events }, pagination);
|
|
4521
|
+
repositories.activity = new ActivityRepository(Activity, plugins, { events }, pagination);
|
|
4522
|
+
repositories.note = new NoteRepository(Note, plugins, { events }, pagination);
|
|
4523
|
+
repositories.lossReason = new LossReasonRepository(LossReason, plugins, { events }, pagination);
|
|
4524
|
+
}
|
|
4525
|
+
if (config.modules.engagement) {
|
|
4526
|
+
const { Subscription, Suppression, EmailEvent, Conversation, Message } = models;
|
|
4527
|
+
if (!Subscription || !Suppression || !EmailEvent || !Conversation || !Message) throw new Error("[crm] engagement module model graph is incomplete");
|
|
4528
|
+
const conversation = new ConversationRepository(Conversation, plugins, { events }, pagination);
|
|
4529
|
+
repositories.subscription = new SubscriptionRepository(Subscription, plugins, { events }, pagination);
|
|
4530
|
+
repositories.suppression = new SuppressionRepository(Suppression, plugins, { events }, pagination);
|
|
4531
|
+
repositories.emailEvent = new EmailEventRepository(EmailEvent, plugins, { events }, pagination);
|
|
4532
|
+
repositories.conversation = conversation;
|
|
4533
|
+
repositories.message = new MessageRepository(Message, plugins, {
|
|
4534
|
+
events,
|
|
4535
|
+
conversations: conversation
|
|
4536
|
+
}, pagination);
|
|
4537
|
+
}
|
|
4538
|
+
if (config.modules.automation) {
|
|
4539
|
+
const { Campaign, Sequence, Enrollment } = models;
|
|
4540
|
+
if (!Campaign || !Sequence || !Enrollment) throw new Error("[crm] automation module model graph is incomplete");
|
|
4541
|
+
const sequence = new SequenceRepository(Sequence, plugins, { events }, pagination);
|
|
4542
|
+
repositories.campaign = new CampaignRepository(Campaign, plugins, { events }, pagination);
|
|
4543
|
+
repositories.sequence = sequence;
|
|
4544
|
+
repositories.enrollment = new EnrollmentRepository(Enrollment, plugins, {
|
|
4545
|
+
events,
|
|
4546
|
+
sequences: sequence
|
|
4547
|
+
}, pagination);
|
|
4548
|
+
}
|
|
4549
|
+
return Object.freeze(repositories);
|
|
4000
4550
|
}
|
|
4001
4551
|
//#endregion
|
|
4002
4552
|
//#region src/events/dispatch.ts
|
|
@@ -4028,7 +4578,9 @@ var CrmEventDispatcher = class {
|
|
|
4028
4578
|
return this.transport.publish(event);
|
|
4029
4579
|
}
|
|
4030
4580
|
subscribe(pattern, handler) {
|
|
4031
|
-
|
|
4581
|
+
const subscribe = this.transport.subscribe?.bind(this.transport);
|
|
4582
|
+
if (!subscribe) throw new Error("crm: configured event transport does not support subscribe");
|
|
4583
|
+
return subscribe(pattern, handler);
|
|
4032
4584
|
}
|
|
4033
4585
|
async close() {
|
|
4034
4586
|
await this.transport.close?.();
|
|
@@ -4074,37 +4626,24 @@ async function flushPending(transport, events, logger) {
|
|
|
4074
4626
|
//#region src/events/in-process-bus.ts
|
|
4075
4627
|
/**
|
|
4076
4628
|
* In-process event bus — default fallback when no `eventTransport` is
|
|
4077
|
-
* provided to `
|
|
4078
|
-
*
|
|
4079
|
-
*
|
|
4629
|
+
* provided to `blueprint.bind(connection, runtime)`.
|
|
4630
|
+
*
|
|
4631
|
+
* A thin subclass of `@classytic/primitives/event-infra`'s shared
|
|
4632
|
+
* `InProcessEventBus`. Prior semantics preserved: glob matching via
|
|
4633
|
+
* `matchEventPattern`, per-handler error isolation, idempotent `close()`.
|
|
4634
|
+
* CRM historically SWALLOWED handler errors when no logger was supplied —
|
|
4635
|
+
* that default is preserved by passing `logger: null` unless the host
|
|
4636
|
+
* injects a `CrmLogger`, whose structured `error(msg, meta)` slot is
|
|
4637
|
+
* bridged onto the shared error-logger contract.
|
|
4080
4638
|
*/
|
|
4081
|
-
var InProcessCrmBus = class {
|
|
4082
|
-
name = "in-process-crm";
|
|
4083
|
-
subs = [];
|
|
4084
|
-
logger;
|
|
4639
|
+
var InProcessCrmBus = class extends InProcessEventBus {
|
|
4085
4640
|
constructor(options) {
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
} catch (err) {
|
|
4093
|
-
this.logger?.error?.(`[crm] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
|
|
4094
|
-
}
|
|
4095
|
-
}
|
|
4096
|
-
async subscribe(pattern, handler) {
|
|
4097
|
-
const sub = {
|
|
4098
|
-
pattern,
|
|
4099
|
-
handler
|
|
4100
|
-
};
|
|
4101
|
-
this.subs.push(sub);
|
|
4102
|
-
return () => {
|
|
4103
|
-
this.subs = this.subs.filter((s) => s !== sub);
|
|
4104
|
-
};
|
|
4105
|
-
}
|
|
4106
|
-
async close() {
|
|
4107
|
-
this.subs = [];
|
|
4641
|
+
const crmLogger = options?.logger;
|
|
4642
|
+
super({
|
|
4643
|
+
name: "in-process-crm",
|
|
4644
|
+
logLabel: "crm",
|
|
4645
|
+
logger: crmLogger?.error ? { error: (message, ...args) => crmLogger.error?.(message, { args }) } : null
|
|
4646
|
+
});
|
|
4108
4647
|
}
|
|
4109
4648
|
};
|
|
4110
4649
|
//#endregion
|
|
@@ -4162,53 +4701,179 @@ async function runUnit(config, ctx, body) {
|
|
|
4162
4701
|
return result;
|
|
4163
4702
|
}
|
|
4164
4703
|
//#endregion
|
|
4165
|
-
//#region src/engine/
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
const
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
...
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
const events = new CrmEventDispatcher({
|
|
4179
|
-
transport: config.eventTransport ?? new InProcessCrmBus(config.logger ? { logger: config.logger } : void 0),
|
|
4180
|
-
outbox: config.outbox,
|
|
4181
|
-
logger: config.logger
|
|
4704
|
+
//#region src/engine/define-crm.ts
|
|
4705
|
+
function defineCrm(shape = {}) {
|
|
4706
|
+
const tenantFieldType = shape.tenantFieldType ?? "objectId";
|
|
4707
|
+
const multiTenant = shape.multiTenant ?? true;
|
|
4708
|
+
const forceRecreate = shape.forceRecreate ?? false;
|
|
4709
|
+
const autoIndex = shape.autoIndex ?? true;
|
|
4710
|
+
const modules = resolveCrmModules(shape.modules);
|
|
4711
|
+
const modelBlueprint = defineCrmModels({
|
|
4712
|
+
tenantFieldType,
|
|
4713
|
+
...shape.modelPrefix !== void 0 ? { modelPrefix: shape.modelPrefix } : {},
|
|
4714
|
+
modules,
|
|
4715
|
+
forceRecreate,
|
|
4716
|
+
autoIndex
|
|
4182
4717
|
});
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4718
|
+
return {
|
|
4719
|
+
id: "crm",
|
|
4720
|
+
modelNames: modelBlueprint.modelNames,
|
|
4721
|
+
bind(connection, runtime = {}) {
|
|
4722
|
+
/**
|
|
4723
|
+
* VERIFY (boot gate) — an injected outbox MUST enlist `ctx.session`.
|
|
4724
|
+
*
|
|
4725
|
+
* Checked FIRST, before the model blueprint binds, for two reasons: a cheaper error
|
|
4726
|
+
* should report before a costlier one (a model collision would otherwise surface first
|
|
4727
|
+
* and hide this), and a bind that throws here must not have mutated the connection's
|
|
4728
|
+
* model registry.
|
|
4729
|
+
*
|
|
4730
|
+
* WHY CRM. `OutboxWriteOptions.session` is best-effort BY CONTRACT, so a store that
|
|
4731
|
+
* ignores it persists events for mutations that later roll back. CRM is the only kernel
|
|
4732
|
+
* in the batch reviewed alongside it with a LIVE, WIRED consumer whose side effect is not
|
|
4733
|
+
* re-derivable: be-prod's `crmCustomerProjectionHandlers` sits on the crm module's arc
|
|
4734
|
+
* `eventHandlers` arm and, on `crm:lead.converted` / `crm:opportunity.won`, WRITES
|
|
4735
|
+
* `crm.stage` and `crm.convertedFromLeadId` onto the Customer document in another
|
|
4736
|
+
* collection. Nothing recomputes those fields and no reconciler exists, so a ghost event
|
|
4737
|
+
* marks a customer converted/won permanently and silently — the exact bug class AGENTS.md
|
|
4738
|
+
* FAIL LOUD is about (a plausible-looking result instead of an error).
|
|
4739
|
+
*
|
|
4740
|
+
* The rollback window is package-owned, not hypothetical: `LeadService.convert()` writes
|
|
4741
|
+
* Account + Contact + Opportunity + the lead transition inside `runUnit`'s transaction and
|
|
4742
|
+
* dispatches `crm:lead.converted` from within it, and spine-crm's convert route calls it
|
|
4743
|
+
* through `engine.withTransaction`.
|
|
4744
|
+
*
|
|
4745
|
+
* `!== true` and not `=== false`: a hand-rolled store has the flag ABSENT, not `false`,
|
|
4746
|
+
* and absence of an answer is not a positive answer (FAIL LOUD rule 3).
|
|
4747
|
+
*
|
|
4748
|
+
* `allowNonTransactional` does NOT waive this. That flag downgrades the BACKEND's
|
|
4749
|
+
* `transactions` capability for hosts that never convert under a session (see
|
|
4750
|
+
* `assertCrmCapabilities`). This gate is about the injected STORE, which is wrong on a
|
|
4751
|
+
* replica set too — letting a dev-convenience flag disable it would be a general default
|
|
4752
|
+
* overriding a specific correctness check (FAIL LOUD rule 1).
|
|
4753
|
+
*/
|
|
4754
|
+
if (runtime.outbox && runtime.outbox.transactionalSave !== true) throw new Error("[crm] the configured OutboxStore does not declare `outbox.transactionalSave` — crm events would not commit atomically with the writes that emit them. See OutboxStore.transactionalSave in @classytic/primitives/outbox; arc's `repositoryAsOutboxStore` declares it. This is a property of the STORE, not of the database, and `allowNonTransactional` does not waive it.");
|
|
4755
|
+
const resolved = {
|
|
4756
|
+
connection,
|
|
4757
|
+
tenantFieldType,
|
|
4758
|
+
...shape.modelPrefix !== void 0 ? { modelPrefix: shape.modelPrefix } : {},
|
|
4759
|
+
multiTenant,
|
|
4760
|
+
forceRecreate,
|
|
4761
|
+
autoIndex,
|
|
4762
|
+
modules,
|
|
4763
|
+
...runtime.logger !== void 0 ? { logger: runtime.logger } : {},
|
|
4764
|
+
...runtime.pagination !== void 0 ? { pagination: runtime.pagination } : {},
|
|
4765
|
+
plugins: runtime.plugins ?? []
|
|
4766
|
+
};
|
|
4767
|
+
const ownsTransport = runtime.eventTransport === void 0;
|
|
4768
|
+
const events = new CrmEventDispatcher({
|
|
4769
|
+
transport: runtime.eventTransport ?? new InProcessCrmBus(runtime.logger ? { logger: runtime.logger } : void 0),
|
|
4770
|
+
...runtime.outbox !== void 0 ? { outbox: runtime.outbox } : {},
|
|
4771
|
+
...runtime.logger !== void 0 ? { logger: runtime.logger } : {}
|
|
4772
|
+
});
|
|
4773
|
+
const models = modelBlueprint.bind(connection);
|
|
4774
|
+
const repositories = createCrmRepositories(models, {
|
|
4200
4775
|
events,
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4776
|
+
config: resolved
|
|
4777
|
+
});
|
|
4778
|
+
const allowNonTransactional = runtime.allowNonTransactional ?? false;
|
|
4779
|
+
if (repositories.lead) assertCrmCapabilities(repositories.lead, {
|
|
4780
|
+
allowNonTransactional,
|
|
4781
|
+
...runtime.logger?.warn !== void 0 ? { logger: { warn: (m) => runtime.logger?.warn?.(m) } } : {}
|
|
4782
|
+
});
|
|
4783
|
+
const moduleViews = {};
|
|
4784
|
+
if (modules.sales) {
|
|
4785
|
+
const { Pipeline, Lead, Opportunity, Contact, Account, Activity, Note, LossReason } = models;
|
|
4786
|
+
const { pipeline, lead, opportunity, contact, account, activity, note, lossReason } = repositories;
|
|
4787
|
+
if (!Pipeline || !Lead || !Opportunity || !Contact || !Account || !Activity || !Note || !LossReason || !pipeline || !lead || !opportunity || !contact || !account || !activity || !note || !lossReason) throw new Error("[crm] sales module failed to initialize");
|
|
4788
|
+
moduleViews.sales = Object.freeze({
|
|
4789
|
+
models: Object.freeze({
|
|
4790
|
+
Pipeline,
|
|
4791
|
+
Lead,
|
|
4792
|
+
Opportunity,
|
|
4793
|
+
Contact,
|
|
4794
|
+
Account,
|
|
4795
|
+
Activity,
|
|
4796
|
+
Note,
|
|
4797
|
+
LossReason
|
|
4798
|
+
}),
|
|
4799
|
+
repositories: Object.freeze({
|
|
4800
|
+
pipeline,
|
|
4801
|
+
lead,
|
|
4802
|
+
opportunity,
|
|
4803
|
+
contact,
|
|
4804
|
+
account,
|
|
4805
|
+
activity,
|
|
4806
|
+
note,
|
|
4807
|
+
lossReason
|
|
4808
|
+
})
|
|
4809
|
+
});
|
|
4810
|
+
}
|
|
4811
|
+
if (modules.engagement) {
|
|
4812
|
+
const { Subscription, Suppression, EmailEvent, Conversation, Message } = models;
|
|
4813
|
+
const { subscription, suppression, emailEvent, conversation, message } = repositories;
|
|
4814
|
+
if (!Subscription || !Suppression || !EmailEvent || !Conversation || !Message || !subscription || !suppression || !emailEvent || !conversation || !message) throw new Error("[crm] engagement module failed to initialize");
|
|
4815
|
+
moduleViews.engagement = Object.freeze({
|
|
4816
|
+
models: Object.freeze({
|
|
4817
|
+
Subscription,
|
|
4818
|
+
Suppression,
|
|
4819
|
+
EmailEvent,
|
|
4820
|
+
Conversation,
|
|
4821
|
+
Message
|
|
4822
|
+
}),
|
|
4823
|
+
repositories: Object.freeze({
|
|
4824
|
+
subscription,
|
|
4825
|
+
suppression,
|
|
4826
|
+
emailEvent,
|
|
4827
|
+
conversation,
|
|
4828
|
+
message
|
|
4829
|
+
})
|
|
4830
|
+
});
|
|
4831
|
+
}
|
|
4832
|
+
if (modules.automation) {
|
|
4833
|
+
const { Campaign, Sequence, Enrollment } = models;
|
|
4834
|
+
const { campaign, sequence, enrollment } = repositories;
|
|
4835
|
+
if (!Campaign || !Sequence || !Enrollment || !campaign || !sequence || !enrollment) throw new Error("[crm] automation module failed to initialize");
|
|
4836
|
+
moduleViews.automation = Object.freeze({
|
|
4837
|
+
models: Object.freeze({
|
|
4838
|
+
Campaign,
|
|
4839
|
+
Sequence,
|
|
4840
|
+
Enrollment
|
|
4841
|
+
}),
|
|
4842
|
+
repositories: Object.freeze({
|
|
4843
|
+
campaign,
|
|
4844
|
+
sequence,
|
|
4845
|
+
enrollment
|
|
4846
|
+
})
|
|
4847
|
+
});
|
|
4848
|
+
}
|
|
4849
|
+
Object.freeze(moduleViews);
|
|
4850
|
+
let closed = false;
|
|
4851
|
+
const close = async () => {
|
|
4852
|
+
if (closed) return;
|
|
4853
|
+
closed = true;
|
|
4854
|
+
if (ownsTransport) await events.close();
|
|
4855
|
+
};
|
|
4856
|
+
return Object.freeze({
|
|
4857
|
+
models,
|
|
4858
|
+
repositories,
|
|
4859
|
+
events,
|
|
4860
|
+
config: Object.freeze(resolved),
|
|
4861
|
+
modules: moduleViews,
|
|
4862
|
+
withTransaction(ctx, body) {
|
|
4863
|
+
return runUnit({
|
|
4864
|
+
connection,
|
|
4865
|
+
events,
|
|
4866
|
+
...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
|
|
4867
|
+
allowNonTransactional
|
|
4868
|
+
}, ctx, body);
|
|
4869
|
+
},
|
|
4870
|
+
async syncIndexes() {
|
|
4871
|
+
await Promise.all(Object.values(models).map((model) => model.syncIndexes()));
|
|
4872
|
+
},
|
|
4873
|
+
close
|
|
4874
|
+
});
|
|
4210
4875
|
}
|
|
4211
|
-
}
|
|
4876
|
+
};
|
|
4212
4877
|
}
|
|
4213
4878
|
//#endregion
|
|
4214
4879
|
//#region src/services/lead.service.ts
|
|
@@ -4254,7 +4919,9 @@ var LeadService = class LeadService {
|
|
|
4254
4919
|
const lead = await this.leads.getById(leadId, ctx);
|
|
4255
4920
|
if (!lead) throw new NotFoundError("Lead", leadId);
|
|
4256
4921
|
if (lead.status === "converted") throw new LeadAlreadyConvertedError(leadId);
|
|
4257
|
-
|
|
4922
|
+
const canQualifyHop = LEAD_MACHINE.canTransition(lead.status, "qualified");
|
|
4923
|
+
const canConvertDirect = LEAD_MACHINE.canTransition(lead.status, "converted");
|
|
4924
|
+
if (!canQualifyHop && !canConvertDirect) throw new InvalidLeadTransitionError({
|
|
4258
4925
|
from: lead.status,
|
|
4259
4926
|
to: "converted",
|
|
4260
4927
|
leadId
|
|
@@ -4347,17 +5014,7 @@ var LeadService = class LeadService {
|
|
|
4347
5014
|
occurredAt: /* @__PURE__ */ new Date(),
|
|
4348
5015
|
...crossRefs.by !== void 0 ? { by: crossRefs.by } : {}
|
|
4349
5016
|
};
|
|
4350
|
-
|
|
4351
|
-
const updated = await this.leads.claim(leadId, {
|
|
4352
|
-
field: "status",
|
|
4353
|
-
from: fromStatus,
|
|
4354
|
-
to: "converted"
|
|
4355
|
-
}, {
|
|
4356
|
-
...patch,
|
|
4357
|
-
statusHistory: [...current?.statusHistory ?? [], entry]
|
|
4358
|
-
}, ctx);
|
|
4359
|
-
if (!updated) throw new NotFoundError("Lead", leadId);
|
|
4360
|
-
return updated;
|
|
5017
|
+
return this.leads.markConverted(leadId, fromStatus, patch, entry, ctx);
|
|
4361
5018
|
}
|
|
4362
5019
|
};
|
|
4363
5020
|
/**
|
|
@@ -4502,6 +5159,15 @@ const LeadAssigned = defineCrmEvent({
|
|
|
4502
5159
|
description: "assign() (re)assigned the lead owner.",
|
|
4503
5160
|
zodSchema: z.object({ leadId: z.string() }).extend(ownerChangeSchema.shape)
|
|
4504
5161
|
});
|
|
5162
|
+
const LeadAnonymized = defineCrmEvent({
|
|
5163
|
+
name: CRM_EVENTS.LEAD_ANONYMIZED,
|
|
5164
|
+
resource: "lead",
|
|
5165
|
+
description: "anonymize() scrubbed a lead's PII (name/email/phone) in place — GDPR right-to-erasure. Non-PII aggregates (score/status/engagement) and the id are preserved; `anonymizedAt` tombstones the row.",
|
|
5166
|
+
zodSchema: z.object({
|
|
5167
|
+
leadId: z.string(),
|
|
5168
|
+
fields: z.array(z.string())
|
|
5169
|
+
})
|
|
5170
|
+
});
|
|
4505
5171
|
const AccountCreated = defineCrmEvent({
|
|
4506
5172
|
name: CRM_EVENTS.ACCOUNT_CREATED,
|
|
4507
5173
|
resource: "account",
|
|
@@ -4568,6 +5234,15 @@ const ContactMerged = defineCrmEvent({
|
|
|
4568
5234
|
description: "merge() folded a secondary contact into the primary; secondary deleted.",
|
|
4569
5235
|
zodSchema: mergeSchema
|
|
4570
5236
|
});
|
|
5237
|
+
const ContactAnonymized = defineCrmEvent({
|
|
5238
|
+
name: CRM_EVENTS.CONTACT_ANONYMIZED,
|
|
5239
|
+
resource: "contact",
|
|
5240
|
+
description: "anonymize() scrubbed a contact's PII (name + every contact channel) in place — GDPR right-to-erasure. Non-PII aggregates (accountId, ownerId, tags) and the id are preserved; `anonymizedAt` tombstones the row.",
|
|
5241
|
+
zodSchema: z.object({
|
|
5242
|
+
contactId: z.string(),
|
|
5243
|
+
fields: z.array(z.string())
|
|
5244
|
+
})
|
|
5245
|
+
});
|
|
4571
5246
|
const OpportunityCreated = defineCrmEvent({
|
|
4572
5247
|
name: CRM_EVENTS.OPPORTUNITY_CREATED,
|
|
4573
5248
|
resource: "opportunity",
|
|
@@ -5023,6 +5698,7 @@ const crmEventDefinitions = [
|
|
|
5023
5698
|
LeadDisqualified,
|
|
5024
5699
|
LeadEnriched,
|
|
5025
5700
|
LeadAssigned,
|
|
5701
|
+
LeadAnonymized,
|
|
5026
5702
|
AccountCreated,
|
|
5027
5703
|
AccountUpdated,
|
|
5028
5704
|
AccountEnriched,
|
|
@@ -5033,6 +5709,7 @@ const crmEventDefinitions = [
|
|
|
5033
5709
|
ContactEnriched,
|
|
5034
5710
|
ContactAssigned,
|
|
5035
5711
|
ContactMerged,
|
|
5712
|
+
ContactAnonymized,
|
|
5036
5713
|
OpportunityCreated,
|
|
5037
5714
|
OpportunityStageAdvanced,
|
|
5038
5715
|
OpportunityStageRegressed,
|
|
@@ -5149,8 +5826,10 @@ function pipelineConversion(opportunities, stageIds) {
|
|
|
5149
5826
|
for (const o of opportunities) {
|
|
5150
5827
|
if (o.stageId === stageId && o.status === "open") currentCount++;
|
|
5151
5828
|
if (o.status === "won" || o.status === "lost") {
|
|
5152
|
-
if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId)
|
|
5153
|
-
|
|
5829
|
+
if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) {
|
|
5830
|
+
if (o.status === "won") wonFromHere++;
|
|
5831
|
+
else lostFromHere++;
|
|
5832
|
+
}
|
|
5154
5833
|
}
|
|
5155
5834
|
}
|
|
5156
5835
|
const closed = wonFromHere + lostFromHere;
|
|
@@ -5577,4 +6256,4 @@ var validators_exports = /* @__PURE__ */ __exportAll({
|
|
|
5577
6256
|
updateSequenceSchema: () => updateSequenceSchema
|
|
5578
6257
|
});
|
|
5579
6258
|
//#endregion
|
|
5580
|
-
export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, CrmEventDispatcher, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, EnrollmentCompleted, EnrollmentCreated, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentResumed, EnrollmentStepSent, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, SuppressionAdded, SuppressionRemoved, SuppressionRepository, UnmanagedSessionError, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel,
|
|
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 };
|