@classytic/crm 0.3.0 → 0.5.1

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,11 +1,104 @@
1
1
  import { t as __exportAll } from "./chunk.js";
2
2
  import { Schema } from "mongoose";
3
- import { Repository, aggregateHelpersPlugin, methodRegistryPlugin, multiTenantPlugin } from "@classytic/mongokit";
3
+ import { Repository, aggregateHelpersPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin } from "@classytic/mongokit";
4
4
  import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
5
5
  import { assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { z } from "zod";
8
8
  import { appendStatus, lastTransitionTo, latestEntry, timeInStatus } from "@classytic/primitives/status-history";
9
+ //#region src/core/errors.ts
10
+ /**
11
+ * CRM error vocabulary. Hosts catch `CrmError` at API edges.
12
+ */
13
+ var CrmError = class extends Error {
14
+ code;
15
+ details;
16
+ constructor(code, message, details) {
17
+ super(message);
18
+ this.name = "CrmError";
19
+ this.code = code;
20
+ if (details !== void 0) this.details = details;
21
+ }
22
+ };
23
+ var NotFoundError = class extends CrmError {
24
+ constructor(kind, id) {
25
+ super(`${kind.toUpperCase()}_NOT_FOUND`, `${kind} '${id}' not found`, {
26
+ kind,
27
+ id
28
+ });
29
+ }
30
+ };
31
+ var InvalidLeadTransitionError = class extends CrmError {
32
+ constructor(details) {
33
+ super("INVALID_LEAD_TRANSITION", `Lead ${details.leadId} cannot move from '${details.from}' to '${details.to}'`, details);
34
+ }
35
+ };
36
+ var InvalidOpportunityTransitionError = class extends CrmError {
37
+ constructor(details) {
38
+ super("INVALID_OPPORTUNITY_TRANSITION", `Opportunity ${details.opportunityId} cannot move from '${details.from}' to '${details.to}'`, details);
39
+ }
40
+ };
41
+ var InvalidActivityTransitionError = class extends CrmError {
42
+ constructor(details) {
43
+ super("INVALID_ACTIVITY_TRANSITION", `Activity ${details.activityId} cannot move from '${details.from}' to '${details.to}'`, details);
44
+ }
45
+ };
46
+ var StageNotInPipelineError = class extends CrmError {
47
+ constructor(details) {
48
+ super("STAGE_NOT_IN_PIPELINE", `Stage '${details.stageId}' is not defined on pipeline '${details.pipelineId}'`, details);
49
+ }
50
+ };
51
+ var LeadAlreadyConvertedError = class extends CrmError {
52
+ constructor(leadId) {
53
+ super("LEAD_ALREADY_CONVERTED", `Lead '${leadId}' is already converted`, { leadId });
54
+ }
55
+ };
56
+ var DuplicateLeadError = class extends CrmError {
57
+ constructor(details) {
58
+ super("DUPLICATE_LEAD", `A lead with this email / phone already exists`, details);
59
+ }
60
+ };
61
+ var CrmModelCollisionError = class extends CrmError {
62
+ constructor(modelName) {
63
+ super("CRM_MODEL_COLLISION", `Mongoose model '${modelName}' is already registered on this connection. Another engine (or a hot reload) owns it. Pass { forceRecreate: true } to createCrm() for test/hot-reload fixtures, or use a separate mongoose connection per engine in production (PACKAGE_RULES §21).`, { modelName });
64
+ }
65
+ };
66
+ var EmptyPipelineError = class extends CrmError {
67
+ constructor(pipelineId) {
68
+ super("EMPTY_PIPELINE", `Pipeline '${pipelineId}' has no stages`, { pipelineId });
69
+ }
70
+ };
71
+ var InvalidCampaignTransitionError = class extends CrmError {
72
+ constructor(details) {
73
+ super("INVALID_CAMPAIGN_TRANSITION", `Campaign ${details.campaignId} cannot move from '${details.from}' to '${details.to}'`, details);
74
+ }
75
+ };
76
+ var DuplicateCampaignError = class extends CrmError {
77
+ constructor(name) {
78
+ super("DUPLICATE_CAMPAIGN", `A campaign named '${name}' already exists`, { name });
79
+ }
80
+ };
81
+ var InvalidSubscriptionTransitionError = class extends CrmError {
82
+ constructor(details) {
83
+ super("INVALID_SUBSCRIPTION_TRANSITION", `Subscription ${details.subscriptionId} cannot move from '${details.from}' to '${details.to}'`, details);
84
+ }
85
+ };
86
+ var DuplicateSubscriptionError = class extends CrmError {
87
+ constructor(details) {
88
+ super("DUPLICATE_SUBSCRIPTION", `Contact '${details.contactId}' already has a live subscription on campaign '${details.campaignId}'`, details);
89
+ }
90
+ };
91
+ var InvalidConfirmationTokenError = class extends CrmError {
92
+ constructor() {
93
+ super("INVALID_CONFIRMATION_TOKEN", "No subscription matches the supplied confirmation token, or it has already been used");
94
+ }
95
+ };
96
+ var InvalidUnsubscribeTokenError = class extends CrmError {
97
+ constructor() {
98
+ super("INVALID_UNSUBSCRIBE_TOKEN", "No subscription matches the supplied unsubscribe token");
99
+ }
100
+ };
101
+ //#endregion
9
102
  //#region src/models/tenant-field.ts
10
103
  /**
11
104
  * Per PACKAGE_RULES §9.2: never hardcode the tenant column's Mongoose
@@ -272,6 +365,9 @@ function buildLeadSchema(config) {
272
365
  schema.index({
273
366
  organizationId: 1,
274
367
  email: 1
368
+ }, {
369
+ unique: true,
370
+ partialFilterExpression: { email: { $type: "string" } }
275
371
  });
276
372
  schema.index({
277
373
  organizationId: 1,
@@ -495,6 +591,26 @@ const contactInfoSchema$1 = new Schema({
495
591
  type: String,
496
592
  trim: true,
497
593
  maxlength: 50
594
+ },
595
+ whatsapp: {
596
+ type: String,
597
+ trim: true,
598
+ maxlength: 50
599
+ },
600
+ telegram: {
601
+ type: String,
602
+ trim: true,
603
+ maxlength: 100
604
+ },
605
+ linkedin: {
606
+ type: String,
607
+ trim: true,
608
+ maxlength: 255
609
+ },
610
+ instagram: {
611
+ type: String,
612
+ trim: true,
613
+ maxlength: 100
498
614
  }
499
615
  }, { _id: false });
500
616
  function buildContactSchema(config) {
@@ -734,6 +850,10 @@ function buildCampaignSchema(config) {
734
850
  },
735
851
  metadata: { type: Schema.Types.Mixed }
736
852
  }, { timestamps: true });
853
+ schema.index({
854
+ organizationId: 1,
855
+ name: 1
856
+ }, { unique: true });
737
857
  schema.index({
738
858
  organizationId: 1,
739
859
  status: 1
@@ -799,8 +919,14 @@ function canTransitionSubscription(from, to) {
799
919
  //#endregion
800
920
  //#region src/models/subscription.schema.ts
801
921
  /**
802
- * Subscription — a Contact's opt-in to a Campaign. Double-opt-in tokens,
803
- * unsubscribe token, compliance audit fields.
922
+ * EmailSubscription — a Contact's opt-in to a Campaign. Double-opt-in
923
+ * tokens, unsubscribe token, compliance audit fields.
924
+ *
925
+ * Registered as Mongoose model `EmailSubscription` (collection
926
+ * `email_subscriptions`) so it doesn't collide with `@classytic/revenue`'s
927
+ * billing-side `Subscription` model when both packages register on the
928
+ * same connection. TypeScript class + file names retain `Subscription` for
929
+ * source-level continuity — only the Mongo identity changed in v0.4.0.
804
930
  */
805
931
  const statusEntrySchema = new Schema({
806
932
  status: {
@@ -881,7 +1007,10 @@ function buildSubscriptionSchema(config) {
881
1007
  },
882
1008
  complainedAt: { type: Date },
883
1009
  metadata: { type: Schema.Types.Mixed }
884
- }, { timestamps: true });
1010
+ }, {
1011
+ timestamps: true,
1012
+ collection: "email_subscriptions"
1013
+ });
885
1014
  schema.index({
886
1015
  organizationId: 1,
887
1016
  campaignId: 1,
@@ -891,6 +1020,9 @@ function buildSubscriptionSchema(config) {
891
1020
  organizationId: 1,
892
1021
  contactId: 1,
893
1022
  campaignId: 1
1023
+ }, {
1024
+ unique: true,
1025
+ partialFilterExpression: { status: { $in: ["pending", "confirmed"] } }
894
1026
  });
895
1027
  schema.index({
896
1028
  organizationId: 1,
@@ -900,7 +1032,8 @@ function buildSubscriptionSchema(config) {
900
1032
  }
901
1033
  function createSubscriptionModel(connection, config) {
902
1034
  if (connection.models.Subscription) connection.deleteModel("Subscription");
903
- return connection.model("Subscription", buildSubscriptionSchema(config));
1035
+ if (connection.models.EmailSubscription) connection.deleteModel("EmailSubscription");
1036
+ return connection.model("EmailSubscription", buildSubscriptionSchema(config));
904
1037
  }
905
1038
  //#endregion
906
1039
  //#region src/enums/activity.ts
@@ -1255,110 +1388,270 @@ function createEmailEventModel(connection, config) {
1255
1388
  return connection.model("EmailEvent", buildEmailEventSchema(config));
1256
1389
  }
1257
1390
  //#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)
1391
+ //#region src/enums/conversation.ts
1392
+ /**
1393
+ * Conversation / Message vocabulary.
1394
+ *
1395
+ * Conversations unify a contact's (or pre-conversion lead's) message
1396
+ * thread across channels (WhatsApp / Telegram / Email / IG-DM / SMS).
1397
+ * The host's webhook adapters write to this surface — the CRM does not
1398
+ * dial outward.
1399
+ */
1400
+ const CONVERSATION_CHANNELS = [
1401
+ "whatsapp",
1402
+ "telegram",
1403
+ "email",
1404
+ "instagram_dm",
1405
+ "sms",
1406
+ "web_chat"
1407
+ ];
1408
+ const CONVERSATION_STATUSES = [
1409
+ "open",
1410
+ "snoozed",
1411
+ "resolved"
1412
+ ];
1413
+ /** Conversations attach to either a Contact or a (yet-unconverted) Lead. */
1414
+ const CONVERSATION_PARTICIPANT_KINDS = ["contact", "lead"];
1415
+ const MESSAGE_DIRECTIONS = ["inbound", "outbound"];
1416
+ const MESSAGE_DELIVERY_STATUSES = [
1417
+ "queued",
1418
+ "sent",
1419
+ "delivered",
1420
+ "read",
1421
+ "failed"
1422
+ ];
1423
+ //#endregion
1424
+ //#region src/models/conversation.schema.ts
1425
+ /**
1426
+ * Conversation — a multi-channel message thread bound to a Contact or
1427
+ * (pre-conversion) Lead. One row per (channel, participant) pair.
1428
+ *
1429
+ * The CRM stores the thread; channel adapters at the host (WhatsApp Cloud
1430
+ * API, Telegram Bot, IMAP, etc.) write inbound `Message`s and emit
1431
+ * `outbound` requests via the host's bridge — this package never reaches
1432
+ * out to a transport itself (PACKAGE_RULES §11).
1433
+ */
1434
+ function buildConversationSchema(config) {
1435
+ const schema = new Schema({
1436
+ organizationId: tenantFieldDef(config),
1437
+ channel: {
1438
+ type: String,
1439
+ enum: CONVERSATION_CHANNELS,
1440
+ required: true,
1441
+ index: true
1442
+ },
1443
+ participantKind: {
1444
+ type: String,
1445
+ enum: CONVERSATION_PARTICIPANT_KINDS,
1446
+ required: true
1447
+ },
1448
+ participantId: {
1449
+ type: Schema.Types.ObjectId,
1450
+ required: true,
1451
+ index: true
1452
+ },
1453
+ channelUserId: {
1454
+ type: String,
1455
+ trim: true,
1456
+ maxlength: 255
1457
+ },
1458
+ status: {
1459
+ type: String,
1460
+ enum: CONVERSATION_STATUSES,
1461
+ default: "open",
1462
+ index: true
1463
+ },
1464
+ lastMessageAt: {
1465
+ type: Date,
1466
+ index: true
1467
+ },
1468
+ unreadCount: {
1469
+ type: Number,
1470
+ default: 0,
1471
+ min: 0
1472
+ },
1473
+ assigneeId: {
1474
+ type: String,
1475
+ index: true
1476
+ },
1477
+ metadata: { type: Schema.Types.Mixed }
1478
+ }, { timestamps: true });
1479
+ schema.index({
1480
+ organizationId: 1,
1481
+ participantKind: 1,
1482
+ participantId: 1
1483
+ });
1484
+ schema.index({
1485
+ organizationId: 1,
1486
+ status: 1,
1487
+ lastMessageAt: -1
1488
+ });
1489
+ schema.index({
1490
+ organizationId: 1,
1491
+ assigneeId: 1,
1492
+ status: 1
1493
+ });
1494
+ schema.index({
1495
+ organizationId: 1,
1496
+ channel: 1,
1497
+ channelUserId: 1
1272
1498
  });
1499
+ return schema;
1500
+ }
1501
+ function createConversationModel(connection, config) {
1502
+ if (connection.models.Conversation) connection.deleteModel("Conversation");
1503
+ return connection.model("Conversation", buildConversationSchema(config));
1273
1504
  }
1274
1505
  //#endregion
1275
- //#region src/core/errors.ts
1506
+ //#region src/models/message.schema.ts
1276
1507
  /**
1277
- * CRM error vocabulary. Hosts catch `CrmError` at API edges.
1508
+ * Message — one event inside a Conversation.
1509
+ *
1510
+ * Channel-agnostic: `body` is plain text + optional attachments[].
1511
+ * Channel-specific quirks (WhatsApp template params, IMAP headers,
1512
+ * Telegram entities) live under `metadata`.
1513
+ *
1514
+ * `externalMessageId` is the dedup key webhooks check against before
1515
+ * appending — at-least-once delivery is the rule, not the exception.
1278
1516
  */
1279
- var CrmError = class extends Error {
1280
- code;
1281
- details;
1282
- constructor(code, message, details) {
1283
- super(message);
1284
- this.name = "CrmError";
1285
- this.code = code;
1286
- if (details !== void 0) this.details = details;
1287
- }
1288
- };
1289
- var NotFoundError = class extends CrmError {
1290
- constructor(kind, id) {
1291
- super(`${kind.toUpperCase()}_NOT_FOUND`, `${kind} '${id}' not found`, {
1292
- kind,
1293
- id
1294
- });
1295
- }
1296
- };
1297
- var InvalidLeadTransitionError = class extends CrmError {
1298
- constructor(details) {
1299
- super("INVALID_LEAD_TRANSITION", `Lead ${details.leadId} cannot move from '${details.from}' to '${details.to}'`, details);
1300
- }
1301
- };
1302
- var InvalidOpportunityTransitionError = class extends CrmError {
1303
- constructor(details) {
1304
- super("INVALID_OPPORTUNITY_TRANSITION", `Opportunity ${details.opportunityId} cannot move from '${details.from}' to '${details.to}'`, details);
1305
- }
1306
- };
1307
- var InvalidActivityTransitionError = class extends CrmError {
1308
- constructor(details) {
1309
- super("INVALID_ACTIVITY_TRANSITION", `Activity ${details.activityId} cannot move from '${details.from}' to '${details.to}'`, details);
1310
- }
1311
- };
1312
- var StageNotInPipelineError = class extends CrmError {
1313
- constructor(details) {
1314
- super("STAGE_NOT_IN_PIPELINE", `Stage '${details.stageId}' is not defined on pipeline '${details.pipelineId}'`, details);
1315
- }
1316
- };
1317
- var LeadAlreadyConvertedError = class extends CrmError {
1318
- constructor(leadId) {
1319
- super("LEAD_ALREADY_CONVERTED", `Lead '${leadId}' is already converted`, { leadId });
1320
- }
1321
- };
1322
- var DuplicateLeadError = class extends CrmError {
1323
- constructor(details) {
1324
- super("DUPLICATE_LEAD", `A lead with this email / phone already exists`, details);
1325
- }
1326
- };
1327
- var EmptyPipelineError = class extends CrmError {
1328
- constructor(pipelineId) {
1329
- super("EMPTY_PIPELINE", `Pipeline '${pipelineId}' has no stages`, { pipelineId });
1330
- }
1331
- };
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);
1517
+ const attachmentSchema = new Schema({
1518
+ type: {
1519
+ type: String,
1520
+ required: true,
1521
+ maxlength: 50
1522
+ },
1523
+ url: {
1524
+ type: String,
1525
+ required: true,
1526
+ maxlength: 2048
1527
+ },
1528
+ name: {
1529
+ type: String,
1530
+ maxlength: 255
1531
+ },
1532
+ size: {
1533
+ type: Number,
1534
+ min: 0
1535
+ },
1536
+ mimeType: {
1537
+ type: String,
1538
+ maxlength: 150
1335
1539
  }
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
- };
1540
+ }, { _id: false });
1541
+ function buildMessageSchema(config) {
1542
+ const schema = new Schema({
1543
+ organizationId: tenantFieldDef(config),
1544
+ conversationId: {
1545
+ type: Schema.Types.ObjectId,
1546
+ ref: "Conversation",
1547
+ required: true,
1548
+ index: true
1549
+ },
1550
+ direction: {
1551
+ type: String,
1552
+ enum: MESSAGE_DIRECTIONS,
1553
+ required: true
1554
+ },
1555
+ body: {
1556
+ type: String,
1557
+ default: "",
1558
+ maxlength: 16384
1559
+ },
1560
+ attachments: {
1561
+ type: [attachmentSchema],
1562
+ default: []
1563
+ },
1564
+ externalMessageId: {
1565
+ type: String,
1566
+ trim: true,
1567
+ maxlength: 255
1568
+ },
1569
+ status: {
1570
+ type: String,
1571
+ enum: MESSAGE_DELIVERY_STATUSES,
1572
+ default: "sent"
1573
+ },
1574
+ senderRef: {
1575
+ type: String,
1576
+ trim: true,
1577
+ maxlength: 255
1578
+ },
1579
+ recipientRef: {
1580
+ type: String,
1581
+ trim: true,
1582
+ maxlength: 255
1583
+ },
1584
+ sentAt: { type: Date },
1585
+ metadata: { type: Schema.Types.Mixed }
1586
+ }, { timestamps: true });
1587
+ schema.index({
1588
+ organizationId: 1,
1589
+ conversationId: 1,
1590
+ createdAt: -1
1591
+ });
1592
+ schema.index({
1593
+ organizationId: 1,
1594
+ externalMessageId: 1
1595
+ }, {
1596
+ unique: true,
1597
+ partialFilterExpression: { externalMessageId: { $type: "string" } }
1598
+ });
1599
+ schema.index({ sentAt: 1 }, {
1600
+ expireAfterSeconds: 365 * 24 * 60 * 60,
1601
+ sparse: true,
1602
+ name: "sentAt_ttl"
1603
+ });
1604
+ return schema;
1605
+ }
1606
+ function createMessageModel(connection, config) {
1607
+ if (connection.models.Message) connection.deleteModel("Message");
1608
+ return connection.model("Message", buildMessageSchema(config));
1609
+ }
1610
+ //#endregion
1611
+ //#region src/models/create-models.ts
1612
+ /** Mongoose model names this package registers — drives the §21 collision check. */
1613
+ const CRM_MODEL_NAMES = [
1614
+ "Pipeline",
1615
+ "Lead",
1616
+ "Opportunity",
1617
+ "Contact",
1618
+ "Account",
1619
+ "Campaign",
1620
+ "EmailSubscription",
1621
+ "Activity",
1622
+ "Note",
1623
+ "LossReason",
1624
+ "EmailEvent",
1625
+ "Conversation",
1626
+ "Message"
1627
+ ];
1628
+ function createCrmModels(connection, config) {
1629
+ const forceRecreate = config.forceRecreate ?? false;
1630
+ const autoIndex = config.autoIndex ?? true;
1631
+ const namesToCheck = [...CRM_MODEL_NAMES, "Subscription"];
1632
+ if (forceRecreate) {
1633
+ for (const name of namesToCheck) if (connection.models[name]) connection.deleteModel(name);
1634
+ } else for (const name of CRM_MODEL_NAMES) if (connection.models[name]) throw new CrmModelCollisionError(name);
1635
+ const register = (name, schema) => {
1636
+ schema.set("autoIndex", autoIndex);
1637
+ return connection.model(name, schema);
1638
+ };
1639
+ return Object.freeze({
1640
+ Pipeline: register("Pipeline", buildPipelineSchema(config)),
1641
+ Lead: register("Lead", buildLeadSchema(config)),
1642
+ Opportunity: register("Opportunity", buildOpportunitySchema(config)),
1643
+ Contact: register("Contact", buildContactSchema(config)),
1644
+ Account: register("Account", buildAccountSchema(config)),
1645
+ Campaign: register("Campaign", buildCampaignSchema(config)),
1646
+ Subscription: register("EmailSubscription", buildSubscriptionSchema(config)),
1647
+ Activity: register("Activity", buildActivitySchema(config)),
1648
+ Note: register("Note", buildNoteSchema(config)),
1649
+ LossReason: register("LossReason", buildLossReasonSchema(config)),
1650
+ EmailEvent: register("EmailEvent", buildEmailEventSchema(config)),
1651
+ Conversation: register("Conversation", buildConversationSchema(config)),
1652
+ Message: register("Message", buildMessageSchema(config))
1653
+ });
1654
+ }
1362
1655
  //#endregion
1363
1656
  //#region src/events/event-constants.ts
1364
1657
  /** PACKAGE_RULES rule 12: `package:resource.verb`. */
@@ -1399,7 +1692,26 @@ const CRM_EVENTS = {
1399
1692
  EMAIL_EVENT_CLICKED: "crm:email_event.clicked",
1400
1693
  EMAIL_EVENT_BOUNCED: "crm:email_event.bounced",
1401
1694
  EMAIL_EVENT_COMPLAINED: "crm:email_event.complained",
1402
- EMAIL_EVENT_UNSUBSCRIBED: "crm:email_event.unsubscribed"
1695
+ EMAIL_EVENT_UNSUBSCRIBED: "crm:email_event.unsubscribed",
1696
+ LEAD_ENRICHED: "crm:lead.enriched",
1697
+ LEAD_ASSIGNED: "crm:lead.assigned",
1698
+ CONTACT_ENRICHED: "crm:contact.enriched",
1699
+ CONTACT_ASSIGNED: "crm:contact.assigned",
1700
+ CONTACT_MERGED: "crm:contact.merged",
1701
+ ACCOUNT_ENRICHED: "crm:account.enriched",
1702
+ ACCOUNT_ASSIGNED: "crm:account.assigned",
1703
+ ACCOUNT_MERGED: "crm:account.merged",
1704
+ OPPORTUNITY_ASSIGNED: "crm:opportunity.assigned",
1705
+ OPPORTUNITY_AMOUNT_CHANGED: "crm:opportunity.amount_changed",
1706
+ OPPORTUNITY_EXTENDED: "crm:opportunity.extended",
1707
+ ACTIVITY_RESCHEDULED: "crm:activity.rescheduled",
1708
+ ACTIVITY_OUTCOME_RECORDED: "crm:activity.outcome_recorded",
1709
+ CONVERSATION_CREATED: "crm:conversation.created",
1710
+ CONVERSATION_ASSIGNED: "crm:conversation.assigned",
1711
+ CONVERSATION_RESOLVED: "crm:conversation.resolved",
1712
+ CONVERSATION_REOPENED: "crm:conversation.reopened",
1713
+ MESSAGE_RECEIVED: "crm:message.received",
1714
+ MESSAGE_SENT: "crm:message.sent"
1403
1715
  };
1404
1716
  //#endregion
1405
1717
  //#region src/events/helpers.ts
@@ -1582,7 +1894,6 @@ var LeadRepository = class extends Repository {
1582
1894
  const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || input.email || input.phone);
1583
1895
  const draft = {
1584
1896
  ...input,
1585
- fullName: computedFullName,
1586
1897
  status: input.status ?? "new",
1587
1898
  score: 0,
1588
1899
  statusHistory: [{
@@ -1590,7 +1901,14 @@ var LeadRepository = class extends Repository {
1590
1901
  occurredAt: now
1591
1902
  }]
1592
1903
  };
1593
- const created = await this.create(draft, ctx);
1904
+ if (computedFullName !== void 0) draft.fullName = computedFullName;
1905
+ let created;
1906
+ try {
1907
+ created = await this.create(draft, ctx);
1908
+ } catch (err) {
1909
+ if (this.rejectDuplicates && input.email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email: input.email });
1910
+ throw err;
1911
+ }
1594
1912
  const score = this.scorer(created);
1595
1913
  let result = created;
1596
1914
  if (score !== 0) {
@@ -1629,6 +1947,50 @@ var LeadRepository = class extends Repository {
1629
1947
  return this._transition(id, "nurturing", extras, ctx);
1630
1948
  }
1631
1949
  /**
1950
+ * Merge enrichment payload (3rd-party provider response or manual
1951
+ * patch) into the lead doc, then re-score. Emits `crm:lead.enriched`
1952
+ * with the field set written so subscribers can fan out (e.g. push a
1953
+ * `recompute_segment` job) without re-reading the doc.
1954
+ *
1955
+ * The `provider` tag flows into the event for attribution; the package
1956
+ * is provider-agnostic so the actual enrichment call lives at the host.
1957
+ */
1958
+ async enrich(id, patch, extras = {}, ctx) {
1959
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Lead", id);
1960
+ if (!await this.update(id, patch, ctx)) throw new NotFoundError("Lead", id);
1961
+ const fields = Object.keys(patch);
1962
+ await this.events.publish(createEvent(CRM_EVENTS.LEAD_ENRICHED, {
1963
+ leadId: id,
1964
+ fields,
1965
+ ...extras.provider !== void 0 ? { provider: extras.provider } : {}
1966
+ }, ctx));
1967
+ return this.recomputeScore(id, ctx);
1968
+ }
1969
+ /**
1970
+ * Assign / re-assign the lead's owner. Emits `crm:lead.assigned` with
1971
+ * the previous owner so audit consumers can compute hand-off chains.
1972
+ */
1973
+ async assign(id, ownerId, ctx) {
1974
+ const before = await this.findOneAndUpdate({
1975
+ _id: id,
1976
+ ownerId: { $ne: ownerId }
1977
+ }, { $set: { ownerId } }, {
1978
+ ...ctx,
1979
+ returnDocument: "before"
1980
+ });
1981
+ if (!before) {
1982
+ const current = await this.getById(id, ctx);
1983
+ if (!current) throw new NotFoundError("Lead", id);
1984
+ return current;
1985
+ }
1986
+ await this.events.publish(createEvent(CRM_EVENTS.LEAD_ASSIGNED, {
1987
+ leadId: id,
1988
+ ownerId,
1989
+ ...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
1990
+ }, ctx));
1991
+ return await this.getById(id, ctx) ?? before;
1992
+ }
1993
+ /**
1632
1994
  * Recompute + persist the lead's score. Called from `createLead`
1633
1995
  * automatically; hosts also call this after enrichment writes.
1634
1996
  */
@@ -1676,6 +2038,29 @@ var LeadRepository = class extends Repository {
1676
2038
  }, ctx));
