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