@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/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