1677
2039
  return updated;
1678
2040
  }
2041
+ /**
2042
+ * Count leads created on or after `since`. Used by dashboard KPI tiles.
2043
+ * Goes through the inherited tenant-scoped `count()` — the plugin
2044
+ * injects `organizationId` (never a manual `if (ctx)` filter, which
2045
+ * silently reads ALL tenants when ctx is absent).
2046
+ */
2047
+ async countCreatedSince(since, ctx) {
2048
+ return this.count({ createdAt: { $gte: since } }, ctx);
2049
+ }
2050
+ /** Latest `limit` leads — lightweight projection for the activity feed. */
2051
+ async findRecentForFeed(limit, ctx) {
2052
+ return this.findAll({}, {
2053
+ ...ctx,
2054
+ sort: { updatedAt: -1 },
2055
+ limit,
2056
+ select: {
2057
+ _id: 1,
2058
+ fullName: 1,
2059
+ status: 1,
2060
+ updatedAt: 1
2061
+ }
2062
+ });
2063
+ }
1679
2064
  };
1680
2065
  //#endregion
1681
2066
  //#region src/repositories/opportunity.repository.ts
@@ -1799,6 +2184,83 @@ var OpportunityRepository = class extends Repository {
1799
2184
  }, ctx));
1800
2185
  return updated;
