@classytic/crm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/index.d.ts +1420 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1943 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1943 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/domain/errors.ts
|
|
5
|
+
/**
|
|
6
|
+
* CRM error vocabulary. Hosts catch `CrmError` at API edges.
|
|
7
|
+
*/
|
|
8
|
+
var CrmError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
details;
|
|
11
|
+
constructor(code, message, details) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CrmError";
|
|
14
|
+
this.code = code;
|
|
15
|
+
if (details !== void 0) this.details = details;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
var NotFoundError = class extends CrmError {
|
|
19
|
+
constructor(kind, id) {
|
|
20
|
+
super(`${kind.toUpperCase()}_NOT_FOUND`, `${kind} '${id}' not found`, {
|
|
21
|
+
kind,
|
|
22
|
+
id
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var InvalidLeadTransitionError = class extends CrmError {
|
|
27
|
+
constructor(details) {
|
|
28
|
+
super("INVALID_LEAD_TRANSITION", `Lead ${details.leadId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var InvalidOpportunityTransitionError = class extends CrmError {
|
|
32
|
+
constructor(details) {
|
|
33
|
+
super("INVALID_OPPORTUNITY_TRANSITION", `Opportunity ${details.opportunityId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var InvalidActivityTransitionError = class extends CrmError {
|
|
37
|
+
constructor(details) {
|
|
38
|
+
super("INVALID_ACTIVITY_TRANSITION", `Activity ${details.activityId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var StageNotInPipelineError = class extends CrmError {
|
|
42
|
+
constructor(details) {
|
|
43
|
+
super("STAGE_NOT_IN_PIPELINE", `Stage '${details.stageId}' is not defined on pipeline '${details.pipelineId}'`, details);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var LeadAlreadyConvertedError = class extends CrmError {
|
|
47
|
+
constructor(leadId) {
|
|
48
|
+
super("LEAD_ALREADY_CONVERTED", `Lead '${leadId}' is already converted`, { leadId });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var DuplicateLeadError = class extends CrmError {
|
|
52
|
+
constructor(details) {
|
|
53
|
+
super("DUPLICATE_LEAD", `A lead with this email / phone already exists`, details);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var EmptyPipelineError = class extends CrmError {
|
|
57
|
+
constructor(pipelineId) {
|
|
58
|
+
super("EMPTY_PIPELINE", `Pipeline '${pipelineId}' has no stages`, { pipelineId });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/domain/ports/clock.port.ts
|
|
63
|
+
const systemClock = { now: () => /* @__PURE__ */ new Date() };
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/adapters/in-memory/id-generator.ts
|
|
66
|
+
const cryptoIdGenerator = { next: () => randomUUID() };
|
|
67
|
+
function createSequentialIdGenerator(prefix = "id_") {
|
|
68
|
+
let n = 0;
|
|
69
|
+
return { next: () => `${prefix}${++n}` };
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/events/event-constants.ts
|
|
73
|
+
/** PACKAGE_RULES rule 12: `package:resource.verb`. */
|
|
74
|
+
const CRM_EVENTS = {
|
|
75
|
+
LEAD_CREATED: "crm:lead.created",
|
|
76
|
+
LEAD_STATUS_CHANGED: "crm:lead.status_changed",
|
|
77
|
+
LEAD_SCORED: "crm:lead.scored",
|
|
78
|
+
LEAD_CONVERTED: "crm:lead.converted",
|
|
79
|
+
LEAD_DISQUALIFIED: "crm:lead.disqualified",
|
|
80
|
+
ACCOUNT_CREATED: "crm:account.created",
|
|
81
|
+
ACCOUNT_UPDATED: "crm:account.updated",
|
|
82
|
+
CONTACT_CREATED: "crm:contact.created",
|
|
83
|
+
CONTACT_UPDATED: "crm:contact.updated",
|
|
84
|
+
OPPORTUNITY_CREATED: "crm:opportunity.created",
|
|
85
|
+
OPPORTUNITY_STAGE_ADVANCED: "crm:opportunity.stage_advanced",
|
|
86
|
+
OPPORTUNITY_STAGE_REGRESSED: "crm:opportunity.stage_regressed",
|
|
87
|
+
OPPORTUNITY_WON: "crm:opportunity.won",
|
|
88
|
+
OPPORTUNITY_LOST: "crm:opportunity.lost",
|
|
89
|
+
OPPORTUNITY_ABANDONED: "crm:opportunity.abandoned",
|
|
90
|
+
PIPELINE_CREATED: "crm:pipeline.created",
|
|
91
|
+
PIPELINE_ARCHIVED: "crm:pipeline.archived",
|
|
92
|
+
ACTIVITY_PLANNED: "crm:activity.planned",
|
|
93
|
+
ACTIVITY_COMPLETED: "crm:activity.completed",
|
|
94
|
+
ACTIVITY_CANCELLED: "crm:activity.cancelled",
|
|
95
|
+
NOTE_ADDED: "crm:note.added"
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/events/publish.ts
|
|
99
|
+
/**
|
|
100
|
+
* Emit a CRM domain event through the host's infrastructure.
|
|
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:
|
|
118
|
+
*
|
|
119
|
+
* - Outbox errors **propagate**. When the host wraps the whole call in
|
|
120
|
+
* `session.withTransaction(...)`, an outbox-save failure will abort
|
|
121
|
+
* the transaction (business write rolls back with the missing outbox
|
|
122
|
+
* row) — this is the desired atomicity. For the no-session case, the
|
|
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.
|
|
127
|
+
*/
|
|
128
|
+
async function emitCrmEvent(transport, outbox, type, payload, meta = {}, session) {
|
|
129
|
+
if (!transport && !outbox) return;
|
|
130
|
+
const event = createEvent(type, payload, {
|
|
131
|
+
source: "crm",
|
|
132
|
+
...meta
|
|
133
|
+
});
|
|
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
|
+
}
|
|
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
|
+
//#endregion
|
|
254
|
+
//#region src/services/activity.service.ts
|
|
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;
|
|
263
|
+
events;
|
|
264
|
+
outbox;
|
|
265
|
+
clock;
|
|
266
|
+
ids;
|
|
267
|
+
constructor(config) {
|
|
268
|
+
this.repo = config.repo;
|
|
269
|
+
this.noteRepo = config.notes;
|
|
270
|
+
this.events = config.events;
|
|
271
|
+
this.outbox = config.outbox;
|
|
272
|
+
this.clock = config.clock ?? systemClock;
|
|
273
|
+
this.ids = config.ids ?? cryptoIdGenerator;
|
|
274
|
+
}
|
|
275
|
+
async plan(input, options = {}) {
|
|
276
|
+
const activity = await this.repo.create({
|
|
277
|
+
id: this.ids.next(),
|
|
278
|
+
type: input.type,
|
|
279
|
+
status: "planned",
|
|
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
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
listNotesBySubject(subjectKind, subjectId) {
|
|
359
|
+
return this.noteRepo.listBySubject(subjectKind, subjectId);
|
|
360
|
+
}
|
|
361
|
+
findById(id) {
|
|
362
|
+
return this.repo.findById(id);
|
|
363
|
+
}
|
|
364
|
+
async _transition(id, target, patch = {}, _options = {}) {
|
|
365
|
+
const existing = await this.repo.findById(id);
|
|
366
|
+
if (!existing) throw new NotFoundError("activity", id);
|
|
367
|
+
if (!canTransitionActivity(existing.status, target)) throw new InvalidActivityTransitionError({
|
|
368
|
+
from: existing.status,
|
|
369
|
+
to: target,
|
|
370
|
+
activityId: id
|
|
371
|
+
});
|
|
372
|
+
const updated = await this.repo.casStatus(id, existing.status, target, patch);
|
|
373
|
+
if (!updated) throw new InvalidActivityTransitionError({
|
|
374
|
+
from: existing.status,
|
|
375
|
+
to: target,
|
|
376
|
+
activityId: id
|
|
377
|
+
});
|
|
378
|
+
return updated;
|
|
379
|
+
}
|
|
380
|
+
async _emit(event, payload, meta, options = {}) {
|
|
381
|
+
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/services/contact.service.ts
|
|
386
|
+
var ContactService = class {
|
|
387
|
+
repo;
|
|
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);
|
|
426
|
+
return updated;
|
|
427
|
+
}
|
|
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
|
+
};
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/domain/enums/lead-status.ts
|
|
452
|
+
/**
|
|
453
|
+
* Lead lifecycle — from initial inquiry through conversion.
|
|
454
|
+
*
|
|
455
|
+
* new — just captured (web form, import, referral)
|
|
456
|
+
* contacted — SDR has reached out
|
|
457
|
+
* qualified — meets ICP / BANT
|
|
458
|
+
* converted — turned into Contact + Account + Opportunity
|
|
459
|
+
* disqualified — rejected (bad fit, spam, duplicate)
|
|
460
|
+
* nurturing — not ready now; parked for later outreach
|
|
461
|
+
*/
|
|
462
|
+
const LEAD_STATUSES = [
|
|
463
|
+
"new",
|
|
464
|
+
"contacted",
|
|
465
|
+
"qualified",
|
|
466
|
+
"converted",
|
|
467
|
+
"disqualified",
|
|
468
|
+
"nurturing"
|
|
469
|
+
];
|
|
470
|
+
const LEAD_TRANSITIONS = {
|
|
471
|
+
new: [
|
|
472
|
+
"contacted",
|
|
473
|
+
"qualified",
|
|
474
|
+
"disqualified",
|
|
475
|
+
"nurturing"
|
|
476
|
+
],
|
|
477
|
+
contacted: [
|
|
478
|
+
"qualified",
|
|
479
|
+
"disqualified",
|
|
480
|
+
"nurturing"
|
|
481
|
+
],
|
|
482
|
+
qualified: [
|
|
483
|
+
"converted",
|
|
484
|
+
"disqualified",
|
|
485
|
+
"nurturing"
|
|
486
|
+
],
|
|
487
|
+
nurturing: [
|
|
488
|
+
"contacted",
|
|
489
|
+
"qualified",
|
|
490
|
+
"disqualified"
|
|
491
|
+
],
|
|
492
|
+
converted: [],
|
|
493
|
+
disqualified: []
|
|
494
|
+
};
|
|
495
|
+
function canTransitionLead(from, to) {
|
|
496
|
+
return LEAD_TRANSITIONS[from].includes(to);
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region src/services/scoring.ts
|
|
500
|
+
/**
|
|
501
|
+
* Reference implementation. Intentionally simple so hosts read it and
|
|
502
|
+
* understand the shape, then replace it.
|
|
503
|
+
*
|
|
504
|
+
* +20 if email is present
|
|
505
|
+
* +20 if phone is present
|
|
506
|
+
* +20 if companyName is present
|
|
507
|
+
* +10 per populated tag up to 40
|
|
508
|
+
* +10 if jobTitle looks director/manager/head (case-insensitive)
|
|
509
|
+
*/
|
|
510
|
+
function defaultLeadScorer(lead) {
|
|
511
|
+
let score = 0;
|
|
512
|
+
if (lead.email) score += 20;
|
|
513
|
+
if (lead.phone) score += 20;
|
|
514
|
+
if (lead.companyName) score += 20;
|
|
515
|
+
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
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Lead lifecycle + the crown-jewel `convert()` verb that atomically
|
|
563
|
+
* spawns a Contact + Account + Opportunity and transitions the lead
|
|
564
|
+
* to `converted`.
|
|
565
|
+
*/
|
|
566
|
+
var LeadService = class {
|
|
567
|
+
repo;
|
|
568
|
+
contacts;
|
|
569
|
+
accounts;
|
|
570
|
+
opportunities;
|
|
571
|
+
events;
|
|
572
|
+
outbox;
|
|
573
|
+
clock;
|
|
574
|
+
ids;
|
|
575
|
+
scorer;
|
|
576
|
+
rejectDuplicates;
|
|
577
|
+
constructor(config) {
|
|
578
|
+
this.repo = config.repo;
|
|
579
|
+
this.contacts = config.contacts;
|
|
580
|
+
this.accounts = config.accounts;
|
|
581
|
+
this.opportunities = config.opportunities;
|
|
582
|
+
this.events = config.events;
|
|
583
|
+
this.outbox = config.outbox;
|
|
584
|
+
this.clock = config.clock ?? systemClock;
|
|
585
|
+
this.ids = config.ids ?? cryptoIdGenerator;
|
|
586
|
+
this.scorer = config.scorer ?? defaultLeadScorer;
|
|
587
|
+
this.rejectDuplicates = config.rejectDuplicates ?? true;
|
|
588
|
+
}
|
|
589
|
+
async create(input, options = {}) {
|
|
590
|
+
if (this.rejectDuplicates && input.email) {
|
|
591
|
+
if (await this.repo.findByEmail(input.email)) throw new DuplicateLeadError({ email: input.email });
|
|
592
|
+
}
|
|
593
|
+
const now = this.clock.now();
|
|
594
|
+
const fullName = input.fullName ?? `${input.firstName ?? ""} ${input.lastName ?? ""}`.trim();
|
|
595
|
+
const draft = {
|
|
596
|
+
id: this.ids.next(),
|
|
597
|
+
fullName: fullName || input.email || input.phone || "Unknown",
|
|
598
|
+
status: "new",
|
|
599
|
+
statusHistory: [{
|
|
600
|
+
status: "new",
|
|
601
|
+
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 } : {}
|
|
614
|
+
};
|
|
615
|
+
const lead = await this.repo.create(draft);
|
|
616
|
+
const scored = await this._updateScore(lead, options);
|
|
617
|
+
await this._emit(CRM_EVENTS.LEAD_CREATED, {
|
|
618
|
+
leadId: scored.id,
|
|
619
|
+
email: scored.email,
|
|
620
|
+
score: scored.score
|
|
621
|
+
}, {
|
|
622
|
+
resource: "lead",
|
|
623
|
+
resourceId: scored.id
|
|
624
|
+
}, options);
|
|
625
|
+
return scored;
|
|
626
|
+
}
|
|
627
|
+
async markContacted(id, by, note, options = {}) {
|
|
628
|
+
return this._transition(id, "contacted", {
|
|
629
|
+
...by !== void 0 ? { by } : {},
|
|
630
|
+
...note !== void 0 ? { note } : {}
|
|
631
|
+
}, options);
|
|
632
|
+
}
|
|
633
|
+
async qualify(id, by, note, options = {}) {
|
|
634
|
+
return this._transition(id, "qualified", {
|
|
635
|
+
...by !== void 0 ? { by } : {},
|
|
636
|
+
...note !== void 0 ? { note } : {}
|
|
637
|
+
}, options);
|
|
638
|
+
}
|
|
639
|
+
async disqualify(id, reason, by, options = {}) {
|
|
640
|
+
const updated = await this._transition(id, "disqualified", {
|
|
641
|
+
note: reason,
|
|
642
|
+
patch: { disqualifyReason: reason },
|
|
643
|
+
...by !== void 0 ? { by } : {}
|
|
644
|
+
}, options);
|
|
645
|
+
await this._emit(CRM_EVENTS.LEAD_DISQUALIFIED, {
|
|
646
|
+
leadId: id,
|
|
647
|
+
reason
|
|
648
|
+
}, void 0, options);
|
|
649
|
+
return updated;
|
|
650
|
+
}
|
|
651
|
+
async nurture(id, by, options = {}) {
|
|
652
|
+
return this._transition(id, "nurturing", { ...by !== void 0 ? { by } : {} }, options);
|
|
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);
|
|
758
|
+
}
|
|
759
|
+
list(filter) {
|
|
760
|
+
return this.repo.list(filter);
|
|
761
|
+
}
|
|
762
|
+
async _updateScore(lead, options = {}) {
|
|
763
|
+
const score = this.scorer(lead);
|
|
764
|
+
if (score === lead.score) return lead;
|
|
765
|
+
const updated = await this.repo.update(lead.id, { score });
|
|
766
|
+
await this._emit(CRM_EVENTS.LEAD_SCORED, {
|
|
767
|
+
leadId: lead.id,
|
|
768
|
+
score
|
|
769
|
+
}, {
|
|
770
|
+
resource: "lead",
|
|
771
|
+
resourceId: lead.id
|
|
772
|
+
}, options);
|
|
773
|
+
return updated;
|
|
774
|
+
}
|
|
775
|
+
async _require(id) {
|
|
776
|
+
const l = await this.repo.findById(id);
|
|
777
|
+
if (!l) throw new NotFoundError("lead", id);
|
|
778
|
+
return l;
|
|
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
|
+
});
|
|
787
|
+
const entry = {
|
|
788
|
+
status: target,
|
|
789
|
+
occurredAt: this.clock.now(),
|
|
790
|
+
...opts.by !== void 0 ? { by: opts.by } : {},
|
|
791
|
+
...opts.note !== void 0 ? { note: opts.note } : {}
|
|
792
|
+
};
|
|
793
|
+
const patch = {
|
|
794
|
+
...opts.patch ?? {},
|
|
795
|
+
statusHistory: [...existing.statusHistory, entry]
|
|
796
|
+
};
|
|
797
|
+
const updated = await this.repo.casStatus(id, existing.status, target, patch);
|
|
798
|
+
if (!updated) throw new InvalidLeadTransitionError({
|
|
799
|
+
from: (await this.repo.findById(id))?.status ?? existing.status,
|
|
800
|
+
to: target,
|
|
801
|
+
leadId: id
|
|
802
|
+
});
|
|
803
|
+
await this._emit(CRM_EVENTS.LEAD_STATUS_CHANGED, {
|
|
804
|
+
leadId: id,
|
|
805
|
+
from: existing.status,
|
|
806
|
+
to: target
|
|
807
|
+
}, {
|
|
808
|
+
resource: "lead",
|
|
809
|
+
resourceId: id
|
|
810
|
+
}, options);
|
|
811
|
+
return updated;
|
|
812
|
+
}
|
|
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
|
+
};
|
|
842
|
+
function canTransitionOpportunity(from, to) {
|
|
843
|
+
return OPPORTUNITY_TRANSITIONS[from].includes(to);
|
|
844
|
+
}
|
|
845
|
+
//#endregion
|
|
846
|
+
//#region src/services/opportunity.service.ts
|
|
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;
|
|
856
|
+
events;
|
|
857
|
+
outbox;
|
|
858
|
+
clock;
|
|
859
|
+
ids;
|
|
860
|
+
constructor(config) {
|
|
861
|
+
this.repo = config.repo;
|
|
862
|
+
this.pipelines = config.pipelines;
|
|
863
|
+
this.events = config.events;
|
|
864
|
+
this.outbox = config.outbox;
|
|
865
|
+
this.clock = config.clock ?? systemClock;
|
|
866
|
+
this.ids = config.ids ?? cryptoIdGenerator;
|
|
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(),
|
|
877
|
+
name: input.name,
|
|
878
|
+
pipelineId: pipeline.id,
|
|
879
|
+
stageId: stage.id,
|
|
880
|
+
status: "open",
|
|
881
|
+
statusHistory: [{
|
|
882
|
+
status: "open",
|
|
883
|
+
stageId: stage.id,
|
|
884
|
+
occurredAt: now
|
|
885
|
+
}],
|
|
886
|
+
probability,
|
|
887
|
+
...input.accountId !== void 0 ? { accountId: input.accountId } : {},
|
|
888
|
+
...input.primaryContactId !== void 0 ? { primaryContactId: input.primaryContactId } : {},
|
|
889
|
+
...input.sourceLeadId !== void 0 ? { sourceLeadId: input.sourceLeadId } : {},
|
|
890
|
+
...input.amount !== void 0 ? { amount: input.amount } : {},
|
|
891
|
+
...input.expectedCloseAt !== void 0 ? { expectedCloseAt: input.expectedCloseAt } : {},
|
|
892
|
+
...input.ownerId !== void 0 ? { ownerId: input.ownerId } : {},
|
|
893
|
+
...input.tags !== void 0 ? { tags: input.tags } : {},
|
|
894
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
895
|
+
});
|
|
896
|
+
if (options.emitEvents !== false) await this._emit(CRM_EVENTS.OPPORTUNITY_CREATED, {
|
|
897
|
+
opportunityId: opportunity.id,
|
|
898
|
+
pipelineId: pipeline.id,
|
|
899
|
+
stageId: stage.id
|
|
900
|
+
}, {
|
|
901
|
+
resource: "opportunity",
|
|
902
|
+
resourceId: opportunity.id
|
|
903
|
+
}, options);
|
|
904
|
+
return opportunity;
|
|
905
|
+
}
|
|
906
|
+
async moveToStage(id, input, options = {}) {
|
|
907
|
+
const existing = await this._require(id);
|
|
908
|
+
if (existing.status !== "open") throw new InvalidOpportunityTransitionError({
|
|
909
|
+
from: existing.status,
|
|
910
|
+
to: existing.status,
|
|
911
|
+
opportunityId: id
|
|
912
|
+
});
|
|
913
|
+
const target = await this.pipelines.getStage(existing.pipelineId, input.stageId);
|
|
914
|
+
const currentStage = await this.pipelines.getStage(existing.pipelineId, existing.stageId);
|
|
915
|
+
const probability = input.probability ?? target.defaultProbability;
|
|
916
|
+
const now = this.clock.now();
|
|
917
|
+
const entry = {
|
|
918
|
+
status: "open",
|
|
919
|
+
stageId: target.id,
|
|
920
|
+
occurredAt: now,
|
|
921
|
+
...input.by !== void 0 ? { by: input.by } : {},
|
|
922
|
+
...input.note !== void 0 ? { note: input.note } : {}
|
|
923
|
+
};
|
|
924
|
+
const patch = {
|
|
925
|
+
probability,
|
|
926
|
+
statusHistory: [...existing.statusHistory, entry]
|
|
927
|
+
};
|
|
928
|
+
const updated = await this.repo.casStage(id, existing.stageId, target.id, patch);
|
|
929
|
+
if (!updated) throw new StageNotInPipelineError({
|
|
930
|
+
pipelineId: existing.pipelineId,
|
|
931
|
+
stageId: target.id
|
|
932
|
+
});
|
|
933
|
+
const eventName = target.sequence > currentStage.sequence ? CRM_EVENTS.OPPORTUNITY_STAGE_ADVANCED : CRM_EVENTS.OPPORTUNITY_STAGE_REGRESSED;
|
|
934
|
+
await this._emit(eventName, {
|
|
935
|
+
opportunityId: id,
|
|
936
|
+
from: {
|
|
937
|
+
stageId: currentStage.id,
|
|
938
|
+
sequence: currentStage.sequence
|
|
939
|
+
},
|
|
940
|
+
to: {
|
|
941
|
+
stageId: target.id,
|
|
942
|
+
sequence: target.sequence
|
|
943
|
+
}
|
|
944
|
+
}, {
|
|
945
|
+
resource: "opportunity",
|
|
946
|
+
resourceId: id
|
|
947
|
+
}, options);
|
|
948
|
+
return updated;
|
|
949
|
+
}
|
|
950
|
+
async win(id, input = {}, options = {}) {
|
|
951
|
+
const patch = {
|
|
952
|
+
closedAt: input.closedAt ?? this.clock.now(),
|
|
953
|
+
probability: 1,
|
|
954
|
+
...input.amount !== void 0 ? { amount: input.amount } : {}
|
|
955
|
+
};
|
|
956
|
+
const updated = await this._transitionStatus(id, "won", patch, input.by, input.note);
|
|
957
|
+
await this._emit(CRM_EVENTS.OPPORTUNITY_WON, {
|
|
958
|
+
opportunityId: id,
|
|
959
|
+
amount: updated.amount
|
|
960
|
+
}, {
|
|
961
|
+
resource: "opportunity",
|
|
962
|
+
resourceId: id
|
|
963
|
+
}, options);
|
|
964
|
+
return updated;
|
|
965
|
+
}
|
|
966
|
+
async lose(id, input = {}, options = {}) {
|
|
967
|
+
const patch = {
|
|
968
|
+
closedAt: input.closedAt ?? this.clock.now(),
|
|
969
|
+
probability: 0,
|
|
970
|
+
...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
|
+
}, {
|
|
977
|
+
resource: "opportunity",
|
|
978
|
+
resourceId: id
|
|
979
|
+
}, options);
|
|
980
|
+
return updated;
|
|
981
|
+
}
|
|
982
|
+
async abandon(id, by, note, options = {}) {
|
|
983
|
+
const patch = {
|
|
984
|
+
closedAt: this.clock.now(),
|
|
985
|
+
probability: 0
|
|
986
|
+
};
|
|
987
|
+
const updated = await this._transitionStatus(id, "abandoned", patch, by, note);
|
|
988
|
+
await this._emit(CRM_EVENTS.OPPORTUNITY_ABANDONED, { opportunityId: id }, {
|
|
989
|
+
resource: "opportunity",
|
|
990
|
+
resourceId: id
|
|
991
|
+
}, options);
|
|
992
|
+
return updated;
|
|
993
|
+
}
|
|
994
|
+
async delete(id) {
|
|
995
|
+
await this._require(id);
|
|
996
|
+
await this.repo.delete(id);
|
|
997
|
+
}
|
|
998
|
+
findById(id) {
|
|
999
|
+
return this.repo.findById(id);
|
|
1000
|
+
}
|
|
1001
|
+
list(filter) {
|
|
1002
|
+
return this.repo.list(filter);
|
|
1003
|
+
}
|
|
1004
|
+
async _require(id) {
|
|
1005
|
+
const o = await this.repo.findById(id);
|
|
1006
|
+
if (!o) throw new NotFoundError("opportunity", id);
|
|
1007
|
+
return o;
|
|
1008
|
+
}
|
|
1009
|
+
async _transitionStatus(id, target, patch, by, note) {
|
|
1010
|
+
const existing = await this._require(id);
|
|
1011
|
+
if (!canTransitionOpportunity(existing.status, target)) throw new InvalidOpportunityTransitionError({
|
|
1012
|
+
from: existing.status,
|
|
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 } : {}
|
|
1023
|
+
};
|
|
1024
|
+
const fullPatch = {
|
|
1025
|
+
...patch,
|
|
1026
|
+
statusHistory: [...existing.statusHistory, entry]
|
|
1027
|
+
};
|
|
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
|
+
}
|
|
1036
|
+
async _emit(event, payload, meta, options = {}) {
|
|
1037
|
+
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/services/pipeline.service.ts
|
|
1042
|
+
/**
|
|
1043
|
+
* Pipeline lifecycle + stage helpers. The service owns stage id
|
|
1044
|
+
* generation so callers don't have to — create a pipeline with an
|
|
1045
|
+
* array of stage seeds and the service hands back the persisted
|
|
1046
|
+
* pipeline with ids assigned.
|
|
1047
|
+
*/
|
|
1048
|
+
var PipelineService = class {
|
|
1049
|
+
repo;
|
|
1050
|
+
events;
|
|
1051
|
+
outbox;
|
|
1052
|
+
clock;
|
|
1053
|
+
ids;
|
|
1054
|
+
constructor(config) {
|
|
1055
|
+
this.repo = config.repo;
|
|
1056
|
+
this.events = config.events;
|
|
1057
|
+
this.outbox = config.outbox;
|
|
1058
|
+
this.clock = config.clock ?? systemClock;
|
|
1059
|
+
this.ids = config.ids ?? cryptoIdGenerator;
|
|
1060
|
+
}
|
|
1061
|
+
async create(input, options = {}) {
|
|
1062
|
+
if (input.stages.length === 0) throw new EmptyPipelineError("<new>");
|
|
1063
|
+
const id = this.ids.next();
|
|
1064
|
+
const stages = input.stages.map((s) => ({
|
|
1065
|
+
id: this.ids.next(),
|
|
1066
|
+
...s
|
|
1067
|
+
}));
|
|
1068
|
+
const pipeline = await this.repo.create({
|
|
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 } : {}
|
|
1075
|
+
});
|
|
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
|
+
}
|
|
1091
|
+
async getStage(pipelineId, stageId) {
|
|
1092
|
+
const stage = (await this._require(pipelineId)).stages.find((s) => s.id === stageId);
|
|
1093
|
+
if (!stage) throw new StageNotInPipelineError({
|
|
1094
|
+
pipelineId,
|
|
1095
|
+
stageId
|
|
1096
|
+
});
|
|
1097
|
+
return stage;
|
|
1098
|
+
}
|
|
1099
|
+
/** First stage in the ordered pipeline (minimum `sequence`). */
|
|
1100
|
+
async getFirstStage(pipelineId) {
|
|
1101
|
+
const stages = [...(await this._require(pipelineId)).stages].sort((a, b) => a.sequence - b.sequence);
|
|
1102
|
+
if (stages.length === 0) throw new EmptyPipelineError(pipelineId);
|
|
1103
|
+
return stages[0];
|
|
1104
|
+
}
|
|
1105
|
+
/** Next stage after `stageId` in the ordered pipeline, or `null` at the end. */
|
|
1106
|
+
async getNextStage(pipelineId, stageId) {
|
|
1107
|
+
const stages = [...(await this._require(pipelineId)).stages].sort((a, b) => a.sequence - b.sequence);
|
|
1108
|
+
const idx = stages.findIndex((s) => s.id === stageId);
|
|
1109
|
+
if (idx === -1) throw new StageNotInPipelineError({
|
|
1110
|
+
pipelineId,
|
|
1111
|
+
stageId
|
|
1112
|
+
});
|
|
1113
|
+
return stages[idx + 1] ?? null;
|
|
1114
|
+
}
|
|
1115
|
+
findById(id) {
|
|
1116
|
+
return this.repo.findById(id);
|
|
1117
|
+
}
|
|
1118
|
+
list(includeArchived) {
|
|
1119
|
+
return this.repo.list(includeArchived);
|
|
1120
|
+
}
|
|
1121
|
+
async _require(id) {
|
|
1122
|
+
const p = await this.repo.findById(id);
|
|
1123
|
+
if (!p) throw new NotFoundError("pipeline", id);
|
|
1124
|
+
return p;
|
|
1125
|
+
}
|
|
1126
|
+
async _emit(event, payload, meta, options = {}) {
|
|
1127
|
+
await emitCrmEvent(this.events, this.outbox, event, payload, meta, options.session);
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
//#endregion
|
|
1131
|
+
//#region src/services/forecast.ts
|
|
1132
|
+
/**
|
|
1133
|
+
* Aggregate opportunities into a forecast table — one row per
|
|
1134
|
+
* bucket (month by default). Committed is `Σ amount`; weighted is
|
|
1135
|
+
* `Σ amount × probability`.
|
|
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.
|
|
1141
|
+
*/
|
|
1142
|
+
function forecast(opportunities, options = {}) {
|
|
1143
|
+
const bucketFn = options.bucket ?? defaultBucket;
|
|
1144
|
+
const include = options.include ?? defaultInclude;
|
|
1145
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1146
|
+
for (const o of opportunities) {
|
|
1147
|
+
if (!include(o)) continue;
|
|
1148
|
+
const amt = o.amount.amount;
|
|
1149
|
+
const bucket = bucketFn(o);
|
|
1150
|
+
const existing = rows.get(bucket);
|
|
1151
|
+
if (existing) {
|
|
1152
|
+
if (existing.currency !== o.amount.currency) throw new Error(`Forecast bucket '${bucket}' mixes currencies (${existing.currency} + ${o.amount.currency}). Partition by currency before calling forecast().`);
|
|
1153
|
+
rows.set(bucket, {
|
|
1154
|
+
...existing,
|
|
1155
|
+
committed: existing.committed + amt,
|
|
1156
|
+
weighted: existing.weighted + Math.round(amt * o.probability),
|
|
1157
|
+
count: existing.count + 1
|
|
1158
|
+
});
|
|
1159
|
+
} else rows.set(bucket, {
|
|
1160
|
+
bucket,
|
|
1161
|
+
committed: amt,
|
|
1162
|
+
weighted: Math.round(amt * o.probability),
|
|
1163
|
+
count: 1,
|
|
1164
|
+
currency: o.amount.currency
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
return [...rows.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
|
1168
|
+
}
|
|
1169
|
+
function defaultInclude(o) {
|
|
1170
|
+
return o.status === "open" && !!o.amount && !!o.expectedCloseAt;
|
|
1171
|
+
}
|
|
1172
|
+
function defaultBucket(o) {
|
|
1173
|
+
const d = o.expectedCloseAt;
|
|
1174
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
1175
|
+
}
|
|
1176
|
+
function pipelineConversion(opportunities, stageIds) {
|
|
1177
|
+
return stageIds.map((stageId) => {
|
|
1178
|
+
let currentCount = 0;
|
|
1179
|
+
let wonFromHere = 0;
|
|
1180
|
+
let lostFromHere = 0;
|
|
1181
|
+
for (const o of opportunities) {
|
|
1182
|
+
if (o.stageId === stageId && o.status === "open") currentCount++;
|
|
1183
|
+
if (o.status === "won" || o.status === "lost") {
|
|
1184
|
+
if (o.statusHistory.some((h) => h.stageId === stageId) || o.stageId === stageId) if (o.status === "won") wonFromHere++;
|
|
1185
|
+
else lostFromHere++;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const closed = wonFromHere + lostFromHere;
|
|
1189
|
+
return {
|
|
1190
|
+
stageId,
|
|
1191
|
+
currentCount,
|
|
1192
|
+
wonFromHere,
|
|
1193
|
+
lostFromHere,
|
|
1194
|
+
conversionRate: closed > 0 ? wonFromHere / closed : 0
|
|
1195
|
+
};
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
//#endregion
|
|
1199
|
+
//#region src/adapters/in-memory/account-repository.ts
|
|
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
|
|
1671
|
+
/**
|
|
1672
|
+
* CRM event catalog — Zod-source-of-truth definitions for every
|
|
1673
|
+
* `crm:*` event.
|
|
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
|
+
* ```
|
|
1702
|
+
*/
|
|
1703
|
+
function defineCrmEvent(input) {
|
|
1704
|
+
const { name, version = 1, description, zodSchema } = input;
|
|
1705
|
+
const def = {
|
|
1706
|
+
name,
|
|
1707
|
+
version,
|
|
1708
|
+
schema: z.toJSONSchema(zodSchema),
|
|
1709
|
+
zodSchema,
|
|
1710
|
+
create(payload, meta) {
|
|
1711
|
+
return createEvent(name, payload, {
|
|
1712
|
+
source: "crm",
|
|
1713
|
+
...meta
|
|
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()
|
|
1753
|
+
});
|
|
1754
|
+
const leadDisqualifiedSchema = z.object({
|
|
1755
|
+
leadId: z.string(),
|
|
1756
|
+
reason: z.string()
|
|
1757
|
+
});
|
|
1758
|
+
const accountCreatedSchema = z.object({ accountId: z.string() });
|
|
1759
|
+
const accountUpdatedSchema = z.object({ accountId: z.string() });
|
|
1760
|
+
const contactCreatedSchema = z.object({
|
|
1761
|
+
contactId: z.string(),
|
|
1762
|
+
email: z.string().optional()
|
|
1763
|
+
});
|
|
1764
|
+
const contactUpdatedSchema = z.object({ contactId: z.string() });
|
|
1765
|
+
const opportunityCreatedSchema = z.object({
|
|
1766
|
+
opportunityId: z.string(),
|
|
1767
|
+
pipelineId: z.string(),
|
|
1768
|
+
stageId: z.string()
|
|
1769
|
+
});
|
|
1770
|
+
const opportunityStageAdvancedSchema = z.object({
|
|
1771
|
+
opportunityId: z.string(),
|
|
1772
|
+
from: stageRef,
|
|
1773
|
+
to: stageRef
|
|
1774
|
+
});
|
|
1775
|
+
const opportunityStageRegressedSchema = opportunityStageAdvancedSchema;
|
|
1776
|
+
const opportunityWonSchema = z.object({
|
|
1777
|
+
opportunityId: z.string(),
|
|
1778
|
+
amount: money.optional()
|
|
1779
|
+
});
|
|
1780
|
+
const opportunityLostSchema = z.object({
|
|
1781
|
+
opportunityId: z.string(),
|
|
1782
|
+
lostReasonId: z.string().optional()
|
|
1783
|
+
});
|
|
1784
|
+
const opportunityAbandonedSchema = z.object({ opportunityId: z.string() });
|
|
1785
|
+
const pipelineCreatedSchema = z.object({ pipelineId: z.string() });
|
|
1786
|
+
const pipelineArchivedSchema = z.object({ pipelineId: z.string() });
|
|
1787
|
+
const activityPlannedSchema = z.object({
|
|
1788
|
+
activityId: z.string(),
|
|
1789
|
+
type: z.string(),
|
|
1790
|
+
subjectKind,
|
|
1791
|
+
subjectId: z.string()
|
|
1792
|
+
});
|
|
1793
|
+
const activityCompletedSchema = z.object({
|
|
1794
|
+
activityId: z.string(),
|
|
1795
|
+
completedAt: z.iso.datetime()
|
|
1796
|
+
});
|
|
1797
|
+
const activityCancelledSchema = z.object({
|
|
1798
|
+
activityId: z.string(),
|
|
1799
|
+
reason: z.string().optional()
|
|
1800
|
+
});
|
|
1801
|
+
const noteAddedSchema = z.object({
|
|
1802
|
+
noteId: z.string(),
|
|
1803
|
+
subjectKind,
|
|
1804
|
+
subjectId: z.string()
|
|
1805
|
+
});
|
|
1806
|
+
const LeadCreated = defineCrmEvent({
|
|
1807
|
+
name: CRM_EVENTS.LEAD_CREATED,
|
|
1808
|
+
description: "A new lead was captured and initial score computed.",
|
|
1809
|
+
zodSchema: leadCreatedSchema
|
|
1810
|
+
});
|
|
1811
|
+
const LeadStatusChanged = defineCrmEvent({
|
|
1812
|
+
name: CRM_EVENTS.LEAD_STATUS_CHANGED,
|
|
1813
|
+
description: "A lead transitioned between lifecycle statuses.",
|
|
1814
|
+
zodSchema: leadStatusChangedSchema
|
|
1815
|
+
});
|
|
1816
|
+
const LeadScored = defineCrmEvent({
|
|
1817
|
+
name: CRM_EVENTS.LEAD_SCORED,
|
|
1818
|
+
description: "A lead score was recomputed and persisted.",
|
|
1819
|
+
zodSchema: leadScoredSchema
|
|
1820
|
+
});
|
|
1821
|
+
const LeadConverted = defineCrmEvent({
|
|
1822
|
+
name: CRM_EVENTS.LEAD_CONVERTED,
|
|
1823
|
+
description: "A lead was converted into a Contact + (optional) Account + Opportunity.",
|
|
1824
|
+
zodSchema: leadConvertedSchema
|
|
1825
|
+
});
|
|
1826
|
+
const LeadDisqualified = defineCrmEvent({
|
|
1827
|
+
name: CRM_EVENTS.LEAD_DISQUALIFIED,
|
|
1828
|
+
description: "A lead was disqualified with a reason.",
|
|
1829
|
+
zodSchema: leadDisqualifiedSchema
|
|
1830
|
+
});
|
|
1831
|
+
const AccountCreated = defineCrmEvent({
|
|
1832
|
+
name: CRM_EVENTS.ACCOUNT_CREATED,
|
|
1833
|
+
description: "A new account was created.",
|
|
1834
|
+
zodSchema: accountCreatedSchema
|
|
1835
|
+
});
|
|
1836
|
+
const AccountUpdated = defineCrmEvent({
|
|
1837
|
+
name: CRM_EVENTS.ACCOUNT_UPDATED,
|
|
1838
|
+
description: "An account record was updated.",
|
|
1839
|
+
zodSchema: accountUpdatedSchema
|
|
1840
|
+
});
|
|
1841
|
+
const ContactCreated = defineCrmEvent({
|
|
1842
|
+
name: CRM_EVENTS.CONTACT_CREATED,
|
|
1843
|
+
description: "A new contact was created.",
|
|
1844
|
+
zodSchema: contactCreatedSchema
|
|
1845
|
+
});
|
|
1846
|
+
const ContactUpdated = defineCrmEvent({
|
|
1847
|
+
name: CRM_EVENTS.CONTACT_UPDATED,
|
|
1848
|
+
description: "A contact record was updated.",
|
|
1849
|
+
zodSchema: contactUpdatedSchema
|
|
1850
|
+
});
|
|
1851
|
+
const OpportunityCreated = defineCrmEvent({
|
|
1852
|
+
name: CRM_EVENTS.OPPORTUNITY_CREATED,
|
|
1853
|
+
description: "A new opportunity was created in a pipeline at a starting stage.",
|
|
1854
|
+
zodSchema: opportunityCreatedSchema
|
|
1855
|
+
});
|
|
1856
|
+
const OpportunityStageAdvanced = defineCrmEvent({
|
|
1857
|
+
name: CRM_EVENTS.OPPORTUNITY_STAGE_ADVANCED,
|
|
1858
|
+
description: "An opportunity moved to a later stage in its pipeline.",
|
|
1859
|
+
zodSchema: opportunityStageAdvancedSchema
|
|
1860
|
+
});
|
|
1861
|
+
const OpportunityStageRegressed = defineCrmEvent({
|
|
1862
|
+
name: CRM_EVENTS.OPPORTUNITY_STAGE_REGRESSED,
|
|
1863
|
+
description: "An opportunity moved back to an earlier stage in its pipeline.",
|
|
1864
|
+
zodSchema: opportunityStageRegressedSchema
|
|
1865
|
+
});
|
|
1866
|
+
const OpportunityWon = defineCrmEvent({
|
|
1867
|
+
name: CRM_EVENTS.OPPORTUNITY_WON,
|
|
1868
|
+
description: "An opportunity closed as won.",
|
|
1869
|
+
zodSchema: opportunityWonSchema
|
|
1870
|
+
});
|
|
1871
|
+
const OpportunityLost = defineCrmEvent({
|
|
1872
|
+
name: CRM_EVENTS.OPPORTUNITY_LOST,
|
|
1873
|
+
description: "An opportunity closed as lost.",
|
|
1874
|
+
zodSchema: opportunityLostSchema
|
|
1875
|
+
});
|
|
1876
|
+
const OpportunityAbandoned = defineCrmEvent({
|
|
1877
|
+
name: CRM_EVENTS.OPPORTUNITY_ABANDONED,
|
|
1878
|
+
description: "An opportunity was abandoned without a close outcome.",
|
|
1879
|
+
zodSchema: opportunityAbandonedSchema
|
|
1880
|
+
});
|
|
1881
|
+
const PipelineCreated = defineCrmEvent({
|
|
1882
|
+
name: CRM_EVENTS.PIPELINE_CREATED,
|
|
1883
|
+
description: "A new pipeline was created.",
|
|
1884
|
+
zodSchema: pipelineCreatedSchema
|
|
1885
|
+
});
|
|
1886
|
+
const PipelineArchived = defineCrmEvent({
|
|
1887
|
+
name: CRM_EVENTS.PIPELINE_ARCHIVED,
|
|
1888
|
+
description: "A pipeline was archived.",
|
|
1889
|
+
zodSchema: pipelineArchivedSchema
|
|
1890
|
+
});
|
|
1891
|
+
const ActivityPlanned = defineCrmEvent({
|
|
1892
|
+
name: CRM_EVENTS.ACTIVITY_PLANNED,
|
|
1893
|
+
description: "An activity was planned against a polymorphic subject.",
|
|
1894
|
+
zodSchema: activityPlannedSchema
|
|
1895
|
+
});
|
|
1896
|
+
const ActivityCompleted = defineCrmEvent({
|
|
1897
|
+
name: CRM_EVENTS.ACTIVITY_COMPLETED,
|
|
1898
|
+
description: "An activity was marked completed.",
|
|
1899
|
+
zodSchema: activityCompletedSchema
|
|
1900
|
+
});
|
|
1901
|
+
const ActivityCancelled = defineCrmEvent({
|
|
1902
|
+
name: CRM_EVENTS.ACTIVITY_CANCELLED,
|
|
1903
|
+
description: "An activity was cancelled.",
|
|
1904
|
+
zodSchema: activityCancelledSchema
|
|
1905
|
+
});
|
|
1906
|
+
const NoteAdded = defineCrmEvent({
|
|
1907
|
+
name: CRM_EVENTS.NOTE_ADDED,
|
|
1908
|
+
description: "A note was attached to a polymorphic subject.",
|
|
1909
|
+
zodSchema: noteAddedSchema
|
|
1910
|
+
});
|
|
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
|
+
//#endregion
|
|
1941
|
+
export { ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountCreated, AccountService, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityPlanned, ActivityService, CRM_EVENTS, ContactCreated, ContactService, ContactUpdated, CrmError, DuplicateLeadError, EmptyPipelineError, InMemoryAccountRepository, InMemoryActivityRepository, InMemoryContactRepository, InMemoryEventTransport, InMemoryLeadRepository, InMemoryLossReasonRepository, InMemoryNoteRepository, InMemoryOpportunityRepository, InMemoryOutreachPort, InMemoryPipelineRepository, InvalidActivityTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadConverted, LeadCreated, LeadDisqualified, LeadScored, LeadService, LeadStatusChanged, NotFoundError, NoteAdded, NullOutreachPort, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityCreated, OpportunityLost, OpportunityService, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PipelineArchived, PipelineCreated, PipelineService, SUBJECT_KINDS, StageNotInPipelineError, canTransitionActivity, canTransitionLead, canTransitionOpportunity, composeScorers, createSequentialIdGenerator, crmEventDefinitions, cryptoIdGenerator, defaultLeadScorer, forecast, pipelineConversion, systemClock };
|
|
1942
|
+
|
|
1943
|
+
//# sourceMappingURL=index.js.map
|