@classytic/crm 0.1.1 → 0.3.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 +61 -0
- package/dist/chunk.js +13 -0
- package/dist/index.d.ts +1312 -1157
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2654 -1625
- package/dist/index.js.map +1 -1
- package/package.json +15 -5
package/dist/index.js
CHANGED
|
@@ -1,7 +1,1278 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk.js";
|
|
2
|
+
import { Schema } from "mongoose";
|
|
3
|
+
import { Repository, aggregateHelpersPlugin, methodRegistryPlugin, multiTenantPlugin } from "@classytic/mongokit";
|
|
4
|
+
import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
|
|
5
|
+
import { assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
|
|
1
6
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
3
7
|
import { z } from "zod";
|
|
4
|
-
|
|
8
|
+
import { appendStatus, lastTransitionTo, latestEntry, timeInStatus } from "@classytic/primitives/status-history";
|
|
9
|
+
//#region src/models/tenant-field.ts
|
|
10
|
+
/**
|
|
11
|
+
* Per PACKAGE_RULES §9.2: never hardcode the tenant column's Mongoose
|
|
12
|
+
* type — let the engine config drive both the schema and the
|
|
13
|
+
* multiTenantPlugin so they stay in sync. Hosts with Better Auth pick
|
|
14
|
+
* `'objectId'` (default for new packages — enables `$lookup`,
|
|
15
|
+
* `.populate()`, QueryParser `?lookup=organizationId`); hosts with
|
|
16
|
+
* UUID/slug auth pick `'string'`.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Tenant-column schema definition. Use this anywhere a model has
|
|
20
|
+
* `organizationId` — never write the raw type in a schema file.
|
|
21
|
+
*/
|
|
22
|
+
function tenantFieldDef(config) {
|
|
23
|
+
return config.tenantFieldType === "objectId" ? {
|
|
24
|
+
type: Schema.Types.ObjectId,
|
|
25
|
+
required: true,
|
|
26
|
+
ref: "organization",
|
|
27
|
+
index: true
|
|
28
|
+
} : {
|
|
29
|
+
type: String,
|
|
30
|
+
required: true,
|
|
31
|
+
index: true
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/models/pipeline.schema.ts
|
|
36
|
+
/**
|
|
37
|
+
* Pipeline + embedded Stages.
|
|
38
|
+
*
|
|
39
|
+
* Sales motions are the cardinality consumers think in — "new business
|
|
40
|
+
* vs. expansion vs. partnerships." Each Pipeline owns its Stages
|
|
41
|
+
* inline (embedded subdocs) because stages have no meaning outside a
|
|
42
|
+
* pipeline and the read pattern is "load the pipeline + all its
|
|
43
|
+
* stages" 99% of the time. Opportunities reference a `(pipelineId,
|
|
44
|
+
* stageId)` pair to pin themselves.
|
|
45
|
+
*/
|
|
46
|
+
const stageSubSchema = new Schema({
|
|
47
|
+
name: {
|
|
48
|
+
type: String,
|
|
49
|
+
required: true,
|
|
50
|
+
trim: true,
|
|
51
|
+
maxlength: 100
|
|
52
|
+
},
|
|
53
|
+
sequence: {
|
|
54
|
+
type: Number,
|
|
55
|
+
required: true,
|
|
56
|
+
min: 1
|
|
57
|
+
},
|
|
58
|
+
defaultProbability: {
|
|
59
|
+
type: Number,
|
|
60
|
+
required: true,
|
|
61
|
+
min: 0,
|
|
62
|
+
max: 1
|
|
63
|
+
},
|
|
64
|
+
color: {
|
|
65
|
+
type: String,
|
|
66
|
+
trim: true,
|
|
67
|
+
maxlength: 32
|
|
68
|
+
},
|
|
69
|
+
description: {
|
|
70
|
+
type: String,
|
|
71
|
+
trim: true,
|
|
72
|
+
maxlength: 500
|
|
73
|
+
}
|
|
74
|
+
}, { _id: true });
|
|
75
|
+
function buildPipelineSchema(config) {
|
|
76
|
+
const schema = new Schema({
|
|
77
|
+
organizationId: tenantFieldDef(config),
|
|
78
|
+
name: {
|
|
79
|
+
type: String,
|
|
80
|
+
required: true,
|
|
81
|
+
trim: true,
|
|
82
|
+
maxlength: 200
|
|
83
|
+
},
|
|
84
|
+
isArchived: {
|
|
85
|
+
type: Boolean,
|
|
86
|
+
default: false,
|
|
87
|
+
index: true
|
|
88
|
+
},
|
|
89
|
+
stages: {
|
|
90
|
+
type: [stageSubSchema],
|
|
91
|
+
default: []
|
|
92
|
+
},
|
|
93
|
+
teamRef: {
|
|
94
|
+
type: String,
|
|
95
|
+
trim: true,
|
|
96
|
+
maxlength: 100
|
|
97
|
+
},
|
|
98
|
+
metadata: { type: Schema.Types.Mixed }
|
|
99
|
+
}, { timestamps: true });
|
|
100
|
+
schema.index({
|
|
101
|
+
organizationId: 1,
|
|
102
|
+
isArchived: 1
|
|
103
|
+
});
|
|
104
|
+
schema.index({
|
|
105
|
+
organizationId: 1,
|
|
106
|
+
name: 1
|
|
107
|
+
});
|
|
108
|
+
return schema;
|
|
109
|
+
}
|
|
110
|
+
function createPipelineModel(connection, config) {
|
|
111
|
+
if (connection.models.Pipeline) connection.deleteModel("Pipeline");
|
|
112
|
+
return connection.model("Pipeline", buildPipelineSchema(config));
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/enums/lead-status.ts
|
|
116
|
+
/**
|
|
117
|
+
* Lead lifecycle — from initial inquiry through conversion.
|
|
118
|
+
*
|
|
119
|
+
* new — just captured (web form, import, referral)
|
|
120
|
+
* contacted — SDR has reached out
|
|
121
|
+
* qualified — meets ICP / BANT
|
|
122
|
+
* converted — turned into Contact + Account + Opportunity
|
|
123
|
+
* disqualified — rejected (bad fit, spam, duplicate)
|
|
124
|
+
* nurturing — not ready now; parked for later outreach
|
|
125
|
+
*/
|
|
126
|
+
const LEAD_STATUSES = [
|
|
127
|
+
"new",
|
|
128
|
+
"contacted",
|
|
129
|
+
"qualified",
|
|
130
|
+
"converted",
|
|
131
|
+
"disqualified",
|
|
132
|
+
"nurturing"
|
|
133
|
+
];
|
|
134
|
+
const LEAD_TRANSITIONS = {
|
|
135
|
+
new: [
|
|
136
|
+
"contacted",
|
|
137
|
+
"qualified",
|
|
138
|
+
"disqualified",
|
|
139
|
+
"nurturing"
|
|
140
|
+
],
|
|
141
|
+
contacted: [
|
|
142
|
+
"qualified",
|
|
143
|
+
"disqualified",
|
|
144
|
+
"nurturing"
|
|
145
|
+
],
|
|
146
|
+
qualified: [
|
|
147
|
+
"converted",
|
|
148
|
+
"disqualified",
|
|
149
|
+
"nurturing"
|
|
150
|
+
],
|
|
151
|
+
nurturing: [
|
|
152
|
+
"contacted",
|
|
153
|
+
"qualified",
|
|
154
|
+
"disqualified"
|
|
155
|
+
],
|
|
156
|
+
converted: [],
|
|
157
|
+
disqualified: []
|
|
158
|
+
};
|
|
159
|
+
function canTransitionLead(from, to) {
|
|
160
|
+
return LEAD_TRANSITIONS[from].includes(to);
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/models/lead.schema.ts
|
|
164
|
+
/**
|
|
165
|
+
* Lead — top-of-funnel inquiry, pre-qualification.
|
|
166
|
+
*/
|
|
167
|
+
const statusEntrySchema$3 = new Schema({
|
|
168
|
+
status: {
|
|
169
|
+
type: String,
|
|
170
|
+
enum: LEAD_STATUSES,
|
|
171
|
+
required: true
|
|
172
|
+
},
|
|
173
|
+
occurredAt: {
|
|
174
|
+
type: Date,
|
|
175
|
+
required: true
|
|
176
|
+
},
|
|
177
|
+
by: { type: String },
|
|
178
|
+
note: { type: String }
|
|
179
|
+
}, { _id: false });
|
|
180
|
+
function buildLeadSchema(config) {
|
|
181
|
+
const schema = new Schema({
|
|
182
|
+
organizationId: tenantFieldDef(config),
|
|
183
|
+
fullName: {
|
|
184
|
+
type: String,
|
|
185
|
+
required: true,
|
|
186
|
+
trim: true,
|
|
187
|
+
maxlength: 200
|
|
188
|
+
},
|
|
189
|
+
firstName: {
|
|
190
|
+
type: String,
|
|
191
|
+
trim: true,
|
|
192
|
+
maxlength: 100
|
|
193
|
+
},
|
|
194
|
+
lastName: {
|
|
195
|
+
type: String,
|
|
196
|
+
trim: true,
|
|
197
|
+
maxlength: 100
|
|
198
|
+
},
|
|
199
|
+
email: {
|
|
200
|
+
type: String,
|
|
201
|
+
trim: true,
|
|
202
|
+
lowercase: true,
|
|
203
|
+
maxlength: 255
|
|
204
|
+
},
|
|
205
|
+
phone: {
|
|
206
|
+
type: String,
|
|
207
|
+
trim: true,
|
|
208
|
+
maxlength: 50
|
|
209
|
+
},
|
|
210
|
+
companyName: {
|
|
211
|
+
type: String,
|
|
212
|
+
trim: true,
|
|
213
|
+
maxlength: 200
|
|
214
|
+
},
|
|
215
|
+
jobTitle: {
|
|
216
|
+
type: String,
|
|
217
|
+
trim: true,
|
|
218
|
+
maxlength: 150
|
|
219
|
+
},
|
|
220
|
+
source: {
|
|
221
|
+
type: String,
|
|
222
|
+
trim: true,
|
|
223
|
+
maxlength: 100
|
|
224
|
+
},
|
|
225
|
+
campaignRef: {
|
|
226
|
+
type: String,
|
|
227
|
+
trim: true,
|
|
228
|
+
maxlength: 100
|
|
229
|
+
},
|
|
230
|
+
score: {
|
|
231
|
+
type: Number,
|
|
232
|
+
default: 0
|
|
233
|
+
},
|
|
234
|
+
status: {
|
|
235
|
+
type: String,
|
|
236
|
+
enum: LEAD_STATUSES,
|
|
237
|
+
default: "new",
|
|
238
|
+
index: true
|
|
239
|
+
},
|
|
240
|
+
statusHistory: {
|
|
241
|
+
type: [statusEntrySchema$3],
|
|
242
|
+
default: []
|
|
243
|
+
},
|
|
244
|
+
ownerId: {
|
|
245
|
+
type: String,
|
|
246
|
+
index: true
|
|
247
|
+
},
|
|
248
|
+
tags: {
|
|
249
|
+
type: [String],
|
|
250
|
+
default: []
|
|
251
|
+
},
|
|
252
|
+
convertedContactId: {
|
|
253
|
+
type: Schema.Types.ObjectId,
|
|
254
|
+
ref: "Contact"
|
|
255
|
+
},
|
|
256
|
+
convertedAccountId: {
|
|
257
|
+
type: Schema.Types.ObjectId,
|
|
258
|
+
ref: "Account"
|
|
259
|
+
},
|
|
260
|
+
convertedOpportunityId: {
|
|
261
|
+
type: Schema.Types.ObjectId,
|
|
262
|
+
ref: "Opportunity"
|
|
263
|
+
},
|
|
264
|
+
convertedAt: { type: Date },
|
|
265
|
+
disqualifyReason: {
|
|
266
|
+
type: String,
|
|
267
|
+
trim: true,
|
|
268
|
+
maxlength: 500
|
|
269
|
+
},
|
|
270
|
+
metadata: { type: Schema.Types.Mixed }
|
|
271
|
+
}, { timestamps: true });
|
|
272
|
+
schema.index({
|
|
273
|
+
organizationId: 1,
|
|
274
|
+
email: 1
|
|
275
|
+
});
|
|
276
|
+
schema.index({
|
|
277
|
+
organizationId: 1,
|
|
278
|
+
createdAt: -1
|
|
279
|
+
});
|
|
280
|
+
schema.index({
|
|
281
|
+
organizationId: 1,
|
|
282
|
+
score: -1
|
|
283
|
+
});
|
|
284
|
+
return schema;
|
|
285
|
+
}
|
|
286
|
+
function createLeadModel(connection, config) {
|
|
287
|
+
if (connection.models.Lead) connection.deleteModel("Lead");
|
|
288
|
+
return connection.model("Lead", buildLeadSchema(config));
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/enums/opportunity-status.ts
|
|
292
|
+
/**
|
|
293
|
+
* Top-level opportunity status. Movement through pipeline STAGES
|
|
294
|
+
* happens while status is `open`; `won` / `lost` are terminal
|
|
295
|
+
* outcomes. Re-opening a lost deal is a host-level decision —
|
|
296
|
+
* convention is to clone the opportunity rather than revive the
|
|
297
|
+
* terminal record.
|
|
298
|
+
*/
|
|
299
|
+
const OPPORTUNITY_STATUSES = [
|
|
300
|
+
"open",
|
|
301
|
+
"won",
|
|
302
|
+
"lost",
|
|
303
|
+
"abandoned"
|
|
304
|
+
];
|
|
305
|
+
const OPPORTUNITY_TRANSITIONS = {
|
|
306
|
+
open: [
|
|
307
|
+
"won",
|
|
308
|
+
"lost",
|
|
309
|
+
"abandoned"
|
|
310
|
+
],
|
|
311
|
+
won: [],
|
|
312
|
+
lost: [],
|
|
313
|
+
abandoned: []
|
|
314
|
+
};
|
|
315
|
+
function canTransitionOpportunity(from, to) {
|
|
316
|
+
return OPPORTUNITY_TRANSITIONS[from].includes(to);
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/models/opportunity.schema.ts
|
|
320
|
+
/**
|
|
321
|
+
* Opportunity — qualified deal moving through a Pipeline's stages.
|
|
322
|
+
* Money amount lives flat (`amount` + `currency`) so `$sum` aggregates work; the repo
|
|
323
|
+
* surface returns it that way too (no Money translation layer per PACKAGE_RULES §4).
|
|
324
|
+
*/
|
|
325
|
+
const statusEntrySchema$2 = new Schema({
|
|
326
|
+
status: {
|
|
327
|
+
type: String,
|
|
328
|
+
enum: OPPORTUNITY_STATUSES,
|
|
329
|
+
required: true
|
|
330
|
+
},
|
|
331
|
+
stageId: { type: String },
|
|
332
|
+
occurredAt: {
|
|
333
|
+
type: Date,
|
|
334
|
+
required: true
|
|
335
|
+
},
|
|
336
|
+
by: { type: String },
|
|
337
|
+
note: { type: String }
|
|
338
|
+
}, { _id: false });
|
|
339
|
+
function buildOpportunitySchema(config) {
|
|
340
|
+
const schema = new Schema({
|
|
341
|
+
organizationId: tenantFieldDef(config),
|
|
342
|
+
name: {
|
|
343
|
+
type: String,
|
|
344
|
+
required: true,
|
|
345
|
+
trim: true,
|
|
346
|
+
maxlength: 200
|
|
347
|
+
},
|
|
348
|
+
accountId: {
|
|
349
|
+
type: Schema.Types.ObjectId,
|
|
350
|
+
ref: "Account"
|
|
351
|
+
},
|
|
352
|
+
primaryContactId: {
|
|
353
|
+
type: Schema.Types.ObjectId,
|
|
354
|
+
ref: "Contact"
|
|
355
|
+
},
|
|
356
|
+
pipelineId: {
|
|
357
|
+
type: Schema.Types.ObjectId,
|
|
358
|
+
ref: "Pipeline",
|
|
359
|
+
required: true
|
|
360
|
+
},
|
|
361
|
+
stageId: {
|
|
362
|
+
type: String,
|
|
363
|
+
required: true,
|
|
364
|
+
index: true
|
|
365
|
+
},
|
|
366
|
+
status: {
|
|
367
|
+
type: String,
|
|
368
|
+
enum: OPPORTUNITY_STATUSES,
|
|
369
|
+
default: "open",
|
|
370
|
+
index: true
|
|
371
|
+
},
|
|
372
|
+
statusHistory: {
|
|
373
|
+
type: [statusEntrySchema$2],
|
|
374
|
+
default: []
|
|
375
|
+
},
|
|
376
|
+
amount: { type: Number },
|
|
377
|
+
currency: {
|
|
378
|
+
type: String,
|
|
379
|
+
default: "USD"
|
|
380
|
+
},
|
|
381
|
+
probability: {
|
|
382
|
+
type: Number,
|
|
383
|
+
min: 0,
|
|
384
|
+
max: 1,
|
|
385
|
+
default: 0
|
|
386
|
+
},
|
|
387
|
+
expectedCloseAt: {
|
|
388
|
+
type: Date,
|
|
389
|
+
index: true
|
|
390
|
+
},
|
|
391
|
+
closedAt: { type: Date },
|
|
392
|
+
lostReasonId: {
|
|
393
|
+
type: Schema.Types.ObjectId,
|
|
394
|
+
ref: "LossReason"
|
|
395
|
+
},
|
|
396
|
+
ownerId: {
|
|
397
|
+
type: String,
|
|
398
|
+
index: true
|
|
399
|
+
},
|
|
400
|
+
sourceLeadId: {
|
|
401
|
+
type: Schema.Types.ObjectId,
|
|
402
|
+
ref: "Lead"
|
|
403
|
+
},
|
|
404
|
+
tags: {
|
|
405
|
+
type: [String],
|
|
406
|
+
default: []
|
|
407
|
+
},
|
|
408
|
+
metadata: { type: Schema.Types.Mixed }
|
|
409
|
+
}, { timestamps: true });
|
|
410
|
+
schema.index({
|
|
411
|
+
organizationId: 1,
|
|
412
|
+
pipelineId: 1,
|
|
413
|
+
stageId: 1
|
|
414
|
+
});
|
|
415
|
+
schema.index({
|
|
416
|
+
organizationId: 1,
|
|
417
|
+
status: 1,
|
|
418
|
+
expectedCloseAt: 1
|
|
419
|
+
});
|
|
420
|
+
schema.index({
|
|
421
|
+
organizationId: 1,
|
|
422
|
+
ownerId: 1,
|
|
423
|
+
status: 1
|
|
424
|
+
});
|
|
425
|
+
return schema;
|
|
426
|
+
}
|
|
427
|
+
function createOpportunityModel(connection, config) {
|
|
428
|
+
if (connection.models.Opportunity) connection.deleteModel("Opportunity");
|
|
429
|
+
return connection.model("Opportunity", buildOpportunitySchema(config));
|
|
430
|
+
}
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/models/contact.schema.ts
|
|
433
|
+
/**
|
|
434
|
+
* Contact — a person in the CRM.
|
|
435
|
+
*
|
|
436
|
+
* Per PACKAGE_RULES §4 (no envelopes / no translation): we store the
|
|
437
|
+
* structured `PersonName` + `ContactInfo` from `@classytic/primitives/person`
|
|
438
|
+
* verbatim, embedded. The host queries `contact.name.given`,
|
|
439
|
+
* `contact.contact.email`, etc. directly — no flat-vs-structured
|
|
440
|
+
* translation layer.
|
|
441
|
+
*/
|
|
442
|
+
const nameSchema = new Schema({
|
|
443
|
+
given: {
|
|
444
|
+
type: String,
|
|
445
|
+
required: true,
|
|
446
|
+
trim: true,
|
|
447
|
+
maxlength: 100
|
|
448
|
+
},
|
|
449
|
+
family: {
|
|
450
|
+
type: String,
|
|
451
|
+
default: "",
|
|
452
|
+
trim: true,
|
|
453
|
+
maxlength: 100
|
|
454
|
+
},
|
|
455
|
+
middle: {
|
|
456
|
+
type: String,
|
|
457
|
+
trim: true,
|
|
458
|
+
maxlength: 100
|
|
459
|
+
},
|
|
460
|
+
prefix: {
|
|
461
|
+
type: String,
|
|
462
|
+
trim: true,
|
|
463
|
+
maxlength: 32
|
|
464
|
+
},
|
|
465
|
+
suffix: {
|
|
466
|
+
type: String,
|
|
467
|
+
trim: true,
|
|
468
|
+
maxlength: 32
|
|
469
|
+
},
|
|
470
|
+
preferred: {
|
|
471
|
+
type: String,
|
|
472
|
+
trim: true,
|
|
473
|
+
maxlength: 100
|
|
474
|
+
}
|
|
475
|
+
}, { _id: false });
|
|
476
|
+
const contactInfoSchema$1 = new Schema({
|
|
477
|
+
email: {
|
|
478
|
+
type: String,
|
|
479
|
+
trim: true,
|
|
480
|
+
lowercase: true,
|
|
481
|
+
maxlength: 255
|
|
482
|
+
},
|
|
483
|
+
personalEmail: {
|
|
484
|
+
type: String,
|
|
485
|
+
trim: true,
|
|
486
|
+
lowercase: true,
|
|
487
|
+
maxlength: 255
|
|
488
|
+
},
|
|
489
|
+
phone: {
|
|
490
|
+
type: String,
|
|
491
|
+
trim: true,
|
|
492
|
+
maxlength: 50
|
|
493
|
+
},
|
|
494
|
+
alternatePhone: {
|
|
495
|
+
type: String,
|
|
496
|
+
trim: true,
|
|
497
|
+
maxlength: 50
|
|
498
|
+
}
|
|
499
|
+
}, { _id: false });
|
|
500
|
+
function buildContactSchema(config) {
|
|
501
|
+
const schema = new Schema({
|
|
502
|
+
organizationId: tenantFieldDef(config),
|
|
503
|
+
name: {
|
|
504
|
+
type: nameSchema,
|
|
505
|
+
required: true
|
|
506
|
+
},
|
|
507
|
+
contact: {
|
|
508
|
+
type: contactInfoSchema$1,
|
|
509
|
+
default: {}
|
|
510
|
+
},
|
|
511
|
+
jobTitle: {
|
|
512
|
+
type: String,
|
|
513
|
+
trim: true,
|
|
514
|
+
maxlength: 150
|
|
515
|
+
},
|
|
516
|
+
accountId: {
|
|
517
|
+
type: Schema.Types.ObjectId,
|
|
518
|
+
ref: "Account"
|
|
519
|
+
},
|
|
520
|
+
ownerId: {
|
|
521
|
+
type: String,
|
|
522
|
+
index: true
|
|
523
|
+
},
|
|
524
|
+
tags: {
|
|
525
|
+
type: [String],
|
|
526
|
+
default: []
|
|
527
|
+
},
|
|
528
|
+
metadata: { type: Schema.Types.Mixed }
|
|
529
|
+
}, {
|
|
530
|
+
timestamps: true,
|
|
531
|
+
toJSON: { virtuals: true },
|
|
532
|
+
toObject: { virtuals: true }
|
|
533
|
+
});
|
|
534
|
+
schema.index({
|
|
535
|
+
organizationId: 1,
|
|
536
|
+
"contact.email": 1
|
|
537
|
+
});
|
|
538
|
+
schema.index({
|
|
539
|
+
organizationId: 1,
|
|
540
|
+
accountId: 1
|
|
541
|
+
});
|
|
542
|
+
schema.index({
|
|
543
|
+
organizationId: 1,
|
|
544
|
+
createdAt: -1
|
|
545
|
+
});
|
|
546
|
+
schema.index({
|
|
547
|
+
organizationId: 1,
|
|
548
|
+
tags: 1
|
|
549
|
+
});
|
|
550
|
+
schema.virtual("fullName").get(function() {
|
|
551
|
+
const n = this.name;
|
|
552
|
+
return n?.preferred?.trim() || [n?.given, n?.family].filter(Boolean).join(" ");
|
|
553
|
+
});
|
|
554
|
+
return schema;
|
|
555
|
+
}
|
|
556
|
+
function createContactModel(connection, config) {
|
|
557
|
+
if (connection.models.Contact) connection.deleteModel("Contact");
|
|
558
|
+
return connection.model("Contact", buildContactSchema(config));
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/models/account.schema.ts
|
|
562
|
+
/**
|
|
563
|
+
* Account — the B2B customer org an Opportunity targets and Contacts belong to.
|
|
564
|
+
* Flat `annualRevenue` (Number + currency) for $sum aggregations.
|
|
565
|
+
*/
|
|
566
|
+
function buildAccountSchema(config) {
|
|
567
|
+
const schema = new Schema({
|
|
568
|
+
organizationId: tenantFieldDef(config),
|
|
569
|
+
name: {
|
|
570
|
+
type: String,
|
|
571
|
+
required: true,
|
|
572
|
+
trim: true,
|
|
573
|
+
maxlength: 200
|
|
574
|
+
},
|
|
575
|
+
domain: {
|
|
576
|
+
type: String,
|
|
577
|
+
trim: true,
|
|
578
|
+
lowercase: true,
|
|
579
|
+
maxlength: 255
|
|
580
|
+
},
|
|
581
|
+
industry: {
|
|
582
|
+
type: String,
|
|
583
|
+
trim: true,
|
|
584
|
+
maxlength: 100
|
|
585
|
+
},
|
|
586
|
+
sizeBucket: {
|
|
587
|
+
type: String,
|
|
588
|
+
trim: true,
|
|
589
|
+
maxlength: 20
|
|
590
|
+
},
|
|
591
|
+
annualRevenueAmount: { type: Number },
|
|
592
|
+
annualRevenueCurrency: {
|
|
593
|
+
type: String,
|
|
594
|
+
default: "USD"
|
|
595
|
+
},
|
|
596
|
+
ownerId: {
|
|
597
|
+
type: String,
|
|
598
|
+
index: true
|
|
599
|
+
},
|
|
600
|
+
tags: {
|
|
601
|
+
type: [String],
|
|
602
|
+
default: []
|
|
603
|
+
},
|
|
604
|
+
metadata: { type: Schema.Types.Mixed }
|
|
605
|
+
}, { timestamps: true });
|
|
606
|
+
schema.index({
|
|
607
|
+
organizationId: 1,
|
|
608
|
+
domain: 1
|
|
609
|
+
});
|
|
610
|
+
schema.index({
|
|
611
|
+
organizationId: 1,
|
|
612
|
+
createdAt: -1
|
|
613
|
+
});
|
|
614
|
+
schema.index({
|
|
615
|
+
organizationId: 1,
|
|
616
|
+
tags: 1
|
|
617
|
+
});
|
|
618
|
+
return schema;
|
|
619
|
+
}
|
|
620
|
+
function createAccountModel(connection, config) {
|
|
621
|
+
if (connection.models.Account) connection.deleteModel("Account");
|
|
622
|
+
return connection.model("Account", buildAccountSchema(config));
|
|
623
|
+
}
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/enums/campaign-status.ts
|
|
626
|
+
/**
|
|
627
|
+
* Campaign lifecycle.
|
|
628
|
+
*
|
|
629
|
+
* draft — being built; not running, no sends, no UTM attribution counted
|
|
630
|
+
* active — running; participating in attribution + automation
|
|
631
|
+
* paused — temporarily halted; can be resumed; no sends fire
|
|
632
|
+
* completed — finished its run; preserved for historical attribution
|
|
633
|
+
* archived — soft-hidden from the default UI; still queryable
|
|
634
|
+
*/
|
|
635
|
+
const CAMPAIGN_STATUSES = [
|
|
636
|
+
"draft",
|
|
637
|
+
"active",
|
|
638
|
+
"paused",
|
|
639
|
+
"completed",
|
|
640
|
+
"archived"
|
|
641
|
+
];
|
|
642
|
+
const CAMPAIGN_TRANSITIONS = {
|
|
643
|
+
draft: ["active", "archived"],
|
|
644
|
+
active: [
|
|
645
|
+
"paused",
|
|
646
|
+
"completed",
|
|
647
|
+
"archived"
|
|
648
|
+
],
|
|
649
|
+
paused: [
|
|
650
|
+
"active",
|
|
651
|
+
"completed",
|
|
652
|
+
"archived"
|
|
653
|
+
],
|
|
654
|
+
completed: ["archived"],
|
|
655
|
+
archived: []
|
|
656
|
+
};
|
|
657
|
+
function canTransitionCampaign(from, to) {
|
|
658
|
+
return CAMPAIGN_TRANSITIONS[from].includes(to);
|
|
659
|
+
}
|
|
660
|
+
//#endregion
|
|
661
|
+
//#region src/models/campaign.schema.ts
|
|
662
|
+
/**
|
|
663
|
+
* Campaign — a marketing/sales motion targeting a contact list.
|
|
664
|
+
* Money fields stored flat (amount + currency) for $sum aggregations.
|
|
665
|
+
*/
|
|
666
|
+
const statusEntrySchema$1 = new Schema({
|
|
667
|
+
status: {
|
|
668
|
+
type: String,
|
|
669
|
+
enum: CAMPAIGN_STATUSES,
|
|
670
|
+
required: true
|
|
671
|
+
},
|
|
672
|
+
occurredAt: {
|
|
673
|
+
type: Date,
|
|
674
|
+
required: true
|
|
675
|
+
},
|
|
676
|
+
by: { type: String },
|
|
677
|
+
note: { type: String }
|
|
678
|
+
}, { _id: false });
|
|
679
|
+
function buildCampaignSchema(config) {
|
|
680
|
+
const schema = new Schema({
|
|
681
|
+
organizationId: tenantFieldDef(config),
|
|
682
|
+
name: {
|
|
683
|
+
type: String,
|
|
684
|
+
required: true,
|
|
685
|
+
trim: true,
|
|
686
|
+
maxlength: 200
|
|
687
|
+
},
|
|
688
|
+
description: {
|
|
689
|
+
type: String,
|
|
690
|
+
trim: true,
|
|
691
|
+
maxlength: 2e3
|
|
692
|
+
},
|
|
693
|
+
status: {
|
|
694
|
+
type: String,
|
|
695
|
+
enum: CAMPAIGN_STATUSES,
|
|
696
|
+
default: "draft",
|
|
697
|
+
index: true
|
|
698
|
+
},
|
|
699
|
+
statusHistory: {
|
|
700
|
+
type: [statusEntrySchema$1],
|
|
701
|
+
default: []
|
|
702
|
+
},
|
|
703
|
+
utmSource: {
|
|
704
|
+
type: String,
|
|
705
|
+
trim: true,
|
|
706
|
+
maxlength: 100
|
|
707
|
+
},
|
|
708
|
+
utmMedium: {
|
|
709
|
+
type: String,
|
|
710
|
+
trim: true,
|
|
711
|
+
maxlength: 100
|
|
712
|
+
},
|
|
713
|
+
utmCampaign: {
|
|
714
|
+
type: String,
|
|
715
|
+
trim: true,
|
|
716
|
+
maxlength: 100
|
|
717
|
+
},
|
|
718
|
+
startDate: { type: Date },
|
|
719
|
+
endDate: { type: Date },
|
|
720
|
+
budgetAmount: { type: Number },
|
|
721
|
+
expectedRevenueAmount: { type: Number },
|
|
722
|
+
actualRevenueAmount: { type: Number },
|
|
723
|
+
currency: {
|
|
724
|
+
type: String,
|
|
725
|
+
default: "USD"
|
|
726
|
+
},
|
|
727
|
+
ownerId: {
|
|
728
|
+
type: String,
|
|
729
|
+
index: true
|
|
730
|
+
},
|
|
731
|
+
tags: {
|
|
732
|
+
type: [String],
|
|
733
|
+
default: []
|
|
734
|
+
},
|
|
735
|
+
metadata: { type: Schema.Types.Mixed }
|
|
736
|
+
}, { timestamps: true });
|
|
737
|
+
schema.index({
|
|
738
|
+
organizationId: 1,
|
|
739
|
+
status: 1
|
|
740
|
+
});
|
|
741
|
+
schema.index({
|
|
742
|
+
organizationId: 1,
|
|
743
|
+
startDate: 1
|
|
744
|
+
});
|
|
745
|
+
schema.index({
|
|
746
|
+
organizationId: 1,
|
|
747
|
+
createdAt: -1
|
|
748
|
+
});
|
|
749
|
+
schema.index({
|
|
750
|
+
organizationId: 1,
|
|
751
|
+
tags: 1
|
|
752
|
+
});
|
|
753
|
+
return schema;
|
|
754
|
+
}
|
|
755
|
+
function createCampaignModel(connection, config) {
|
|
756
|
+
if (connection.models.Campaign) connection.deleteModel("Campaign");
|
|
757
|
+
return connection.model("Campaign", buildCampaignSchema(config));
|
|
758
|
+
}
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/enums/subscription-status.ts
|
|
761
|
+
/**
|
|
762
|
+
* Subscription lifecycle.
|
|
763
|
+
*
|
|
764
|
+
* pending — double-opt-in pending confirmation
|
|
765
|
+
* confirmed — actively subscribed; eligible to receive sends
|
|
766
|
+
* unsubscribed — user opted out via unsubscribe link (or operator action)
|
|
767
|
+
* bounced — provider reported hard bounce; permanent suppression
|
|
768
|
+
* complained — recipient hit "spam" / "report" in their email client;
|
|
769
|
+
* permanent suppression (CAN-SPAM + provider trust)
|
|
770
|
+
*
|
|
771
|
+
* All terminal states are non-reopenable. Re-subscribing creates a new
|
|
772
|
+
* Subscription row — the audit trail of unsubscribes/bounces is preserved.
|
|
773
|
+
*/
|
|
774
|
+
const SUBSCRIPTION_STATUSES = [
|
|
775
|
+
"pending",
|
|
776
|
+
"confirmed",
|
|
777
|
+
"unsubscribed",
|
|
778
|
+
"bounced",
|
|
779
|
+
"complained"
|
|
780
|
+
];
|
|
781
|
+
const SUBSCRIPTION_TRANSITIONS = {
|
|
782
|
+
pending: [
|
|
783
|
+
"confirmed",
|
|
784
|
+
"unsubscribed",
|
|
785
|
+
"bounced"
|
|
786
|
+
],
|
|
787
|
+
confirmed: [
|
|
788
|
+
"unsubscribed",
|
|
789
|
+
"bounced",
|
|
790
|
+
"complained"
|
|
791
|
+
],
|
|
792
|
+
unsubscribed: [],
|
|
793
|
+
bounced: [],
|
|
794
|
+
complained: []
|
|
795
|
+
};
|
|
796
|
+
function canTransitionSubscription(from, to) {
|
|
797
|
+
return SUBSCRIPTION_TRANSITIONS[from].includes(to);
|
|
798
|
+
}
|
|
799
|
+
//#endregion
|
|
800
|
+
//#region src/models/subscription.schema.ts
|
|
801
|
+
/**
|
|
802
|
+
* Subscription — a Contact's opt-in to a Campaign. Double-opt-in tokens,
|
|
803
|
+
* unsubscribe token, compliance audit fields.
|
|
804
|
+
*/
|
|
805
|
+
const statusEntrySchema = new Schema({
|
|
806
|
+
status: {
|
|
807
|
+
type: String,
|
|
808
|
+
enum: SUBSCRIPTION_STATUSES,
|
|
809
|
+
required: true
|
|
810
|
+
},
|
|
811
|
+
occurredAt: {
|
|
812
|
+
type: Date,
|
|
813
|
+
required: true
|
|
814
|
+
},
|
|
815
|
+
by: { type: String },
|
|
816
|
+
note: { type: String }
|
|
817
|
+
}, { _id: false });
|
|
818
|
+
function buildSubscriptionSchema(config) {
|
|
819
|
+
const schema = new Schema({
|
|
820
|
+
organizationId: tenantFieldDef(config),
|
|
821
|
+
contactId: {
|
|
822
|
+
type: Schema.Types.ObjectId,
|
|
823
|
+
ref: "Contact",
|
|
824
|
+
required: true
|
|
825
|
+
},
|
|
826
|
+
campaignId: {
|
|
827
|
+
type: Schema.Types.ObjectId,
|
|
828
|
+
ref: "Campaign",
|
|
829
|
+
required: true
|
|
830
|
+
},
|
|
831
|
+
status: {
|
|
832
|
+
type: String,
|
|
833
|
+
enum: SUBSCRIPTION_STATUSES,
|
|
834
|
+
default: "pending",
|
|
835
|
+
index: true
|
|
836
|
+
},
|
|
837
|
+
statusHistory: {
|
|
838
|
+
type: [statusEntrySchema],
|
|
839
|
+
default: []
|
|
840
|
+
},
|
|
841
|
+
source: {
|
|
842
|
+
type: String,
|
|
843
|
+
trim: true,
|
|
844
|
+
maxlength: 100
|
|
845
|
+
},
|
|
846
|
+
consentTimestamp: {
|
|
847
|
+
type: Date,
|
|
848
|
+
required: true
|
|
849
|
+
},
|
|
850
|
+
consentIpAddress: {
|
|
851
|
+
type: String,
|
|
852
|
+
trim: true,
|
|
853
|
+
maxlength: 64
|
|
854
|
+
},
|
|
855
|
+
consentUserAgent: {
|
|
856
|
+
type: String,
|
|
857
|
+
trim: true,
|
|
858
|
+
maxlength: 500
|
|
859
|
+
},
|
|
860
|
+
confirmationToken: {
|
|
861
|
+
type: String,
|
|
862
|
+
index: true
|
|
863
|
+
},
|
|
864
|
+
unsubscribeToken: {
|
|
865
|
+
type: String,
|
|
866
|
+
required: true,
|
|
867
|
+
index: true
|
|
868
|
+
},
|
|
869
|
+
confirmedAt: { type: Date },
|
|
870
|
+
unsubscribedAt: { type: Date },
|
|
871
|
+
unsubscribeReason: {
|
|
872
|
+
type: String,
|
|
873
|
+
trim: true,
|
|
874
|
+
maxlength: 500
|
|
875
|
+
},
|
|
876
|
+
bouncedAt: { type: Date },
|
|
877
|
+
bounceReason: {
|
|
878
|
+
type: String,
|
|
879
|
+
trim: true,
|
|
880
|
+
maxlength: 500
|
|
881
|
+
},
|
|
882
|
+
complainedAt: { type: Date },
|
|
883
|
+
metadata: { type: Schema.Types.Mixed }
|
|
884
|
+
}, { timestamps: true });
|
|
885
|
+
schema.index({
|
|
886
|
+
organizationId: 1,
|
|
887
|
+
campaignId: 1,
|
|
888
|
+
status: 1
|
|
889
|
+
});
|
|
890
|
+
schema.index({
|
|
891
|
+
organizationId: 1,
|
|
892
|
+
contactId: 1,
|
|
893
|
+
campaignId: 1
|
|
894
|
+
});
|
|
895
|
+
schema.index({
|
|
896
|
+
organizationId: 1,
|
|
897
|
+
createdAt: -1
|
|
898
|
+
});
|
|
899
|
+
return schema;
|
|
900
|
+
}
|
|
901
|
+
function createSubscriptionModel(connection, config) {
|
|
902
|
+
if (connection.models.Subscription) connection.deleteModel("Subscription");
|
|
903
|
+
return connection.model("Subscription", buildSubscriptionSchema(config));
|
|
904
|
+
}
|
|
905
|
+
//#endregion
|
|
906
|
+
//#region src/enums/activity.ts
|
|
907
|
+
/**
|
|
908
|
+
* Activity type vocabulary. The package stores `type` as free-form
|
|
909
|
+
* string so hosts can extend; these constants cover the common
|
|
910
|
+
* channels that automation tooling typically keys off.
|
|
911
|
+
*/
|
|
912
|
+
const ACTIVITY_TYPES = [
|
|
913
|
+
"call",
|
|
914
|
+
"email",
|
|
915
|
+
"meeting",
|
|
916
|
+
"task",
|
|
917
|
+
"note",
|
|
918
|
+
"sms",
|
|
919
|
+
"social",
|
|
920
|
+
"demo",
|
|
921
|
+
"other"
|
|
922
|
+
];
|
|
923
|
+
const ACTIVITY_STATUSES = [
|
|
924
|
+
"planned",
|
|
925
|
+
"in_progress",
|
|
926
|
+
"completed",
|
|
927
|
+
"cancelled"
|
|
928
|
+
];
|
|
929
|
+
const ACTIVITY_TRANSITIONS = {
|
|
930
|
+
planned: [
|
|
931
|
+
"in_progress",
|
|
932
|
+
"completed",
|
|
933
|
+
"cancelled"
|
|
934
|
+
],
|
|
935
|
+
in_progress: ["completed", "cancelled"],
|
|
936
|
+
completed: [],
|
|
937
|
+
cancelled: []
|
|
938
|
+
};
|
|
939
|
+
function canTransitionActivity(from, to) {
|
|
940
|
+
return ACTIVITY_TRANSITIONS[from].includes(to);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Kinds of entity an Activity can reference. Polymorphic linking lets
|
|
944
|
+
* hosts log a call against a Lead, an Opportunity, a Contact, or a
|
|
945
|
+
* stand-alone Account.
|
|
946
|
+
*/
|
|
947
|
+
const SUBJECT_KINDS = [
|
|
948
|
+
"lead",
|
|
949
|
+
"opportunity",
|
|
950
|
+
"contact",
|
|
951
|
+
"account"
|
|
952
|
+
];
|
|
953
|
+
//#endregion
|
|
954
|
+
//#region src/models/activity.schema.ts
|
|
955
|
+
/**
|
|
956
|
+
* Activity — call / email / meeting / task tied polymorphically to a CRM subject
|
|
957
|
+
* (Contact / Lead / Opportunity / Account).
|
|
958
|
+
*/
|
|
959
|
+
function buildActivitySchema(config) {
|
|
960
|
+
const schema = new Schema({
|
|
961
|
+
organizationId: tenantFieldDef(config),
|
|
962
|
+
type: {
|
|
963
|
+
type: String,
|
|
964
|
+
enum: ACTIVITY_TYPES,
|
|
965
|
+
required: true,
|
|
966
|
+
index: true
|
|
967
|
+
},
|
|
968
|
+
status: {
|
|
969
|
+
type: String,
|
|
970
|
+
enum: ACTIVITY_STATUSES,
|
|
971
|
+
default: "planned",
|
|
972
|
+
index: true
|
|
973
|
+
},
|
|
974
|
+
subjectKind: {
|
|
975
|
+
type: String,
|
|
976
|
+
enum: SUBJECT_KINDS,
|
|
977
|
+
required: true
|
|
978
|
+
},
|
|
979
|
+
subjectId: {
|
|
980
|
+
type: Schema.Types.ObjectId,
|
|
981
|
+
required: true,
|
|
982
|
+
index: true
|
|
983
|
+
},
|
|
984
|
+
title: {
|
|
985
|
+
type: String,
|
|
986
|
+
required: true,
|
|
987
|
+
trim: true,
|
|
988
|
+
maxlength: 200
|
|
989
|
+
},
|
|
990
|
+
description: {
|
|
991
|
+
type: String,
|
|
992
|
+
trim: true,
|
|
993
|
+
maxlength: 2e3
|
|
994
|
+
},
|
|
995
|
+
ownerId: {
|
|
996
|
+
type: String,
|
|
997
|
+
index: true
|
|
998
|
+
},
|
|
999
|
+
participantIds: {
|
|
1000
|
+
type: [String],
|
|
1001
|
+
default: []
|
|
1002
|
+
},
|
|
1003
|
+
dueDate: {
|
|
1004
|
+
type: Date,
|
|
1005
|
+
index: true
|
|
1006
|
+
},
|
|
1007
|
+
completedAt: { type: Date },
|
|
1008
|
+
externalRef: {
|
|
1009
|
+
type: String,
|
|
1010
|
+
trim: true,
|
|
1011
|
+
maxlength: 200
|
|
1012
|
+
},
|
|
1013
|
+
metadata: { type: Schema.Types.Mixed }
|
|
1014
|
+
}, { timestamps: true });
|
|
1015
|
+
schema.index({
|
|
1016
|
+
organizationId: 1,
|
|
1017
|
+
subjectKind: 1,
|
|
1018
|
+
subjectId: 1
|
|
1019
|
+
});
|
|
1020
|
+
schema.index({
|
|
1021
|
+
organizationId: 1,
|
|
1022
|
+
status: 1,
|
|
1023
|
+
dueDate: 1
|
|
1024
|
+
});
|
|
1025
|
+
schema.index({
|
|
1026
|
+
organizationId: 1,
|
|
1027
|
+
ownerId: 1,
|
|
1028
|
+
status: 1
|
|
1029
|
+
});
|
|
1030
|
+
schema.index({
|
|
1031
|
+
organizationId: 1,
|
|
1032
|
+
type: 1
|
|
1033
|
+
});
|
|
1034
|
+
schema.index({
|
|
1035
|
+
organizationId: 1,
|
|
1036
|
+
createdAt: -1
|
|
1037
|
+
});
|
|
1038
|
+
return schema;
|
|
1039
|
+
}
|
|
1040
|
+
function createActivityModel(connection, config) {
|
|
1041
|
+
if (connection.models.Activity) connection.deleteModel("Activity");
|
|
1042
|
+
return connection.model("Activity", buildActivitySchema(config));
|
|
1043
|
+
}
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region src/models/note.schema.ts
|
|
1046
|
+
/**
|
|
1047
|
+
* Note — free-form annotation tied polymorphically to a CRM subject.
|
|
1048
|
+
* Separate from Activity because notes don't have a lifecycle (no
|
|
1049
|
+
* planned/completed/cancelled).
|
|
1050
|
+
*/
|
|
1051
|
+
function buildNoteSchema(config) {
|
|
1052
|
+
const schema = new Schema({
|
|
1053
|
+
organizationId: tenantFieldDef(config),
|
|
1054
|
+
subjectKind: {
|
|
1055
|
+
type: String,
|
|
1056
|
+
enum: SUBJECT_KINDS,
|
|
1057
|
+
required: true
|
|
1058
|
+
},
|
|
1059
|
+
subjectId: {
|
|
1060
|
+
type: Schema.Types.ObjectId,
|
|
1061
|
+
required: true,
|
|
1062
|
+
index: true
|
|
1063
|
+
},
|
|
1064
|
+
body: {
|
|
1065
|
+
type: String,
|
|
1066
|
+
required: true,
|
|
1067
|
+
trim: true,
|
|
1068
|
+
maxlength: 1e4
|
|
1069
|
+
},
|
|
1070
|
+
authorId: {
|
|
1071
|
+
type: String,
|
|
1072
|
+
index: true
|
|
1073
|
+
},
|
|
1074
|
+
pinned: {
|
|
1075
|
+
type: Boolean,
|
|
1076
|
+
default: false,
|
|
1077
|
+
index: true
|
|
1078
|
+
},
|
|
1079
|
+
metadata: { type: Schema.Types.Mixed }
|
|
1080
|
+
}, { timestamps: true });
|
|
1081
|
+
schema.index({
|
|
1082
|
+
organizationId: 1,
|
|
1083
|
+
subjectKind: 1,
|
|
1084
|
+
subjectId: 1,
|
|
1085
|
+
pinned: -1,
|
|
1086
|
+
createdAt: -1
|
|
1087
|
+
});
|
|
1088
|
+
return schema;
|
|
1089
|
+
}
|
|
1090
|
+
function createNoteModel(connection, config) {
|
|
1091
|
+
if (connection.models.Note) connection.deleteModel("Note");
|
|
1092
|
+
return connection.model("Note", buildNoteSchema(config));
|
|
1093
|
+
}
|
|
1094
|
+
//#endregion
|
|
1095
|
+
//#region src/models/loss-reason.schema.ts
|
|
1096
|
+
/**
|
|
1097
|
+
* LossReason — per-org taxonomy for "why did this deal die?" Referenced
|
|
1098
|
+
* by `Opportunity.lostReasonId` on `lose()` transitions for forecast +
|
|
1099
|
+
* win-loss reporting.
|
|
1100
|
+
*/
|
|
1101
|
+
function buildLossReasonSchema(config) {
|
|
1102
|
+
const schema = new Schema({
|
|
1103
|
+
organizationId: tenantFieldDef(config),
|
|
1104
|
+
name: {
|
|
1105
|
+
type: String,
|
|
1106
|
+
required: true,
|
|
1107
|
+
trim: true,
|
|
1108
|
+
maxlength: 200
|
|
1109
|
+
},
|
|
1110
|
+
description: {
|
|
1111
|
+
type: String,
|
|
1112
|
+
trim: true,
|
|
1113
|
+
maxlength: 1e3
|
|
1114
|
+
},
|
|
1115
|
+
isActive: {
|
|
1116
|
+
type: Boolean,
|
|
1117
|
+
default: true,
|
|
1118
|
+
index: true
|
|
1119
|
+
},
|
|
1120
|
+
sequence: {
|
|
1121
|
+
type: Number,
|
|
1122
|
+
default: 0
|
|
1123
|
+
},
|
|
1124
|
+
metadata: { type: Schema.Types.Mixed }
|
|
1125
|
+
}, { timestamps: true });
|
|
1126
|
+
schema.index({
|
|
1127
|
+
organizationId: 1,
|
|
1128
|
+
isActive: 1,
|
|
1129
|
+
sequence: 1
|
|
1130
|
+
});
|
|
1131
|
+
return schema;
|
|
1132
|
+
}
|
|
1133
|
+
function createLossReasonModel(connection, config) {
|
|
1134
|
+
if (connection.models.LossReason) connection.deleteModel("LossReason");
|
|
1135
|
+
return connection.model("LossReason", buildLossReasonSchema(config));
|
|
1136
|
+
}
|
|
1137
|
+
//#endregion
|
|
1138
|
+
//#region src/enums/email-event-kind.ts
|
|
1139
|
+
/**
|
|
1140
|
+
* Email engagement event kinds, recorded against a Contact / Campaign /
|
|
1141
|
+
* Subscription tuple as the recipient interacts with a sent message.
|
|
1142
|
+
*
|
|
1143
|
+
* open — recipient's mail client loaded the tracking pixel.
|
|
1144
|
+
* Imperfect signal: image-block clients suppress it, some
|
|
1145
|
+
* clients pre-fetch and inflate counts. Useful in aggregate.
|
|
1146
|
+
*
|
|
1147
|
+
* click — recipient followed a tracked link. High-signal: actual
|
|
1148
|
+
* user action, not background fetch.
|
|
1149
|
+
*
|
|
1150
|
+
* bounce — provider reported delivery failed. Pair with the
|
|
1151
|
+
* `bounce` status on Subscription for permanent suppression.
|
|
1152
|
+
*
|
|
1153
|
+
* complaint — recipient hit "spam" / "report" in their inbox. Pair
|
|
1154
|
+
* with `complained` Subscription status.
|
|
1155
|
+
*
|
|
1156
|
+
* unsubscribe — recipient hit the unsubscribe link in the email. May
|
|
1157
|
+
* fire BEFORE the Subscription itself is marked unsubscribed
|
|
1158
|
+
* (the host can choose to confirm before flipping status).
|
|
1159
|
+
*
|
|
1160
|
+
* Open and click are "engagement signal"; bounce/complaint/unsubscribe are
|
|
1161
|
+
* "suppression signal". The service exposes aggregates over both
|
|
1162
|
+
* categories so consumers can compute open rate, click rate, bounce rate
|
|
1163
|
+
* without filtering kind themselves.
|
|
1164
|
+
*/
|
|
1165
|
+
const EMAIL_EVENT_KINDS = [
|
|
1166
|
+
"open",
|
|
1167
|
+
"click",
|
|
1168
|
+
"bounce",
|
|
1169
|
+
"complaint",
|
|
1170
|
+
"unsubscribe"
|
|
1171
|
+
];
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/models/email-event.schema.ts
|
|
1174
|
+
/**
|
|
1175
|
+
* EmailEvent — append-only engagement / suppression log per recipient.
|
|
1176
|
+
*
|
|
1177
|
+
* Open / click / bounce / complaint / unsubscribe — all surface against
|
|
1178
|
+
* a polymorphic subject (Contact OR Subscription). Aggregates power
|
|
1179
|
+
* drip-sequence branches ("if opened") + campaign analytics.
|
|
1180
|
+
*/
|
|
1181
|
+
const EMAIL_EVENT_SUBJECT_KINDS = ["contact", "subscription"];
|
|
1182
|
+
function buildEmailEventSchema(config) {
|
|
1183
|
+
const schema = new Schema({
|
|
1184
|
+
organizationId: tenantFieldDef(config),
|
|
1185
|
+
kind: {
|
|
1186
|
+
type: String,
|
|
1187
|
+
enum: EMAIL_EVENT_KINDS,
|
|
1188
|
+
required: true,
|
|
1189
|
+
index: true
|
|
1190
|
+
},
|
|
1191
|
+
subjectKind: {
|
|
1192
|
+
type: String,
|
|
1193
|
+
enum: EMAIL_EVENT_SUBJECT_KINDS,
|
|
1194
|
+
required: true
|
|
1195
|
+
},
|
|
1196
|
+
subjectId: {
|
|
1197
|
+
type: Schema.Types.ObjectId,
|
|
1198
|
+
required: true,
|
|
1199
|
+
index: true
|
|
1200
|
+
},
|
|
1201
|
+
campaignId: {
|
|
1202
|
+
type: Schema.Types.ObjectId,
|
|
1203
|
+
ref: "Campaign",
|
|
1204
|
+
index: true
|
|
1205
|
+
},
|
|
1206
|
+
messageId: {
|
|
1207
|
+
type: String,
|
|
1208
|
+
trim: true,
|
|
1209
|
+
maxlength: 200
|
|
1210
|
+
},
|
|
1211
|
+
url: {
|
|
1212
|
+
type: String,
|
|
1213
|
+
trim: true,
|
|
1214
|
+
maxlength: 2048
|
|
1215
|
+
},
|
|
1216
|
+
userAgent: {
|
|
1217
|
+
type: String,
|
|
1218
|
+
trim: true,
|
|
1219
|
+
maxlength: 500
|
|
1220
|
+
},
|
|
1221
|
+
ip: {
|
|
1222
|
+
type: String,
|
|
1223
|
+
trim: true,
|
|
1224
|
+
maxlength: 64
|
|
1225
|
+
},
|
|
1226
|
+
metadata: { type: Schema.Types.Mixed }
|
|
1227
|
+
}, { timestamps: {
|
|
1228
|
+
createdAt: true,
|
|
1229
|
+
updatedAt: false
|
|
1230
|
+
} });
|
|
1231
|
+
schema.index({
|
|
1232
|
+
organizationId: 1,
|
|
1233
|
+
createdAt: -1
|
|
1234
|
+
});
|
|
1235
|
+
schema.index({
|
|
1236
|
+
organizationId: 1,
|
|
1237
|
+
subjectKind: 1,
|
|
1238
|
+
subjectId: 1,
|
|
1239
|
+
createdAt: -1
|
|
1240
|
+
});
|
|
1241
|
+
schema.index({
|
|
1242
|
+
organizationId: 1,
|
|
1243
|
+
campaignId: 1,
|
|
1244
|
+
kind: 1,
|
|
1245
|
+
createdAt: -1
|
|
1246
|
+
});
|
|
1247
|
+
schema.index({
|
|
1248
|
+
organizationId: 1,
|
|
1249
|
+
messageId: 1
|
|
1250
|
+
});
|
|
1251
|
+
return schema;
|
|
1252
|
+
}
|
|
1253
|
+
function createEmailEventModel(connection, config) {
|
|
1254
|
+
if (connection.models.EmailEvent) connection.deleteModel("EmailEvent");
|
|
1255
|
+
return connection.model("EmailEvent", buildEmailEventSchema(config));
|
|
1256
|
+
}
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/models/create-models.ts
|
|
1259
|
+
function createCrmModels(connection, config) {
|
|
1260
|
+
return Object.freeze({
|
|
1261
|
+
Pipeline: createPipelineModel(connection, config),
|
|
1262
|
+
Lead: createLeadModel(connection, config),
|
|
1263
|
+
Opportunity: createOpportunityModel(connection, config),
|
|
1264
|
+
Contact: createContactModel(connection, config),
|
|
1265
|
+
Account: createAccountModel(connection, config),
|
|
1266
|
+
Campaign: createCampaignModel(connection, config),
|
|
1267
|
+
Subscription: createSubscriptionModel(connection, config),
|
|
1268
|
+
Activity: createActivityModel(connection, config),
|
|
1269
|
+
Note: createNoteModel(connection, config),
|
|
1270
|
+
LossReason: createLossReasonModel(connection, config),
|
|
1271
|
+
EmailEvent: createEmailEventModel(connection, config)
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1275
|
+
//#region src/core/errors.ts
|
|
5
1276
|
/**
|
|
6
1277
|
* CRM error vocabulary. Hosts catch `CrmError` at API edges.
|
|
7
1278
|
*/
|
|
@@ -58,16 +1329,36 @@ var EmptyPipelineError = class extends CrmError {
|
|
|
58
1329
|
super("EMPTY_PIPELINE", `Pipeline '${pipelineId}' has no stages`, { pipelineId });
|
|
59
1330
|
}
|
|
60
1331
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
1332
|
+
var InvalidCampaignTransitionError = class extends CrmError {
|
|
1333
|
+
constructor(details) {
|
|
1334
|
+
super("INVALID_CAMPAIGN_TRANSITION", `Campaign ${details.campaignId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
var DuplicateCampaignError = class extends CrmError {
|
|
1338
|
+
constructor(name) {
|
|
1339
|
+
super("DUPLICATE_CAMPAIGN", `A campaign named '${name}' already exists`, { name });
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
var InvalidSubscriptionTransitionError = class extends CrmError {
|
|
1343
|
+
constructor(details) {
|
|
1344
|
+
super("INVALID_SUBSCRIPTION_TRANSITION", `Subscription ${details.subscriptionId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
var DuplicateSubscriptionError = class extends CrmError {
|
|
1348
|
+
constructor(details) {
|
|
1349
|
+
super("DUPLICATE_SUBSCRIPTION", `Contact '${details.contactId}' already has a live subscription on campaign '${details.campaignId}'`, details);
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
var InvalidConfirmationTokenError = class extends CrmError {
|
|
1353
|
+
constructor() {
|
|
1354
|
+
super("INVALID_CONFIRMATION_TOKEN", "No subscription matches the supplied confirmation token, or it has already been used");
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
var InvalidUnsubscribeTokenError = class extends CrmError {
|
|
1358
|
+
constructor() {
|
|
1359
|
+
super("INVALID_UNSUBSCRIBE_TOKEN", "No subscription matches the supplied unsubscribe token");
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
71
1362
|
//#endregion
|
|
72
1363
|
//#region src/events/event-constants.ts
|
|
73
1364
|
/** PACKAGE_RULES rule 12: `package:resource.verb`. */
|
|
@@ -92,420 +1383,166 @@ const CRM_EVENTS = {
|
|
|
92
1383
|
ACTIVITY_PLANNED: "crm:activity.planned",
|
|
93
1384
|
ACTIVITY_COMPLETED: "crm:activity.completed",
|
|
94
1385
|
ACTIVITY_CANCELLED: "crm:activity.cancelled",
|
|
95
|
-
NOTE_ADDED: "crm:note.added"
|
|
1386
|
+
NOTE_ADDED: "crm:note.added",
|
|
1387
|
+
CAMPAIGN_CREATED: "crm:campaign.created",
|
|
1388
|
+
CAMPAIGN_ACTIVATED: "crm:campaign.activated",
|
|
1389
|
+
CAMPAIGN_PAUSED: "crm:campaign.paused",
|
|
1390
|
+
CAMPAIGN_RESUMED: "crm:campaign.resumed",
|
|
1391
|
+
CAMPAIGN_COMPLETED: "crm:campaign.completed",
|
|
1392
|
+
CAMPAIGN_ARCHIVED: "crm:campaign.archived",
|
|
1393
|
+
SUBSCRIPTION_CREATED: "crm:subscription.created",
|
|
1394
|
+
SUBSCRIPTION_CONFIRMED: "crm:subscription.confirmed",
|
|
1395
|
+
SUBSCRIPTION_UNSUBSCRIBED: "crm:subscription.unsubscribed",
|
|
1396
|
+
SUBSCRIPTION_BOUNCED: "crm:subscription.bounced",
|
|
1397
|
+
SUBSCRIPTION_COMPLAINED: "crm:subscription.complained",
|
|
1398
|
+
EMAIL_EVENT_OPENED: "crm:email_event.opened",
|
|
1399
|
+
EMAIL_EVENT_CLICKED: "crm:email_event.clicked",
|
|
1400
|
+
EMAIL_EVENT_BOUNCED: "crm:email_event.bounced",
|
|
1401
|
+
EMAIL_EVENT_COMPLAINED: "crm:email_event.complained",
|
|
1402
|
+
EMAIL_EVENT_UNSUBSCRIBED: "crm:email_event.unsubscribed"
|
|
96
1403
|
};
|
|
97
1404
|
//#endregion
|
|
98
|
-
//#region src/events/
|
|
1405
|
+
//#region src/events/helpers.ts
|
|
99
1406
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Three independent seams, all optional:
|
|
103
|
-
*
|
|
104
|
-
* - `transport` — `EventTransport` (Kafka / SQS / Redis / in-process).
|
|
105
|
-
* When present, the event is delivered via `transport.publish(event)`.
|
|
106
|
-
* - `outbox` — `OutboxStore` (any backend implementing the primitives
|
|
107
|
-
* contract — a host-owned mongo `event_outbox` collection, an arc
|
|
108
|
-
* `repositoryAsOutboxStore(repo)` adapter, or a custom adapter). When
|
|
109
|
-
* present, the event is persisted via `outbox.save(event, { session })`
|
|
110
|
-
* **before** the transport publish so durable at-least-once delivery
|
|
111
|
-
* is guaranteed even when the transport is down.
|
|
112
|
-
* - `session` — any DB transaction handle the host is running. Threaded
|
|
113
|
-
* into `outbox.save(event, { session })` so the outbox row commits
|
|
114
|
-
* atomically with the host's business write. Typed `unknown` to keep
|
|
115
|
-
* crm framework-agnostic.
|
|
116
|
-
*
|
|
117
|
-
* Error semantics:
|
|
1407
|
+
* Event creation helper.
|
|
118
1408
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* caller handles the error.
|
|
124
|
-
* - Transport errors are **logged and swallowed**. When an outbox is
|
|
125
|
-
* wired the host's relay guarantees redelivery, so a flaky transport
|
|
126
|
-
* must not block the request.
|
|
1409
|
+
* Thin wrapper around `@classytic/primitives`' `createEvent` that fills the
|
|
1410
|
+
* shared `EventMeta` from a `CrmContext` (userId/actorId, organizationId,
|
|
1411
|
+
* correlationId). Use this instead of importing `createEvent` directly so
|
|
1412
|
+
* the `source: 'crm'` stamp + tenant scoping stay consistent.
|
|
127
1413
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const event = createEvent(type, payload, {
|
|
1414
|
+
function createEvent(type, payload, ctx, meta) {
|
|
1415
|
+
return createEvent$1(type, payload, {
|
|
131
1416
|
source: "crm",
|
|
1417
|
+
...ctx?.actorId !== void 0 ? { actorId: String(ctx.actorId) } : {},
|
|
1418
|
+
...ctx?.organizationId !== void 0 ? { organizationId: String(ctx.organizationId) } : {},
|
|
1419
|
+
...ctx?.correlationId !== void 0 ? { correlationId: ctx.correlationId } : {},
|
|
132
1420
|
...meta
|
|
133
1421
|
});
|
|
134
|
-
if (outbox) await outbox.save(event, session !== void 0 ? { session } : {});
|
|
135
|
-
if (transport) try {
|
|
136
|
-
await transport.publish(event);
|
|
137
|
-
} catch (err) {
|
|
138
|
-
console.error(`[crm] events.publish failed for ${event.type}:`, err);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
//#endregion
|
|
142
|
-
//#region src/services/account.service.ts
|
|
143
|
-
var AccountService = class {
|
|
144
|
-
repo;
|
|
145
|
-
events;
|
|
146
|
-
outbox;
|
|
147
|
-
clock;
|
|
148
|
-
ids;
|
|
149
|
-
constructor(config) {
|
|
150
|
-
this.repo = config.repo;
|
|
151
|
-
this.events = config.events;
|
|
152
|
-
this.outbox = config.outbox;
|
|
153
|
-
this.clock = config.clock ?? systemClock;
|
|
154
|
-
this.ids = config.ids ?? cryptoIdGenerator;
|
|
155
|
-
}
|
|
156
|
-
async create(input, options = {}) {
|
|
157
|
-
const account = await this.repo.create({
|
|
158
|
-
id: this.ids.next(),
|
|
159
|
-
name: input.name,
|
|
160
|
-
...input.domain !== void 0 ? { domain: input.domain } : {},
|
|
161
|
-
...input.industry !== void 0 ? { industry: input.industry } : {},
|
|
162
|
-
...input.sizeBucket !== void 0 ? { sizeBucket: input.sizeBucket } : {},
|
|
163
|
-
...input.annualRevenue !== void 0 ? { annualRevenue: input.annualRevenue } : {},
|
|
164
|
-
...input.ownerId !== void 0 ? { ownerId: input.ownerId } : {},
|
|
165
|
-
...input.tags !== void 0 ? { tags: input.tags } : {},
|
|
166
|
-
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
167
|
-
});
|
|
168
|
-
if (options.emitEvents !== false) await this._emit(CRM_EVENTS.ACCOUNT_CREATED, { accountId: account.id }, {
|
|
169
|
-
resource: "account",
|
|
170
|
-
resourceId: account.id
|
|
171
|
-
}, options);
|
|
172
|
-
return account;
|
|
173
|
-
}
|
|
174
|
-
async update(id, patch, options = {}) {
|
|
175
|
-
await this._require(id);
|
|
176
|
-
const updated = await this.repo.update(id, patch);
|
|
177
|
-
if (options.emitEvents !== false) await this._emit(CRM_EVENTS.ACCOUNT_UPDATED, { accountId: id }, {
|
|
178
|
-
resource: "account",
|
|
179
|
-
resourceId: id
|
|
180
|
-
}, options);
|
|
181
|
-
return updated;
|
|
182
|
-
}
|
|
183
|
-
async delete(id) {
|
|
184
|
-
await this._require(id);
|
|
185
|
-
await this.repo.delete(id);
|
|
186
|
-
}
|
|
187
|
-
findById(id) {
|
|
188
|
-
return this.repo.findById(id);
|
|
189
|
-
}
|
|
190
|
-
findByDomain(domain) {
|
|
191
|
-
return this.repo.findByDomain(domain);
|
|
192
|
-
}
|
|
193
|
-
list(filter) {
|
|
194
|
-
return this.repo.list(filter);
|
|
195
|
-
}
|
|
196
|
-
async _require(id) {
|
|
197
|
-
const a = await this.repo.findById(id);
|
|
198
|
-
if (!a) throw new NotFoundError("account", id);
|
|
199
|
-
return a;
|
|
200
|
-
}
|
|
201
|
-
async _emit(event, payload, meta, options = {}) {
|
|
202
|
-
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
//#endregion
|
|
206
|
-
//#region src/domain/enums/activity.ts
|
|
207
|
-
/**
|
|
208
|
-
* Activity type vocabulary. The package stores `type` as free-form
|
|
209
|
-
* string so hosts can extend; these constants cover the common
|
|
210
|
-
* channels that automation tooling typically keys off.
|
|
211
|
-
*/
|
|
212
|
-
const ACTIVITY_TYPES = [
|
|
213
|
-
"call",
|
|
214
|
-
"email",
|
|
215
|
-
"meeting",
|
|
216
|
-
"task",
|
|
217
|
-
"note",
|
|
218
|
-
"sms",
|
|
219
|
-
"social",
|
|
220
|
-
"demo",
|
|
221
|
-
"other"
|
|
222
|
-
];
|
|
223
|
-
const ACTIVITY_STATUSES = [
|
|
224
|
-
"planned",
|
|
225
|
-
"in_progress",
|
|
226
|
-
"completed",
|
|
227
|
-
"cancelled"
|
|
228
|
-
];
|
|
229
|
-
const ACTIVITY_TRANSITIONS = {
|
|
230
|
-
planned: [
|
|
231
|
-
"in_progress",
|
|
232
|
-
"completed",
|
|
233
|
-
"cancelled"
|
|
234
|
-
],
|
|
235
|
-
in_progress: ["completed", "cancelled"],
|
|
236
|
-
completed: [],
|
|
237
|
-
cancelled: []
|
|
238
|
-
};
|
|
239
|
-
function canTransitionActivity(from, to) {
|
|
240
|
-
return ACTIVITY_TRANSITIONS[from].includes(to);
|
|
241
1422
|
}
|
|
242
|
-
/**
|
|
243
|
-
* Kinds of entity an Activity can reference. Polymorphic linking lets
|
|
244
|
-
* hosts log a call against a Lead, an Opportunity, a Contact, or a
|
|
245
|
-
* stand-alone Account.
|
|
246
|
-
*/
|
|
247
|
-
const SUBJECT_KINDS = [
|
|
248
|
-
"lead",
|
|
249
|
-
"opportunity",
|
|
250
|
-
"contact",
|
|
251
|
-
"account"
|
|
252
|
-
];
|
|
253
1423
|
//#endregion
|
|
254
|
-
//#region src/
|
|
255
|
-
|
|
256
|
-
* Polymorphic activity / note service. Activities have a lifecycle
|
|
257
|
-
* (`planned → in_progress → completed | cancelled`); notes are
|
|
258
|
-
* immutable-ish (create + update body, no status).
|
|
259
|
-
*/
|
|
260
|
-
var ActivityService = class {
|
|
261
|
-
repo;
|
|
262
|
-
noteRepo;
|
|
1424
|
+
//#region src/repositories/pipeline.repository.ts
|
|
1425
|
+
var PipelineRepository = class extends Repository {
|
|
263
1426
|
events;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
subjectKind: input.subjectKind,
|
|
281
|
-
subjectId: input.subjectId,
|
|
282
|
-
...input.subject !== void 0 ? { subject: input.subject } : {},
|
|
283
|
-
...input.body !== void 0 ? { body: input.body } : {},
|
|
284
|
-
...input.scheduledAt !== void 0 ? { scheduledAt: input.scheduledAt } : {},
|
|
285
|
-
...input.ownerId !== void 0 ? { ownerId: input.ownerId } : {},
|
|
286
|
-
...input.participantIds !== void 0 ? { participantIds: input.participantIds } : {},
|
|
287
|
-
...input.externalRef !== void 0 ? { externalRef: input.externalRef } : {},
|
|
288
|
-
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
289
|
-
});
|
|
290
|
-
await this._emit(CRM_EVENTS.ACTIVITY_PLANNED, {
|
|
291
|
-
activityId: activity.id,
|
|
292
|
-
type: activity.type,
|
|
293
|
-
subjectKind: activity.subjectKind,
|
|
294
|
-
subjectId: activity.subjectId
|
|
295
|
-
}, {
|
|
296
|
-
resource: "activity",
|
|
297
|
-
resourceId: activity.id
|
|
298
|
-
}, options);
|
|
299
|
-
return activity;
|
|
300
|
-
}
|
|
301
|
-
async start(id, options = {}) {
|
|
302
|
-
return this._transition(id, "in_progress", {}, options);
|
|
303
|
-
}
|
|
304
|
-
async complete(id, completedAt, options = {}) {
|
|
305
|
-
const now = completedAt ?? this.clock.now();
|
|
306
|
-
const updated = await this._transition(id, "completed", { completedAt: now }, options);
|
|
307
|
-
await this._emit(CRM_EVENTS.ACTIVITY_COMPLETED, {
|
|
308
|
-
activityId: id,
|
|
309
|
-
completedAt: now
|
|
310
|
-
}, {
|
|
311
|
-
resource: "activity",
|
|
312
|
-
resourceId: id
|
|
313
|
-
}, options);
|
|
314
|
-
return updated;
|
|
315
|
-
}
|
|
316
|
-
async cancel(id, reason, options = {}) {
|
|
317
|
-
const patch = { cancelledAt: this.clock.now() };
|
|
318
|
-
if (reason !== void 0) patch.metadata = {
|
|
319
|
-
...patch.metadata ?? {},
|
|
320
|
-
cancelReason: reason
|
|
321
|
-
};
|
|
322
|
-
const updated = await this._transition(id, "cancelled", patch, options);
|
|
323
|
-
await this._emit(CRM_EVENTS.ACTIVITY_CANCELLED, {
|
|
324
|
-
activityId: id,
|
|
325
|
-
reason
|
|
326
|
-
}, {
|
|
327
|
-
resource: "activity",
|
|
328
|
-
resourceId: id
|
|
329
|
-
}, options);
|
|
330
|
-
return updated;
|
|
331
|
-
}
|
|
332
|
-
async addNote(input, options = {}) {
|
|
333
|
-
const note = await this.noteRepo.create({
|
|
334
|
-
id: this.ids.next(),
|
|
335
|
-
subjectKind: input.subjectKind,
|
|
336
|
-
subjectId: input.subjectId,
|
|
337
|
-
body: input.body,
|
|
338
|
-
format: input.format ?? "plain",
|
|
339
|
-
...input.authorId !== void 0 ? { authorId: input.authorId } : {},
|
|
340
|
-
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
341
|
-
});
|
|
342
|
-
await this._emit(CRM_EVENTS.NOTE_ADDED, {
|
|
343
|
-
noteId: note.id,
|
|
344
|
-
subjectKind: note.subjectKind,
|
|
345
|
-
subjectId: note.subjectId
|
|
346
|
-
}, {
|
|
347
|
-
resource: "note",
|
|
348
|
-
resourceId: note.id
|
|
349
|
-
}, options);
|
|
350
|
-
return note;
|
|
351
|
-
}
|
|
352
|
-
listBySubject(subjectKind, subjectId) {
|
|
353
|
-
return this.repo.list({
|
|
354
|
-
subjectKind,
|
|
355
|
-
subjectId
|
|
1427
|
+
constructor(model, plugins, deps, pagination) {
|
|
1428
|
+
super(model, plugins, pagination);
|
|
1429
|
+
this.events = deps.events;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Read a single embedded stage. Used by Opportunity create / move to
|
|
1433
|
+
* validate stage membership + pick up `defaultProbability`.
|
|
1434
|
+
*/
|
|
1435
|
+
async getStage(pipelineId, stageId, ctx) {
|
|
1436
|
+
const pipeline = await this.getById(pipelineId, ctx);
|
|
1437
|
+
if (!pipeline) throw new NotFoundError("Pipeline", pipelineId);
|
|
1438
|
+
if (pipeline.stages.length === 0) throw new EmptyPipelineError(pipelineId);
|
|
1439
|
+
const stage = pipeline.stages.find((s) => String(s._id) === stageId);
|
|
1440
|
+
if (!stage) throw new StageNotInPipelineError({
|
|
1441
|
+
pipelineId,
|
|
1442
|
+
stageId
|
|
356
1443
|
});
|
|
1444
|
+
return stage;
|
|
357
1445
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
from: existing.status,
|
|
375
|
-
to: target,
|
|
376
|
-
activityId: id
|
|
1446
|
+
/** First stage by `sequence` ascending. */
|
|
1447
|
+
async getFirstStage(pipelineId, ctx) {
|
|
1448
|
+
const pipeline = await this.getById(pipelineId, ctx);
|
|
1449
|
+
if (!pipeline) throw new NotFoundError("Pipeline", pipelineId);
|
|
1450
|
+
if (pipeline.stages.length === 0) throw new EmptyPipelineError(pipelineId);
|
|
1451
|
+
return [...pipeline.stages].sort((a, b) => a.sequence - b.sequence)[0];
|
|
1452
|
+
}
|
|
1453
|
+
/** Stage immediately after the given one. `null` at terminal stage. */
|
|
1454
|
+
async getNextStage(pipelineId, stageId, ctx) {
|
|
1455
|
+
const pipeline = await this.getById(pipelineId, ctx);
|
|
1456
|
+
if (!pipeline) throw new NotFoundError("Pipeline", pipelineId);
|
|
1457
|
+
const ordered = [...pipeline.stages].sort((a, b) => a.sequence - b.sequence);
|
|
1458
|
+
const idx = ordered.findIndex((s) => String(s._id) === stageId);
|
|
1459
|
+
if (idx === -1) throw new StageNotInPipelineError({
|
|
1460
|
+
pipelineId,
|
|
1461
|
+
stageId
|
|
377
1462
|
});
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
async _emit(event, payload, meta, options = {}) {
|
|
381
|
-
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
1463
|
+
return ordered[idx + 1] ?? null;
|
|
382
1464
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
events;
|
|
389
|
-
outbox;
|
|
390
|
-
clock;
|
|
391
|
-
ids;
|
|
392
|
-
constructor(config) {
|
|
393
|
-
this.repo = config.repo;
|
|
394
|
-
this.events = config.events;
|
|
395
|
-
this.outbox = config.outbox;
|
|
396
|
-
this.clock = config.clock ?? systemClock;
|
|
397
|
-
this.ids = config.ids ?? cryptoIdGenerator;
|
|
398
|
-
}
|
|
399
|
-
async create(input, options = {}) {
|
|
400
|
-
const contact = await this.repo.create({
|
|
401
|
-
id: this.ids.next(),
|
|
402
|
-
name: input.name,
|
|
403
|
-
contact: input.contact ?? {},
|
|
404
|
-
...input.jobTitle !== void 0 ? { jobTitle: input.jobTitle } : {},
|
|
405
|
-
...input.accountId !== void 0 ? { accountId: input.accountId } : {},
|
|
406
|
-
...input.ownerId !== void 0 ? { ownerId: input.ownerId } : {},
|
|
407
|
-
...input.tags !== void 0 ? { tags: input.tags } : {},
|
|
408
|
-
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
409
|
-
});
|
|
410
|
-
if (options.emitEvents !== false) await this._emit(CRM_EVENTS.CONTACT_CREATED, {
|
|
411
|
-
contactId: contact.id,
|
|
412
|
-
email: contact.contact.email
|
|
413
|
-
}, {
|
|
414
|
-
resource: "contact",
|
|
415
|
-
resourceId: contact.id
|
|
416
|
-
}, options);
|
|
417
|
-
return contact;
|
|
418
|
-
}
|
|
419
|
-
async update(id, patch, options = {}) {
|
|
420
|
-
await this._require(id);
|
|
421
|
-
const updated = await this.repo.update(id, patch);
|
|
422
|
-
if (options.emitEvents !== false) await this._emit(CRM_EVENTS.CONTACT_UPDATED, { contactId: id }, {
|
|
423
|
-
resource: "contact",
|
|
424
|
-
resourceId: id
|
|
425
|
-
}, options);
|
|
1465
|
+
/** Soft-deprecate — existing opps still move; no new ones can be created. */
|
|
1466
|
+
async archive(id, ctx) {
|
|
1467
|
+
const updated = await this.update(id, { isArchived: true }, ctx);
|
|
1468
|
+
if (!updated) throw new NotFoundError("Pipeline", id);
|
|
1469
|
+
await this.events.publish(createEvent(CRM_EVENTS.PIPELINE_ARCHIVED, { pipelineId: id }, ctx));
|
|
426
1470
|
return updated;
|
|
427
1471
|
}
|
|
428
|
-
async delete(id) {
|
|
429
|
-
await this._require(id);
|
|
430
|
-
await this.repo.delete(id);
|
|
431
|
-
}
|
|
432
|
-
findById(id) {
|
|
433
|
-
return this.repo.findById(id);
|
|
434
|
-
}
|
|
435
|
-
findByEmail(email) {
|
|
436
|
-
return this.repo.findByEmail(email);
|
|
437
|
-
}
|
|
438
|
-
list(filter) {
|
|
439
|
-
return this.repo.list(filter);
|
|
440
|
-
}
|
|
441
|
-
async _require(id) {
|
|
442
|
-
const c = await this.repo.findById(id);
|
|
443
|
-
if (!c) throw new NotFoundError("contact", id);
|
|
444
|
-
return c;
|
|
445
|
-
}
|
|
446
|
-
async _emit(event, payload, meta, options = {}) {
|
|
447
|
-
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
448
|
-
}
|
|
449
1472
|
};
|
|
450
1473
|
//#endregion
|
|
451
|
-
//#region src/
|
|
1474
|
+
//#region src/core/state-machines.ts
|
|
452
1475
|
/**
|
|
453
|
-
*
|
|
1476
|
+
* Declarative state machines for every CRM aggregate that has lifecycle
|
|
1477
|
+
* transitions. Single source of truth for what `from -> to` moves are
|
|
1478
|
+
* legal — the repositories' domain verbs (`qualify`, `convert`, `win`,
|
|
1479
|
+
* `lose`, `confirm`, `unsubscribe`, ...) call `assertTransition` before
|
|
1480
|
+
* they touch Mongo.
|
|
454
1481
|
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
1482
|
+
* Per PACKAGE_RULES §30: "State machines map to Arc Actions". The host
|
|
1483
|
+
* exposes each domain verb as an arc Action (`POST /:id/action` with
|
|
1484
|
+
* `{ action: 'qualify' }`); the verb's implementation lives on the
|
|
1485
|
+
* Repository class and uses these machines internally.
|
|
1486
|
+
*
|
|
1487
|
+
* Built on `@classytic/primitives/state-machine` so the same shape lights
|
|
1488
|
+
* up across kits (mongokit's `claim()` pairing, sqlitekit follow-ups).
|
|
461
1489
|
*/
|
|
462
|
-
const
|
|
463
|
-
"
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
1490
|
+
const LEAD_MACHINE = defineStateMachine({
|
|
1491
|
+
name: "Lead",
|
|
1492
|
+
transitions: LEAD_TRANSITIONS,
|
|
1493
|
+
errorFactory: ({ entityId, from, to }) => new InvalidLeadTransitionError({
|
|
1494
|
+
from,
|
|
1495
|
+
to,
|
|
1496
|
+
leadId: entityId
|
|
1497
|
+
})
|
|
1498
|
+
});
|
|
1499
|
+
const OPPORTUNITY_MACHINE = defineStateMachine({
|
|
1500
|
+
name: "Opportunity",
|
|
1501
|
+
transitions: OPPORTUNITY_TRANSITIONS,
|
|
1502
|
+
errorFactory: ({ entityId, from, to }) => new InvalidOpportunityTransitionError({
|
|
1503
|
+
from,
|
|
1504
|
+
to,
|
|
1505
|
+
opportunityId: entityId
|
|
1506
|
+
})
|
|
1507
|
+
});
|
|
1508
|
+
const CAMPAIGN_MACHINE = defineStateMachine({
|
|
1509
|
+
name: "Campaign",
|
|
1510
|
+
transitions: CAMPAIGN_TRANSITIONS,
|
|
1511
|
+
errorFactory: ({ entityId, from, to }) => new InvalidCampaignTransitionError({
|
|
1512
|
+
from,
|
|
1513
|
+
to,
|
|
1514
|
+
campaignId: entityId
|
|
1515
|
+
})
|
|
1516
|
+
});
|
|
1517
|
+
const SUBSCRIPTION_MACHINE = defineStateMachine({
|
|
1518
|
+
name: "Subscription",
|
|
1519
|
+
transitions: SUBSCRIPTION_TRANSITIONS,
|
|
1520
|
+
errorFactory: ({ entityId, from, to }) => new InvalidSubscriptionTransitionError({
|
|
1521
|
+
from,
|
|
1522
|
+
to,
|
|
1523
|
+
subscriptionId: entityId
|
|
1524
|
+
})
|
|
1525
|
+
});
|
|
1526
|
+
const ACTIVITY_MACHINE = defineStateMachine({
|
|
1527
|
+
name: "Activity",
|
|
1528
|
+
transitions: ACTIVITY_TRANSITIONS,
|
|
1529
|
+
errorFactory: ({ entityId, from, to }) => new InvalidActivityTransitionError({
|
|
1530
|
+
from,
|
|
1531
|
+
to,
|
|
1532
|
+
activityId: entityId
|
|
1533
|
+
})
|
|
1534
|
+
});
|
|
498
1535
|
//#endregion
|
|
499
|
-
//#region src/
|
|
1536
|
+
//#region src/shared/calculators/scoring.ts
|
|
500
1537
|
/**
|
|
501
|
-
* Reference implementation. Intentionally simple
|
|
502
|
-
*
|
|
1538
|
+
* Reference implementation. Intentionally simple — hosts read it and
|
|
1539
|
+
* replace it.
|
|
503
1540
|
*
|
|
504
1541
|
* +20 if email is present
|
|
505
1542
|
* +20 if phone is present
|
|
506
1543
|
* +20 if companyName is present
|
|
507
1544
|
* +10 per populated tag up to 40
|
|
508
|
-
* +10 if jobTitle
|
|
1545
|
+
* +10 if jobTitle hints senior (director / manager / head / chief / vp / founder)
|
|
509
1546
|
*/
|
|
510
1547
|
function defaultLeadScorer(lead) {
|
|
511
1548
|
let score = 0;
|
|
@@ -513,631 +1550,1022 @@ function defaultLeadScorer(lead) {
|
|
|
513
1550
|
if (lead.phone) score += 20;
|
|
514
1551
|
if (lead.companyName) score += 20;
|
|
515
1552
|
score += Math.min(40, (lead.tags?.length ?? 0) * 10);
|
|
516
|
-
if (lead.jobTitle && /(director|manager|head|chief|vp|founder)/i.test(lead.jobTitle)) score += 10;
|
|
517
|
-
return score;
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* Compose a scoring pipeline — each rule returns an increment
|
|
521
|
-
* (positive or negative). Useful when host logic is a bunch of
|
|
522
|
-
* small, testable rules.
|
|
523
|
-
*
|
|
524
|
-
* @example
|
|
525
|
-
* ```ts
|
|
526
|
-
* const scorer = composeScorers(
|
|
527
|
-
* (l) => (l.email ? 20 : 0),
|
|
528
|
-
* (l) => (l.source === 'referral' ? 30 : 0),
|
|
529
|
-
* (l) => (l.tags?.includes('enterprise') ? 50 : 0),
|
|
530
|
-
* );
|
|
531
|
-
* ```
|
|
532
|
-
*/
|
|
533
|
-
function composeScorers(...rules) {
|
|
534
|
-
return (lead) => rules.reduce((acc, r) => acc + r(lead), 0);
|
|
535
|
-
}
|
|
536
|
-
//#endregion
|
|
537
|
-
//#region src/services/lead.service.ts
|
|
538
|
-
/**
|
|
539
|
-
* Derive a `PersonName` from a Lead's flat fields. Leads usually arrive from
|
|
540
|
-
* web forms where the capture is either a split `firstName`/`lastName` or a
|
|
541
|
-
* single `fullName` string — we support both without forcing callers to
|
|
542
|
-
* normalise upstream.
|
|
543
|
-
*/
|
|
544
|
-
function splitLeadName(lead) {
|
|
545
|
-
if (lead.firstName || lead.lastName) return {
|
|
546
|
-
given: lead.firstName ?? "",
|
|
547
|
-
family: lead.lastName ?? "",
|
|
548
|
-
...lead.fullName && lead.fullName !== `${lead.firstName ?? ""} ${lead.lastName ?? ""}`.trim() ? { preferred: lead.fullName } : {}
|
|
549
|
-
};
|
|
550
|
-
const parts = (lead.fullName ?? "").trim().split(/\s+/);
|
|
551
|
-
if (parts.length <= 1) return {
|
|
552
|
-
given: parts[0] ?? "Unknown",
|
|
553
|
-
family: ""
|
|
554
|
-
};
|
|
555
|
-
const family = parts.pop() ?? "";
|
|
556
|
-
return {
|
|
557
|
-
given: parts.join(" "),
|
|
558
|
-
family
|
|
559
|
-
};
|
|
1553
|
+
if (lead.jobTitle && /(director|manager|head|chief|vp|founder)/i.test(lead.jobTitle)) score += 10;
|
|
1554
|
+
return score;
|
|
560
1555
|
}
|
|
561
|
-
/**
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
contacts;
|
|
569
|
-
accounts;
|
|
570
|
-
opportunities;
|
|
1556
|
+
/** Compose multiple small rules into one scorer. */
|
|
1557
|
+
function composeScorers(...rules) {
|
|
1558
|
+
return (lead) => rules.reduce((acc, r) => acc + r(lead), 0);
|
|
1559
|
+
}
|
|
1560
|
+
//#endregion
|
|
1561
|
+
//#region src/repositories/lead.repository.ts
|
|
1562
|
+
var LeadRepository = class extends Repository {
|
|
571
1563
|
events;
|
|
572
|
-
outbox;
|
|
573
|
-
clock;
|
|
574
|
-
ids;
|
|
575
1564
|
scorer;
|
|
576
1565
|
rejectDuplicates;
|
|
577
|
-
constructor(
|
|
578
|
-
|
|
579
|
-
this.
|
|
580
|
-
this.
|
|
581
|
-
this.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
async create(input, options = {}) {
|
|
1566
|
+
constructor(model, plugins, deps, pagination) {
|
|
1567
|
+
super(model, plugins, pagination);
|
|
1568
|
+
this.events = deps.events;
|
|
1569
|
+
this.scorer = deps.scorer ?? defaultLeadScorer;
|
|
1570
|
+
this.rejectDuplicates = deps.rejectDuplicates ?? true;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Create a Lead. Auto-scores on insert so hosts can route immediately.
|
|
1574
|
+
* Rejects duplicate emails inside the same tenant unless
|
|
1575
|
+
* `rejectDuplicates: false` was set at engine boot.
|
|
1576
|
+
*/
|
|
1577
|
+
async createLead(input, ctx) {
|
|
590
1578
|
if (this.rejectDuplicates && input.email) {
|
|
591
|
-
if (await this.
|
|
1579
|
+
if (await this.getOne({ email: input.email }, ctx)) throw new DuplicateLeadError({ email: input.email });
|
|
592
1580
|
}
|
|
593
|
-
const now =
|
|
594
|
-
const
|
|
1581
|
+
const now = /* @__PURE__ */ new Date();
|
|
1582
|
+
const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || input.email || input.phone);
|
|
595
1583
|
const draft = {
|
|
596
|
-
|
|
597
|
-
fullName:
|
|
598
|
-
status: "new",
|
|
1584
|
+
...input,
|
|
1585
|
+
fullName: computedFullName,
|
|
1586
|
+
status: input.status ?? "new",
|
|
1587
|
+
score: 0,
|
|
599
1588
|
statusHistory: [{
|
|
600
|
-
status: "new",
|
|
1589
|
+
status: input.status ?? "new",
|
|
601
1590
|
occurredAt: now
|
|
602
|
-
}]
|
|
603
|
-
...input.firstName !== void 0 ? { firstName: input.firstName } : {},
|
|
604
|
-
...input.lastName !== void 0 ? { lastName: input.lastName } : {},
|
|
605
|
-
...input.email !== void 0 ? { email: input.email } : {},
|
|
606
|
-
...input.phone !== void 0 ? { phone: input.phone } : {},
|
|
607
|
-
...input.companyName !== void 0 ? { companyName: input.companyName } : {},
|
|
608
|
-
...input.jobTitle !== void 0 ? { jobTitle: input.jobTitle } : {},
|
|
609
|
-
...input.source !== void 0 ? { source: input.source } : {},
|
|
610
|
-
...input.campaignRef !== void 0 ? { campaignRef: input.campaignRef } : {},
|
|
611
|
-
...input.ownerId !== void 0 ? { ownerId: input.ownerId } : {},
|
|
612
|
-
...input.tags !== void 0 ? { tags: input.tags } : {},
|
|
613
|
-
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
1591
|
+
}]
|
|
614
1592
|
};
|
|
615
|
-
const
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
|
|
1593
|
+
const created = await this.create(draft, ctx);
|
|
1594
|
+
const score = this.scorer(created);
|
|
1595
|
+
let result = created;
|
|
1596
|
+
if (score !== 0) {
|
|
1597
|
+
result = await this.update(String(created._id), { score }, ctx) ?? created;
|
|
1598
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_SCORED, {
|
|
1599
|
+
leadId: String(created._id),
|
|
1600
|
+
score
|
|
1601
|
+
}, ctx));
|
|
1602
|
+
}
|
|
1603
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_CREATED, {
|
|
1604
|
+
leadId: String(result._id),
|
|
1605
|
+
email: result.email,
|
|
1606
|
+
score: result.score
|
|
1607
|
+
}, ctx));
|
|
1608
|
+
return result;
|
|
626
1609
|
}
|
|
627
|
-
async markContacted(id,
|
|
628
|
-
return this._transition(id, "contacted",
|
|
629
|
-
...by !== void 0 ? { by } : {},
|
|
630
|
-
...note !== void 0 ? { note } : {}
|
|
631
|
-
}, options);
|
|
1610
|
+
async markContacted(id, extras = {}, ctx) {
|
|
1611
|
+
return this._transition(id, "contacted", extras, ctx);
|
|
632
1612
|
}
|
|
633
|
-
async qualify(id,
|
|
634
|
-
return this._transition(id, "qualified",
|
|
635
|
-
...by !== void 0 ? { by } : {},
|
|
636
|
-
...note !== void 0 ? { note } : {}
|
|
637
|
-
}, options);
|
|
1613
|
+
async qualify(id, extras = {}, ctx) {
|
|
1614
|
+
return this._transition(id, "qualified", extras, ctx);
|
|
638
1615
|
}
|
|
639
|
-
async disqualify(id, reason,
|
|
1616
|
+
async disqualify(id, reason, extras = {}, ctx) {
|
|
640
1617
|
const updated = await this._transition(id, "disqualified", {
|
|
1618
|
+
...extras,
|
|
641
1619
|
note: reason,
|
|
642
|
-
patch: { disqualifyReason: reason }
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
await this._emit(CRM_EVENTS.LEAD_DISQUALIFIED, {
|
|
1620
|
+
patch: { disqualifyReason: reason }
|
|
1621
|
+
}, ctx);
|
|
1622
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_DISQUALIFIED, {
|
|
646
1623
|
leadId: id,
|
|
647
1624
|
reason
|
|
648
|
-
},
|
|
1625
|
+
}, ctx));
|
|
649
1626
|
return updated;
|
|
650
1627
|
}
|
|
651
|
-
async nurture(id,
|
|
652
|
-
return this._transition(id, "nurturing",
|
|
653
|
-
}
|
|
654
|
-
async convert(id, input, options = {}) {
|
|
655
|
-
const existing = await this._require(id);
|
|
656
|
-
if (existing.status === "converted") throw new LeadAlreadyConvertedError(id);
|
|
657
|
-
if (!canTransitionLead(existing.status, "converted")) throw new InvalidLeadTransitionError({
|
|
658
|
-
from: existing.status,
|
|
659
|
-
to: "converted",
|
|
660
|
-
leadId: id
|
|
661
|
-
});
|
|
662
|
-
let account;
|
|
663
|
-
let contact;
|
|
664
|
-
let opportunity;
|
|
665
|
-
try {
|
|
666
|
-
const innerOpts = {
|
|
667
|
-
emitEvents: false,
|
|
668
|
-
...options.session !== void 0 ? { session: options.session } : {}
|
|
669
|
-
};
|
|
670
|
-
if (existing.companyName) account = await this.accounts.create({
|
|
671
|
-
name: existing.companyName,
|
|
672
|
-
...existing.ownerId !== void 0 ? { ownerId: existing.ownerId } : {}
|
|
673
|
-
}, innerOpts);
|
|
674
|
-
const { given, family, preferred } = splitLeadName(existing);
|
|
675
|
-
contact = await this.contacts.create({
|
|
676
|
-
name: {
|
|
677
|
-
given,
|
|
678
|
-
family,
|
|
679
|
-
...preferred ? { preferred } : {}
|
|
680
|
-
},
|
|
681
|
-
contact: {
|
|
682
|
-
...existing.email !== void 0 ? { email: existing.email } : {},
|
|
683
|
-
...existing.phone !== void 0 ? { phone: existing.phone } : {}
|
|
684
|
-
},
|
|
685
|
-
...existing.jobTitle !== void 0 ? { jobTitle: existing.jobTitle } : {},
|
|
686
|
-
...account !== void 0 ? { accountId: account.id } : {},
|
|
687
|
-
...existing.ownerId !== void 0 ? { ownerId: existing.ownerId } : {}
|
|
688
|
-
}, innerOpts);
|
|
689
|
-
const opportunityName = input.opportunityName ?? `${existing.companyName ?? existing.fullName} — ${existing.source ?? "New"}`;
|
|
690
|
-
opportunity = await this.opportunities.create({
|
|
691
|
-
name: opportunityName,
|
|
692
|
-
pipelineId: input.pipelineId,
|
|
693
|
-
...account !== void 0 ? { accountId: account.id } : {},
|
|
694
|
-
primaryContactId: contact.id,
|
|
695
|
-
sourceLeadId: id,
|
|
696
|
-
...input.amount !== void 0 ? { amount: input.amount } : {},
|
|
697
|
-
...input.expectedCloseAt !== void 0 ? { expectedCloseAt: input.expectedCloseAt } : {},
|
|
698
|
-
...existing.ownerId !== void 0 ? { ownerId: existing.ownerId } : {}
|
|
699
|
-
}, innerOpts);
|
|
700
|
-
const convertedAt = this.clock.now();
|
|
701
|
-
const lead = await this._transition(id, "converted", {
|
|
702
|
-
patch: {
|
|
703
|
-
convertedContactId: contact.id,
|
|
704
|
-
...account !== void 0 ? { convertedAccountId: account.id } : {},
|
|
705
|
-
convertedOpportunityId: opportunity.id,
|
|
706
|
-
convertedAt
|
|
707
|
-
},
|
|
708
|
-
...input.by !== void 0 ? { by: input.by } : {}
|
|
709
|
-
}, options);
|
|
710
|
-
if (account) await this._emit(CRM_EVENTS.ACCOUNT_CREATED, { accountId: account.id }, {
|
|
711
|
-
resource: "account",
|
|
712
|
-
resourceId: account.id
|
|
713
|
-
}, options);
|
|
714
|
-
await this._emit(CRM_EVENTS.CONTACT_CREATED, {
|
|
715
|
-
contactId: contact.id,
|
|
716
|
-
email: contact.contact.email
|
|
717
|
-
}, {
|
|
718
|
-
resource: "contact",
|
|
719
|
-
resourceId: contact.id
|
|
720
|
-
}, options);
|
|
721
|
-
await this._emit(CRM_EVENTS.OPPORTUNITY_CREATED, {
|
|
722
|
-
opportunityId: opportunity.id,
|
|
723
|
-
pipelineId: opportunity.pipelineId,
|
|
724
|
-
stageId: opportunity.stageId
|
|
725
|
-
}, {
|
|
726
|
-
resource: "opportunity",
|
|
727
|
-
resourceId: opportunity.id
|
|
728
|
-
}, options);
|
|
729
|
-
await this._emit(CRM_EVENTS.LEAD_CONVERTED, {
|
|
730
|
-
leadId: id,
|
|
731
|
-
contactId: contact.id,
|
|
732
|
-
accountId: account?.id,
|
|
733
|
-
opportunityId: opportunity.id
|
|
734
|
-
}, {
|
|
735
|
-
resource: "lead",
|
|
736
|
-
resourceId: id
|
|
737
|
-
}, options);
|
|
738
|
-
return {
|
|
739
|
-
lead,
|
|
740
|
-
contactId: contact.id,
|
|
741
|
-
...account !== void 0 ? { accountId: account.id } : {},
|
|
742
|
-
opportunityId: opportunity.id
|
|
743
|
-
};
|
|
744
|
-
} catch (error) {
|
|
745
|
-
if (opportunity?.id && this.opportunities.delete) await this.opportunities.delete(opportunity.id);
|
|
746
|
-
if (contact?.id && this.contacts.delete) await this.contacts.delete(contact.id);
|
|
747
|
-
if (account?.id && this.accounts.delete) await this.accounts.delete(account.id);
|
|
748
|
-
throw error;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
/** Recompute score and persist. Hosts can call on update or on demand. */
|
|
752
|
-
async recomputeScore(id, options = {}) {
|
|
753
|
-
const lead = await this._require(id);
|
|
754
|
-
return this._updateScore(lead, options);
|
|
755
|
-
}
|
|
756
|
-
findById(id) {
|
|
757
|
-
return this.repo.findById(id);
|
|
1628
|
+
async nurture(id, extras = {}, ctx) {
|
|
1629
|
+
return this._transition(id, "nurturing", extras, ctx);
|
|
758
1630
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1631
|
+
/**
|
|
1632
|
+
* Recompute + persist the lead's score. Called from `createLead`
|
|
1633
|
+
* automatically; hosts also call this after enrichment writes.
|
|
1634
|
+
*/
|
|
1635
|
+
async recomputeScore(id, ctx) {
|
|
1636
|
+
const lead = await this.getById(id, ctx);
|
|
1637
|
+
if (!lead) throw new NotFoundError("Lead", id);
|
|
763
1638
|
const score = this.scorer(lead);
|
|
764
1639
|
if (score === lead.score) return lead;
|
|
765
|
-
const updated = await this.
|
|
766
|
-
|
|
767
|
-
|
|
1640
|
+
const updated = await this.update(id, { score }, ctx);
|
|
1641
|
+
if (!updated) throw new NotFoundError("Lead", id);
|
|
1642
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_SCORED, {
|
|
1643
|
+
leadId: id,
|
|
768
1644
|
score
|
|
769
|
-
},
|
|
770
|
-
resource: "lead",
|
|
771
|
-
resourceId: lead.id
|
|
772
|
-
}, options);
|
|
1645
|
+
}, ctx));
|
|
773
1646
|
return updated;
|
|
774
1647
|
}
|
|
775
|
-
async
|
|
776
|
-
const
|
|
777
|
-
if (!
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
async _transition(id, target, opts = {}, options = {}) {
|
|
781
|
-
const existing = await this._require(id);
|
|
782
|
-
if (!canTransitionLead(existing.status, target)) throw new InvalidLeadTransitionError({
|
|
783
|
-
from: existing.status,
|
|
784
|
-
to: target,
|
|
785
|
-
leadId: id
|
|
786
|
-
});
|
|
1648
|
+
async _transition(id, target, extras, ctx) {
|
|
1649
|
+
const existing = await this.getById(id, ctx);
|
|
1650
|
+
if (!existing) throw new NotFoundError("Lead", id);
|
|
1651
|
+
LEAD_MACHINE.assertTransition(id, existing.status, target);
|
|
787
1652
|
const entry = {
|
|
788
1653
|
status: target,
|
|
789
|
-
occurredAt:
|
|
790
|
-
...
|
|
791
|
-
...
|
|
1654
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
1655
|
+
...extras.by !== void 0 ? { by: extras.by } : {},
|
|
1656
|
+
...extras.note !== void 0 ? { note: extras.note } : {}
|
|
792
1657
|
};
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1658
|
+
const updated = await assertAndClaim(LEAD_MACHINE, this, id, {
|
|
1659
|
+
from: existing.status,
|
|
1660
|
+
to: target,
|
|
1661
|
+
patch: {
|
|
1662
|
+
...extras.patch ?? {},
|
|
1663
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
1664
|
+
},
|
|
1665
|
+
options: ctx
|
|
1666
|
+
});
|
|
798
1667
|
if (!updated) throw new InvalidLeadTransitionError({
|
|
799
|
-
from: (await this.
|
|
1668
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
800
1669
|
to: target,
|
|
801
1670
|
leadId: id
|
|
802
1671
|
});
|
|
803
|
-
await this.
|
|
1672
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_STATUS_CHANGED, {
|
|
804
1673
|
leadId: id,
|
|
805
1674
|
from: existing.status,
|
|
806
1675
|
to: target
|
|
807
|
-
},
|
|
808
|
-
resource: "lead",
|
|
809
|
-
resourceId: id
|
|
810
|
-
}, options);
|
|
1676
|
+
}, ctx));
|
|
811
1677
|
return updated;
|
|
812
1678
|
}
|
|
813
|
-
async _emit(event, payload, meta, options = {}) {
|
|
814
|
-
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
815
|
-
}
|
|
816
|
-
};
|
|
817
|
-
//#endregion
|
|
818
|
-
//#region src/domain/enums/opportunity-status.ts
|
|
819
|
-
/**
|
|
820
|
-
* Top-level opportunity status. Movement through pipeline STAGES
|
|
821
|
-
* happens while status is `open`; `won` / `lost` are terminal
|
|
822
|
-
* outcomes. Re-opening a lost deal is a host-level decision —
|
|
823
|
-
* convention is to clone the opportunity rather than revive the
|
|
824
|
-
* terminal record.
|
|
825
|
-
*/
|
|
826
|
-
const OPPORTUNITY_STATUSES = [
|
|
827
|
-
"open",
|
|
828
|
-
"won",
|
|
829
|
-
"lost",
|
|
830
|
-
"abandoned"
|
|
831
|
-
];
|
|
832
|
-
const OPPORTUNITY_TRANSITIONS = {
|
|
833
|
-
open: [
|
|
834
|
-
"won",
|
|
835
|
-
"lost",
|
|
836
|
-
"abandoned"
|
|
837
|
-
],
|
|
838
|
-
won: [],
|
|
839
|
-
lost: [],
|
|
840
|
-
abandoned: []
|
|
841
1679
|
};
|
|
842
|
-
function canTransitionOpportunity(from, to) {
|
|
843
|
-
return OPPORTUNITY_TRANSITIONS[from].includes(to);
|
|
844
|
-
}
|
|
845
1680
|
//#endregion
|
|
846
|
-
//#region src/
|
|
847
|
-
|
|
848
|
-
* Opportunity lifecycle — stage movement + outcome resolution. Stage
|
|
849
|
-
* changes call the pipeline service to validate membership + default
|
|
850
|
-
* probability; status transitions (`won` / `lost` / `abandoned`) are
|
|
851
|
-
* terminal.
|
|
852
|
-
*/
|
|
853
|
-
var OpportunityService = class {
|
|
854
|
-
repo;
|
|
855
|
-
pipelines;
|
|
1681
|
+
//#region src/repositories/opportunity.repository.ts
|
|
1682
|
+
var OpportunityRepository = class extends Repository {
|
|
856
1683
|
events;
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
this.
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
this.
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
async create(input, options = {}) {
|
|
869
|
-
const pipeline = await this.pipelines.findById(input.pipelineId);
|
|
870
|
-
if (!pipeline) throw new NotFoundError("pipeline", input.pipelineId);
|
|
871
|
-
if (pipeline.stages.length === 0) throw new EmptyPipelineError(pipeline.id);
|
|
872
|
-
const stage = input.stageId ? await this.pipelines.getStage(pipeline.id, input.stageId) : await this.pipelines.getFirstStage(pipeline.id);
|
|
873
|
-
const probability = input.probability ?? stage.defaultProbability;
|
|
874
|
-
const now = this.clock.now();
|
|
875
|
-
const opportunity = await this.repo.create({
|
|
876
|
-
id: this.ids.next(),
|
|
1684
|
+
pipelines;
|
|
1685
|
+
constructor(model, plugins, deps, pagination) {
|
|
1686
|
+
super(model, plugins, pagination);
|
|
1687
|
+
this.events = deps.events;
|
|
1688
|
+
this.pipelines = deps.pipelines;
|
|
1689
|
+
}
|
|
1690
|
+
async openOpportunity(input, ctx) {
|
|
1691
|
+
const stage = input.stageId ? await this.pipelines.getStage(input.pipelineId, input.stageId, ctx) : await this.pipelines.getFirstStage(input.pipelineId, ctx);
|
|
1692
|
+
const now = /* @__PURE__ */ new Date();
|
|
1693
|
+
const draft = {
|
|
877
1694
|
name: input.name,
|
|
878
|
-
pipelineId:
|
|
879
|
-
stageId: stage.
|
|
1695
|
+
pipelineId: input.pipelineId,
|
|
1696
|
+
stageId: String(stage._id),
|
|
880
1697
|
status: "open",
|
|
1698
|
+
probability: input.probability ?? stage.defaultProbability,
|
|
1699
|
+
currency: input.currency ?? "USD",
|
|
881
1700
|
statusHistory: [{
|
|
882
1701
|
status: "open",
|
|
883
|
-
stageId: stage.
|
|
1702
|
+
stageId: String(stage._id),
|
|
884
1703
|
occurredAt: now
|
|
885
1704
|
}],
|
|
886
|
-
probability,
|
|
887
1705
|
...input.accountId !== void 0 ? { accountId: input.accountId } : {},
|
|
888
1706
|
...input.primaryContactId !== void 0 ? { primaryContactId: input.primaryContactId } : {},
|
|
889
1707
|
...input.sourceLeadId !== void 0 ? { sourceLeadId: input.sourceLeadId } : {},
|
|
890
1708
|
...input.amount !== void 0 ? { amount: input.amount } : {},
|
|
891
1709
|
...input.expectedCloseAt !== void 0 ? { expectedCloseAt: input.expectedCloseAt } : {},
|
|
892
1710
|
...input.ownerId !== void 0 ? { ownerId: input.ownerId } : {},
|
|
893
|
-
...input.tags !== void 0 ? { tags: input.tags } : {},
|
|
1711
|
+
...input.tags !== void 0 ? { tags: [...input.tags] } : {},
|
|
894
1712
|
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1713
|
+
};
|
|
1714
|
+
const created = await this.create(draft, ctx);
|
|
1715
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_CREATED, {
|
|
1716
|
+
opportunityId: String(created._id),
|
|
1717
|
+
pipelineId: input.pipelineId,
|
|
1718
|
+
stageId: String(stage._id)
|
|
1719
|
+
}, ctx));
|
|
1720
|
+
return created;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Atomic stage move. CAS on `(status='open', stageId=current)` so two
|
|
1724
|
+
* concurrent moves can't both win. Emits `stage_advanced` or
|
|
1725
|
+
* `stage_regressed` based on `sequence` ordering of the embedded stages.
|
|
1726
|
+
*/
|
|
1727
|
+
async moveToStage(id, input, ctx) {
|
|
1728
|
+
const existing = await this.getById(id, ctx);
|
|
1729
|
+
if (!existing) throw new NotFoundError("Opportunity", id);
|
|
908
1730
|
if (existing.status !== "open") throw new InvalidOpportunityTransitionError({
|
|
909
1731
|
from: existing.status,
|
|
910
1732
|
to: existing.status,
|
|
911
1733
|
opportunityId: id
|
|
912
1734
|
});
|
|
913
|
-
const target = await this.pipelines.getStage(existing.pipelineId, input.stageId);
|
|
914
|
-
const
|
|
1735
|
+
const target = await this.pipelines.getStage(String(existing.pipelineId), input.stageId, ctx);
|
|
1736
|
+
const current = await this.pipelines.getStage(String(existing.pipelineId), existing.stageId, ctx);
|
|
915
1737
|
const probability = input.probability ?? target.defaultProbability;
|
|
916
|
-
const now = this.clock.now();
|
|
917
1738
|
const entry = {
|
|
918
1739
|
status: "open",
|
|
919
|
-
stageId: target.
|
|
920
|
-
occurredAt:
|
|
1740
|
+
stageId: String(target._id),
|
|
1741
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
921
1742
|
...input.by !== void 0 ? { by: input.by } : {},
|
|
922
1743
|
...input.note !== void 0 ? { note: input.note } : {}
|
|
923
1744
|
};
|
|
924
|
-
const
|
|
1745
|
+
const updated = await this.claim(id, {
|
|
1746
|
+
field: "stageId",
|
|
1747
|
+
from: existing.stageId,
|
|
1748
|
+
to: String(target._id),
|
|
1749
|
+
where: { status: "open" }
|
|
1750
|
+
}, {
|
|
925
1751
|
probability,
|
|
926
|
-
statusHistory: [...existing.statusHistory, entry]
|
|
927
|
-
};
|
|
928
|
-
const updated = await this.repo.casStage(id, existing.stageId, target.id, patch);
|
|
1752
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
1753
|
+
}, ctx);
|
|
929
1754
|
if (!updated) throw new StageNotInPipelineError({
|
|
930
|
-
pipelineId: existing.pipelineId,
|
|
931
|
-
stageId: target.
|
|
1755
|
+
pipelineId: String(existing.pipelineId),
|
|
1756
|
+
stageId: String(target._id)
|
|
932
1757
|
});
|
|
933
|
-
const
|
|
934
|
-
await this.
|
|
1758
|
+
const event = target.sequence > current.sequence ? CRM_EVENTS.OPPORTUNITY_STAGE_ADVANCED : CRM_EVENTS.OPPORTUNITY_STAGE_REGRESSED;
|
|
1759
|
+
await this.events.publish(createEvent(event, {
|
|
935
1760
|
opportunityId: id,
|
|
936
1761
|
from: {
|
|
937
|
-
stageId:
|
|
938
|
-
sequence:
|
|
1762
|
+
stageId: String(current._id),
|
|
1763
|
+
sequence: current.sequence
|
|
939
1764
|
},
|
|
940
1765
|
to: {
|
|
941
|
-
stageId: target.
|
|
1766
|
+
stageId: String(target._id),
|
|
942
1767
|
sequence: target.sequence
|
|
943
1768
|
}
|
|
944
|
-
},
|
|
945
|
-
resource: "opportunity",
|
|
946
|
-
resourceId: id
|
|
947
|
-
}, options);
|
|
1769
|
+
}, ctx));
|
|
948
1770
|
return updated;
|
|
949
1771
|
}
|
|
950
|
-
async win(id, input = {},
|
|
951
|
-
const
|
|
952
|
-
closedAt: input.closedAt ??
|
|
1772
|
+
async win(id, input = {}, ctx) {
|
|
1773
|
+
const updated = await this._closeStatus(id, "won", {
|
|
1774
|
+
closedAt: input.closedAt ?? /* @__PURE__ */ new Date(),
|
|
953
1775
|
probability: 1,
|
|
954
1776
|
...input.amount !== void 0 ? { amount: input.amount } : {}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
1777
|
+
}, {
|
|
1778
|
+
...input.by !== void 0 ? { by: input.by } : {},
|
|
1779
|
+
...input.note !== void 0 ? { note: input.note } : {}
|
|
1780
|
+
}, ctx);
|
|
1781
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_WON, {
|
|
958
1782
|
opportunityId: id,
|
|
959
1783
|
amount: updated.amount
|
|
960
|
-
},
|
|
961
|
-
resource: "opportunity",
|
|
962
|
-
resourceId: id
|
|
963
|
-
}, options);
|
|
1784
|
+
}, ctx));
|
|
964
1785
|
return updated;
|
|
965
1786
|
}
|
|
966
|
-
async lose(id, input = {},
|
|
967
|
-
const
|
|
968
|
-
closedAt: input.closedAt ??
|
|
1787
|
+
async lose(id, input = {}, ctx) {
|
|
1788
|
+
const updated = await this._closeStatus(id, "lost", {
|
|
1789
|
+
closedAt: input.closedAt ?? /* @__PURE__ */ new Date(),
|
|
969
1790
|
probability: 0,
|
|
970
1791
|
...input.lostReasonId !== void 0 ? { lostReasonId: input.lostReasonId } : {}
|
|
971
|
-
};
|
|
972
|
-
const updated = await this._transitionStatus(id, "lost", patch, input.by, input.note);
|
|
973
|
-
await this._emit(CRM_EVENTS.OPPORTUNITY_LOST, {
|
|
974
|
-
opportunityId: id,
|
|
975
|
-
lostReasonId: input.lostReasonId
|
|
976
1792
|
}, {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
},
|
|
1793
|
+
...input.by !== void 0 ? { by: input.by } : {},
|
|
1794
|
+
...input.note !== void 0 ? { note: input.note } : {}
|
|
1795
|
+
}, ctx);
|
|
1796
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_LOST, {
|
|
1797
|
+
opportunityId: id,
|
|
1798
|
+
lostReasonId: updated.lostReasonId
|
|
1799
|
+
}, ctx));
|
|
980
1800
|
return updated;
|
|
981
1801
|
}
|
|
982
|
-
async abandon(id, by, note,
|
|
983
|
-
const
|
|
984
|
-
closedAt:
|
|
1802
|
+
async abandon(id, by, note, ctx) {
|
|
1803
|
+
const updated = await this._closeStatus(id, "abandoned", {
|
|
1804
|
+
closedAt: /* @__PURE__ */ new Date(),
|
|
985
1805
|
probability: 0
|
|
1806
|
+
}, {
|
|
1807
|
+
...by !== void 0 ? { by } : {},
|
|
1808
|
+
...note !== void 0 ? { note } : {}
|
|
1809
|
+
}, ctx);
|
|
1810
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_ABANDONED, { opportunityId: id }, ctx));
|
|
1811
|
+
return updated;
|
|
1812
|
+
}
|
|
1813
|
+
async _closeStatus(id, target, patch, extras, ctx) {
|
|
1814
|
+
const existing = await this.getById(id, ctx);
|
|
1815
|
+
if (!existing) throw new NotFoundError("Opportunity", id);
|
|
1816
|
+
OPPORTUNITY_MACHINE.assertTransition(id, existing.status, target);
|
|
1817
|
+
const entry = {
|
|
1818
|
+
status: target,
|
|
1819
|
+
stageId: existing.stageId,
|
|
1820
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
1821
|
+
...extras.by !== void 0 ? { by: extras.by } : {},
|
|
1822
|
+
...extras.note !== void 0 ? { note: extras.note } : {}
|
|
1823
|
+
};
|
|
1824
|
+
const updated = await assertAndClaim(OPPORTUNITY_MACHINE, this, id, {
|
|
1825
|
+
from: existing.status,
|
|
1826
|
+
to: target,
|
|
1827
|
+
patch: {
|
|
1828
|
+
...patch,
|
|
1829
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
1830
|
+
},
|
|
1831
|
+
options: ctx
|
|
1832
|
+
});
|
|
1833
|
+
if (!updated) throw new InvalidOpportunityTransitionError({
|
|
1834
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
1835
|
+
to: target,
|
|
1836
|
+
opportunityId: id
|
|
1837
|
+
});
|
|
1838
|
+
return updated;
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
//#endregion
|
|
1842
|
+
//#region src/repositories/contact.repository.ts
|
|
1843
|
+
var ContactRepository = class extends Repository {
|
|
1844
|
+
events;
|
|
1845
|
+
constructor(model, plugins, deps, pagination) {
|
|
1846
|
+
super(model, plugins, pagination);
|
|
1847
|
+
this.events = deps.events;
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Create + emit `crm:contact.created`. Hosts call this instead of
|
|
1851
|
+
* `create()` when they want the event side-effect (e.g. webhook fan-out).
|
|
1852
|
+
*/
|
|
1853
|
+
async createContact(input, ctx) {
|
|
1854
|
+
const created = await this.create(input, ctx);
|
|
1855
|
+
await this.events.publish(createEvent(CRM_EVENTS.CONTACT_CREATED, {
|
|
1856
|
+
contactId: String(created._id),
|
|
1857
|
+
email: created.contact?.email
|
|
1858
|
+
}, ctx));
|
|
1859
|
+
return created;
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
//#endregion
|
|
1863
|
+
//#region src/repositories/account.repository.ts
|
|
1864
|
+
var AccountRepository = class extends Repository {
|
|
1865
|
+
events;
|
|
1866
|
+
constructor(model, plugins, deps, pagination) {
|
|
1867
|
+
super(model, plugins, pagination);
|
|
1868
|
+
this.events = deps.events;
|
|
1869
|
+
}
|
|
1870
|
+
async createAccount(input, ctx) {
|
|
1871
|
+
const created = await this.create(input, ctx);
|
|
1872
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_CREATED, {
|
|
1873
|
+
accountId: String(created._id),
|
|
1874
|
+
name: created.name
|
|
1875
|
+
}, ctx));
|
|
1876
|
+
return created;
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
//#endregion
|
|
1880
|
+
//#region src/repositories/campaign.repository.ts
|
|
1881
|
+
var CampaignRepository = class extends Repository {
|
|
1882
|
+
events;
|
|
1883
|
+
rejectDuplicates;
|
|
1884
|
+
constructor(model, plugins, deps, pagination) {
|
|
1885
|
+
super(model, plugins, pagination);
|
|
1886
|
+
this.events = deps.events;
|
|
1887
|
+
this.rejectDuplicates = deps.rejectDuplicates ?? true;
|
|
1888
|
+
}
|
|
1889
|
+
async createCampaign(input, ctx) {
|
|
1890
|
+
if (this.rejectDuplicates && input.name) {
|
|
1891
|
+
if (await this.getOne({ name: input.name }, ctx)) throw new DuplicateCampaignError(input.name);
|
|
1892
|
+
}
|
|
1893
|
+
const now = /* @__PURE__ */ new Date();
|
|
1894
|
+
const draft = {
|
|
1895
|
+
...input,
|
|
1896
|
+
status: input.status ?? "draft",
|
|
1897
|
+
currency: input.currency ?? "USD",
|
|
1898
|
+
statusHistory: [{
|
|
1899
|
+
status: input.status ?? "draft",
|
|
1900
|
+
occurredAt: now
|
|
1901
|
+
}]
|
|
1902
|
+
};
|
|
1903
|
+
const created = await this.create(draft, ctx);
|
|
1904
|
+
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_CREATED, {
|
|
1905
|
+
campaignId: String(created._id),
|
|
1906
|
+
name: created.name
|
|
1907
|
+
}, ctx));
|
|
1908
|
+
return created;
|
|
1909
|
+
}
|
|
1910
|
+
async activate(id, by, ctx) {
|
|
1911
|
+
const updated = await this._transition(id, "active", { ...by !== void 0 ? { by } : {} }, ctx);
|
|
1912
|
+
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_ACTIVATED, { campaignId: id }, ctx));
|
|
1913
|
+
return updated;
|
|
1914
|
+
}
|
|
1915
|
+
async pause(id, reason, by, ctx) {
|
|
1916
|
+
const updated = await this._transition(id, "paused", {
|
|
1917
|
+
...by !== void 0 ? { by } : {},
|
|
1918
|
+
...reason !== void 0 ? { note: reason } : {}
|
|
1919
|
+
}, ctx);
|
|
1920
|
+
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_PAUSED, {
|
|
1921
|
+
campaignId: id,
|
|
1922
|
+
reason
|
|
1923
|
+
}, ctx));
|
|
1924
|
+
return updated;
|
|
1925
|
+
}
|
|
1926
|
+
async resume(id, by, ctx) {
|
|
1927
|
+
const updated = await this._transition(id, "active", { ...by !== void 0 ? { by } : {} }, ctx);
|
|
1928
|
+
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_RESUMED, { campaignId: id }, ctx));
|
|
1929
|
+
return updated;
|
|
1930
|
+
}
|
|
1931
|
+
async complete(id, extras = {}, ctx) {
|
|
1932
|
+
const patch = extras.actualRevenueAmount !== void 0 ? { actualRevenueAmount: extras.actualRevenueAmount } : {};
|
|
1933
|
+
const updated = await this._transition(id, "completed", extras, ctx, patch);
|
|
1934
|
+
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_COMPLETED, {
|
|
1935
|
+
campaignId: id,
|
|
1936
|
+
actualRevenueAmount: updated.actualRevenueAmount
|
|
1937
|
+
}, ctx));
|
|
1938
|
+
return updated;
|
|
1939
|
+
}
|
|
1940
|
+
async archive(id, by, ctx) {
|
|
1941
|
+
const updated = await this._transition(id, "archived", { ...by !== void 0 ? { by } : {} }, ctx);
|
|
1942
|
+
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_ARCHIVED, { campaignId: id }, ctx));
|
|
1943
|
+
return updated;
|
|
1944
|
+
}
|
|
1945
|
+
async _transition(id, target, extras, ctx, patch = {}) {
|
|
1946
|
+
const existing = await this.getById(id, ctx);
|
|
1947
|
+
if (!existing) throw new NotFoundError("Campaign", id);
|
|
1948
|
+
CAMPAIGN_MACHINE.assertTransition(id, existing.status, target);
|
|
1949
|
+
const entry = {
|
|
1950
|
+
status: target,
|
|
1951
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
1952
|
+
...extras.by !== void 0 ? { by: extras.by } : {},
|
|
1953
|
+
...extras.note !== void 0 ? { note: extras.note } : {}
|
|
1954
|
+
};
|
|
1955
|
+
const updated = await assertAndClaim(CAMPAIGN_MACHINE, this, id, {
|
|
1956
|
+
from: existing.status,
|
|
1957
|
+
to: target,
|
|
1958
|
+
patch: {
|
|
1959
|
+
...patch,
|
|
1960
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
1961
|
+
},
|
|
1962
|
+
options: ctx
|
|
1963
|
+
});
|
|
1964
|
+
if (!updated) throw new InvalidCampaignTransitionError({
|
|
1965
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
1966
|
+
to: target,
|
|
1967
|
+
campaignId: id
|
|
1968
|
+
});
|
|
1969
|
+
return updated;
|
|
1970
|
+
}
|
|
1971
|
+
};
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region src/repositories/subscription.repository.ts
|
|
1974
|
+
/**
|
|
1975
|
+
* SubscriptionRepository — extends mongokit Repository.
|
|
1976
|
+
*
|
|
1977
|
+
* Double opt-in flow:
|
|
1978
|
+
* subscribe() → status `pending` (or `confirmed` when DOI off)
|
|
1979
|
+
* confirm(token) → `pending → confirmed`
|
|
1980
|
+
* unsubscribe() → terminal `unsubscribed`
|
|
1981
|
+
* markBounced() → terminal `bounced`
|
|
1982
|
+
* markComplained() → terminal `complained`
|
|
1983
|
+
*
|
|
1984
|
+
* Token lookups (confirm, unsubscribe) use `getOne({ token })` — no
|
|
1985
|
+
* proxy `findByConfirmationToken` (Rule §3).
|
|
1986
|
+
*/
|
|
1987
|
+
var SubscriptionRepository = class extends Repository {
|
|
1988
|
+
events;
|
|
1989
|
+
defaultRequireDoubleOptIn;
|
|
1990
|
+
constructor(model, plugins, deps, pagination) {
|
|
1991
|
+
super(model, plugins, pagination);
|
|
1992
|
+
this.events = deps.events;
|
|
1993
|
+
this.defaultRequireDoubleOptIn = deps.defaultRequireDoubleOptIn ?? true;
|
|
1994
|
+
}
|
|
1995
|
+
async subscribe(input, ctx) {
|
|
1996
|
+
if (await this.getOne({
|
|
1997
|
+
contactId: input.contactId,
|
|
1998
|
+
campaignId: input.campaignId,
|
|
1999
|
+
status: { $in: ["pending", "confirmed"] }
|
|
2000
|
+
}, ctx)) throw new DuplicateSubscriptionError({
|
|
2001
|
+
contactId: input.contactId,
|
|
2002
|
+
campaignId: input.campaignId
|
|
2003
|
+
});
|
|
2004
|
+
const doubleOptIn = input.requireDoubleOptIn ?? this.defaultRequireDoubleOptIn;
|
|
2005
|
+
const now = /* @__PURE__ */ new Date();
|
|
2006
|
+
const status = doubleOptIn ? "pending" : "confirmed";
|
|
2007
|
+
const draft = {
|
|
2008
|
+
contactId: input.contactId,
|
|
2009
|
+
campaignId: input.campaignId,
|
|
2010
|
+
status,
|
|
2011
|
+
statusHistory: [{
|
|
2012
|
+
status,
|
|
2013
|
+
occurredAt: now
|
|
2014
|
+
}],
|
|
2015
|
+
consentTimestamp: now,
|
|
2016
|
+
unsubscribeToken: randomUUID(),
|
|
2017
|
+
...doubleOptIn ? { confirmationToken: randomUUID() } : { confirmedAt: now },
|
|
2018
|
+
...input.source !== void 0 ? { source: input.source } : {},
|
|
2019
|
+
...input.consentIpAddress !== void 0 ? { consentIpAddress: input.consentIpAddress } : {},
|
|
2020
|
+
...input.consentUserAgent !== void 0 ? { consentUserAgent: input.consentUserAgent } : {},
|
|
2021
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
2022
|
+
};
|
|
2023
|
+
const sub = await this.create(draft, ctx);
|
|
2024
|
+
await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_CREATED, {
|
|
2025
|
+
subscriptionId: String(sub._id),
|
|
2026
|
+
contactId: sub.contactId,
|
|
2027
|
+
campaignId: sub.campaignId,
|
|
2028
|
+
requiresConfirmation: doubleOptIn
|
|
2029
|
+
}, ctx));
|
|
2030
|
+
return sub;
|
|
2031
|
+
}
|
|
2032
|
+
async confirm(token, ctx) {
|
|
2033
|
+
const sub = await this.getOne({ confirmationToken: token }, ctx);
|
|
2034
|
+
if (!sub || sub.status !== "pending") throw new InvalidConfirmationTokenError();
|
|
2035
|
+
const confirmedAt = /* @__PURE__ */ new Date();
|
|
2036
|
+
const updated = await this._transition(String(sub._id), "confirmed", { patch: { confirmedAt } }, ctx);
|
|
2037
|
+
await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_CONFIRMED, {
|
|
2038
|
+
subscriptionId: String(sub._id),
|
|
2039
|
+
contactId: sub.contactId,
|
|
2040
|
+
campaignId: sub.campaignId,
|
|
2041
|
+
confirmedAt: confirmedAt.toISOString()
|
|
2042
|
+
}, ctx));
|
|
2043
|
+
return updated;
|
|
2044
|
+
}
|
|
2045
|
+
async unsubscribe(token, reason, ctx) {
|
|
2046
|
+
const sub = await this.getOne({ unsubscribeToken: token }, ctx);
|
|
2047
|
+
if (!sub) throw new InvalidUnsubscribeTokenError();
|
|
2048
|
+
if (sub.status === "unsubscribed") return sub;
|
|
2049
|
+
const unsubscribedAt = /* @__PURE__ */ new Date();
|
|
2050
|
+
const updated = await this._transition(String(sub._id), "unsubscribed", {
|
|
2051
|
+
...reason !== void 0 ? { note: reason } : {},
|
|
2052
|
+
patch: {
|
|
2053
|
+
unsubscribedAt,
|
|
2054
|
+
...reason !== void 0 ? { unsubscribeReason: reason } : {}
|
|
2055
|
+
}
|
|
2056
|
+
}, ctx);
|
|
2057
|
+
await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_UNSUBSCRIBED, {
|
|
2058
|
+
subscriptionId: String(sub._id),
|
|
2059
|
+
contactId: sub.contactId,
|
|
2060
|
+
campaignId: sub.campaignId,
|
|
2061
|
+
...reason !== void 0 ? { reason } : {}
|
|
2062
|
+
}, ctx));
|
|
2063
|
+
return updated;
|
|
2064
|
+
}
|
|
2065
|
+
async markBounced(id, reason, ctx) {
|
|
2066
|
+
const existing = await this.getById(id, ctx);
|
|
2067
|
+
if (!existing) throw new NotFoundError("Subscription", id);
|
|
2068
|
+
if (existing.status === "bounced") return existing;
|
|
2069
|
+
const bouncedAt = /* @__PURE__ */ new Date();
|
|
2070
|
+
const updated = await this._transition(id, "bounced", {
|
|
2071
|
+
...reason !== void 0 ? { note: reason } : {},
|
|
2072
|
+
patch: {
|
|
2073
|
+
bouncedAt,
|
|
2074
|
+
...reason !== void 0 ? { bounceReason: reason } : {}
|
|
2075
|
+
}
|
|
2076
|
+
}, ctx);
|
|
2077
|
+
await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_BOUNCED, {
|
|
2078
|
+
subscriptionId: id,
|
|
2079
|
+
contactId: existing.contactId,
|
|
2080
|
+
campaignId: existing.campaignId,
|
|
2081
|
+
...reason !== void 0 ? { reason } : {}
|
|
2082
|
+
}, ctx));
|
|
2083
|
+
return updated;
|
|
2084
|
+
}
|
|
2085
|
+
async markComplained(id, ctx) {
|
|
2086
|
+
const existing = await this.getById(id, ctx);
|
|
2087
|
+
if (!existing) throw new NotFoundError("Subscription", id);
|
|
2088
|
+
if (existing.status === "complained") return existing;
|
|
2089
|
+
const complainedAt = /* @__PURE__ */ new Date();
|
|
2090
|
+
const updated = await this._transition(id, "complained", { patch: { complainedAt } }, ctx);
|
|
2091
|
+
await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_COMPLAINED, {
|
|
2092
|
+
subscriptionId: id,
|
|
2093
|
+
contactId: existing.contactId,
|
|
2094
|
+
campaignId: existing.campaignId
|
|
2095
|
+
}, ctx));
|
|
2096
|
+
return updated;
|
|
2097
|
+
}
|
|
2098
|
+
async _transition(id, target, opts, ctx) {
|
|
2099
|
+
const existing = await this.getById(id, ctx);
|
|
2100
|
+
if (!existing) throw new NotFoundError("Subscription", id);
|
|
2101
|
+
SUBSCRIPTION_MACHINE.assertTransition(id, existing.status, target);
|
|
2102
|
+
const entry = {
|
|
2103
|
+
status: target,
|
|
2104
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
2105
|
+
...opts.by !== void 0 ? { by: opts.by } : {},
|
|
2106
|
+
...opts.note !== void 0 ? { note: opts.note } : {}
|
|
2107
|
+
};
|
|
2108
|
+
const updated = await assertAndClaim(SUBSCRIPTION_MACHINE, this, id, {
|
|
2109
|
+
from: existing.status,
|
|
2110
|
+
to: target,
|
|
2111
|
+
patch: {
|
|
2112
|
+
...opts.patch ?? {},
|
|
2113
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
2114
|
+
},
|
|
2115
|
+
options: ctx
|
|
2116
|
+
});
|
|
2117
|
+
if (!updated) throw new InvalidSubscriptionTransitionError({
|
|
2118
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
2119
|
+
to: target,
|
|
2120
|
+
subscriptionId: id
|
|
2121
|
+
});
|
|
2122
|
+
return updated;
|
|
2123
|
+
}
|
|
2124
|
+
};
|
|
2125
|
+
//#endregion
|
|
2126
|
+
//#region src/repositories/activity.repository.ts
|
|
2127
|
+
var ActivityRepository = class extends Repository {
|
|
2128
|
+
events;
|
|
2129
|
+
constructor(model, plugins, deps, pagination) {
|
|
2130
|
+
super(model, plugins, pagination);
|
|
2131
|
+
this.events = deps.events;
|
|
2132
|
+
}
|
|
2133
|
+
async plan(input, ctx) {
|
|
2134
|
+
const draft = {
|
|
2135
|
+
...input,
|
|
2136
|
+
status: "planned"
|
|
986
2137
|
};
|
|
987
|
-
const
|
|
988
|
-
await this.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
2138
|
+
const created = await this.create(draft, ctx);
|
|
2139
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_PLANNED, {
|
|
2140
|
+
activityId: String(created._id),
|
|
2141
|
+
type: created.type,
|
|
2142
|
+
subjectKind: created.subjectKind,
|
|
2143
|
+
subjectId: String(created.subjectId)
|
|
2144
|
+
}, ctx));
|
|
2145
|
+
return created;
|
|
2146
|
+
}
|
|
2147
|
+
async start(id, ctx) {
|
|
2148
|
+
return this._transition(id, "in_progress", {}, ctx);
|
|
2149
|
+
}
|
|
2150
|
+
async complete(id, completedAt, ctx) {
|
|
2151
|
+
const ts = completedAt ?? /* @__PURE__ */ new Date();
|
|
2152
|
+
const updated = await this._transition(id, "completed", { completedAt: ts }, ctx);
|
|
2153
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_COMPLETED, {
|
|
2154
|
+
activityId: id,
|
|
2155
|
+
completedAt: ts.toISOString()
|
|
2156
|
+
}, ctx));
|
|
2157
|
+
return updated;
|
|
2158
|
+
}
|
|
2159
|
+
async cancel(id, reason, ctx) {
|
|
2160
|
+
const updated = await this._transition(id, "cancelled", reason ? { metadata: { cancelReason: reason } } : {}, ctx);
|
|
2161
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_CANCELLED, {
|
|
2162
|
+
activityId: id,
|
|
2163
|
+
...reason !== void 0 ? { reason } : {}
|
|
2164
|
+
}, ctx));
|
|
2165
|
+
return updated;
|
|
2166
|
+
}
|
|
2167
|
+
async _transition(id, target, patch, ctx) {
|
|
2168
|
+
const existing = await this.getById(id, ctx);
|
|
2169
|
+
if (!existing) throw new NotFoundError("Activity", id);
|
|
2170
|
+
ACTIVITY_MACHINE.assertTransition(id, existing.status, target);
|
|
2171
|
+
const updated = await assertAndClaim(ACTIVITY_MACHINE, this, id, {
|
|
2172
|
+
from: existing.status,
|
|
2173
|
+
to: target,
|
|
2174
|
+
patch,
|
|
2175
|
+
options: ctx
|
|
2176
|
+
});
|
|
2177
|
+
if (!updated) throw new InvalidActivityTransitionError({
|
|
2178
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
2179
|
+
to: target,
|
|
2180
|
+
activityId: id
|
|
2181
|
+
});
|
|
2182
|
+
return updated;
|
|
2183
|
+
}
|
|
2184
|
+
};
|
|
2185
|
+
//#endregion
|
|
2186
|
+
//#region src/repositories/note.repository.ts
|
|
2187
|
+
var NoteRepository = class extends Repository {
|
|
2188
|
+
events;
|
|
2189
|
+
constructor(model, plugins, deps, pagination) {
|
|
2190
|
+
super(model, plugins, pagination);
|
|
2191
|
+
this.events = deps.events;
|
|
2192
|
+
}
|
|
2193
|
+
async addNote(input, ctx) {
|
|
2194
|
+
const created = await this.create(input, ctx);
|
|
2195
|
+
await this.events.publish(createEvent(CRM_EVENTS.NOTE_ADDED, {
|
|
2196
|
+
noteId: String(created._id),
|
|
2197
|
+
subjectKind: created.subjectKind,
|
|
2198
|
+
subjectId: String(created.subjectId)
|
|
2199
|
+
}, ctx));
|
|
2200
|
+
return created;
|
|
2201
|
+
}
|
|
2202
|
+
};
|
|
2203
|
+
//#endregion
|
|
2204
|
+
//#region src/repositories/loss-reason.repository.ts
|
|
2205
|
+
var LossReasonRepository = class extends Repository {
|
|
2206
|
+
events;
|
|
2207
|
+
constructor(model, plugins, deps, pagination) {
|
|
2208
|
+
super(model, plugins, pagination);
|
|
2209
|
+
this.events = deps.events;
|
|
2210
|
+
}
|
|
2211
|
+
/** Hide from pickers without breaking historical FKs. */
|
|
2212
|
+
async archive(id, ctx) {
|
|
2213
|
+
const updated = await this.update(id, { isActive: false }, ctx);
|
|
2214
|
+
if (!updated) throw new NotFoundError("LossReason", id);
|
|
992
2215
|
return updated;
|
|
993
2216
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
2217
|
+
};
|
|
2218
|
+
//#endregion
|
|
2219
|
+
//#region src/repositories/email-event.repository.ts
|
|
2220
|
+
const KIND_TO_EVENT = {
|
|
2221
|
+
open: CRM_EVENTS.EMAIL_EVENT_OPENED,
|
|
2222
|
+
click: CRM_EVENTS.EMAIL_EVENT_CLICKED,
|
|
2223
|
+
bounce: CRM_EVENTS.EMAIL_EVENT_BOUNCED,
|
|
2224
|
+
complaint: CRM_EVENTS.EMAIL_EVENT_COMPLAINED,
|
|
2225
|
+
unsubscribe: CRM_EVENTS.EMAIL_EVENT_UNSUBSCRIBED
|
|
2226
|
+
};
|
|
2227
|
+
var EmailEventRepository = class extends Repository {
|
|
2228
|
+
events;
|
|
2229
|
+
constructor(model, plugins, deps, pagination) {
|
|
2230
|
+
super(model, plugins, pagination);
|
|
2231
|
+
this.events = deps.events;
|
|
2232
|
+
}
|
|
2233
|
+
async recordOpen(input, ctx) {
|
|
2234
|
+
return this._record("open", input, ctx);
|
|
2235
|
+
}
|
|
2236
|
+
async recordClick(input, ctx) {
|
|
2237
|
+
return this._record("click", input, ctx);
|
|
2238
|
+
}
|
|
2239
|
+
async recordBounce(input, ctx) {
|
|
2240
|
+
return this._record("bounce", input, ctx);
|
|
2241
|
+
}
|
|
2242
|
+
async recordComplaint(input, ctx) {
|
|
2243
|
+
return this._record("complaint", input, ctx);
|
|
997
2244
|
}
|
|
998
|
-
|
|
999
|
-
return this.
|
|
2245
|
+
async recordUnsubscribe(input, ctx) {
|
|
2246
|
+
return this._record("unsubscribe", input, ctx);
|
|
2247
|
+
}
|
|
2248
|
+
async _record(kind, input, ctx) {
|
|
2249
|
+
const draft = {
|
|
2250
|
+
kind,
|
|
2251
|
+
subjectKind: input.subjectKind,
|
|
2252
|
+
subjectId: input.subjectId,
|
|
2253
|
+
...input.campaignId !== void 0 ? { campaignId: input.campaignId } : {},
|
|
2254
|
+
...input.messageId !== void 0 ? { messageId: input.messageId } : {},
|
|
2255
|
+
...input.url !== void 0 ? { url: input.url } : {},
|
|
2256
|
+
...input.userAgent !== void 0 ? { userAgent: input.userAgent } : {},
|
|
2257
|
+
...input.ip !== void 0 ? { ip: input.ip } : {},
|
|
2258
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
2259
|
+
};
|
|
2260
|
+
const created = await this.create(draft, ctx);
|
|
2261
|
+
await this.events.publish(createEvent(KIND_TO_EVENT[kind], {
|
|
2262
|
+
eventId: String(created._id),
|
|
2263
|
+
kind,
|
|
2264
|
+
subjectKind: input.subjectKind,
|
|
2265
|
+
subjectId: input.subjectId,
|
|
2266
|
+
campaignId: input.campaignId
|
|
2267
|
+
}, ctx));
|
|
2268
|
+
return created;
|
|
1000
2269
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
2270
|
+
};
|
|
2271
|
+
//#endregion
|
|
2272
|
+
//#region src/repositories/create-repositories.ts
|
|
2273
|
+
/**
|
|
2274
|
+
* Repository factory — composes the shared mongokit plugin stack and
|
|
2275
|
+
* constructs every CRM repository on the host's models.
|
|
2276
|
+
*
|
|
2277
|
+
* Plugins applied (in order, per PACKAGE_RULES §9 / §22):
|
|
2278
|
+
* 1. methodRegistryPlugin — required wiring for other plugins
|
|
2279
|
+
* 2. multiTenantPlugin — when `multiTenant !== false`
|
|
2280
|
+
* 3. aggregateHelpersPlugin
|
|
2281
|
+
* 4. host-supplied `plugins[]` — appended last so hosts can override
|
|
2282
|
+
*/
|
|
2283
|
+
function createCrmRepositories(models, deps) {
|
|
2284
|
+
const { events, config } = deps;
|
|
2285
|
+
const pagination = config.pagination;
|
|
2286
|
+
const plugins = [methodRegistryPlugin()];
|
|
2287
|
+
if (config.multiTenant) plugins.push(multiTenantPlugin({
|
|
2288
|
+
tenantField: "organizationId",
|
|
2289
|
+
contextKey: "organizationId",
|
|
2290
|
+
required: true,
|
|
2291
|
+
fieldType: config.tenantFieldType
|
|
2292
|
+
}));
|
|
2293
|
+
plugins.push(aggregateHelpersPlugin());
|
|
2294
|
+
if (config.plugins.length > 0) plugins.push(...config.plugins);
|
|
2295
|
+
const pipeline = new PipelineRepository(models.Pipeline, plugins, { events }, pagination);
|
|
2296
|
+
const lead = new LeadRepository(models.Lead, plugins, { events }, pagination);
|
|
2297
|
+
const opportunity = new OpportunityRepository(models.Opportunity, plugins, {
|
|
2298
|
+
events,
|
|
2299
|
+
pipelines: pipeline
|
|
2300
|
+
}, pagination);
|
|
2301
|
+
const contact = new ContactRepository(models.Contact, plugins, { events }, pagination);
|
|
2302
|
+
const account = new AccountRepository(models.Account, plugins, { events }, pagination);
|
|
2303
|
+
const campaign = new CampaignRepository(models.Campaign, plugins, { events }, pagination);
|
|
2304
|
+
const subscription = new SubscriptionRepository(models.Subscription, plugins, { events }, pagination);
|
|
2305
|
+
const activity = new ActivityRepository(models.Activity, plugins, { events }, pagination);
|
|
2306
|
+
const note = new NoteRepository(models.Note, plugins, { events }, pagination);
|
|
2307
|
+
const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
|
|
2308
|
+
const emailEvent = new EmailEventRepository(models.EmailEvent, plugins, { events }, pagination);
|
|
2309
|
+
return Object.freeze({
|
|
2310
|
+
pipeline,
|
|
2311
|
+
lead,
|
|
2312
|
+
opportunity,
|
|
2313
|
+
contact,
|
|
2314
|
+
account,
|
|
2315
|
+
campaign,
|
|
2316
|
+
subscription,
|
|
2317
|
+
activity,
|
|
2318
|
+
note,
|
|
2319
|
+
lossReason,
|
|
2320
|
+
emailEvent
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
//#endregion
|
|
2324
|
+
//#region src/events/in-process-bus.ts
|
|
2325
|
+
/**
|
|
2326
|
+
* In-process event bus — default fallback when no `eventTransport` is
|
|
2327
|
+
* provided to `createCrm`. Structurally identical to arc's
|
|
2328
|
+
* `MemoryEventTransport`; pattern matching is delegated to primitives.
|
|
2329
|
+
* Handler errors are swallowed — event emission never blocks writes.
|
|
2330
|
+
*/
|
|
2331
|
+
var InProcessCrmBus = class {
|
|
2332
|
+
name = "in-process-crm";
|
|
2333
|
+
subs = [];
|
|
2334
|
+
logger;
|
|
2335
|
+
constructor(options) {
|
|
2336
|
+
if (options?.logger) this.logger = options.logger;
|
|
1003
2337
|
}
|
|
1004
|
-
async
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
2338
|
+
async publish(event) {
|
|
2339
|
+
const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
|
|
2340
|
+
for (const sub of matching) try {
|
|
2341
|
+
await sub.handler(event);
|
|
2342
|
+
} catch (err) {
|
|
2343
|
+
this.logger?.error?.(`[crm] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
|
|
2344
|
+
}
|
|
1008
2345
|
}
|
|
1009
|
-
async
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
to: target,
|
|
1014
|
-
opportunityId: id
|
|
1015
|
-
});
|
|
1016
|
-
const now = this.clock.now();
|
|
1017
|
-
const entry = {
|
|
1018
|
-
status: target,
|
|
1019
|
-
stageId: existing.stageId,
|
|
1020
|
-
occurredAt: now,
|
|
1021
|
-
...by !== void 0 ? { by } : {},
|
|
1022
|
-
...note !== void 0 ? { note } : {}
|
|
2346
|
+
async subscribe(pattern, handler) {
|
|
2347
|
+
const sub = {
|
|
2348
|
+
pattern,
|
|
2349
|
+
handler
|
|
1023
2350
|
};
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
2351
|
+
this.subs.push(sub);
|
|
2352
|
+
return () => {
|
|
2353
|
+
this.subs = this.subs.filter((s) => s !== sub);
|
|
1027
2354
|
};
|
|
1028
|
-
const updated = await this.repo.casStatus(id, existing.status, target, fullPatch);
|
|
1029
|
-
if (!updated) throw new InvalidOpportunityTransitionError({
|
|
1030
|
-
from: (await this.repo.findById(id))?.status ?? existing.status,
|
|
1031
|
-
to: target,
|
|
1032
|
-
opportunityId: id
|
|
1033
|
-
});
|
|
1034
|
-
return updated;
|
|
1035
2355
|
}
|
|
1036
|
-
async
|
|
1037
|
-
|
|
2356
|
+
async close() {
|
|
2357
|
+
this.subs = [];
|
|
1038
2358
|
}
|
|
1039
2359
|
};
|
|
1040
2360
|
//#endregion
|
|
1041
|
-
//#region src/
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
2361
|
+
//#region src/engine/create-crm.ts
|
|
2362
|
+
async function createCrm(config) {
|
|
2363
|
+
if (!config.connection) throw new Error("[crm] CrmConfig.connection is required");
|
|
2364
|
+
const resolved = {
|
|
2365
|
+
connection: config.connection,
|
|
2366
|
+
tenantFieldType: config.tenantFieldType ?? "objectId",
|
|
2367
|
+
multiTenant: config.multiTenant ?? true,
|
|
2368
|
+
...config.logger !== void 0 ? { logger: config.logger } : {},
|
|
2369
|
+
...config.pagination !== void 0 ? { pagination: config.pagination } : {},
|
|
2370
|
+
plugins: config.plugins ?? []
|
|
2371
|
+
};
|
|
2372
|
+
const events = config.eventTransport ?? new InProcessCrmBus(config.logger ? { logger: config.logger } : void 0);
|
|
2373
|
+
const models = createCrmModels(config.connection, resolved);
|
|
2374
|
+
const repositories = createCrmRepositories(models, {
|
|
2375
|
+
events,
|
|
2376
|
+
config: resolved
|
|
2377
|
+
});
|
|
2378
|
+
return Object.freeze({
|
|
2379
|
+
models,
|
|
2380
|
+
repositories,
|
|
2381
|
+
events,
|
|
2382
|
+
config: Object.freeze(resolved),
|
|
2383
|
+
async dispose() {
|
|
2384
|
+
await events.close?.();
|
|
2385
|
+
}
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
//#endregion
|
|
2389
|
+
//#region src/services/lead.service.ts
|
|
2390
|
+
var LeadService = class LeadService {
|
|
2391
|
+
leads;
|
|
2392
|
+
contacts;
|
|
2393
|
+
accounts;
|
|
2394
|
+
opportunities;
|
|
1050
2395
|
events;
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
this.
|
|
1056
|
-
this.events =
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
id,
|
|
1070
|
-
name: input.name,
|
|
1071
|
-
isArchived: false,
|
|
1072
|
-
stages,
|
|
1073
|
-
...input.teamRef !== void 0 ? { teamRef: input.teamRef } : {},
|
|
1074
|
-
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
2396
|
+
constructor(deps) {
|
|
2397
|
+
this.leads = deps.lead;
|
|
2398
|
+
this.contacts = deps.contact;
|
|
2399
|
+
this.accounts = deps.account;
|
|
2400
|
+
this.opportunities = deps.opportunity;
|
|
2401
|
+
this.events = deps.events;
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Construct from an already-built CrmEngine — the host's preferred path.
|
|
2405
|
+
* Avoids manually wiring four repositories.
|
|
2406
|
+
*/
|
|
2407
|
+
static fromEngine(engine) {
|
|
2408
|
+
return new LeadService({
|
|
2409
|
+
lead: engine.repositories.lead,
|
|
2410
|
+
contact: engine.repositories.contact,
|
|
2411
|
+
account: engine.repositories.account,
|
|
2412
|
+
opportunity: engine.repositories.opportunity,
|
|
2413
|
+
events: engine.events
|
|
1075
2414
|
});
|
|
1076
|
-
await this._emit(CRM_EVENTS.PIPELINE_CREATED, { pipelineId: pipeline.id }, {
|
|
1077
|
-
resource: "pipeline",
|
|
1078
|
-
resourceId: pipeline.id
|
|
1079
|
-
}, options);
|
|
1080
|
-
return pipeline;
|
|
1081
|
-
}
|
|
1082
|
-
async archive(id, options = {}) {
|
|
1083
|
-
await this._require(id);
|
|
1084
|
-
const updated = await this.repo.update(id, { isArchived: true });
|
|
1085
|
-
await this._emit(CRM_EVENTS.PIPELINE_ARCHIVED, { pipelineId: id }, {
|
|
1086
|
-
resource: "pipeline",
|
|
1087
|
-
resourceId: id
|
|
1088
|
-
}, options);
|
|
1089
|
-
return updated;
|
|
1090
2415
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
2416
|
+
/**
|
|
2417
|
+
* Convert a Lead into Account + Contact + Opportunity, then transition
|
|
2418
|
+
* the lead. Wrap the call in `connection.transaction()` and pass the
|
|
2419
|
+
* session via `ctx.session` for atomicity — all four writes commit or
|
|
2420
|
+
* roll back together.
|
|
2421
|
+
*
|
|
2422
|
+
* Best-effort compensation: if a downstream write fails, already-
|
|
2423
|
+
* created docs are deleted in reverse order. The reverse delete may
|
|
2424
|
+
* itself fail (e.g. transient DB issues) — when transactions are
|
|
2425
|
+
* active, prefer that path; compensation is the no-transaction
|
|
2426
|
+
* fallback.
|
|
2427
|
+
*/
|
|
2428
|
+
async convert(leadId, input, ctx) {
|
|
2429
|
+
const lead = await this.leads.getById(leadId, ctx);
|
|
2430
|
+
if (!lead) throw new NotFoundError("Lead", leadId);
|
|
2431
|
+
if (lead.status === "converted") throw new LeadAlreadyConvertedError(leadId);
|
|
2432
|
+
if (!LEAD_MACHINE.canTransition(lead.status, "converted")) throw new InvalidLeadTransitionError({
|
|
2433
|
+
from: lead.status,
|
|
2434
|
+
to: "converted",
|
|
2435
|
+
leadId
|
|
1096
2436
|
});
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
2437
|
+
let account;
|
|
2438
|
+
let contact;
|
|
2439
|
+
let opportunity;
|
|
2440
|
+
try {
|
|
2441
|
+
if (lead.companyName) account = await this.accounts.createAccount({
|
|
2442
|
+
name: lead.companyName,
|
|
2443
|
+
...lead.ownerId !== void 0 ? { ownerId: lead.ownerId } : {}
|
|
2444
|
+
}, ctx);
|
|
2445
|
+
const name = splitLeadName(lead);
|
|
2446
|
+
const contactInfo = {
|
|
2447
|
+
...lead.email !== void 0 ? { email: lead.email } : {},
|
|
2448
|
+
...lead.phone !== void 0 ? { phone: lead.phone } : {}
|
|
2449
|
+
};
|
|
2450
|
+
contact = await this.contacts.createContact({
|
|
2451
|
+
name,
|
|
2452
|
+
contact: contactInfo,
|
|
2453
|
+
...lead.jobTitle !== void 0 ? { jobTitle: lead.jobTitle } : {},
|
|
2454
|
+
...account !== void 0 ? { accountId: String(account._id) } : {},
|
|
2455
|
+
...lead.ownerId !== void 0 ? { ownerId: lead.ownerId } : {}
|
|
2456
|
+
}, ctx);
|
|
2457
|
+
const opportunityName = input.opportunityName ?? `${lead.companyName ?? lead.fullName} — ${lead.source ?? "New"}`;
|
|
2458
|
+
opportunity = await this.opportunities.openOpportunity({
|
|
2459
|
+
name: opportunityName,
|
|
2460
|
+
pipelineId: input.pipelineId,
|
|
2461
|
+
...account !== void 0 ? { accountId: String(account._id) } : {},
|
|
2462
|
+
primaryContactId: String(contact._id),
|
|
2463
|
+
sourceLeadId: leadId,
|
|
2464
|
+
...input.amount !== void 0 ? { amount: input.amount } : {},
|
|
2465
|
+
...input.currency !== void 0 ? { currency: input.currency } : {},
|
|
2466
|
+
...input.expectedCloseAt !== void 0 ? { expectedCloseAt: input.expectedCloseAt } : {},
|
|
2467
|
+
...lead.ownerId !== void 0 ? { ownerId: lead.ownerId } : {}
|
|
2468
|
+
}, ctx);
|
|
2469
|
+
const finalLead = await this.leads.qualify(leadId, {}, ctx).catch(() => null) ?? lead;
|
|
2470
|
+
const converted = await this._markConverted(String(finalLead._id), finalLead.status, {
|
|
2471
|
+
contactId: String(contact._id),
|
|
2472
|
+
...account !== void 0 ? { accountId: String(account._id) } : {},
|
|
2473
|
+
opportunityId: String(opportunity._id),
|
|
2474
|
+
...input.by !== void 0 ? { by: input.by } : {}
|
|
2475
|
+
}, ctx);
|
|
2476
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_CONVERTED, {
|
|
2477
|
+
leadId,
|
|
2478
|
+
contactId: String(contact._id),
|
|
2479
|
+
accountId: account ? String(account._id) : void 0,
|
|
2480
|
+
opportunityId: String(opportunity._id)
|
|
2481
|
+
}, ctx));
|
|
2482
|
+
return {
|
|
2483
|
+
lead: converted,
|
|
2484
|
+
contact,
|
|
2485
|
+
...account !== void 0 ? { account } : {},
|
|
2486
|
+
opportunity,
|
|
2487
|
+
contactId: String(contact._id),
|
|
2488
|
+
...account !== void 0 ? { accountId: String(account._id) } : {},
|
|
2489
|
+
opportunityId: String(opportunity._id)
|
|
2490
|
+
};
|
|
2491
|
+
} catch (err) {
|
|
2492
|
+
if (!ctx?.session) try {
|
|
2493
|
+
if (opportunity?._id) await this.opportunities.delete(String(opportunity._id), ctx);
|
|
2494
|
+
if (contact?._id) await this.contacts.delete(String(contact._id), ctx);
|
|
2495
|
+
if (account?._id) await this.accounts.delete(String(account._id), ctx);
|
|
2496
|
+
} catch {}
|
|
2497
|
+
throw err;
|
|
2498
|
+
}
|
|
1104
2499
|
}
|
|
1105
|
-
/**
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
2500
|
+
/**
|
|
2501
|
+
* The state-machine hop from whichever status the lead is in to
|
|
2502
|
+
* `converted`. We do this directly (instead of calling
|
|
2503
|
+
* `LeadRepository.qualify` first then a hypothetical `complete`)
|
|
2504
|
+
* because the conversion is "this lead is done" regardless of where
|
|
2505
|
+
* it started — bypassing the `qualified` waypoint is intentional when
|
|
2506
|
+
* a Lead is converted directly from `new` (importers, webhooks).
|
|
2507
|
+
*/
|
|
2508
|
+
async _markConverted(leadId, fromStatus, crossRefs, ctx) {
|
|
2509
|
+
if (!LEAD_MACHINE.canTransition(fromStatus, "converted")) throw new InvalidLeadTransitionError({
|
|
2510
|
+
from: fromStatus,
|
|
2511
|
+
to: "converted",
|
|
2512
|
+
leadId
|
|
1112
2513
|
});
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
2514
|
+
const patch = {
|
|
2515
|
+
convertedContactId: crossRefs.contactId,
|
|
2516
|
+
convertedOpportunityId: crossRefs.opportunityId,
|
|
2517
|
+
convertedAt: /* @__PURE__ */ new Date(),
|
|
2518
|
+
...crossRefs.accountId !== void 0 ? { convertedAccountId: crossRefs.accountId } : {}
|
|
2519
|
+
};
|
|
2520
|
+
const entry = {
|
|
2521
|
+
status: "converted",
|
|
2522
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
2523
|
+
...crossRefs.by !== void 0 ? { by: crossRefs.by } : {}
|
|
2524
|
+
};
|
|
2525
|
+
const current = await this.leads.getById(leadId, ctx);
|
|
2526
|
+
const updated = await this.leads.claim(leadId, {
|
|
2527
|
+
field: "status",
|
|
2528
|
+
from: fromStatus,
|
|
2529
|
+
to: "converted"
|
|
2530
|
+
}, {
|
|
2531
|
+
...patch,
|
|
2532
|
+
statusHistory: [...current?.statusHistory ?? [], entry]
|
|
2533
|
+
}, ctx);
|
|
2534
|
+
if (!updated) throw new NotFoundError("Lead", leadId);
|
|
2535
|
+
return updated;
|
|
1128
2536
|
}
|
|
1129
2537
|
};
|
|
2538
|
+
/**
|
|
2539
|
+
* Derive a `PersonName` from a Lead's flat fields. Web forms capture
|
|
2540
|
+
* either split `firstName`/`lastName` or a single `fullName`.
|
|
2541
|
+
*/
|
|
2542
|
+
function splitLeadName(lead) {
|
|
2543
|
+
if (lead.firstName || lead.lastName) {
|
|
2544
|
+
const out = {
|
|
2545
|
+
given: lead.firstName ?? "",
|
|
2546
|
+
family: lead.lastName ?? ""
|
|
2547
|
+
};
|
|
2548
|
+
const combined = `${lead.firstName ?? ""} ${lead.lastName ?? ""}`.trim();
|
|
2549
|
+
if (lead.fullName && lead.fullName !== combined) out.preferred = lead.fullName;
|
|
2550
|
+
return out;
|
|
2551
|
+
}
|
|
2552
|
+
const parts = (lead.fullName ?? "").trim().split(/\s+/);
|
|
2553
|
+
if (parts.length <= 1) return {
|
|
2554
|
+
given: parts[0] ?? "Unknown",
|
|
2555
|
+
family: ""
|
|
2556
|
+
};
|
|
2557
|
+
const family = parts.pop() ?? "";
|
|
2558
|
+
return {
|
|
2559
|
+
given: parts.join(" "),
|
|
2560
|
+
family
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
1130
2563
|
//#endregion
|
|
1131
|
-
//#region src/
|
|
2564
|
+
//#region src/shared/calculators/forecast.ts
|
|
1132
2565
|
/**
|
|
1133
|
-
* Aggregate opportunities into a forecast table — one row per
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
* Integer-safe: sums are computed on minor-unit amounts (consistent
|
|
1138
|
-
* with `@classytic/primitives/money`'s integer convention). Currency
|
|
1139
|
-
* mismatch across a bucket throws — hosts dealing in multi-currency
|
|
1140
|
-
* run this once per currency after partitioning.
|
|
2566
|
+
* Aggregate opportunities into a forecast table — one row per bucket.
|
|
2567
|
+
* Currency mismatch within a bucket throws — partition by currency
|
|
2568
|
+
* upstream when multi-currency.
|
|
1141
2569
|
*/
|
|
1142
2570
|
function forecast(opportunities, options = {}) {
|
|
1143
2571
|
const bucketFn = options.bucket ?? defaultBucket;
|
|
@@ -1145,11 +2573,11 @@ function forecast(opportunities, options = {}) {
|
|
|
1145
2573
|
const rows = /* @__PURE__ */ new Map();
|
|
1146
2574
|
for (const o of opportunities) {
|
|
1147
2575
|
if (!include(o)) continue;
|
|
1148
|
-
const amt = o.amount
|
|
2576
|
+
const amt = o.amount;
|
|
1149
2577
|
const bucket = bucketFn(o);
|
|
1150
2578
|
const existing = rows.get(bucket);
|
|
1151
2579
|
if (existing) {
|
|
1152
|
-
if (existing.currency !== o.
|
|
2580
|
+
if (existing.currency !== o.currency) throw new Error(`Forecast bucket '${bucket}' mixes currencies (${existing.currency} + ${o.currency}). Partition by currency before calling forecast().`);
|
|
1153
2581
|
rows.set(bucket, {
|
|
1154
2582
|
...existing,
|
|
1155
2583
|
committed: existing.committed + amt,
|
|
@@ -1161,18 +2589,22 @@ function forecast(opportunities, options = {}) {
|
|
|
1161
2589
|
committed: amt,
|
|
1162
2590
|
weighted: Math.round(amt * o.probability),
|
|
1163
2591
|
count: 1,
|
|
1164
|
-
currency: o.
|
|
2592
|
+
currency: o.currency
|
|
1165
2593
|
});
|
|
1166
2594
|
}
|
|
1167
2595
|
return [...rows.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
1168
2596
|
}
|
|
1169
2597
|
function defaultInclude(o) {
|
|
1170
|
-
return o.status === "open" &&
|
|
2598
|
+
return o.status === "open" && o.amount !== void 0 && !!o.expectedCloseAt;
|
|
1171
2599
|
}
|
|
1172
2600
|
function defaultBucket(o) {
|
|
1173
2601
|
const d = o.expectedCloseAt;
|
|
1174
2602
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
1175
2603
|
}
|
|
2604
|
+
/**
|
|
2605
|
+
* "Where do deals die?" summary — opportunities partitioned across
|
|
2606
|
+
* stages with current count + won/lost-from-here.
|
|
2607
|
+
*/
|
|
1176
2608
|
function pipelineConversion(opportunities, stageIds) {
|
|
1177
2609
|
return stageIds.map((stageId) => {
|
|
1178
2610
|
let currentCount = 0;
|
|
@@ -1181,7 +2613,7 @@ function pipelineConversion(opportunities, stageIds) {
|
|
|
1181
2613
|
for (const o of opportunities) {
|
|
1182
2614
|
if (o.stageId === stageId && o.status === "open") currentCount++;
|
|
1183
2615
|
if (o.status === "won" || o.status === "lost") {
|
|
1184
|
-
if (o.statusHistory
|
|
2616
|
+
if (o.statusHistory?.some((h) => h.stageId === stageId) || o.stageId === stageId) if (o.status === "won") wonFromHere++;
|
|
1185
2617
|
else lostFromHere++;
|
|
1186
2618
|
}
|
|
1187
2619
|
}
|
|
@@ -1196,748 +2628,345 @@ function pipelineConversion(opportunities, stageIds) {
|
|
|
1196
2628
|
});
|
|
1197
2629
|
}
|
|
1198
2630
|
//#endregion
|
|
1199
|
-
//#region src/
|
|
1200
|
-
var InMemoryAccountRepository = class {
|
|
1201
|
-
items = /* @__PURE__ */ new Map();
|
|
1202
|
-
constructor(clock = systemClock) {
|
|
1203
|
-
this.clock = clock;
|
|
1204
|
-
}
|
|
1205
|
-
async findById(id) {
|
|
1206
|
-
return this.items.get(id) ?? null;
|
|
1207
|
-
}
|
|
1208
|
-
async findByDomain(domain) {
|
|
1209
|
-
for (const a of this.items.values()) if (a.domain?.toLowerCase() === domain.toLowerCase()) return a;
|
|
1210
|
-
return null;
|
|
1211
|
-
}
|
|
1212
|
-
async list(filter = {}) {
|
|
1213
|
-
return [...this.items.values()].filter((a) => {
|
|
1214
|
-
if (filter.ownerId !== void 0 && a.ownerId !== filter.ownerId) return false;
|
|
1215
|
-
if (filter.industry !== void 0 && a.industry !== filter.industry) return false;
|
|
1216
|
-
if (filter.domain !== void 0 && a.domain?.toLowerCase() !== filter.domain.toLowerCase()) return false;
|
|
1217
|
-
if (filter.tags && filter.tags.length > 0) {
|
|
1218
|
-
const at = new Set(a.tags ?? []);
|
|
1219
|
-
if (!filter.tags.every((t) => at.has(t))) return false;
|
|
1220
|
-
}
|
|
1221
|
-
return true;
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1224
|
-
async create(input) {
|
|
1225
|
-
const now = this.clock.now();
|
|
1226
|
-
const a = {
|
|
1227
|
-
...input,
|
|
1228
|
-
createdAt: now,
|
|
1229
|
-
updatedAt: now
|
|
1230
|
-
};
|
|
1231
|
-
this.items.set(a.id, a);
|
|
1232
|
-
return a;
|
|
1233
|
-
}
|
|
1234
|
-
async update(id, patch) {
|
|
1235
|
-
const e = this.items.get(id);
|
|
1236
|
-
if (!e) throw new Error(`Account '${id}' not found`);
|
|
1237
|
-
const a = {
|
|
1238
|
-
...e,
|
|
1239
|
-
...patch,
|
|
1240
|
-
id,
|
|
1241
|
-
updatedAt: this.clock.now()
|
|
1242
|
-
};
|
|
1243
|
-
this.items.set(id, a);
|
|
1244
|
-
return a;
|
|
1245
|
-
}
|
|
1246
|
-
async delete(id) {
|
|
1247
|
-
this.items.delete(id);
|
|
1248
|
-
}
|
|
1249
|
-
};
|
|
1250
|
-
//#endregion
|
|
1251
|
-
//#region src/adapters/in-memory/activity-repository.ts
|
|
1252
|
-
var InMemoryActivityRepository = class {
|
|
1253
|
-
items = /* @__PURE__ */ new Map();
|
|
1254
|
-
constructor(clock = systemClock) {
|
|
1255
|
-
this.clock = clock;
|
|
1256
|
-
}
|
|
1257
|
-
async findById(id) {
|
|
1258
|
-
return this.items.get(id) ?? null;
|
|
1259
|
-
}
|
|
1260
|
-
async list(filter = {}) {
|
|
1261
|
-
return [...this.items.values()].filter((a) => {
|
|
1262
|
-
if (filter.subjectKind !== void 0 && a.subjectKind !== filter.subjectKind) return false;
|
|
1263
|
-
if (filter.subjectId !== void 0 && a.subjectId !== filter.subjectId) return false;
|
|
1264
|
-
if (filter.status !== void 0) {
|
|
1265
|
-
if (!(Array.isArray(filter.status) ? filter.status : [filter.status]).includes(a.status)) return false;
|
|
1266
|
-
}
|
|
1267
|
-
if (filter.ownerId !== void 0 && a.ownerId !== filter.ownerId) return false;
|
|
1268
|
-
if (filter.scheduledBetween !== void 0) {
|
|
1269
|
-
if (!a.scheduledAt) return false;
|
|
1270
|
-
if (a.scheduledAt < filter.scheduledBetween.from || a.scheduledAt > filter.scheduledBetween.to) return false;
|
|
1271
|
-
}
|
|
1272
|
-
return true;
|
|
1273
|
-
});
|
|
1274
|
-
}
|
|
1275
|
-
async create(input) {
|
|
1276
|
-
const now = this.clock.now();
|
|
1277
|
-
const a = {
|
|
1278
|
-
...input,
|
|
1279
|
-
createdAt: now,
|
|
1280
|
-
updatedAt: now
|
|
1281
|
-
};
|
|
1282
|
-
this.items.set(a.id, a);
|
|
1283
|
-
return a;
|
|
1284
|
-
}
|
|
1285
|
-
async update(id, patch) {
|
|
1286
|
-
const e = this.items.get(id);
|
|
1287
|
-
if (!e) throw new Error(`Activity '${id}' not found`);
|
|
1288
|
-
const a = {
|
|
1289
|
-
...e,
|
|
1290
|
-
...patch,
|
|
1291
|
-
id,
|
|
1292
|
-
updatedAt: this.clock.now()
|
|
1293
|
-
};
|
|
1294
|
-
this.items.set(id, a);
|
|
1295
|
-
return a;
|
|
1296
|
-
}
|
|
1297
|
-
async casStatus(id, expected, next, patch = {}) {
|
|
1298
|
-
const e = this.items.get(id);
|
|
1299
|
-
if (!e || e.status !== expected) return null;
|
|
1300
|
-
const updated = {
|
|
1301
|
-
...e,
|
|
1302
|
-
...patch,
|
|
1303
|
-
id,
|
|
1304
|
-
status: next,
|
|
1305
|
-
updatedAt: this.clock.now()
|
|
1306
|
-
};
|
|
1307
|
-
this.items.set(id, updated);
|
|
1308
|
-
return updated;
|
|
1309
|
-
}
|
|
1310
|
-
};
|
|
1311
|
-
//#endregion
|
|
1312
|
-
//#region src/adapters/in-memory/contact-repository.ts
|
|
1313
|
-
var InMemoryContactRepository = class {
|
|
1314
|
-
items = /* @__PURE__ */ new Map();
|
|
1315
|
-
constructor(clock = systemClock) {
|
|
1316
|
-
this.clock = clock;
|
|
1317
|
-
}
|
|
1318
|
-
async findById(id) {
|
|
1319
|
-
return this.items.get(id) ?? null;
|
|
1320
|
-
}
|
|
1321
|
-
async findByEmail(email) {
|
|
1322
|
-
const needle = email.toLowerCase();
|
|
1323
|
-
for (const c of this.items.values()) if (c.contact.email?.toLowerCase() === needle) return c;
|
|
1324
|
-
return null;
|
|
1325
|
-
}
|
|
1326
|
-
async list(filter = {}) {
|
|
1327
|
-
const email = filter.email?.toLowerCase();
|
|
1328
|
-
return [...this.items.values()].filter((c) => {
|
|
1329
|
-
if (filter.accountId !== void 0 && c.accountId !== filter.accountId) return false;
|
|
1330
|
-
if (filter.ownerId !== void 0 && c.ownerId !== filter.ownerId) return false;
|
|
1331
|
-
if (email !== void 0 && c.contact.email?.toLowerCase() !== email) return false;
|
|
1332
|
-
if (filter.tags && filter.tags.length > 0) {
|
|
1333
|
-
const ct = new Set(c.tags ?? []);
|
|
1334
|
-
if (!filter.tags.every((t) => ct.has(t))) return false;
|
|
1335
|
-
}
|
|
1336
|
-
return true;
|
|
1337
|
-
});
|
|
1338
|
-
}
|
|
1339
|
-
async create(input) {
|
|
1340
|
-
const now = this.clock.now();
|
|
1341
|
-
const c = {
|
|
1342
|
-
...input,
|
|
1343
|
-
createdAt: now,
|
|
1344
|
-
updatedAt: now
|
|
1345
|
-
};
|
|
1346
|
-
this.items.set(c.id, c);
|
|
1347
|
-
return c;
|
|
1348
|
-
}
|
|
1349
|
-
async update(id, patch) {
|
|
1350
|
-
const e = this.items.get(id);
|
|
1351
|
-
if (!e) throw new Error(`Contact '${id}' not found`);
|
|
1352
|
-
const c = {
|
|
1353
|
-
...e,
|
|
1354
|
-
...patch,
|
|
1355
|
-
id,
|
|
1356
|
-
updatedAt: this.clock.now()
|
|
1357
|
-
};
|
|
1358
|
-
this.items.set(id, c);
|
|
1359
|
-
return c;
|
|
1360
|
-
}
|
|
1361
|
-
async delete(id) {
|
|
1362
|
-
this.items.delete(id);
|
|
1363
|
-
}
|
|
1364
|
-
};
|
|
1365
|
-
//#endregion
|
|
1366
|
-
//#region src/adapters/in-memory/event-transport.ts
|
|
1367
|
-
var InMemoryEventTransport = class {
|
|
1368
|
-
name = "crm-in-memory";
|
|
1369
|
-
events = [];
|
|
1370
|
-
subscribers = [];
|
|
1371
|
-
async publish(event) {
|
|
1372
|
-
this.events.push(event);
|
|
1373
|
-
for (const subscriber of this.subscribers) {
|
|
1374
|
-
if (!matchEventPattern(subscriber.pattern, event.type)) continue;
|
|
1375
|
-
await subscriber.handler(event);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
async subscribe(pattern, handler) {
|
|
1379
|
-
const entry = {
|
|
1380
|
-
pattern,
|
|
1381
|
-
handler
|
|
1382
|
-
};
|
|
1383
|
-
this.subscribers.push(entry);
|
|
1384
|
-
return async () => {
|
|
1385
|
-
const idx = this.subscribers.indexOf(entry);
|
|
1386
|
-
if (idx >= 0) this.subscribers.splice(idx, 1);
|
|
1387
|
-
};
|
|
1388
|
-
}
|
|
1389
|
-
};
|
|
1390
|
-
//#endregion
|
|
1391
|
-
//#region src/adapters/in-memory/lead-repository.ts
|
|
1392
|
-
var InMemoryLeadRepository = class {
|
|
1393
|
-
items = /* @__PURE__ */ new Map();
|
|
1394
|
-
constructor(clock = systemClock) {
|
|
1395
|
-
this.clock = clock;
|
|
1396
|
-
}
|
|
1397
|
-
async findById(id) {
|
|
1398
|
-
return this.items.get(id) ?? null;
|
|
1399
|
-
}
|
|
1400
|
-
async findByEmail(email) {
|
|
1401
|
-
for (const l of this.items.values()) if (l.email?.toLowerCase() === email.toLowerCase()) return l;
|
|
1402
|
-
return null;
|
|
1403
|
-
}
|
|
1404
|
-
async list(filter = {}) {
|
|
1405
|
-
return [...this.items.values()].filter((l) => {
|
|
1406
|
-
if (filter.status !== void 0) {
|
|
1407
|
-
if (!(Array.isArray(filter.status) ? filter.status : [filter.status]).includes(l.status)) return false;
|
|
1408
|
-
}
|
|
1409
|
-
if (filter.ownerId !== void 0 && l.ownerId !== filter.ownerId) return false;
|
|
1410
|
-
if (filter.source !== void 0 && l.source !== filter.source) return false;
|
|
1411
|
-
if (filter.minScore !== void 0 && (l.score ?? 0) < filter.minScore) return false;
|
|
1412
|
-
return true;
|
|
1413
|
-
});
|
|
1414
|
-
}
|
|
1415
|
-
async create(input) {
|
|
1416
|
-
const now = this.clock.now();
|
|
1417
|
-
const l = {
|
|
1418
|
-
...input,
|
|
1419
|
-
createdAt: now,
|
|
1420
|
-
updatedAt: now
|
|
1421
|
-
};
|
|
1422
|
-
this.items.set(l.id, l);
|
|
1423
|
-
return l;
|
|
1424
|
-
}
|
|
1425
|
-
async update(id, patch) {
|
|
1426
|
-
const e = this.items.get(id);
|
|
1427
|
-
if (!e) throw new Error(`Lead '${id}' not found`);
|
|
1428
|
-
const l = {
|
|
1429
|
-
...e,
|
|
1430
|
-
...patch,
|
|
1431
|
-
id,
|
|
1432
|
-
updatedAt: this.clock.now()
|
|
1433
|
-
};
|
|
1434
|
-
this.items.set(id, l);
|
|
1435
|
-
return l;
|
|
1436
|
-
}
|
|
1437
|
-
async casStatus(id, expected, next, patch = {}) {
|
|
1438
|
-
const e = this.items.get(id);
|
|
1439
|
-
if (!e || e.status !== expected) return null;
|
|
1440
|
-
const updated = {
|
|
1441
|
-
...e,
|
|
1442
|
-
...patch,
|
|
1443
|
-
id,
|
|
1444
|
-
status: next,
|
|
1445
|
-
updatedAt: this.clock.now()
|
|
1446
|
-
};
|
|
1447
|
-
this.items.set(id, updated);
|
|
1448
|
-
return updated;
|
|
1449
|
-
}
|
|
1450
|
-
};
|
|
1451
|
-
//#endregion
|
|
1452
|
-
//#region src/adapters/in-memory/loss-reason-repository.ts
|
|
1453
|
-
var InMemoryLossReasonRepository = class {
|
|
1454
|
-
items = /* @__PURE__ */ new Map();
|
|
1455
|
-
constructor(clock = systemClock) {
|
|
1456
|
-
this.clock = clock;
|
|
1457
|
-
}
|
|
1458
|
-
async findById(id) {
|
|
1459
|
-
return this.items.get(id) ?? null;
|
|
1460
|
-
}
|
|
1461
|
-
async list(activeOnly = true) {
|
|
1462
|
-
return [...this.items.values()].filter((r) => activeOnly ? r.active : true);
|
|
1463
|
-
}
|
|
1464
|
-
async create(input) {
|
|
1465
|
-
const now = this.clock.now();
|
|
1466
|
-
const r = {
|
|
1467
|
-
...input,
|
|
1468
|
-
createdAt: now,
|
|
1469
|
-
updatedAt: now
|
|
1470
|
-
};
|
|
1471
|
-
this.items.set(r.id, r);
|
|
1472
|
-
return r;
|
|
1473
|
-
}
|
|
1474
|
-
async update(id, patch) {
|
|
1475
|
-
const e = this.items.get(id);
|
|
1476
|
-
if (!e) throw new Error(`LossReason '${id}' not found`);
|
|
1477
|
-
const r = {
|
|
1478
|
-
...e,
|
|
1479
|
-
...patch,
|
|
1480
|
-
id,
|
|
1481
|
-
updatedAt: this.clock.now()
|
|
1482
|
-
};
|
|
1483
|
-
this.items.set(id, r);
|
|
1484
|
-
return r;
|
|
1485
|
-
}
|
|
1486
|
-
};
|
|
1487
|
-
//#endregion
|
|
1488
|
-
//#region src/adapters/in-memory/note-repository.ts
|
|
1489
|
-
var InMemoryNoteRepository = class {
|
|
1490
|
-
items = /* @__PURE__ */ new Map();
|
|
1491
|
-
constructor(clock = systemClock) {
|
|
1492
|
-
this.clock = clock;
|
|
1493
|
-
}
|
|
1494
|
-
async findById(id) {
|
|
1495
|
-
return this.items.get(id) ?? null;
|
|
1496
|
-
}
|
|
1497
|
-
async listBySubject(subjectKind, subjectId) {
|
|
1498
|
-
return [...this.items.values()].filter((n) => n.subjectKind === subjectKind && n.subjectId === subjectId);
|
|
1499
|
-
}
|
|
1500
|
-
async create(input) {
|
|
1501
|
-
const now = this.clock.now();
|
|
1502
|
-
const n = {
|
|
1503
|
-
...input,
|
|
1504
|
-
createdAt: now,
|
|
1505
|
-
updatedAt: now
|
|
1506
|
-
};
|
|
1507
|
-
this.items.set(n.id, n);
|
|
1508
|
-
return n;
|
|
1509
|
-
}
|
|
1510
|
-
async update(id, patch) {
|
|
1511
|
-
const e = this.items.get(id);
|
|
1512
|
-
if (!e) throw new Error(`Note '${id}' not found`);
|
|
1513
|
-
const n = {
|
|
1514
|
-
...e,
|
|
1515
|
-
...patch,
|
|
1516
|
-
id,
|
|
1517
|
-
updatedAt: this.clock.now()
|
|
1518
|
-
};
|
|
1519
|
-
this.items.set(id, n);
|
|
1520
|
-
return n;
|
|
1521
|
-
}
|
|
1522
|
-
async delete(id) {
|
|
1523
|
-
this.items.delete(id);
|
|
1524
|
-
}
|
|
1525
|
-
};
|
|
1526
|
-
//#endregion
|
|
1527
|
-
//#region src/adapters/in-memory/opportunity-repository.ts
|
|
1528
|
-
var InMemoryOpportunityRepository = class {
|
|
1529
|
-
items = /* @__PURE__ */ new Map();
|
|
1530
|
-
constructor(clock = systemClock) {
|
|
1531
|
-
this.clock = clock;
|
|
1532
|
-
}
|
|
1533
|
-
async findById(id) {
|
|
1534
|
-
return this.items.get(id) ?? null;
|
|
1535
|
-
}
|
|
1536
|
-
async list(filter = {}) {
|
|
1537
|
-
return [...this.items.values()].filter((o) => {
|
|
1538
|
-
if (filter.status !== void 0) {
|
|
1539
|
-
if (!(Array.isArray(filter.status) ? filter.status : [filter.status]).includes(o.status)) return false;
|
|
1540
|
-
}
|
|
1541
|
-
if (filter.pipelineId !== void 0 && o.pipelineId !== filter.pipelineId) return false;
|
|
1542
|
-
if (filter.stageId !== void 0 && o.stageId !== filter.stageId) return false;
|
|
1543
|
-
if (filter.ownerId !== void 0 && o.ownerId !== filter.ownerId) return false;
|
|
1544
|
-
if (filter.accountId !== void 0 && o.accountId !== filter.accountId) return false;
|
|
1545
|
-
if (filter.closingBetween !== void 0) {
|
|
1546
|
-
if (!o.expectedCloseAt) return false;
|
|
1547
|
-
if (o.expectedCloseAt < filter.closingBetween.from || o.expectedCloseAt > filter.closingBetween.to) return false;
|
|
1548
|
-
}
|
|
1549
|
-
return true;
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
async create(input) {
|
|
1553
|
-
const now = this.clock.now();
|
|
1554
|
-
const o = {
|
|
1555
|
-
...input,
|
|
1556
|
-
createdAt: now,
|
|
1557
|
-
updatedAt: now
|
|
1558
|
-
};
|
|
1559
|
-
this.items.set(o.id, o);
|
|
1560
|
-
return o;
|
|
1561
|
-
}
|
|
1562
|
-
async update(id, patch) {
|
|
1563
|
-
const e = this.items.get(id);
|
|
1564
|
-
if (!e) throw new Error(`Opportunity '${id}' not found`);
|
|
1565
|
-
const o = {
|
|
1566
|
-
...e,
|
|
1567
|
-
...patch,
|
|
1568
|
-
id,
|
|
1569
|
-
updatedAt: this.clock.now()
|
|
1570
|
-
};
|
|
1571
|
-
this.items.set(id, o);
|
|
1572
|
-
return o;
|
|
1573
|
-
}
|
|
1574
|
-
async delete(id) {
|
|
1575
|
-
this.items.delete(id);
|
|
1576
|
-
}
|
|
1577
|
-
async casStatus(id, expected, next, patch = {}) {
|
|
1578
|
-
const e = this.items.get(id);
|
|
1579
|
-
if (!e || e.status !== expected) return null;
|
|
1580
|
-
const updated = {
|
|
1581
|
-
...e,
|
|
1582
|
-
...patch,
|
|
1583
|
-
id,
|
|
1584
|
-
status: next,
|
|
1585
|
-
updatedAt: this.clock.now()
|
|
1586
|
-
};
|
|
1587
|
-
this.items.set(id, updated);
|
|
1588
|
-
return updated;
|
|
1589
|
-
}
|
|
1590
|
-
async casStage(id, expectedStageId, nextStageId, patch = {}) {
|
|
1591
|
-
const e = this.items.get(id);
|
|
1592
|
-
if (!e || e.stageId !== expectedStageId) return null;
|
|
1593
|
-
const updated = {
|
|
1594
|
-
...e,
|
|
1595
|
-
...patch,
|
|
1596
|
-
id,
|
|
1597
|
-
stageId: nextStageId,
|
|
1598
|
-
updatedAt: this.clock.now()
|
|
1599
|
-
};
|
|
1600
|
-
this.items.set(id, updated);
|
|
1601
|
-
return updated;
|
|
1602
|
-
}
|
|
1603
|
-
};
|
|
1604
|
-
//#endregion
|
|
1605
|
-
//#region src/adapters/in-memory/pipeline-repository.ts
|
|
1606
|
-
var InMemoryPipelineRepository = class {
|
|
1607
|
-
items = /* @__PURE__ */ new Map();
|
|
1608
|
-
constructor(clock = systemClock) {
|
|
1609
|
-
this.clock = clock;
|
|
1610
|
-
}
|
|
1611
|
-
async findById(id) {
|
|
1612
|
-
return this.items.get(id) ?? null;
|
|
1613
|
-
}
|
|
1614
|
-
async list(includeArchived = false) {
|
|
1615
|
-
return [...this.items.values()].filter((p) => includeArchived || !p.isArchived);
|
|
1616
|
-
}
|
|
1617
|
-
async create(input) {
|
|
1618
|
-
const now = this.clock.now();
|
|
1619
|
-
const p = {
|
|
1620
|
-
...input,
|
|
1621
|
-
createdAt: now,
|
|
1622
|
-
updatedAt: now
|
|
1623
|
-
};
|
|
1624
|
-
this.items.set(p.id, p);
|
|
1625
|
-
return p;
|
|
1626
|
-
}
|
|
1627
|
-
async update(id, patch) {
|
|
1628
|
-
const e = this.items.get(id);
|
|
1629
|
-
if (!e) throw new Error(`Pipeline '${id}' not found`);
|
|
1630
|
-
const p = {
|
|
1631
|
-
...e,
|
|
1632
|
-
...patch,
|
|
1633
|
-
id,
|
|
1634
|
-
updatedAt: this.clock.now()
|
|
1635
|
-
};
|
|
1636
|
-
this.items.set(id, p);
|
|
1637
|
-
return p;
|
|
1638
|
-
}
|
|
1639
|
-
};
|
|
1640
|
-
//#endregion
|
|
1641
|
-
//#region src/adapters/in-memory/outreach-port.ts
|
|
1642
|
-
var NullOutreachPort = class {
|
|
1643
|
-
counter = 0;
|
|
1644
|
-
async send(_request) {
|
|
1645
|
-
return {
|
|
1646
|
-
providerMessageId: `null_msg_${++this.counter}`,
|
|
1647
|
-
acceptedAt: /* @__PURE__ */ new Date()
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
};
|
|
1651
|
-
var InMemoryOutreachPort = class {
|
|
1652
|
-
sent = [];
|
|
1653
|
-
counter = 0;
|
|
1654
|
-
constructor(nowMs = () => Date.now()) {
|
|
1655
|
-
this.nowMs = nowMs;
|
|
1656
|
-
}
|
|
1657
|
-
async send(request) {
|
|
1658
|
-
const res = {
|
|
1659
|
-
providerMessageId: `msg_${++this.counter}`,
|
|
1660
|
-
acceptedAt: new Date(this.nowMs())
|
|
1661
|
-
};
|
|
1662
|
-
this.sent.push({
|
|
1663
|
-
...request,
|
|
1664
|
-
...res
|
|
1665
|
-
});
|
|
1666
|
-
return res;
|
|
1667
|
-
}
|
|
1668
|
-
};
|
|
1669
|
-
//#endregion
|
|
1670
|
-
//#region src/events/event-catalog.ts
|
|
2631
|
+
//#region src/validators/common.ts
|
|
1671
2632
|
/**
|
|
1672
|
-
*
|
|
1673
|
-
*
|
|
1674
|
-
*
|
|
1675
|
-
* Each definition exposes:
|
|
1676
|
-
* - `.zodSchema` — source of truth, used by host code's `.safeParse()`
|
|
1677
|
-
* - `.schema` — JSON Schema derived via `z.toJSONSchema()`, consumed
|
|
1678
|
-
* by Arc's EventRegistry + OpenAPI plugin
|
|
1679
|
-
* - `.create(...)` — DomainEvent envelope builder, structurally compatible
|
|
1680
|
-
* with `@classytic/arc`'s `EventDefinitionOutput`
|
|
1681
|
-
*
|
|
1682
|
-
* Structurally compatible with Arc 2.10's `EventRegistry` — hosts register
|
|
1683
|
-
* `crmEventDefinitions` directly, no adapter code. CRM does NOT import from
|
|
1684
|
-
* `@classytic/arc` (PACKAGE_RULES §11); compatibility is purely structural.
|
|
1685
|
-
*
|
|
1686
|
-
* Timestamps are modelled as `z.iso.datetime()` — events cross the wire and
|
|
1687
|
-
* JSON Schema cannot represent `Date` (PACKAGE_RULES §18.5 + §25). Subscribers
|
|
1688
|
-
* that need a `Date` instance call `new Date(payload.completedAt)`.
|
|
1689
|
-
*
|
|
1690
|
-
* @example Wiring into an Arc app
|
|
1691
|
-
* ```ts
|
|
1692
|
-
* import { createEventRegistry } from '@classytic/arc/events';
|
|
1693
|
-
* import { crmEventDefinitions } from '@classytic/crm';
|
|
1694
|
-
*
|
|
1695
|
-
* const registry = createEventRegistry();
|
|
1696
|
-
* for (const def of crmEventDefinitions) registry.register(def);
|
|
1697
|
-
*
|
|
1698
|
-
* const app = await createApp({
|
|
1699
|
-
* arcPlugins: { events: { registry, validateMode: 'reject' } },
|
|
1700
|
-
* });
|
|
1701
|
-
* ```
|
|
2633
|
+
* Shared zod primitives — `objectIdLike` (string-or-objectId), metadata
|
|
2634
|
+
* record, tag list. Centralised so every resource validator is consistent.
|
|
1702
2635
|
*/
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
};
|
|
1717
|
-
if (description !== void 0) def.description = description;
|
|
1718
|
-
return def;
|
|
1719
|
-
}
|
|
1720
|
-
const money = z.object({
|
|
1721
|
-
amount: z.number().nonnegative(),
|
|
1722
|
-
currency: z.string().length(3)
|
|
1723
|
-
});
|
|
1724
|
-
const subjectKind = z.enum([
|
|
1725
|
-
"lead",
|
|
1726
|
-
"contact",
|
|
1727
|
-
"account",
|
|
1728
|
-
"opportunity"
|
|
1729
|
-
]);
|
|
1730
|
-
const stageRef = z.object({
|
|
1731
|
-
stageId: z.string(),
|
|
1732
|
-
sequence: z.number().int().nonnegative()
|
|
1733
|
-
});
|
|
1734
|
-
const leadCreatedSchema = z.object({
|
|
1735
|
-
leadId: z.string(),
|
|
1736
|
-
email: z.string().optional(),
|
|
1737
|
-
score: z.number().optional()
|
|
1738
|
-
});
|
|
1739
|
-
const leadStatusChangedSchema = z.object({
|
|
1740
|
-
leadId: z.string(),
|
|
1741
|
-
from: z.string(),
|
|
1742
|
-
to: z.string()
|
|
1743
|
-
});
|
|
1744
|
-
const leadScoredSchema = z.object({
|
|
1745
|
-
leadId: z.string(),
|
|
1746
|
-
score: z.number()
|
|
1747
|
-
});
|
|
1748
|
-
const leadConvertedSchema = z.object({
|
|
1749
|
-
leadId: z.string(),
|
|
1750
|
-
contactId: z.string(),
|
|
1751
|
-
accountId: z.string().optional(),
|
|
1752
|
-
opportunityId: z.string()
|
|
2636
|
+
const objectIdLike = z.union([z.string().min(1), z.string()]);
|
|
2637
|
+
const metadataSchema = z.record(z.string(), z.unknown()).optional();
|
|
2638
|
+
const tagsSchema = z.array(z.string().min(1).max(64)).max(50).optional();
|
|
2639
|
+
const moneyAmountSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
2640
|
+
const currencyCodeSchema = z.string().regex(/^[A-Z]{3}$/, "Currency must be a 3-letter ISO code");
|
|
2641
|
+
//#endregion
|
|
2642
|
+
//#region src/validators/pipeline.schema.ts
|
|
2643
|
+
const stageInputSchema = z.object({
|
|
2644
|
+
name: z.string().min(1).max(100),
|
|
2645
|
+
sequence: z.number().int().min(1),
|
|
2646
|
+
defaultProbability: z.number().min(0).max(1),
|
|
2647
|
+
color: z.string().max(32).optional(),
|
|
2648
|
+
description: z.string().max(500).optional()
|
|
1753
2649
|
});
|
|
1754
|
-
const
|
|
1755
|
-
|
|
1756
|
-
|
|
2650
|
+
const createPipelineSchema = z.object({
|
|
2651
|
+
name: z.string().min(1).max(200),
|
|
2652
|
+
stages: z.array(stageInputSchema).default([]),
|
|
2653
|
+
teamRef: z.string().max(100).optional(),
|
|
2654
|
+
metadata: metadataSchema
|
|
1757
2655
|
});
|
|
1758
|
-
const
|
|
1759
|
-
const
|
|
1760
|
-
const
|
|
1761
|
-
|
|
1762
|
-
|
|
2656
|
+
const updatePipelineSchema = createPipelineSchema.partial();
|
|
2657
|
+
const stageInputSchemaExport = stageInputSchema;
|
|
2658
|
+
const pipelineFilterSchema = z.object({
|
|
2659
|
+
isArchived: z.coerce.boolean().optional(),
|
|
2660
|
+
teamRef: z.string().optional()
|
|
1763
2661
|
});
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2662
|
+
//#endregion
|
|
2663
|
+
//#region src/validators/lead.schema.ts
|
|
2664
|
+
const leadFieldsSchema = z.object({
|
|
2665
|
+
fullName: z.string().min(1).max(200).optional(),
|
|
2666
|
+
firstName: z.string().min(1).max(100).optional(),
|
|
2667
|
+
lastName: z.string().min(1).max(100).optional(),
|
|
2668
|
+
email: z.string().email().max(255).optional(),
|
|
2669
|
+
phone: z.string().min(3).max(50).optional(),
|
|
2670
|
+
companyName: z.string().min(1).max(200).optional(),
|
|
2671
|
+
jobTitle: z.string().min(1).max(150).optional(),
|
|
2672
|
+
source: z.string().min(1).max(100).optional(),
|
|
2673
|
+
campaignRef: z.string().min(1).max(100).optional(),
|
|
2674
|
+
ownerId: z.string().optional(),
|
|
2675
|
+
tags: tagsSchema,
|
|
2676
|
+
metadata: metadataSchema
|
|
1769
2677
|
});
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
2678
|
+
const createLeadSchema = leadFieldsSchema.refine((v) => v.fullName || v.firstName || v.lastName || v.email || v.phone, "Lead must have at least one of fullName, firstName/lastName, email, phone");
|
|
2679
|
+
const updateLeadSchema = leadFieldsSchema.partial().refine((v) => Object.keys(v).length > 0, "updateLead requires at least one field");
|
|
2680
|
+
const leadFilterSchema = z.object({
|
|
2681
|
+
status: z.enum(LEAD_STATUSES).optional(),
|
|
2682
|
+
ownerId: z.string().optional(),
|
|
2683
|
+
email: z.string().optional(),
|
|
2684
|
+
tags: z.array(z.string()).optional(),
|
|
2685
|
+
source: z.string().optional(),
|
|
2686
|
+
campaignRef: z.string().optional(),
|
|
2687
|
+
scoreMin: z.number().int().optional(),
|
|
2688
|
+
scoreMax: z.number().int().optional()
|
|
1774
2689
|
});
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
amount:
|
|
2690
|
+
const convertLeadSchema = z.object({
|
|
2691
|
+
pipelineId: objectIdLike,
|
|
2692
|
+
opportunityName: z.string().min(1).max(200).optional(),
|
|
2693
|
+
amount: z.number().nonnegative().optional(),
|
|
2694
|
+
currency: z.string().regex(/^[A-Z]{3}$/).optional(),
|
|
2695
|
+
expectedCloseAt: z.coerce.date().optional(),
|
|
2696
|
+
by: z.string().optional()
|
|
1779
2697
|
});
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
2698
|
+
//#endregion
|
|
2699
|
+
//#region src/validators/opportunity.schema.ts
|
|
2700
|
+
const openOpportunitySchema = z.object({
|
|
2701
|
+
name: z.string().min(1).max(200),
|
|
2702
|
+
pipelineId: objectIdLike,
|
|
2703
|
+
stageId: z.string().optional(),
|
|
2704
|
+
accountId: objectIdLike.optional(),
|
|
2705
|
+
primaryContactId: objectIdLike.optional(),
|
|
2706
|
+
sourceLeadId: objectIdLike.optional(),
|
|
2707
|
+
amount: z.number().nonnegative().optional(),
|
|
2708
|
+
currency: currencyCodeSchema.optional(),
|
|
2709
|
+
probability: z.number().min(0).max(1).optional(),
|
|
2710
|
+
expectedCloseAt: z.coerce.date().optional(),
|
|
2711
|
+
ownerId: z.string().optional(),
|
|
2712
|
+
tags: tagsSchema,
|
|
2713
|
+
metadata: metadataSchema
|
|
1783
2714
|
});
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
type: z.string(),
|
|
1790
|
-
subjectKind,
|
|
1791
|
-
subjectId: z.string()
|
|
2715
|
+
const moveStageSchema = z.object({
|
|
2716
|
+
stageId: z.string().min(1),
|
|
2717
|
+
by: z.string().optional(),
|
|
2718
|
+
note: z.string().max(500).optional(),
|
|
2719
|
+
probability: z.number().min(0).max(1).optional()
|
|
1792
2720
|
});
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1795
|
-
|
|
2721
|
+
const closeWinSchema = z.object({
|
|
2722
|
+
by: z.string().optional(),
|
|
2723
|
+
note: z.string().max(500).optional(),
|
|
2724
|
+
closedAt: z.coerce.date().optional(),
|
|
2725
|
+
amount: z.number().nonnegative().optional()
|
|
1796
2726
|
});
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1799
|
-
|
|
2727
|
+
const closeLossSchema = z.object({
|
|
2728
|
+
by: z.string().optional(),
|
|
2729
|
+
note: z.string().max(500).optional(),
|
|
2730
|
+
closedAt: z.coerce.date().optional(),
|
|
2731
|
+
lostReasonId: objectIdLike.optional()
|
|
1800
2732
|
});
|
|
1801
|
-
const
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
2733
|
+
const opportunityFilterSchema = z.object({
|
|
2734
|
+
status: z.enum(OPPORTUNITY_STATUSES).optional(),
|
|
2735
|
+
pipelineId: objectIdLike.optional(),
|
|
2736
|
+
stageId: z.string().optional(),
|
|
2737
|
+
ownerId: z.string().optional(),
|
|
2738
|
+
accountId: objectIdLike.optional(),
|
|
2739
|
+
expectedCloseFrom: z.coerce.date().optional(),
|
|
2740
|
+
expectedCloseTo: z.coerce.date().optional()
|
|
1805
2741
|
});
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
2742
|
+
//#endregion
|
|
2743
|
+
//#region src/validators/contact.schema.ts
|
|
2744
|
+
const contactNameSchema = z.object({
|
|
2745
|
+
given: z.string().min(1).max(100),
|
|
2746
|
+
family: z.string().min(0).max(100).default(""),
|
|
2747
|
+
middle: z.string().max(100).optional(),
|
|
2748
|
+
prefix: z.string().max(32).optional(),
|
|
2749
|
+
suffix: z.string().max(32).optional(),
|
|
2750
|
+
preferred: z.string().max(100).optional()
|
|
1810
2751
|
});
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
2752
|
+
const contactInfoSchema = z.object({
|
|
2753
|
+
email: z.string().email().max(255).optional(),
|
|
2754
|
+
personalEmail: z.string().email().max(255).optional(),
|
|
2755
|
+
phone: z.string().max(50).optional(),
|
|
2756
|
+
alternatePhone: z.string().max(50).optional()
|
|
1815
2757
|
});
|
|
1816
|
-
const
|
|
1817
|
-
name:
|
|
1818
|
-
|
|
1819
|
-
|
|
2758
|
+
const createContactSchema = z.object({
|
|
2759
|
+
name: contactNameSchema,
|
|
2760
|
+
contact: contactInfoSchema.default({}),
|
|
2761
|
+
jobTitle: z.string().max(150).optional(),
|
|
2762
|
+
accountId: objectIdLike.optional(),
|
|
2763
|
+
ownerId: z.string().optional(),
|
|
2764
|
+
tags: tagsSchema,
|
|
2765
|
+
metadata: metadataSchema
|
|
1820
2766
|
});
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2767
|
+
const updateContactSchema = createContactSchema.partial();
|
|
2768
|
+
const contactFilterSchema = z.object({
|
|
2769
|
+
accountId: objectIdLike.optional(),
|
|
2770
|
+
ownerId: z.string().optional(),
|
|
2771
|
+
email: z.string().optional(),
|
|
2772
|
+
tags: z.array(z.string()).optional()
|
|
1825
2773
|
});
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
2774
|
+
//#endregion
|
|
2775
|
+
//#region src/validators/account.schema.ts
|
|
2776
|
+
const createAccountSchema = z.object({
|
|
2777
|
+
name: z.string().min(1).max(200),
|
|
2778
|
+
domain: z.string().max(255).optional(),
|
|
2779
|
+
industry: z.string().max(100).optional(),
|
|
2780
|
+
sizeBucket: z.string().max(20).optional(),
|
|
2781
|
+
annualRevenueAmount: z.number().nonnegative().optional(),
|
|
2782
|
+
annualRevenueCurrency: currencyCodeSchema.optional(),
|
|
2783
|
+
ownerId: z.string().optional(),
|
|
2784
|
+
tags: tagsSchema,
|
|
2785
|
+
metadata: metadataSchema
|
|
1830
2786
|
});
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
2787
|
+
const updateAccountSchema = createAccountSchema.partial();
|
|
2788
|
+
const accountFilterSchema = z.object({
|
|
2789
|
+
domain: z.string().optional(),
|
|
2790
|
+
industry: z.string().optional(),
|
|
2791
|
+
ownerId: z.string().optional(),
|
|
2792
|
+
tags: z.array(z.string()).optional()
|
|
1835
2793
|
});
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
2794
|
+
//#endregion
|
|
2795
|
+
//#region src/validators/campaign.schema.ts
|
|
2796
|
+
const createCampaignSchema = z.object({
|
|
2797
|
+
name: z.string().min(1).max(200),
|
|
2798
|
+
description: z.string().max(2e3).optional(),
|
|
2799
|
+
utmSource: z.string().max(100).optional(),
|
|
2800
|
+
utmMedium: z.string().max(100).optional(),
|
|
2801
|
+
utmCampaign: z.string().max(100).optional(),
|
|
2802
|
+
startDate: z.coerce.date().optional(),
|
|
2803
|
+
endDate: z.coerce.date().optional(),
|
|
2804
|
+
budgetAmount: z.number().nonnegative().optional(),
|
|
2805
|
+
expectedRevenueAmount: z.number().nonnegative().optional(),
|
|
2806
|
+
currency: currencyCodeSchema.optional(),
|
|
2807
|
+
ownerId: z.string().optional(),
|
|
2808
|
+
tags: tagsSchema,
|
|
2809
|
+
metadata: metadataSchema
|
|
1840
2810
|
});
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2811
|
+
const updateCampaignSchema = createCampaignSchema.partial();
|
|
2812
|
+
const completeCampaignSchema = z.object({
|
|
2813
|
+
by: z.string().optional(),
|
|
2814
|
+
note: z.string().max(500).optional(),
|
|
2815
|
+
actualRevenueAmount: z.number().nonnegative().optional()
|
|
1845
2816
|
});
|
|
1846
|
-
const
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2817
|
+
const campaignFilterSchema = z.object({
|
|
2818
|
+
status: z.enum(CAMPAIGN_STATUSES).optional(),
|
|
2819
|
+
ownerId: z.string().optional(),
|
|
2820
|
+
tags: z.array(z.string()).optional(),
|
|
2821
|
+
startDateFrom: z.coerce.date().optional(),
|
|
2822
|
+
startDateTo: z.coerce.date().optional()
|
|
1850
2823
|
});
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
2824
|
+
//#endregion
|
|
2825
|
+
//#region src/validators/subscription.schema.ts
|
|
2826
|
+
const subscribeSchema = z.object({
|
|
2827
|
+
contactId: objectIdLike,
|
|
2828
|
+
campaignId: objectIdLike,
|
|
2829
|
+
source: z.string().max(100).optional(),
|
|
2830
|
+
consentIpAddress: z.string().max(64).optional(),
|
|
2831
|
+
consentUserAgent: z.string().max(500).optional(),
|
|
2832
|
+
metadata: metadataSchema,
|
|
2833
|
+
requireDoubleOptIn: z.boolean().optional()
|
|
1855
2834
|
});
|
|
1856
|
-
const
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
2835
|
+
const confirmSubscriptionSchema = z.object({ token: z.string().min(1).max(200) });
|
|
2836
|
+
const unsubscribeSchema = z.object({
|
|
2837
|
+
token: z.string().min(1).max(200),
|
|
2838
|
+
reason: z.string().max(500).optional()
|
|
1860
2839
|
});
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
2840
|
+
const subscriptionFilterSchema = z.object({
|
|
2841
|
+
status: z.enum(SUBSCRIPTION_STATUSES).optional(),
|
|
2842
|
+
contactId: objectIdLike.optional(),
|
|
2843
|
+
campaignId: objectIdLike.optional()
|
|
1865
2844
|
});
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2845
|
+
//#endregion
|
|
2846
|
+
//#region src/validators/activity.schema.ts
|
|
2847
|
+
const planActivitySchema = z.object({
|
|
2848
|
+
type: z.enum(ACTIVITY_TYPES),
|
|
2849
|
+
subjectKind: z.enum(SUBJECT_KINDS),
|
|
2850
|
+
subjectId: objectIdLike,
|
|
2851
|
+
title: z.string().min(1).max(200),
|
|
2852
|
+
description: z.string().max(2e3).optional(),
|
|
2853
|
+
ownerId: z.string().optional(),
|
|
2854
|
+
participantIds: z.array(z.string()).max(50).optional(),
|
|
2855
|
+
dueDate: z.coerce.date().optional(),
|
|
2856
|
+
externalRef: z.string().max(200).optional(),
|
|
2857
|
+
metadata: metadataSchema
|
|
1870
2858
|
});
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2859
|
+
const updateActivitySchema = planActivitySchema.partial();
|
|
2860
|
+
const cancelActivitySchema = z.object({ reason: z.string().max(500).optional() });
|
|
2861
|
+
const activityFilterSchema = z.object({
|
|
2862
|
+
type: z.enum(ACTIVITY_TYPES).optional(),
|
|
2863
|
+
status: z.enum(ACTIVITY_STATUSES).optional(),
|
|
2864
|
+
subjectKind: z.enum(SUBJECT_KINDS).optional(),
|
|
2865
|
+
subjectId: objectIdLike.optional(),
|
|
2866
|
+
ownerId: z.string().optional(),
|
|
2867
|
+
dueDateFrom: z.coerce.date().optional(),
|
|
2868
|
+
dueDateTo: z.coerce.date().optional()
|
|
1875
2869
|
});
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2870
|
+
//#endregion
|
|
2871
|
+
//#region src/validators/note.schema.ts
|
|
2872
|
+
const addNoteSchema = z.object({
|
|
2873
|
+
subjectKind: z.enum(SUBJECT_KINDS),
|
|
2874
|
+
subjectId: objectIdLike,
|
|
2875
|
+
body: z.string().min(1).max(1e4),
|
|
2876
|
+
authorId: z.string().optional(),
|
|
2877
|
+
pinned: z.boolean().optional(),
|
|
2878
|
+
metadata: metadataSchema
|
|
1880
2879
|
});
|
|
1881
|
-
const
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
2880
|
+
const updateNoteSchema = z.object({
|
|
2881
|
+
body: z.string().min(1).max(1e4).optional(),
|
|
2882
|
+
pinned: z.boolean().optional(),
|
|
2883
|
+
metadata: metadataSchema
|
|
1885
2884
|
});
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2885
|
+
const noteFilterSchema = z.object({
|
|
2886
|
+
subjectKind: z.enum(SUBJECT_KINDS).optional(),
|
|
2887
|
+
subjectId: objectIdLike.optional(),
|
|
2888
|
+
authorId: z.string().optional(),
|
|
2889
|
+
pinned: z.coerce.boolean().optional()
|
|
1890
2890
|
});
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2891
|
+
//#endregion
|
|
2892
|
+
//#region src/validators/loss-reason.schema.ts
|
|
2893
|
+
const createLossReasonSchema = z.object({
|
|
2894
|
+
name: z.string().min(1).max(200),
|
|
2895
|
+
description: z.string().max(1e3).optional(),
|
|
2896
|
+
isActive: z.boolean().optional(),
|
|
2897
|
+
sequence: z.number().int().min(0).optional(),
|
|
2898
|
+
metadata: metadataSchema
|
|
1895
2899
|
});
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2900
|
+
const updateLossReasonSchema = createLossReasonSchema.partial();
|
|
2901
|
+
const lossReasonFilterSchema = z.object({ isActive: z.coerce.boolean().optional() });
|
|
2902
|
+
//#endregion
|
|
2903
|
+
//#region src/validators/email-event.schema.ts
|
|
2904
|
+
const recordEmailEventSchema = z.object({
|
|
2905
|
+
subjectKind: z.enum(EMAIL_EVENT_SUBJECT_KINDS),
|
|
2906
|
+
subjectId: objectIdLike,
|
|
2907
|
+
campaignId: objectIdLike.optional(),
|
|
2908
|
+
messageId: z.string().max(200).optional(),
|
|
2909
|
+
url: z.string().max(2048).optional(),
|
|
2910
|
+
userAgent: z.string().max(500).optional(),
|
|
2911
|
+
ip: z.string().max(64).optional(),
|
|
2912
|
+
metadata: metadataSchema
|
|
1900
2913
|
});
|
|
1901
|
-
const
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2914
|
+
const emailEventFilterSchema = z.object({
|
|
2915
|
+
kind: z.enum(EMAIL_EVENT_KINDS).optional(),
|
|
2916
|
+
subjectKind: z.enum(EMAIL_EVENT_SUBJECT_KINDS).optional(),
|
|
2917
|
+
subjectId: objectIdLike.optional(),
|
|
2918
|
+
campaignId: objectIdLike.optional(),
|
|
2919
|
+
messageId: z.string().optional()
|
|
1905
2920
|
});
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2921
|
+
//#endregion
|
|
2922
|
+
//#region src/validators/index.ts
|
|
2923
|
+
var validators_exports = /* @__PURE__ */ __exportAll({
|
|
2924
|
+
accountFilterSchema: () => accountFilterSchema,
|
|
2925
|
+
activityFilterSchema: () => activityFilterSchema,
|
|
2926
|
+
addNoteSchema: () => addNoteSchema,
|
|
2927
|
+
campaignFilterSchema: () => campaignFilterSchema,
|
|
2928
|
+
cancelActivitySchema: () => cancelActivitySchema,
|
|
2929
|
+
closeLossSchema: () => closeLossSchema,
|
|
2930
|
+
closeWinSchema: () => closeWinSchema,
|
|
2931
|
+
completeCampaignSchema: () => completeCampaignSchema,
|
|
2932
|
+
confirmSubscriptionSchema: () => confirmSubscriptionSchema,
|
|
2933
|
+
contactFilterSchema: () => contactFilterSchema,
|
|
2934
|
+
convertLeadSchema: () => convertLeadSchema,
|
|
2935
|
+
createAccountSchema: () => createAccountSchema,
|
|
2936
|
+
createCampaignSchema: () => createCampaignSchema,
|
|
2937
|
+
createContactSchema: () => createContactSchema,
|
|
2938
|
+
createLeadSchema: () => createLeadSchema,
|
|
2939
|
+
createLossReasonSchema: () => createLossReasonSchema,
|
|
2940
|
+
createPipelineSchema: () => createPipelineSchema,
|
|
2941
|
+
currencyCodeSchema: () => currencyCodeSchema,
|
|
2942
|
+
emailEventFilterSchema: () => emailEventFilterSchema,
|
|
2943
|
+
leadFilterSchema: () => leadFilterSchema,
|
|
2944
|
+
lossReasonFilterSchema: () => lossReasonFilterSchema,
|
|
2945
|
+
metadataSchema: () => metadataSchema,
|
|
2946
|
+
moneyAmountSchema: () => moneyAmountSchema,
|
|
2947
|
+
moveStageSchema: () => moveStageSchema,
|
|
2948
|
+
noteFilterSchema: () => noteFilterSchema,
|
|
2949
|
+
objectIdLike: () => objectIdLike,
|
|
2950
|
+
openOpportunitySchema: () => openOpportunitySchema,
|
|
2951
|
+
opportunityFilterSchema: () => opportunityFilterSchema,
|
|
2952
|
+
pipelineFilterSchema: () => pipelineFilterSchema,
|
|
2953
|
+
planActivitySchema: () => planActivitySchema,
|
|
2954
|
+
recordEmailEventSchema: () => recordEmailEventSchema,
|
|
2955
|
+
stageInputSchemaExport: () => stageInputSchemaExport,
|
|
2956
|
+
subscribeSchema: () => subscribeSchema,
|
|
2957
|
+
subscriptionFilterSchema: () => subscriptionFilterSchema,
|
|
2958
|
+
tagsSchema: () => tagsSchema,
|
|
2959
|
+
unsubscribeSchema: () => unsubscribeSchema,
|
|
2960
|
+
updateAccountSchema: () => updateAccountSchema,
|
|
2961
|
+
updateActivitySchema: () => updateActivitySchema,
|
|
2962
|
+
updateCampaignSchema: () => updateCampaignSchema,
|
|
2963
|
+
updateContactSchema: () => updateContactSchema,
|
|
2964
|
+
updateLeadSchema: () => updateLeadSchema,
|
|
2965
|
+
updateLossReasonSchema: () => updateLossReasonSchema,
|
|
2966
|
+
updateNoteSchema: () => updateNoteSchema,
|
|
2967
|
+
updatePipelineSchema: () => updatePipelineSchema
|
|
1910
2968
|
});
|
|
1911
|
-
/**
|
|
1912
|
-
* Every CRM event defined in the package — pass to Arc's `EventRegistry`.
|
|
1913
|
-
* Hosts wire ONE array; the whole `crm:*` namespace becomes introspectable
|
|
1914
|
-
* via OpenAPI and auto-validated at publish time when
|
|
1915
|
-
* `eventPlugin({ validateMode: 'reject' })` is set.
|
|
1916
|
-
*/
|
|
1917
|
-
const crmEventDefinitions = [
|
|
1918
|
-
LeadCreated,
|
|
1919
|
-
LeadStatusChanged,
|
|
1920
|
-
LeadScored,
|
|
1921
|
-
LeadConverted,
|
|
1922
|
-
LeadDisqualified,
|
|
1923
|
-
AccountCreated,
|
|
1924
|
-
AccountUpdated,
|
|
1925
|
-
ContactCreated,
|
|
1926
|
-
ContactUpdated,
|
|
1927
|
-
OpportunityCreated,
|
|
1928
|
-
OpportunityStageAdvanced,
|
|
1929
|
-
OpportunityStageRegressed,
|
|
1930
|
-
OpportunityWon,
|
|
1931
|
-
OpportunityLost,
|
|
1932
|
-
OpportunityAbandoned,
|
|
1933
|
-
PipelineCreated,
|
|
1934
|
-
PipelineArchived,
|
|
1935
|
-
ActivityPlanned,
|
|
1936
|
-
ActivityCompleted,
|
|
1937
|
-
ActivityCancelled,
|
|
1938
|
-
NoteAdded
|
|
1939
|
-
];
|
|
1940
2969
|
//#endregion
|
|
1941
|
-
export { ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES,
|
|
2970
|
+
export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountRepository, ActivityRepository, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CRM_EVENTS, CampaignRepository, ContactRepository, CrmError, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, EmailEventRepository, EmptyPipelineError, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadRepository, LeadService, LossReasonRepository, NotFoundError, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityRepository, PipelineRepository, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, StageNotInPipelineError, SubscriptionRepository, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildEmailEventSchema, buildLeadSchema, buildLossReasonSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSubscriptionSchema, canTransitionActivity, canTransitionCampaign, canTransitionLead, canTransitionOpportunity, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, defaultLeadScorer, forecast, lastTransitionTo, latestEntry, pipelineConversion, tenantFieldDef, timeInStatus, validators_exports as validators };
|
|
1942
2971
|
|
|
1943
2972
|
//# sourceMappingURL=index.js.map
|