1801
2186
  }
2187
+ /**
2188
+ * Re-assign opportunity owner. Mutates `ownerId` only; no status
2189
+ * transition. Emits `crm:opportunity.assigned` with the previous owner.
2190
+ */
2191
+ async assign(id, ownerId, ctx) {
2192
+ const before = await this.findOneAndUpdate({
2193
+ _id: id,
2194
+ ownerId: { $ne: ownerId }
2195
+ }, { $set: { ownerId } }, {
2196
+ ...ctx,
2197
+ returnDocument: "before"
2198
+ });
2199
+ if (!before) {
2200
+ const current = await this.getById(id, ctx);
2201
+ if (!current) throw new NotFoundError("Opportunity", id);
2202
+ return current;
2203
+ }
2204
+ await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_ASSIGNED, {
2205
+ opportunityId: id,
2206
+ ownerId,
2207
+ ...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
2208
+ }, ctx));
2209
+ return await this.getById(id, ctx) ?? before;
2210
+ }
2211
+ /**
2212
+ * Adjust the deal amount on an open opportunity. Rejected after
2213
+ * win/lose/abandon — closed-deal amounts are immutable history.
2214
+ * Emits `crm:opportunity.amount_changed` with the delta so forecast
2215
+ * subscribers can re-bucket.
2216
+ */
2217
+ async updateAmount(id, amount, extras = {}, ctx) {
2218
+ const existing = await this.getById(id, ctx);
2219
+ if (!existing) throw new NotFoundError("Opportunity", id);
2220
+ if (existing.status !== "open") throw new InvalidOpportunityTransitionError({
2221
+ from: existing.status,
2222
+ to: existing.status,
2223
+ opportunityId: id
2224
+ });
2225
+ if (amount === existing.amount) return existing;
2226
+ const updated = await this.update(id, { amount }, ctx);
2227
+ if (!updated) throw new NotFoundError("Opportunity", id);
2228
+ await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_AMOUNT_CHANGED, {
2229
+ opportunityId: id,
2230
+ amount,
2231
+ ...existing.amount !== void 0 ? { previousAmount: existing.amount } : {},
2232
+ ...extras.reason !== void 0 ? { reason: extras.reason } : {},
2233
+ ...extras.by !== void 0 ? { by: extras.by } : {}
2234
+ }, ctx));
2235
+ return updated;
2236
+ }
2237
+ /**
2238
+ * Push `expectedCloseAt` further out. Only valid on open opps —
2239
+ * extending a closed deal is meaningless. The new date must be later
2240
+ * than the current one; pulling the date in is silently accepted
2241
+ * (no event is meaningful there).
2242
+ */
2243
+ async extendCloseDate(id, newDate, extras = {}, ctx) {
2244
+ const existing = await this.getById(id, ctx);
2245
+ if (!existing) throw new NotFoundError("Opportunity", id);
2246
+ if (existing.status !== "open") throw new InvalidOpportunityTransitionError({
2247
+ from: existing.status,
2248
+ to: existing.status,
2249
+ opportunityId: id
2250
+ });
2251
+ const previous = existing.expectedCloseAt;
2252
+ if (previous && newDate <= previous) return existing;
2253
+ const updated = await this.update(id, { expectedCloseAt: newDate }, ctx);
2254
+ if (!updated) throw new NotFoundError("Opportunity", id);
2255
+ await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_EXTENDED, {
2256
+ opportunityId: id,
2257
+ expectedCloseAt: newDate.toISOString(),
2258
+ ...previous ? { previousExpectedCloseAt: previous.toISOString() } : {},
2259
+ ...extras.reason !== void 0 ? { reason: extras.reason } : {},
2260
+ ...extras.by !== void 0 ? { by: extras.by } : {}
2261
+ }, ctx));
2262
+ return updated;
2263
+ }
1802
2264
  async abandon(id, by, note, ctx) {
1803
2265
  const updated = await this._closeStatus(id, "abandoned", {
1804
2266
  closedAt: /* @__PURE__ */ new Date(),
@@ -1837,6 +2299,58 @@ var OpportunityRepository = class extends Repository {
1837
2299
  });
1838
2300
  return updated;
1839
2301
  }
2302
+ /** Count opportunities with status `open`. Used by dashboard KPI tiles. */
2303
+ async countOpen(ctx) {
2304
+ return this.count({ status: "open" }, ctx);
2305
+ }
2306
+ /**
2307
+ * Sum `amount` on won opportunities updated within `since`.
2308
+ * Goes through the tenant-scoped `findAll()` — the multiTenant plugin
2309
+ * injects `organizationId`, so a missing ctx can't leak other tenants'
2310
+ * revenue (a manual `if (ctx)` filter would).
2311
+ */
2312
+ async sumWonSince(since, ctx) {
2313
+ const rows = await this.findAll({
2314
+ status: "won",
2315
+ updatedAt: { $gte: since }
2316
+ }, {
2317
+ ...ctx,
2318
+ select: {
2319
+ amount: 1,
2320
+ currency: 1
2321
+ }
2322
+ });
2323
+ if (rows.length === 0) return null;
2324
+ const byCurrency = /* @__PURE__ */ new Map();
2325
+ for (const r of rows) {
2326
+ const cur = r.currency ?? "UNKNOWN";
2327
+ byCurrency.set(cur, (byCurrency.get(cur) ?? 0) + (r.amount ?? 0));
2328
+ }
2329
+ let topCurrency = "";
2330
+ let topTotal = -Infinity;
2331
+ for (const [cur, total] of byCurrency) if (total > topTotal) {
2332
+ topTotal = total;
2333
+ topCurrency = cur;
2334
+ }
2335
+ return {
2336
+ total: topTotal,
2337
+ currency: topCurrency
2338
+ };
2339
+ }
2340
+ /** Latest `limit` opportunities — lightweight projection for the activity feed. */
2341
+ async findRecentForFeed(limit, ctx) {
2342
+ return this.findAll({}, {
2343
+ ...ctx,
2344
+ sort: { updatedAt: -1 },
2345
+ limit,
2346
+ select: {
2347
+ _id: 1,
2348
+ name: 1,
2349
+ status: 1,
2350
+ updatedAt: 1
2351
+ }
2352
+ });
2353
+ }
1840
2354
  };
