@classytic/crm 0.1.1 → 0.3.0

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