1841
2355
  //#endregion
1842
2356
  //#region src/repositories/contact.repository.ts
@@ -1858,7 +2372,108 @@ var ContactRepository = class extends Repository {
1858
2372
  }, ctx));
1859
2373
  return created;
1860
2374
  }
2375
+ /**
2376
+ * Merge enrichment payload into the contact. The `patch` is applied
2377
+ * verbatim; deep-merge of `contact.*` channels is the caller's job
2378
+ * (they own the provider response shape).
2379
+ */
2380
+ async enrich(id, patch, extras = {}, ctx) {
2381
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Contact", id);
2382
+ const updated = await this.update(id, patch, ctx);
2383
+ if (!updated) throw new NotFoundError("Contact", id);
2384
+ await this.events.publish(createEvent(CRM_EVENTS.CONTACT_ENRICHED, {
2385
+ contactId: id,
2386
+ fields: Object.keys(patch),
2387
+ ...extras.provider !== void 0 ? { provider: extras.provider } : {}
2388
+ }, ctx));
2389
+ return updated;
2390
+ }
2391
+ /** Set / re-assign the contact's owner. Atomic; no-op when unchanged. */
2392
+ async assign(id, ownerId, ctx) {
2393
+ const before = await this.findOneAndUpdate({
2394
+ _id: id,
2395
+ ownerId: { $ne: ownerId }
2396
+ }, { $set: { ownerId } }, {
2397
+ ...ctx,
2398
+ returnDocument: "before"
2399
+ });
2400
+ if (!before) {
2401
+ const current = await this.getById(id, ctx);
2402
+ if (!current) throw new NotFoundError("Contact", id);
2403
+ return current;
2404
+ }
2405
+ await this.events.publish(createEvent(CRM_EVENTS.CONTACT_ASSIGNED, {
2406
+ contactId: id,
2407
+ ownerId,
2408
+ ...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
2409
+ }, ctx));
2410
+ return await this.getById(id, ctx) ?? before;
2411
+ }
2412
+ /**
2413
+ * Merge two contacts. Secondary's non-conflicting fields are folded
2414
+ * into primary, tags are unioned, then secondary is deleted.
2415
+ *
2416
+ * Hosts wanting transactional safety can pass `ctx.session` —
2417
+ * mongokit threads it through `update` + `delete`.
2418
+ *
2419
+ * Re-pointing related Activities / Notes / Opportunities is the host's
2420
+ * job (they own those relationships) — subscribe to
2421
+ * `crm:contact.merged` and rewrite `subjectId` / `primaryContactId`
2422
+ * from there.
2423
+ */
2424
+ async merge(input, ctx) {
2425
+ const { primaryId, secondaryId } = input;
2426
+ if (primaryId === secondaryId) throw new Error("[crm] cannot merge a contact with itself");
2427
+ const strategy = input.strategy ?? "preferPrimary";
2428
+ const [primary, secondary] = await Promise.all([this.getById(primaryId, ctx), this.getById(secondaryId, ctx)]);
2429
+ if (!primary) throw new NotFoundError("Contact", primaryId);
2430
+ if (!secondary) throw new NotFoundError("Contact", secondaryId);
2431
+ const winner = strategy === "preferPrimary" ? primary : secondary;
2432
+ const loser = strategy === "preferPrimary" ? secondary : primary;
2433
+ const winnerContact = plainOf(winner.contact);
2434
+ const loserContact = plainOf(loser.contact);
2435
+ const winnerName = plainOf(winner.name);
2436
+ const loserName = plainOf(loser.name);
2437
+ const mergedContactInfo = {
2438
+ ...loserContact,
2439
+ ...stripEmpty(winnerContact)
2440
+ };
2441
+ const patch = {
2442
+ name: {
2443
+ ...loserName,
2444
+ ...stripEmpty(winnerName)
2445
+ },
2446
+ contact: mergedContactInfo,
2447
+ tags: Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
2448
+ ...winner.jobTitle !== void 0 ? { jobTitle: winner.jobTitle } : loser.jobTitle !== void 0 ? { jobTitle: loser.jobTitle } : {},
2449
+ ...winner.accountId !== void 0 ? { accountId: winner.accountId } : loser.accountId !== void 0 ? { accountId: loser.accountId } : {},
2450
+ ...winner.ownerId !== void 0 ? { ownerId: winner.ownerId } : loser.ownerId !== void 0 ? { ownerId: loser.ownerId } : {}
2451
+ };
2452
+ const updated = await this.update(primaryId, patch, ctx);
2453
+ if (!updated) throw new NotFoundError("Contact", primaryId);
2454
+ await this.delete(secondaryId, ctx);
2455
+ await this.events.publish(createEvent(CRM_EVENTS.CONTACT_MERGED, {
2456
+ primaryId,
2457
+ secondaryId,
2458
+ strategy
2459
+ }, ctx));
2460
+ return updated;
2461
+ }
1861
2462
  };
2463
+ function stripEmpty(obj) {
2464
+ const out = {};
2465
+ for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null && v !== "") out[k] = v;
2466
+ return out;
2467
+ }
2468
+ /**
2469
+ * Convert a Mongoose subdocument (or plain object) into a plain JS object.
2470
+ * Spreading a Mongoose subdoc directly leaks `$__parent` / `_doc` etc.
2471
+ */
2472
+ function plainOf(value) {
2473
+ if (value === null || value === void 0) return {};
2474
+ if (typeof value.toObject === "function") return value.toObject();
2475
+ return value;
2476
+ }
1862
2477
  //#endregion
1863
2478
  //#region src/repositories/account.repository.ts
1864
2479
  var AccountRepository = class extends Repository {
@@ -1875,7 +2490,72 @@ var AccountRepository = class extends Repository {
1875
2490
  }, ctx));
1876
2491
  return created;
1877
2492
  }
2493
+ async enrich(id, patch, extras = {}, ctx) {
2494
+ if (!await this.getById(id, ctx)) throw new NotFoundError("Account", id);
2495
+ const updated = await this.update(id, patch, ctx);
2496
+ if (!updated) throw new NotFoundError("Account", id);
2497
+ await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_ENRICHED, {
2498
+ accountId: id,
2499
+ fields: Object.keys(patch),
2500
+ ...extras.provider !== void 0 ? { provider: extras.provider } : {}
2501
+ }, ctx));
2502
+ return updated;
2503
+ }
2504
+ async assign(id, ownerId, ctx) {
2505
+ const before = await this.findOneAndUpdate({
2506
+ _id: id,
2507
+ ownerId: { $ne: ownerId }
2508
+ }, { $set: { ownerId } }, {
2509
+ ...ctx,
2510
+ returnDocument: "before"
2511
+ });
2512
+ if (!before) {
2513
+ const current = await this.getById(id, ctx);
2514
+ if (!current) throw new NotFoundError("Account", id);
2515
+ return current;
2516
+ }
2517
+ await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_ASSIGNED, {
2518
+ accountId: id,
2519
+ ownerId,
2520
+ ...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
2521
+ }, ctx));
2522
+ return await this.getById(id, ctx) ?? before;
2523
+ }
2524
+ async merge(input, ctx) {
2525
+ const { primaryId, secondaryId } = input;
2526
+ if (primaryId === secondaryId) throw new Error("[crm] cannot merge an account with itself");
2527
+ const strategy = input.strategy ?? "preferPrimary";
2528
+ const [primary, secondary] = await Promise.all([this.getById(primaryId, ctx), this.getById(secondaryId, ctx)]);
2529
+ if (!primary) throw new NotFoundError("Account", primaryId);
2530
+ if (!secondary) throw new NotFoundError("Account", secondaryId);
2531
+ const winner = strategy === "preferPrimary" ? primary : secondary;
2532
+ const loser = strategy === "preferPrimary" ? secondary : primary;
2533
+ const mergedTags = Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
2534
+ const patch = {
2535
+ name: winner.name || loser.name,
2536
+ tags: mergedTags,
2537
+ ...firstDefined("domain", winner.domain, loser.domain),
2538
+ ...firstDefined("industry", winner.industry, loser.industry),
2539
+ ...firstDefined("sizeBucket", winner.sizeBucket, loser.sizeBucket),
2540
+ ...firstDefined("annualRevenueAmount", winner.annualRevenueAmount, loser.annualRevenueAmount),
2541
+ ...firstDefined("ownerId", winner.ownerId, loser.ownerId)
2542
+ };
2543
+ const updated = await this.update(primaryId, patch, ctx);
2544
+ if (!updated) throw new NotFoundError("Account", primaryId);
2545
+ await this.delete(secondaryId, ctx);
2546
+ await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_MERGED, {
2547
+ primaryId,
2548
+ secondaryId,
2549
+ strategy
2550
+ }, ctx));
2551
+ return updated;
2552
+ }
1878
2553
  };
2554
+ function firstDefined(key, a, b) {
2555
+ if (a !== void 0 && a !== null) return { [key]: a };
2556
+ if (b !== void 0 && b !== null) return { [key]: b };
2557
+ return {};
2558
+ }
1879
2559
  //#endregion
1880
2560
  //#region src/repositories/campaign.repository.ts
1881
2561
  var CampaignRepository = class extends Repository {
@@ -1900,7 +2580,13 @@ var CampaignRepository = class extends Repository {
1900
2580
  occurredAt: now
1901
2581
  }]
1902
2582
  };
1903
- const created = await this.create(draft, ctx);
2583
+ let created;
2584
+ try {
2585
+ created = await this.create(draft, ctx);
2586
+ } catch (err) {
2587
+ if (this.rejectDuplicates && input.name && isDuplicateKeyError(err)) throw new DuplicateCampaignError(input.name);
2588
+ throw err;
2589
+ }
1904
2590
  await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_CREATED, {
1905
2591
  campaignId: String(created._id),
1906
2592
  name: created.name
@@ -2020,7 +2706,16 @@ var SubscriptionRepository = class extends Repository {
2020
2706
  ...input.consentUserAgent !== void 0 ? { consentUserAgent: input.consentUserAgent } : {},
2021
2707
  ...input.metadata !== void 0 ? { metadata: input.metadata } : {}
2022
2708
  };
2023
- const sub = await this.create(draft, ctx);
2709
+ let sub;
2710
+ try {
2711
+ sub = await this.create(draft, ctx);
2712
+ } catch (err) {
2713
+ if (isDuplicateKeyError(err)) throw new DuplicateSubscriptionError({
2714
+ contactId: input.contactId,
2715
+ campaignId: input.campaignId
2716
+ });
2717
+ throw err;
2718
+ }
2024
2719
  await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_CREATED, {
2025
2720
  subscriptionId: String(sub._id),
2026
2721
  contactId: sub.contactId,
@@ -2164,6 +2859,55 @@ var ActivityRepository = class extends Repository {
2164
2859
  }, ctx));
2165
2860
  return updated;
2166
2861
  }
2862
+ /**
2863
+ * Push an activity's `dueDate`. Allowed on planned / in_progress only;
2864
+ * rescheduling a completed or cancelled activity makes no sense.
2865
+ * Emits `crm:activity.rescheduled` with the previous date for audit.
2866
+ */
2867
+ async reschedule(id, newDueDate, extras = {}, ctx) {
2868
+ const existing = await this.getById(id, ctx);
2869
+ if (!existing) throw new NotFoundError("Activity", id);
2870
+ if (existing.status !== "planned" && existing.status !== "in_progress") throw new InvalidActivityTransitionError({
2871
+ from: existing.status,
2872
+ to: existing.status,
2873
+ activityId: id
2874
+ });
2875
+ const previous = existing.dueDate;
2876
+ const updated = await this.update(id, { dueDate: newDueDate }, ctx);
2877
+ if (!updated) throw new NotFoundError("Activity", id);
2878
+ await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_RESCHEDULED, {
2879
+ activityId: id,
2880
+ dueDate: newDueDate.toISOString(),
2881
+ ...previous ? { previousDueDate: previous.toISOString() } : {},
2882
+ ...extras.reason !== void 0 ? { reason: extras.reason } : {},
2883
+ ...extras.by !== void 0 ? { by: extras.by } : {}
2884
+ }, ctx));
2885
+ return updated;
2886
+ }
2887
+ /**
2888
+ * Record the outcome of an activity (call result, email engagement,
2889
+ * meeting confirmation). Stored under `metadata.outcome` so it stays
2890
+ * polymorphic — the shape is the channel's concern, not this package's.
2891
+ *
2892
+ * Does NOT transition status — call `complete()` separately when the
2893
+ * outcome implies completion. This split lets agents log partial
2894
+ * outcomes (e.g. "left voicemail") without forcing terminal state.
2895
+ */
2896
+ async recordOutcome(id, outcome, ctx) {
2897
+ const existing = await this.getById(id, ctx);
2898
+ if (!existing) throw new NotFoundError("Activity", id);
2899
+ const mergedMetadata = {
2900
+ ...existing.metadata ?? {},
2901
+ outcome
2902
+ };
2903
+ const updated = await this.update(id, { metadata: mergedMetadata }, ctx);
2904
+ if (!updated) throw new NotFoundError("Activity", id);
2905
+ await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_OUTCOME_RECORDED, {
2906
+ activityId: id,
2907
+ outcome
2908
+ }, ctx));
2909
+ return updated;
2910
+ }
2167
2911
  async _transition(id, target, patch, ctx) {
2168
2912
  const existing = await this.getById(id, ctx);
2169
2913
  if (!existing) throw new NotFoundError("Activity", id);
@@ -2181,6 +2925,29 @@ var ActivityRepository = class extends Repository {
2181
2925
  });
2182
2926
  return updated;
2183
2927
  }
2928
+ /** Count `planned` activities with `dueDate` before `asOf`. Dashboard KPI. */
2929
+ async countOverduePlanned(asOf, ctx) {
2930
+ return this.count({
2931
+ status: "planned",
2932
+ dueDate: { $lt: asOf }
2933
+ }, ctx);
2934
+ }
2935
+ /** Latest `limit` activities — lightweight projection for the activity feed. */
2936
+ async findRecentForFeed(limit, ctx) {
2937
+ return this.findAll({}, {
2938
+ ...ctx,
2939
+ sort: { updatedAt: -1 },
2940
+ limit,
2941
+ select: {
2942
+ _id: 1,
2943
+ title: 1,
2944
+ status: 1,
2945
+ updatedAt: 1,
2946
+ subjectKind: 1,
2947
+ subjectId: 1
2948
+ }
2949
+ });
2950
+ }
2184
2951
  };
2185
2952
  //#endregion
2186
2953
  //#region src/repositories/note.repository.ts
@@ -2269,6 +3036,161 @@ var EmailEventRepository = class extends Repository {
2269
3036
  }
2270
3037
  };
2271
3038
  //#endregion
3039
+ //#region src/repositories/conversation.repository.ts
3040
+ var ConversationRepository = class extends Repository {
3041
+ events;
3042
+ constructor(model, plugins, deps, pagination) {
3043
+ super(model, plugins, pagination);
3044
+ this.events = deps.events;
3045
+ }
3046
+ async openConversation(input, ctx) {
3047
+ const draft = {
3048
+ channel: input.channel,
3049
+ participantKind: input.participantKind,
3050
+ participantId: input.participantId,
3051
+ status: "open",
3052
+ unreadCount: 0,
3053
+ ...input.channelUserId !== void 0 ? { channelUserId: input.channelUserId } : {},
3054
+ ...input.assigneeId !== void 0 ? { assigneeId: input.assigneeId } : {},
3055
+ ...input.metadata !== void 0 ? { metadata: input.metadata } : {}
3056
+ };
3057
+ const created = await this.create(draft, ctx);
3058
+ await this.events.publish(createEvent(CRM_EVENTS.CONVERSATION_CREATED, {
3059
+ conversationId: String(created._id),
3060
+ channel: created.channel,
3061
+ participantKind: created.participantKind,
3062
+ participantId: String(created.participantId)
3063
+ }, ctx));
3064
+ return created;
3065
+ }
3066
+ async assign(id, assigneeId, ctx) {
3067
+ const before = await this.findOneAndUpdate({
3068
+ _id: id,
3069
+ assigneeId: { $ne: assigneeId }
3070
+ }, { $set: { assigneeId } }, {
3071
+ ...ctx,
3072
+ returnDocument: "before"
3073
+ });
3074
+ if (!before) {
3075
+ const current = await this.getById(id, ctx);
3076
+ if (!current) throw new NotFoundError("Conversation", id);
3077
+ return current;
3078
+ }
3079
+ await this.events.publish(createEvent(CRM_EVENTS.CONVERSATION_ASSIGNED, {
3080
+ conversationId: id,
3081
+ assigneeId,
3082
+ ...before.assigneeId !== void 0 ? { previousAssigneeId: before.assigneeId } : {}
3083
+ }, ctx));
3084
+ return await this.getById(id, ctx) ?? before;
3085
+ }
3086
+ async resolve(id, ctx) {
3087
+ return this._setStatus(id, "resolved", CRM_EVENTS.CONVERSATION_RESOLVED, ctx);
3088
+ }
3089
+ async reopen(id, ctx) {
3090
+ return this._setStatus(id, "open", CRM_EVENTS.CONVERSATION_REOPENED, ctx);
3091
+ }
3092
+ /** Reset unread badge. Pure UI affordance, no event. */
3093
+ async markRead(id, ctx) {
3094
+ const updated = await this.update(id, { unreadCount: 0 }, ctx);
3095
+ if (!updated) throw new NotFoundError("Conversation", id);
3096
+ return updated;
3097
+ }
3098
+ /**
3099
+ * Bump `lastMessageAt` and (for inbound) increment `unreadCount`.
3100
+ * Called by `MessageRepository` when a message is recorded — not part
3101
+ * of the public surface. Returns the conversation for the caller's
3102
+ * follow-up writes.
3103
+ */
3104
+ async touch(id, args, ctx) {
3105
+ const existing = await this.getById(id, ctx);
3106
+ if (!existing) return null;
3107
+ const patch = { lastMessageAt: args.sentAt };
3108
+ if (args.direction === "inbound") patch.unreadCount = (existing.unreadCount ?? 0) + 1;
3109
+ return await this.update(id, patch, ctx) ?? null;
3110
+ }
3111
+ async _setStatus(id, next, eventType, ctx) {
3112
+ const existing = await this.getById(id, ctx);
3113
+ if (!existing) throw new NotFoundError("Conversation", id);
3114
+ if (existing.status === next) return existing;
3115
+ const updated = await this.update(id, { status: next }, ctx);
3116
+ if (!updated) throw new NotFoundError("Conversation", id);
3117
+ await this.events.publish(createEvent(eventType, {
3118
+ conversationId: id,
3119
+ status: next,
3120
+ previousStatus: existing.status
3121
+ }, ctx));
3122
+ return updated;
3123
+ }
3124
+ };
3125
+ //#endregion
3126
+ //#region src/repositories/message.repository.ts
3127
+ var MessageRepository = class extends Repository {
3128
+ events;
3129
+ conversations;
3130
+ constructor(model, plugins, deps, pagination) {
3131
+ super(model, plugins, pagination);
3132
+ this.events = deps.events;
3133
+ this.conversations = deps.conversations;
3134
+ }
3135
+ async recordInbound(input, ctx) {
3136
+ return this._record(input, "inbound", "delivered", CRM_EVENTS.MESSAGE_RECEIVED, ctx);
3137
+ }
3138
+ async recordOutbound(input, ctx) {
3139
+ return this._record(input, "outbound", "sent", CRM_EVENTS.MESSAGE_SENT, ctx);
3140
+ }
3141
+ /** Wire delivery callback (e.g. WhatsApp `status` webhook). */
3142
+ async updateStatus(id, status, ctx) {
3143
+ const existing = await this.getById(id, ctx);
3144
+ if (!existing) throw new NotFoundError("Message", id);
3145
+ if (existing.status === status) return existing;
3146
+ const updated = await this.update(id, { status }, ctx);
3147
+ if (!updated) throw new NotFoundError("Message", id);
3148
+ return updated;
3149
+ }
3150
+ async _record(input, direction, defaultStatus, eventType, ctx) {
3151
+ const sentAt = input.sentAt ?? /* @__PURE__ */ new Date();
3152
+ const draft = {
3153
+ conversationId: input.conversationId,
3154
+ direction,
3155
+ body: input.body,
3156
+ attachments: input.attachments ?? [],
3157
+ status: defaultStatus,
3158
+ sentAt,
3159
+ ...input.externalMessageId !== void 0 ? { externalMessageId: input.externalMessageId } : {},
3160
+ ...input.senderRef !== void 0 ? { senderRef: input.senderRef } : {},
3161
+ ...input.recipientRef !== void 0 ? { recipientRef: input.recipientRef } : {},
3162
+ ...input.metadata !== void 0 ? { metadata: input.metadata } : {}
3163
+ };
3164
+ const created = await this.create(draft, ctx);
3165
+ await this.conversations.touch(input.conversationId, {
3166
+ direction,
3167
+ sentAt
3168
+ }, ctx);
3169
+ await this.events.publish(createEvent(eventType, {
3170
+ messageId: String(created._id),
3171
+ conversationId: input.conversationId,
3172
+ direction,
3173
+ ...input.externalMessageId !== void 0 ? { externalMessageId: input.externalMessageId } : {}
3174
+ }, ctx));
3175
+ return created;
3176
+ }
3177
+ /** Latest `limit` messages — lightweight projection for the activity feed. */
3178
+ async findRecentForFeed(limit, ctx) {
3179
+ return this.findAll({}, {
3180
+ ...ctx,
3181
+ sort: { sentAt: -1 },
3182
+ limit,
3183
+ select: {
3184
+ _id: 1,
3185
+ body: 1,
3186
+ direction: 1,
3187
+ conversationId: 1,
3188
+ sentAt: 1
3189
+ }
3190
+ });
3191
+ }
3192
+ };
3193
+ //#endregion
2272
3194
  //#region src/repositories/create-repositories.ts
2273
3195
  /**
2274
3196
  * Repository factory — composes the shared mongokit plugin stack and
@@ -2306,6 +3228,11 @@ function createCrmRepositories(models, deps) {
2306
3228
  const note = new NoteRepository(models.Note, plugins, { events }, pagination);
2307
3229
  const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
2308
3230
  const emailEvent = new EmailEventRepository(models.EmailEvent, plugins, { events }, pagination);
3231
+ const conversation = new ConversationRepository(models.Conversation, plugins, { events }, pagination);
3232
+ const message = new MessageRepository(models.Message, plugins, {
3233
+ events,
3234
+ conversations: conversation
3235
+ }, pagination);
2309
3236
  return Object.freeze({
2310
3237
  pipeline,
2311
3238
  lead,
@@ -2317,7 +3244,9 @@ function createCrmRepositories(models, deps) {
2317
3244
  activity,
2318
3245
  note,
2319
3246
  lossReason,
2320
- emailEvent
3247
+ emailEvent,
3248
+ conversation,
3249
+ message
2321
3250
  });
2322
3251
  }
2323
3252
  //#endregion
@@ -2358,6 +3287,24 @@ var InProcessCrmBus = class {
2358
3287
  }
2359
3288
  };
2360
3289
  //#endregion
3290
+ //#region src/engine/ensure-ready.ts
3291
+ const REQUIRED_CAPABILITIES = [
3292
+ "upsert",
3293
+ "getOrCreate",
3294
+ "duplicateKeyError"
3295
+ ];
3296
+ function assertCrmCapabilities(probe, options = {}) {
3297
+ const caps = probe.capabilities;
3298
+ if (!caps) throw new Error("[crm] backend declares no `capabilities` descriptor (requires @classytic/repo-core 0.6 / mongokit 3.16+).");
3299
+ const missing = REQUIRED_CAPABILITIES.filter((flag) => caps[flag] !== true);
3300
+ if (missing.length > 0) throw new Error(`[crm] backend missing required capabilities: ${missing.join(", ")}. Lead dedup + conversion depend on them.`);
3301
+ if (caps.transactions !== true) {
3302
+ const msg = "[crm] backend does not support transactions — LeadService.convert() cannot run atomically across Account/Contact/Opportunity. Run MongoDB as a replica set, or pass { allowNonTransactional: true } if convert() is never called with a session.";
3303
+ if (options.allowNonTransactional) options.logger?.warn?.(msg);
3304
+ else throw new Error(msg);
3305
+ }
3306
+ }
3307
+ //#endregion
2361
3308
  //#region src/engine/create-crm.ts
2362
3309
  async function createCrm(config) {
2363
3310
  if (!config.connection) throw new Error("[crm] CrmConfig.connection is required");
@@ -2365,6 +3312,8 @@ async function createCrm(config) {
2365
3312
  connection: config.connection,
2366
3313
  tenantFieldType: config.tenantFieldType ?? "objectId",
2367
3314
  multiTenant: config.multiTenant ?? true,
3315
+ forceRecreate: config.forceRecreate ?? false,
3316
+ autoIndex: config.autoIndex ?? true,
2368
3317
  ...config.logger !== void 0 ? { logger: config.logger } : {},
2369
3318
  ...config.pagination !== void 0 ? { pagination: config.pagination } : {},
2370
3319
  plugins: config.plugins ?? []
@@ -2375,11 +3324,18 @@ async function createCrm(config) {
2375
3324
  events,
2376
3325
  config: resolved
2377
3326
  });
3327
+ assertCrmCapabilities(repositories.lead, {
3328
+ ...config.allowNonTransactional !== void 0 ? { allowNonTransactional: config.allowNonTransactional } : {},
3329
+ ...config.logger?.warn !== void 0 ? { logger: { warn: (m) => config.logger?.warn?.(m) } } : {}
3330
+ });
2378
3331
  return Object.freeze({
2379
3332
  models,
2380
3333
  repositories,
2381
3334
  events,
2382
3335
  config: Object.freeze(resolved),
3336
+ async syncIndexes() {
3337
+ await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
3338
+ },
2383
3339
  async dispose() {
2384
3340
  await events.close?.();
2385
3341
  }
@@ -2561,6 +3517,602 @@ function splitLeadName(lead) {
2561
3517
  };
2562
3518
  }
2563
3519
  //#endregion
3520
+ //#region src/events/event-catalog.ts
3521
+ /**
3522
+ * Zod-source event catalog (PACKAGE_RULES rule 18).
3523
+ *
3524
+ * One Zod schema per event is the source of truth — runtime validation,
3525
+ * static types, and Arc introspection all derive from it. Hosts wire the
3526
+ * definitions into arc's `EventRegistry`:
3527
+ *
3528
+ * ```ts
3529
+ * for (const def of crmEventDefinitions) registry.register(def);
3530
+ * ```
3531
+ *
3532
+ * which lights up publish-time validation (`eventPlugin({ registry,
3533
+ * validateMode: 'reject' })`), OpenAPI event introspection, and
3534
+ * `arc describe` output — with zero arc imports here (the definition shape
3535
+ * is structurally identical to arc's `EventDefinitionOutput`).
3536
+ *
3537
+ * Timestamps in payloads are `z.iso.datetime()` (NEVER `z.coerce.date()`):
3538
+ * `z.toJSONSchema()` throws on coerce.date, and events are ISO strings on
3539
+ * the wire (rule 25).
3540
+ */
3541
+ function defineCrmEvent(input) {
3542
+ const { name, resource, description, version = 1, zodSchema } = input;
3543
+ return {
3544
+ name,
3545
+ version,
3546
+ description,
3547
+ zodSchema,
3548
+ schema: z.toJSONSchema(zodSchema),
3549
+ create(payload, meta) {
3550
+ return createEvent$1(name, payload, {
3551
+ resource,
3552
+ source: "crm",
3553
+ ...meta
3554
+ });
3555
+ }
3556
+ };
3557
+ }
3558
+ const ownerChangeSchema = z.object({
3559
+ ownerId: z.string(),
3560
+ previousOwnerId: z.string().optional()
3561
+ });
3562
+ const enrichSchema = z.object({
3563
+ fields: z.array(z.string()),
3564
+ provider: z.string().optional()
3565
+ });
3566
+ const mergeSchema = z.object({
3567
+ primaryId: z.string(),
3568
+ secondaryId: z.string(),
3569
+ strategy: z.enum(["preferPrimary", "preferSecondary"])
3570
+ });
3571
+ const stageRefSchema = z.object({
3572
+ stageId: z.string(),
3573
+ sequence: z.number()
3574
+ });
3575
+ const LeadCreated = defineCrmEvent({
3576
+ name: CRM_EVENTS.LEAD_CREATED,
3577
+ resource: "lead",
3578
+ description: "createLead() inserted a scored lead.",
3579
+ zodSchema: z.object({
3580
+ leadId: z.string(),
3581
+ email: z.string().optional(),
3582
+ score: z.number()
3583
+ })
3584
+ });
3585
+ const LeadStatusChanged = defineCrmEvent({
3586
+ name: CRM_EVENTS.LEAD_STATUS_CHANGED,
3587
+ resource: "lead",
3588
+ description: "A lead lifecycle transition committed (qualify/disqualify/nurture/...).",
3589
+ zodSchema: z.object({
3590
+ leadId: z.string(),
3591
+ from: z.string(),
3592
+ to: z.string()
3593
+ })
3594
+ });
3595
+ const LeadScored = defineCrmEvent({
3596
+ name: CRM_EVENTS.LEAD_SCORED,
3597
+ resource: "lead",
3598
+ description: "A lead score was (re)computed and persisted.",
3599
+ zodSchema: z.object({
3600
+ leadId: z.string(),
3601
+ score: z.number()
3602
+ })
3603
+ });
3604
+ const LeadConverted = defineCrmEvent({
3605
+ name: CRM_EVENTS.LEAD_CONVERTED,
3606
+ resource: "lead",
3607
+ description: "convert() spawned Contact (+ Account) + Opportunity and closed the lead.",
3608
+ zodSchema: z.object({
3609
+ leadId: z.string(),
3610
+ contactId: z.string(),
3611
+ accountId: z.string().optional(),
3612
+ opportunityId: z.string()
3613
+ })
3614
+ });
3615
+ const LeadDisqualified = defineCrmEvent({
3616
+ name: CRM_EVENTS.LEAD_DISQUALIFIED,
3617
+ resource: "lead",
3618
+ description: "disqualify() rejected a lead with a reason.",
3619
+ zodSchema: z.object({
3620
+ leadId: z.string(),
3621
+ reason: z.string()
3622
+ })
3623
+ });
3624
+ const LeadEnriched = defineCrmEvent({
3625
+ name: CRM_EVENTS.LEAD_ENRICHED,
3626
+ resource: "lead",
3627
+ description: "enrich() merged a provider/manual patch into the lead.",
3628
+ zodSchema: z.object({ leadId: z.string() }).extend(enrichSchema.shape)
3629
+ });
3630
+ const LeadAssigned = defineCrmEvent({
3631
+ name: CRM_EVENTS.LEAD_ASSIGNED,
3632
+ resource: "lead",
3633
+ description: "assign() (re)assigned the lead owner.",
3634
+ zodSchema: z.object({ leadId: z.string() }).extend(ownerChangeSchema.shape)
3635
+ });
3636
+ const AccountCreated = defineCrmEvent({
3637
+ name: CRM_EVENTS.ACCOUNT_CREATED,
3638
+ resource: "account",
3639
+ description: "createAccount() inserted an account.",
3640
+ zodSchema: z.object({
3641
+ accountId: z.string(),
3642
+ name: z.string()
3643
+ })
3644
+ });
3645
+ const AccountUpdated = defineCrmEvent({
3646
+ name: CRM_EVENTS.ACCOUNT_UPDATED,
3647
+ resource: "account",
3648
+ description: "An account record was updated.",
3649
+ zodSchema: z.object({ accountId: z.string() })
3650
+ });
3651
+ const AccountEnriched = defineCrmEvent({
3652
+ name: CRM_EVENTS.ACCOUNT_ENRICHED,
3653
+ resource: "account",
3654
+ description: "enrich() merged a patch into the account.",
3655
+ zodSchema: z.object({ accountId: z.string() }).extend(enrichSchema.shape)
3656
+ });
3657
+ const AccountAssigned = defineCrmEvent({
3658
+ name: CRM_EVENTS.ACCOUNT_ASSIGNED,
3659
+ resource: "account",
3660
+ description: "assign() (re)assigned the account owner.",
3661
+ zodSchema: z.object({ accountId: z.string() }).extend(ownerChangeSchema.shape)
3662
+ });
3663
+ const AccountMerged = defineCrmEvent({
3664
+ name: CRM_EVENTS.ACCOUNT_MERGED,
3665
+ resource: "account",
3666
+ description: "merge() folded a secondary account into the primary; secondary deleted.",
3667
+ zodSchema: mergeSchema
3668
+ });
3669
+ const ContactCreated = defineCrmEvent({
3670
+ name: CRM_EVENTS.CONTACT_CREATED,
3671
+ resource: "contact",
3672
+ description: "createContact() inserted a contact.",
3673
+ zodSchema: z.object({
3674
+ contactId: z.string(),
3675
+ email: z.string().optional()
3676
+ })
3677
+ });
3678
+ const ContactUpdated = defineCrmEvent({
3679
+ name: CRM_EVENTS.CONTACT_UPDATED,
3680
+ resource: "contact",
3681
+ description: "A contact record was updated.",
3682
+ zodSchema: z.object({ contactId: z.string() })
3683
+ });
3684
+ const ContactEnriched = defineCrmEvent({
3685
+ name: CRM_EVENTS.CONTACT_ENRICHED,
3686
+ resource: "contact",
3687
+ description: "enrich() merged a patch into the contact.",
3688
+ zodSchema: z.object({ contactId: z.string() }).extend(enrichSchema.shape)
3689
+ });
3690
+ const ContactAssigned = defineCrmEvent({
3691
+ name: CRM_EVENTS.CONTACT_ASSIGNED,
3692
+ resource: "contact",
3693
+ description: "assign() (re)assigned the contact owner.",
3694
+ zodSchema: z.object({ contactId: z.string() }).extend(ownerChangeSchema.shape)
3695
+ });
3696
+ const ContactMerged = defineCrmEvent({
3697
+ name: CRM_EVENTS.CONTACT_MERGED,
3698
+ resource: "contact",
3699
+ description: "merge() folded a secondary contact into the primary; secondary deleted.",
3700
+ zodSchema: mergeSchema
3701
+ });
3702
+ const OpportunityCreated = defineCrmEvent({
3703
+ name: CRM_EVENTS.OPPORTUNITY_CREATED,
3704
+ resource: "opportunity",
3705
+ description: "openOpportunity() created a deal in a pipeline stage.",
3706
+ zodSchema: z.object({
3707
+ opportunityId: z.string(),
3708
+ pipelineId: z.string(),
3709
+ stageId: z.string()
3710
+ })
3711
+ });
3712
+ const OpportunityStageAdvanced = defineCrmEvent({
3713
+ name: CRM_EVENTS.OPPORTUNITY_STAGE_ADVANCED,
3714
+ resource: "opportunity",
3715
+ description: "moveToStage() moved the deal forward (higher stage sequence).",
3716
+ zodSchema: z.object({
3717
+ opportunityId: z.string(),
3718
+ from: stageRefSchema,
3719
+ to: stageRefSchema
3720
+ })
3721
+ });
3722
+ const OpportunityStageRegressed = defineCrmEvent({
3723
+ name: CRM_EVENTS.OPPORTUNITY_STAGE_REGRESSED,
3724
+ resource: "opportunity",
3725
+ description: "moveToStage() moved the deal backward (lower stage sequence).",
3726
+ zodSchema: z.object({
3727
+ opportunityId: z.string(),
3728
+ from: stageRefSchema,
3729
+ to: stageRefSchema
3730
+ })
3731
+ });
3732
+ const OpportunityWon = defineCrmEvent({
3733
+ name: CRM_EVENTS.OPPORTUNITY_WON,
3734
+ resource: "opportunity",
3735
+ description: "win() closed the deal as won (terminal).",
3736
+ zodSchema: z.object({
3737
+ opportunityId: z.string(),
3738
+ amount: z.number().optional()
3739
+ })
3740
+ });
3741
+ const OpportunityLost = defineCrmEvent({
3742
+ name: CRM_EVENTS.OPPORTUNITY_LOST,
3743
+ resource: "opportunity",
3744
+ description: "lose() closed the deal as lost (terminal).",
3745
+ zodSchema: z.object({
3746
+ opportunityId: z.string(),
3747
+ lostReasonId: z.string().optional()
3748
+ })
3749
+ });
3750
+ const OpportunityAbandoned = defineCrmEvent({
3751
+ name: CRM_EVENTS.OPPORTUNITY_ABANDONED,
3752
+ resource: "opportunity",
3753
+ description: "abandon() closed the deal as abandoned (terminal).",
3754
+ zodSchema: z.object({ opportunityId: z.string() })
3755
+ });
3756
+ const OpportunityAssigned = defineCrmEvent({
3757
+ name: CRM_EVENTS.OPPORTUNITY_ASSIGNED,
3758
+ resource: "opportunity",
3759
+ description: "assign() (re)assigned the opportunity owner.",
3760
+ zodSchema: z.object({ opportunityId: z.string() }).extend(ownerChangeSchema.shape)
3761
+ });
3762
+ const OpportunityAmountChanged = defineCrmEvent({
3763
+ name: CRM_EVENTS.OPPORTUNITY_AMOUNT_CHANGED,
3764
+ resource: "opportunity",
3765
+ description: "updateAmount() changed an open deal value.",
3766
+ zodSchema: z.object({
3767
+ opportunityId: z.string(),
3768
+ amount: z.number(),
3769
+ previousAmount: z.number().optional(),
3770
+ reason: z.string().optional(),
3771
+ by: z.string().optional()
3772
+ })
3773
+ });
3774
+ const OpportunityExtended = defineCrmEvent({
3775
+ name: CRM_EVENTS.OPPORTUNITY_EXTENDED,
3776
+ resource: "opportunity",
3777
+ description: "extendCloseDate() pushed expectedCloseAt further out.",
3778
+ zodSchema: z.object({
3779
+ opportunityId: z.string(),
3780
+ expectedCloseAt: z.iso.datetime(),
3781
+ previousExpectedCloseAt: z.iso.datetime().optional(),
3782
+ reason: z.string().optional(),
3783
+ by: z.string().optional()
3784
+ })
3785
+ });
3786
+ const PipelineCreated = defineCrmEvent({
3787
+ name: CRM_EVENTS.PIPELINE_CREATED,
3788
+ resource: "pipeline",
3789
+ description: "A pipeline was created.",
3790
+ zodSchema: z.object({ pipelineId: z.string() })
3791
+ });
3792
+ const PipelineArchived = defineCrmEvent({
3793
+ name: CRM_EVENTS.PIPELINE_ARCHIVED,
3794
+ resource: "pipeline",
3795
+ description: "archive() soft-deprecated a pipeline.",
3796
+ zodSchema: z.object({ pipelineId: z.string() })
3797
+ });
3798
+ const ActivityPlanned = defineCrmEvent({
3799
+ name: CRM_EVENTS.ACTIVITY_PLANNED,
3800
+ resource: "activity",
3801
+ description: "plan() created a planned activity against a subject.",
3802
+ zodSchema: z.object({
3803
+ activityId: z.string(),
3804
+ type: z.string(),
3805
+ subjectKind: z.string(),
3806
+ subjectId: z.string()
3807
+ })
3808
+ });
3809
+ const ActivityCompleted = defineCrmEvent({
3810
+ name: CRM_EVENTS.ACTIVITY_COMPLETED,
3811
+ resource: "activity",
3812
+ description: "complete() finished an activity.",
3813
+ zodSchema: z.object({
3814
+ activityId: z.string(),
3815
+ completedAt: z.iso.datetime()
3816
+ })
3817
+ });
3818
+ const ActivityCancelled = defineCrmEvent({
3819
+ name: CRM_EVENTS.ACTIVITY_CANCELLED,
3820
+ resource: "activity",
3821
+ description: "cancel() cancelled an activity.",
3822
+ zodSchema: z.object({
3823
+ activityId: z.string(),
3824
+ reason: z.string().optional()
3825
+ })
3826
+ });
3827
+ const ActivityRescheduled = defineCrmEvent({
3828
+ name: CRM_EVENTS.ACTIVITY_RESCHEDULED,
3829
+ resource: "activity",
3830
+ description: "reschedule() moved an activity due date.",
3831
+ zodSchema: z.object({
3832
+ activityId: z.string(),
3833
+ dueDate: z.iso.datetime(),
3834
+ previousDueDate: z.iso.datetime().optional(),
3835
+ reason: z.string().optional(),
3836
+ by: z.string().optional()
3837
+ })
3838
+ });
3839
+ const ActivityOutcomeRecorded = defineCrmEvent({
3840
+ name: CRM_EVENTS.ACTIVITY_OUTCOME_RECORDED,
3841
+ resource: "activity",
3842
+ description: "recordOutcome() logged an activity outcome (no status change).",
3843
+ zodSchema: z.object({
3844
+ activityId: z.string(),
3845
+ outcome: z.record(z.string(), z.unknown())
3846
+ })
3847
+ });
3848
+ const NoteAdded = defineCrmEvent({
3849
+ name: CRM_EVENTS.NOTE_ADDED,
3850
+ resource: "note",
3851
+ description: "A note was appended to a subject timeline.",
3852
+ zodSchema: z.object({
3853
+ noteId: z.string(),
3854
+ subjectKind: z.string(),
3855
+ subjectId: z.string()
3856
+ })
3857
+ });
3858
+ const CampaignCreated = defineCrmEvent({
3859
+ name: CRM_EVENTS.CAMPAIGN_CREATED,
3860
+ resource: "campaign",
3861
+ description: "createCampaign() inserted a campaign.",
3862
+ zodSchema: z.object({
3863
+ campaignId: z.string(),
3864
+ name: z.string()
3865
+ })
3866
+ });
3867
+ const CampaignActivated = defineCrmEvent({
3868
+ name: CRM_EVENTS.CAMPAIGN_ACTIVATED,
3869
+ resource: "campaign",
3870
+ description: "activate() moved a campaign to active.",
3871
+ zodSchema: z.object({ campaignId: z.string() })
3872
+ });
3873
+ const CampaignPaused = defineCrmEvent({
3874
+ name: CRM_EVENTS.CAMPAIGN_PAUSED,
3875
+ resource: "campaign",
3876
+ description: "pause() suspended an active campaign.",
3877
+ zodSchema: z.object({
3878
+ campaignId: z.string(),
3879
+ reason: z.string().optional()
3880
+ })
3881
+ });
3882
+ const CampaignResumed = defineCrmEvent({
3883
+ name: CRM_EVENTS.CAMPAIGN_RESUMED,
3884
+ resource: "campaign",
3885
+ description: "resume() reactivated a paused campaign.",
3886
+ zodSchema: z.object({ campaignId: z.string() })
3887
+ });
3888
+ const CampaignCompleted = defineCrmEvent({
3889
+ name: CRM_EVENTS.CAMPAIGN_COMPLETED,
3890
+ resource: "campaign",
3891
+ description: "complete() closed a campaign.",
3892
+ zodSchema: z.object({
3893
+ campaignId: z.string(),
3894
+ actualRevenueAmount: z.number().optional()
3895
+ })
3896
+ });
3897
+ const CampaignArchived = defineCrmEvent({
3898
+ name: CRM_EVENTS.CAMPAIGN_ARCHIVED,
3899
+ resource: "campaign",
3900
+ description: "archive() archived a campaign (terminal).",
3901
+ zodSchema: z.object({ campaignId: z.string() })
3902
+ });
3903
+ const SubscriptionCreated = defineCrmEvent({
3904
+ name: CRM_EVENTS.SUBSCRIPTION_CREATED,
3905
+ resource: "subscription",
3906
+ description: "subscribe() created an opt-in (pending or confirmed).",
3907
+ zodSchema: z.object({
3908
+ subscriptionId: z.string(),
3909
+ contactId: z.string(),
3910
+ campaignId: z.string(),
3911
+ requiresConfirmation: z.boolean()
3912
+ })
3913
+ });
3914
+ const SubscriptionConfirmed = defineCrmEvent({
3915
+ name: CRM_EVENTS.SUBSCRIPTION_CONFIRMED,
3916
+ resource: "subscription",
3917
+ description: "confirm() validated a double-opt-in token.",
3918
+ zodSchema: z.object({
3919
+ subscriptionId: z.string(),
3920
+ contactId: z.string(),
3921
+ campaignId: z.string(),
3922
+ confirmedAt: z.iso.datetime()
3923
+ })
3924
+ });
3925
+ const SubscriptionUnsubscribed = defineCrmEvent({
3926
+ name: CRM_EVENTS.SUBSCRIPTION_UNSUBSCRIBED,
3927
+ resource: "subscription",
3928
+ description: "unsubscribe() opted a contact out (terminal).",
3929
+ zodSchema: z.object({
3930
+ subscriptionId: z.string(),
3931
+ contactId: z.string(),
3932
+ campaignId: z.string(),
3933
+ reason: z.string().optional()
3934
+ })
3935
+ });
3936
+ const SubscriptionBounced = defineCrmEvent({
3937
+ name: CRM_EVENTS.SUBSCRIPTION_BOUNCED,
3938
+ resource: "subscription",
3939
+ description: "markBounced() recorded a hard bounce (terminal).",
3940
+ zodSchema: z.object({
3941
+ subscriptionId: z.string(),
3942
+ contactId: z.string(),
3943
+ campaignId: z.string(),
3944
+ reason: z.string().optional()
3945
+ })
3946
+ });
3947
+ const SubscriptionComplained = defineCrmEvent({
3948
+ name: CRM_EVENTS.SUBSCRIPTION_COMPLAINED,
3949
+ resource: "subscription",
3950
+ description: "markComplained() recorded a spam complaint (terminal).",
3951
+ zodSchema: z.object({
3952
+ subscriptionId: z.string(),
3953
+ contactId: z.string(),
3954
+ campaignId: z.string()
3955
+ })
3956
+ });
3957
+ const emailEventSchema = z.object({
3958
+ eventId: z.string(),
3959
+ kind: z.string(),
3960
+ subjectKind: z.string(),
3961
+ subjectId: z.string(),
3962
+ campaignId: z.string().optional()
3963
+ });
3964
+ const EmailEventOpened = defineCrmEvent({
3965
+ name: CRM_EVENTS.EMAIL_EVENT_OPENED,
3966
+ resource: "email_event",
3967
+ description: "recordOpen() logged an email open.",
3968
+ zodSchema: emailEventSchema
3969
+ });
3970
+ const EmailEventClicked = defineCrmEvent({
3971
+ name: CRM_EVENTS.EMAIL_EVENT_CLICKED,
3972
+ resource: "email_event",
3973
+ description: "recordClick() logged a link click.",
3974
+ zodSchema: emailEventSchema
3975
+ });
3976
+ const EmailEventBounced = defineCrmEvent({
3977
+ name: CRM_EVENTS.EMAIL_EVENT_BOUNCED,
3978
+ resource: "email_event",
3979
+ description: "recordBounce() logged a bounce.",
3980
+ zodSchema: emailEventSchema
3981
+ });
3982
+ const EmailEventComplained = defineCrmEvent({
3983
+ name: CRM_EVENTS.EMAIL_EVENT_COMPLAINED,
3984
+ resource: "email_event",
3985
+ description: "recordComplaint() logged a spam complaint.",
3986
+ zodSchema: emailEventSchema
3987
+ });
3988
+ const EmailEventUnsubscribed = defineCrmEvent({
3989
+ name: CRM_EVENTS.EMAIL_EVENT_UNSUBSCRIBED,
3990
+ resource: "email_event",
3991
+ description: "recordUnsubscribe() logged a one-click unsubscribe.",
3992
+ zodSchema: emailEventSchema
3993
+ });
3994
+ const ConversationCreated = defineCrmEvent({
3995
+ name: CRM_EVENTS.CONVERSATION_CREATED,
3996
+ resource: "conversation",
3997
+ description: "openConversation() started a conversation thread.",
3998
+ zodSchema: z.object({
3999
+ conversationId: z.string(),
4000
+ channel: z.string(),
4001
+ participantKind: z.string(),
4002
+ participantId: z.string()
4003
+ })
4004
+ });
4005
+ const ConversationAssigned = defineCrmEvent({
4006
+ name: CRM_EVENTS.CONVERSATION_ASSIGNED,
4007
+ resource: "conversation",
4008
+ description: "assign() (re)assigned a conversation owner.",
4009
+ zodSchema: z.object({
4010
+ conversationId: z.string(),
4011
+ assigneeId: z.string(),
4012
+ previousAssigneeId: z.string().optional()
4013
+ })
4014
+ });
4015
+ const ConversationResolved = defineCrmEvent({
4016
+ name: CRM_EVENTS.CONVERSATION_RESOLVED,
4017
+ resource: "conversation",
4018
+ description: "resolve() closed a conversation.",
4019
+ zodSchema: z.object({
4020
+ conversationId: z.string(),
4021
+ status: z.string(),
4022
+ previousStatus: z.string()
4023
+ })
4024
+ });
4025
+ const ConversationReopened = defineCrmEvent({
4026
+ name: CRM_EVENTS.CONVERSATION_REOPENED,
4027
+ resource: "conversation",
4028
+ description: "reopen() reopened a resolved conversation.",
4029
+ zodSchema: z.object({
4030
+ conversationId: z.string(),
4031
+ status: z.string(),
4032
+ previousStatus: z.string()
4033
+ })
4034
+ });
4035
+ const MessageReceived = defineCrmEvent({
4036
+ name: CRM_EVENTS.MESSAGE_RECEIVED,
4037
+ resource: "message",
4038
+ description: "recordInbound() logged an inbound message.",
4039
+ zodSchema: z.object({
4040
+ messageId: z.string(),
4041
+ conversationId: z.string(),
4042
+ direction: z.string(),
4043
+ externalMessageId: z.string().optional()
4044
+ })
4045
+ });
4046
+ const MessageSent = defineCrmEvent({
4047
+ name: CRM_EVENTS.MESSAGE_SENT,
4048
+ resource: "message",
4049
+ description: "recordOutbound() logged an outbound message.",
4050
+ zodSchema: z.object({
4051
+ messageId: z.string(),
4052
+ conversationId: z.string(),
4053
+ direction: z.string(),
4054
+ externalMessageId: z.string().optional()
4055
+ })
4056
+ });
4057
+ const crmEventDefinitions = [
4058
+ LeadCreated,
4059
+ LeadStatusChanged,
4060
+ LeadScored,
4061
+ LeadConverted,
4062
+ LeadDisqualified,
4063
+ LeadEnriched,
4064
+ LeadAssigned,
4065
+ AccountCreated,
4066
+ AccountUpdated,
4067
+ AccountEnriched,
4068
+ AccountAssigned,
4069
+ AccountMerged,
4070
+ ContactCreated,
4071
+ ContactUpdated,
4072
+ ContactEnriched,
4073
+ ContactAssigned,
4074
+ ContactMerged,
4075
+ OpportunityCreated,
4076
+ OpportunityStageAdvanced,
4077
+ OpportunityStageRegressed,
4078
+ OpportunityWon,
4079
+ OpportunityLost,
4080
+ OpportunityAbandoned,
4081
+ OpportunityAssigned,
4082
+ OpportunityAmountChanged,
4083
+ OpportunityExtended,
4084
+ PipelineCreated,
4085
+ PipelineArchived,
4086
+ ActivityPlanned,
4087
+ ActivityCompleted,
4088
+ ActivityCancelled,
4089
+ ActivityRescheduled,
4090
+ ActivityOutcomeRecorded,
4091
+ NoteAdded,
4092
+ CampaignCreated,
4093
+ CampaignActivated,
4094
+ CampaignPaused,
4095
+ CampaignResumed,
4096
+ CampaignCompleted,
4097
+ CampaignArchived,
4098
+ SubscriptionCreated,
4099
+ SubscriptionConfirmed,
4100
+ SubscriptionUnsubscribed,
4101
+ SubscriptionBounced,
4102
+ SubscriptionComplained,
4103
+ EmailEventOpened,
4104
+ EmailEventClicked,
4105
+ EmailEventBounced,
4106
+ EmailEventComplained,
4107
+ EmailEventUnsubscribed,
4108
+ ConversationCreated,
4109
+ ConversationAssigned,
4110
+ ConversationResolved,
4111
+ ConversationReopened,
4112
+ MessageReceived,
4113
+ MessageSent
4114
+ ];
4115
+ //#endregion
2564
4116
  //#region src/shared/calculators/forecast.ts
2565
4117
  /**
2566
4118
  * Aggregate opportunities into a forecast table — one row per bucket.
@@ -2753,7 +4305,11 @@ const contactInfoSchema = z.object({
2753
4305
  email: z.string().email().max(255).optional(),
2754
4306
  personalEmail: z.string().email().max(255).optional(),
2755
4307
  phone: z.string().max(50).optional(),
2756
- alternatePhone: z.string().max(50).optional()
4308
+ alternatePhone: z.string().max(50).optional(),
4309
+ whatsapp: z.string().max(50).optional(),
4310
+ telegram: z.string().max(100).optional(),
4311
+ linkedin: z.string().max(255).optional(),
4312
+ instagram: z.string().max(100).optional()
2757
4313
  });
2758
4314
  const createContactSchema = z.object({
2759
4315
  name: contactNameSchema,
@@ -2967,6 +4523,4 @@ var validators_exports = /* @__PURE__ */ __exportAll({
2967
4523
  updatePipelineSchema: () => updatePipelineSchema
2968
4524
  });
2969
4525
  //#endregion
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 };
2971
-
2972
- //# sourceMappingURL=index.js.map
4526
+ export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PipelineArchived, PipelineCreated, PipelineRepository, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSubscriptionSchema, canTransitionActivity, canTransitionCampaign, canTransitionLead, canTransitionOpportunity, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, forecast, lastTransitionTo, latestEntry, pipelineConversion, tenantFieldDef, timeInStatus, validators_exports as validators };