@classytic/crm 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +157 -255
- package/dist/events.d.ts +2 -0
- package/dist/events.js +716 -0
- package/dist/index.d.ts +732 -820
- package/dist/index.js +1055 -214
- package/dist/index2.d.ts +746 -0
- package/package.json +13 -9
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- /package/dist/{chunk.js → rolldown-runtime.js} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,11 +1,104 @@
|
|
|
1
|
-
import { t as __exportAll } from "./
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime.js";
|
|
2
|
+
import { AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRescheduled, CRM_EVENTS, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationResolved, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventUnsubscribed, InProcessCrmBus, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadScored, LeadStatusChanged, MessageReceived, MessageSent, NoteAdded, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PipelineArchived, PipelineCreated, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionUnsubscribed, createEvent, crmEventDefinitions } from "./events.js";
|
|
2
3
|
import { Schema } from "mongoose";
|
|
3
|
-
import { Repository, aggregateHelpersPlugin, methodRegistryPlugin, multiTenantPlugin } from "@classytic/mongokit";
|
|
4
|
-
import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
|
|
4
|
+
import { Repository, aggregateHelpersPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin } from "@classytic/mongokit";
|
|
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
|
-
*
|
|
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
|
-
}, {
|
|
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
|
-
|
|
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,176 +1388,275 @@ function createEmailEventModel(connection, config) {
|
|
|
1255
1388
|
return connection.model("EmailEvent", buildEmailEventSchema(config));
|
|
1256
1389
|
}
|
|
1257
1390
|
//#endregion
|
|
1258
|
-
//#region src/
|
|
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
|
|
1391
|
+
//#region src/enums/conversation.ts
|
|
1276
1392
|
/**
|
|
1277
|
-
*
|
|
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.
|
|
1278
1399
|
*/
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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);
|
|
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
|
-
};
|
|
1362
|
-
//#endregion
|
|
1363
|
-
//#region src/events/event-constants.ts
|
|
1364
|
-
/** PACKAGE_RULES rule 12: `package:resource.verb`. */
|
|
1365
|
-
const CRM_EVENTS = {
|
|
1366
|
-
LEAD_CREATED: "crm:lead.created",
|
|
1367
|
-
LEAD_STATUS_CHANGED: "crm:lead.status_changed",
|
|
1368
|
-
LEAD_SCORED: "crm:lead.scored",
|
|
1369
|
-
LEAD_CONVERTED: "crm:lead.converted",
|
|
1370
|
-
LEAD_DISQUALIFIED: "crm:lead.disqualified",
|
|
1371
|
-
ACCOUNT_CREATED: "crm:account.created",
|
|
1372
|
-
ACCOUNT_UPDATED: "crm:account.updated",
|
|
1373
|
-
CONTACT_CREATED: "crm:contact.created",
|
|
1374
|
-
CONTACT_UPDATED: "crm:contact.updated",
|
|
1375
|
-
OPPORTUNITY_CREATED: "crm:opportunity.created",
|
|
1376
|
-
OPPORTUNITY_STAGE_ADVANCED: "crm:opportunity.stage_advanced",
|
|
1377
|
-
OPPORTUNITY_STAGE_REGRESSED: "crm:opportunity.stage_regressed",
|
|
1378
|
-
OPPORTUNITY_WON: "crm:opportunity.won",
|
|
1379
|
-
OPPORTUNITY_LOST: "crm:opportunity.lost",
|
|
1380
|
-
OPPORTUNITY_ABANDONED: "crm:opportunity.abandoned",
|
|
1381
|
-
PIPELINE_CREATED: "crm:pipeline.created",
|
|
1382
|
-
PIPELINE_ARCHIVED: "crm:pipeline.archived",
|
|
1383
|
-
ACTIVITY_PLANNED: "crm:activity.planned",
|
|
1384
|
-
ACTIVITY_COMPLETED: "crm:activity.completed",
|
|
1385
|
-
ACTIVITY_CANCELLED: "crm:activity.cancelled",
|
|
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"
|
|
1403
|
-
};
|
|
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
|
+
];
|
|
1404
1423
|
//#endregion
|
|
1405
|
-
//#region src/
|
|
1424
|
+
//#region src/models/conversation.schema.ts
|
|
1406
1425
|
/**
|
|
1407
|
-
*
|
|
1426
|
+
* Conversation — a multi-channel message thread bound to a Contact or
|
|
1427
|
+
* (pre-conversion) Lead. One row per (channel, participant) pair.
|
|
1408
1428
|
*
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
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).
|
|
1413
1433
|
*/
|
|
1414
|
-
function
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
|
1421
1483
|
});
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
|
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));
|
|
1504
|
+
}
|
|
1505
|
+
//#endregion
|
|
1506
|
+
//#region src/models/message.schema.ts
|
|
1507
|
+
/**
|
|
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.
|
|
1516
|
+
*/
|
|
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
|
|
1539
|
+
}
|
|
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
|
+
}
|
|
1655
|
+
//#endregion
|
|
1656
|
+
//#region src/repositories/pipeline.repository.ts
|
|
1657
|
+
var PipelineRepository = class extends Repository {
|
|
1658
|
+
events;
|
|
1659
|
+
constructor(model, plugins, deps, pagination) {
|
|
1428
1660
|
super(model, plugins, pagination);
|
|
1429
1661
|
this.events = deps.events;
|
|
1430
1662
|
}
|
|
@@ -1582,7 +1814,6 @@ var LeadRepository = class extends Repository {
|
|
|
1582
1814
|
const computedFullName = input.fullName ?? ([input.firstName, input.lastName].filter(Boolean).join(" ").trim() || input.email || input.phone);
|
|
1583
1815
|
const draft = {
|
|
1584
1816
|
...input,
|
|
1585
|
-
fullName: computedFullName,
|
|
1586
1817
|
status: input.status ?? "new",
|
|
1587
1818
|
score: 0,
|
|
1588
1819
|
statusHistory: [{
|
|
@@ -1590,7 +1821,14 @@ var LeadRepository = class extends Repository {
|
|
|
1590
1821
|
occurredAt: now
|
|
1591
1822
|
}]
|
|
1592
1823
|
};
|
|
1593
|
-
|
|
1824
|
+
if (computedFullName !== void 0) draft.fullName = computedFullName;
|
|
1825
|
+
let created;
|
|
1826
|
+
try {
|
|
1827
|
+
created = await this.create(draft, ctx);
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
if (this.rejectDuplicates && input.email && isDuplicateKeyError(err)) throw new DuplicateLeadError({ email: input.email });
|
|
1830
|
+
throw err;
|
|
1831
|
+
}
|
|
1594
1832
|
const score = this.scorer(created);
|
|
1595
1833
|
let result = created;
|
|
1596
1834
|
if (score !== 0) {
|
|
@@ -1629,6 +1867,50 @@ var LeadRepository = class extends Repository {
|
|
|
1629
1867
|
return this._transition(id, "nurturing", extras, ctx);
|
|
1630
1868
|
}
|
|
1631
1869
|
/**
|
|
1870
|
+
* Merge enrichment payload (3rd-party provider response or manual
|
|
1871
|
+
* patch) into the lead doc, then re-score. Emits `crm:lead.enriched`
|
|
1872
|
+
* with the field set written so subscribers can fan out (e.g. push a
|
|
1873
|
+
* `recompute_segment` job) without re-reading the doc.
|
|
1874
|
+
*
|
|
1875
|
+
* The `provider` tag flows into the event for attribution; the package
|
|
1876
|
+
* is provider-agnostic so the actual enrichment call lives at the host.
|
|
1877
|
+
*/
|
|
1878
|
+
async enrich(id, patch, extras = {}, ctx) {
|
|
1879
|
+
if (!await this.getById(id, ctx)) throw new NotFoundError("Lead", id);
|
|
1880
|
+
if (!await this.update(id, patch, ctx)) throw new NotFoundError("Lead", id);
|
|
1881
|
+
const fields = Object.keys(patch);
|
|
1882
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_ENRICHED, {
|
|
1883
|
+
leadId: id,
|
|
1884
|
+
fields,
|
|
1885
|
+
...extras.provider !== void 0 ? { provider: extras.provider } : {}
|
|
1886
|
+
}, ctx));
|
|
1887
|
+
return this.recomputeScore(id, ctx);
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Assign / re-assign the lead's owner. Emits `crm:lead.assigned` with
|
|
1891
|
+
* the previous owner so audit consumers can compute hand-off chains.
|
|
1892
|
+
*/
|
|
1893
|
+
async assign(id, ownerId, ctx) {
|
|
1894
|
+
const before = await this.findOneAndUpdate({
|
|
1895
|
+
_id: id,
|
|
1896
|
+
ownerId: { $ne: ownerId }
|
|
1897
|
+
}, { $set: { ownerId } }, {
|
|
1898
|
+
...ctx,
|
|
1899
|
+
returnDocument: "before"
|
|
1900
|
+
});
|
|
1901
|
+
if (!before) {
|
|
1902
|
+
const current = await this.getById(id, ctx);
|
|
1903
|
+
if (!current) throw new NotFoundError("Lead", id);
|
|
1904
|
+
return current;
|
|
1905
|
+
}
|
|
1906
|
+
await this.events.publish(createEvent(CRM_EVENTS.LEAD_ASSIGNED, {
|
|
1907
|
+
leadId: id,
|
|
1908
|
+
ownerId,
|
|
1909
|
+
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
1910
|
+
}, ctx));
|
|
1911
|
+
return await this.getById(id, ctx) ?? before;
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1632
1914
|
* Recompute + persist the lead's score. Called from `createLead`
|
|
1633
1915
|
* automatically; hosts also call this after enrichment writes.
|
|
1634
1916
|
*/
|
|
@@ -1676,6 +1958,29 @@ var LeadRepository = class extends Repository {
|
|
|
1676
1958
|
}, ctx));
|
|
1677
1959
|
return updated;
|
|
1678
1960
|
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Count leads created on or after `since`. Used by dashboard KPI tiles.
|
|
1963
|
+
* Goes through the inherited tenant-scoped `count()` — the plugin
|
|
1964
|
+
* injects `organizationId` (never a manual `if (ctx)` filter, which
|
|
1965
|
+
* silently reads ALL tenants when ctx is absent).
|
|
1966
|
+
*/
|
|
1967
|
+
async countCreatedSince(since, ctx) {
|
|
1968
|
+
return this.count({ createdAt: { $gte: since } }, ctx);
|
|
1969
|
+
}
|
|
1970
|
+
/** Latest `limit` leads — lightweight projection for the activity feed. */
|
|
1971
|
+
async findRecentForFeed(limit, ctx) {
|
|
1972
|
+
return this.findAll({}, {
|
|
1973
|
+
...ctx,
|
|
1974
|
+
sort: { updatedAt: -1 },
|
|
1975
|
+
limit,
|
|
1976
|
+
select: {
|
|
1977
|
+
_id: 1,
|
|
1978
|
+
fullName: 1,
|
|
1979
|
+
status: 1,
|
|
1980
|
+
updatedAt: 1
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1679
1984
|
};
|
|
1680
1985
|
//#endregion
|
|
1681
1986
|
//#region src/repositories/opportunity.repository.ts
|
|
@@ -1799,6 +2104,83 @@ var OpportunityRepository = class extends Repository {
|
|
|
1799
2104
|
}, ctx));
|
|
1800
2105
|
return updated;
|
|
1801
2106
|
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Re-assign opportunity owner. Mutates `ownerId` only; no status
|
|
2109
|
+
* transition. Emits `crm:opportunity.assigned` with the previous owner.
|
|
2110
|
+
*/
|
|
2111
|
+
async assign(id, ownerId, ctx) {
|
|
2112
|
+
const before = await this.findOneAndUpdate({
|
|
2113
|
+
_id: id,
|
|
2114
|
+
ownerId: { $ne: ownerId }
|
|
2115
|
+
}, { $set: { ownerId } }, {
|
|
2116
|
+
...ctx,
|
|
2117
|
+
returnDocument: "before"
|
|
2118
|
+
});
|
|
2119
|
+
if (!before) {
|
|
2120
|
+
const current = await this.getById(id, ctx);
|
|
2121
|
+
if (!current) throw new NotFoundError("Opportunity", id);
|
|
2122
|
+
return current;
|
|
2123
|
+
}
|
|
2124
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_ASSIGNED, {
|
|
2125
|
+
opportunityId: id,
|
|
2126
|
+
ownerId,
|
|
2127
|
+
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
2128
|
+
}, ctx));
|
|
2129
|
+
return await this.getById(id, ctx) ?? before;
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Adjust the deal amount on an open opportunity. Rejected after
|
|
2133
|
+
* win/lose/abandon — closed-deal amounts are immutable history.
|
|
2134
|
+
* Emits `crm:opportunity.amount_changed` with the delta so forecast
|
|
2135
|
+
* subscribers can re-bucket.
|
|
2136
|
+
*/
|
|
2137
|
+
async updateAmount(id, amount, extras = {}, ctx) {
|
|
2138
|
+
const existing = await this.getById(id, ctx);
|
|
2139
|
+
if (!existing) throw new NotFoundError("Opportunity", id);
|
|
2140
|
+
if (existing.status !== "open") throw new InvalidOpportunityTransitionError({
|
|
2141
|
+
from: existing.status,
|
|
2142
|
+
to: existing.status,
|
|
2143
|
+
opportunityId: id
|
|
2144
|
+
});
|
|
2145
|
+
if (amount === existing.amount) return existing;
|
|
2146
|
+
const updated = await this.update(id, { amount }, ctx);
|
|
2147
|
+
if (!updated) throw new NotFoundError("Opportunity", id);
|
|
2148
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_AMOUNT_CHANGED, {
|
|
2149
|
+
opportunityId: id,
|
|
2150
|
+
amount,
|
|
2151
|
+
...existing.amount !== void 0 ? { previousAmount: existing.amount } : {},
|
|
2152
|
+
...extras.reason !== void 0 ? { reason: extras.reason } : {},
|
|
2153
|
+
...extras.by !== void 0 ? { by: extras.by } : {}
|
|
2154
|
+
}, ctx));
|
|
2155
|
+
return updated;
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Push `expectedCloseAt` further out. Only valid on open opps —
|
|
2159
|
+
* extending a closed deal is meaningless. The new date must be later
|
|
2160
|
+
* than the current one; pulling the date in is silently accepted
|
|
2161
|
+
* (no event is meaningful there).
|
|
2162
|
+
*/
|
|
2163
|
+
async extendCloseDate(id, newDate, extras = {}, ctx) {
|
|
2164
|
+
const existing = await this.getById(id, ctx);
|
|
2165
|
+
if (!existing) throw new NotFoundError("Opportunity", id);
|
|
2166
|
+
if (existing.status !== "open") throw new InvalidOpportunityTransitionError({
|
|
2167
|
+
from: existing.status,
|
|
2168
|
+
to: existing.status,
|
|
2169
|
+
opportunityId: id
|
|
2170
|
+
});
|
|
2171
|
+
const previous = existing.expectedCloseAt;
|
|
2172
|
+
if (previous && newDate <= previous) return existing;
|
|
2173
|
+
const updated = await this.update(id, { expectedCloseAt: newDate }, ctx);
|
|
2174
|
+
if (!updated) throw new NotFoundError("Opportunity", id);
|
|
2175
|
+
await this.events.publish(createEvent(CRM_EVENTS.OPPORTUNITY_EXTENDED, {
|
|
2176
|
+
opportunityId: id,
|
|
2177
|
+
expectedCloseAt: newDate.toISOString(),
|
|
2178
|
+
...previous ? { previousExpectedCloseAt: previous.toISOString() } : {},
|
|
2179
|
+
...extras.reason !== void 0 ? { reason: extras.reason } : {},
|
|
2180
|
+
...extras.by !== void 0 ? { by: extras.by } : {}
|
|
2181
|
+
}, ctx));
|
|
2182
|
+
return updated;
|
|
2183
|
+
}
|
|
1802
2184
|
async abandon(id, by, note, ctx) {
|
|
1803
2185
|
const updated = await this._closeStatus(id, "abandoned", {
|
|
1804
2186
|
closedAt: /* @__PURE__ */ new Date(),
|
|
@@ -1837,6 +2219,58 @@ var OpportunityRepository = class extends Repository {
|
|
|
1837
2219
|
});
|
|
1838
2220
|
return updated;
|
|
1839
2221
|
}
|
|
2222
|
+
/** Count opportunities with status `open`. Used by dashboard KPI tiles. */
|
|
2223
|
+
async countOpen(ctx) {
|
|
2224
|
+
return this.count({ status: "open" }, ctx);
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Sum `amount` on won opportunities updated within `since`.
|
|
2228
|
+
* Goes through the tenant-scoped `findAll()` — the multiTenant plugin
|
|
2229
|
+
* injects `organizationId`, so a missing ctx can't leak other tenants'
|
|
2230
|
+
* revenue (a manual `if (ctx)` filter would).
|
|
2231
|
+
*/
|
|
2232
|
+
async sumWonSince(since, ctx) {
|
|
2233
|
+
const rows = await this.findAll({
|
|
2234
|
+
status: "won",
|
|
2235
|
+
updatedAt: { $gte: since }
|
|
2236
|
+
}, {
|
|
2237
|
+
...ctx,
|
|
2238
|
+
select: {
|
|
2239
|
+
amount: 1,
|
|
2240
|
+
currency: 1
|
|
2241
|
+
}
|
|
2242
|
+
});
|
|
2243
|
+
if (rows.length === 0) return null;
|
|
2244
|
+
const byCurrency = /* @__PURE__ */ new Map();
|
|
2245
|
+
for (const r of rows) {
|
|
2246
|
+
const cur = r.currency ?? "UNKNOWN";
|
|
2247
|
+
byCurrency.set(cur, (byCurrency.get(cur) ?? 0) + (r.amount ?? 0));
|
|
2248
|
+
}
|
|
2249
|
+
let topCurrency = "";
|
|
2250
|
+
let topTotal = -Infinity;
|
|
2251
|
+
for (const [cur, total] of byCurrency) if (total > topTotal) {
|
|
2252
|
+
topTotal = total;
|
|
2253
|
+
topCurrency = cur;
|
|
2254
|
+
}
|
|
2255
|
+
return {
|
|
2256
|
+
total: topTotal,
|
|
2257
|
+
currency: topCurrency
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
/** Latest `limit` opportunities — lightweight projection for the activity feed. */
|
|
2261
|
+
async findRecentForFeed(limit, ctx) {
|
|
2262
|
+
return this.findAll({}, {
|
|
2263
|
+
...ctx,
|
|
2264
|
+
sort: { updatedAt: -1 },
|
|
2265
|
+
limit,
|
|
2266
|
+
select: {
|
|
2267
|
+
_id: 1,
|
|
2268
|
+
name: 1,
|
|
2269
|
+
status: 1,
|
|
2270
|
+
updatedAt: 1
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
1840
2274
|
};
|
|
1841
2275
|
//#endregion
|
|
1842
2276
|
//#region src/repositories/contact.repository.ts
|
|
@@ -1858,7 +2292,108 @@ var ContactRepository = class extends Repository {
|
|
|
1858
2292
|
}, ctx));
|
|
1859
2293
|
return created;
|
|
1860
2294
|
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Merge enrichment payload into the contact. The `patch` is applied
|
|
2297
|
+
* verbatim; deep-merge of `contact.*` channels is the caller's job
|
|
2298
|
+
* (they own the provider response shape).
|
|
2299
|
+
*/
|
|
2300
|
+
async enrich(id, patch, extras = {}, ctx) {
|
|
2301
|
+
if (!await this.getById(id, ctx)) throw new NotFoundError("Contact", id);
|
|
2302
|
+
const updated = await this.update(id, patch, ctx);
|
|
2303
|
+
if (!updated) throw new NotFoundError("Contact", id);
|
|
2304
|
+
await this.events.publish(createEvent(CRM_EVENTS.CONTACT_ENRICHED, {
|
|
2305
|
+
contactId: id,
|
|
2306
|
+
fields: Object.keys(patch),
|
|
2307
|
+
...extras.provider !== void 0 ? { provider: extras.provider } : {}
|
|
2308
|
+
}, ctx));
|
|
2309
|
+
return updated;
|
|
2310
|
+
}
|
|
2311
|
+
/** Set / re-assign the contact's owner. Atomic; no-op when unchanged. */
|
|
2312
|
+
async assign(id, ownerId, ctx) {
|
|
2313
|
+
const before = await this.findOneAndUpdate({
|
|
2314
|
+
_id: id,
|
|
2315
|
+
ownerId: { $ne: ownerId }
|
|
2316
|
+
}, { $set: { ownerId } }, {
|
|
2317
|
+
...ctx,
|
|
2318
|
+
returnDocument: "before"
|
|
2319
|
+
});
|
|
2320
|
+
if (!before) {
|
|
2321
|
+
const current = await this.getById(id, ctx);
|
|
2322
|
+
if (!current) throw new NotFoundError("Contact", id);
|
|
2323
|
+
return current;
|
|
2324
|
+
}
|
|
2325
|
+
await this.events.publish(createEvent(CRM_EVENTS.CONTACT_ASSIGNED, {
|
|
2326
|
+
contactId: id,
|
|
2327
|
+
ownerId,
|
|
2328
|
+
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
2329
|
+
}, ctx));
|
|
2330
|
+
return await this.getById(id, ctx) ?? before;
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Merge two contacts. Secondary's non-conflicting fields are folded
|
|
2334
|
+
* into primary, tags are unioned, then secondary is deleted.
|
|
2335
|
+
*
|
|
2336
|
+
* Hosts wanting transactional safety can pass `ctx.session` —
|
|
2337
|
+
* mongokit threads it through `update` + `delete`.
|
|
2338
|
+
*
|
|
2339
|
+
* Re-pointing related Activities / Notes / Opportunities is the host's
|
|
2340
|
+
* job (they own those relationships) — subscribe to
|
|
2341
|
+
* `crm:contact.merged` and rewrite `subjectId` / `primaryContactId`
|
|
2342
|
+
* from there.
|
|
2343
|
+
*/
|
|
2344
|
+
async merge(input, ctx) {
|
|
2345
|
+
const { primaryId, secondaryId } = input;
|
|
2346
|
+
if (primaryId === secondaryId) throw new Error("[crm] cannot merge a contact with itself");
|
|
2347
|
+
const strategy = input.strategy ?? "preferPrimary";
|
|
2348
|
+
const [primary, secondary] = await Promise.all([this.getById(primaryId, ctx), this.getById(secondaryId, ctx)]);
|
|
2349
|
+
if (!primary) throw new NotFoundError("Contact", primaryId);
|
|
2350
|
+
if (!secondary) throw new NotFoundError("Contact", secondaryId);
|
|
2351
|
+
const winner = strategy === "preferPrimary" ? primary : secondary;
|
|
2352
|
+
const loser = strategy === "preferPrimary" ? secondary : primary;
|
|
2353
|
+
const winnerContact = plainOf(winner.contact);
|
|
2354
|
+
const loserContact = plainOf(loser.contact);
|
|
2355
|
+
const winnerName = plainOf(winner.name);
|
|
2356
|
+
const loserName = plainOf(loser.name);
|
|
2357
|
+
const mergedContactInfo = {
|
|
2358
|
+
...loserContact,
|
|
2359
|
+
...stripEmpty(winnerContact)
|
|
2360
|
+
};
|
|
2361
|
+
const patch = {
|
|
2362
|
+
name: {
|
|
2363
|
+
...loserName,
|
|
2364
|
+
...stripEmpty(winnerName)
|
|
2365
|
+
},
|
|
2366
|
+
contact: mergedContactInfo,
|
|
2367
|
+
tags: Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []])),
|
|
2368
|
+
...winner.jobTitle !== void 0 ? { jobTitle: winner.jobTitle } : loser.jobTitle !== void 0 ? { jobTitle: loser.jobTitle } : {},
|
|
2369
|
+
...winner.accountId !== void 0 ? { accountId: winner.accountId } : loser.accountId !== void 0 ? { accountId: loser.accountId } : {},
|
|
2370
|
+
...winner.ownerId !== void 0 ? { ownerId: winner.ownerId } : loser.ownerId !== void 0 ? { ownerId: loser.ownerId } : {}
|
|
2371
|
+
};
|
|
2372
|
+
const updated = await this.update(primaryId, patch, ctx);
|
|
2373
|
+
if (!updated) throw new NotFoundError("Contact", primaryId);
|
|
2374
|
+
await this.delete(secondaryId, ctx);
|
|
2375
|
+
await this.events.publish(createEvent(CRM_EVENTS.CONTACT_MERGED, {
|
|
2376
|
+
primaryId,
|
|
2377
|
+
secondaryId,
|
|
2378
|
+
strategy
|
|
2379
|
+
}, ctx));
|
|
2380
|
+
return updated;
|
|
2381
|
+
}
|
|
1861
2382
|
};
|
|
2383
|
+
function stripEmpty(obj) {
|
|
2384
|
+
const out = {};
|
|
2385
|
+
for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null && v !== "") out[k] = v;
|
|
2386
|
+
return out;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Convert a Mongoose subdocument (or plain object) into a plain JS object.
|
|
2390
|
+
* Spreading a Mongoose subdoc directly leaks `$__parent` / `_doc` etc.
|
|
2391
|
+
*/
|
|
2392
|
+
function plainOf(value) {
|
|
2393
|
+
if (value === null || value === void 0) return {};
|
|
2394
|
+
if (typeof value.toObject === "function") return value.toObject();
|
|
2395
|
+
return value;
|
|
2396
|
+
}
|
|
1862
2397
|
//#endregion
|
|
1863
2398
|
//#region src/repositories/account.repository.ts
|
|
1864
2399
|
var AccountRepository = class extends Repository {
|
|
@@ -1875,7 +2410,72 @@ var AccountRepository = class extends Repository {
|
|
|
1875
2410
|
}, ctx));
|
|
1876
2411
|
return created;
|
|
1877
2412
|
}
|
|
2413
|
+
async enrich(id, patch, extras = {}, ctx) {
|
|
2414
|
+
if (!await this.getById(id, ctx)) throw new NotFoundError("Account", id);
|
|
2415
|
+
const updated = await this.update(id, patch, ctx);
|
|
2416
|
+
if (!updated) throw new NotFoundError("Account", id);
|
|
2417
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_ENRICHED, {
|
|
2418
|
+
accountId: id,
|
|
2419
|
+
fields: Object.keys(patch),
|
|
2420
|
+
...extras.provider !== void 0 ? { provider: extras.provider } : {}
|
|
2421
|
+
}, ctx));
|
|
2422
|
+
return updated;
|
|
2423
|
+
}
|
|
2424
|
+
async assign(id, ownerId, ctx) {
|
|
2425
|
+
const before = await this.findOneAndUpdate({
|
|
2426
|
+
_id: id,
|
|
2427
|
+
ownerId: { $ne: ownerId }
|
|
2428
|
+
}, { $set: { ownerId } }, {
|
|
2429
|
+
...ctx,
|
|
2430
|
+
returnDocument: "before"
|
|
2431
|
+
});
|
|
2432
|
+
if (!before) {
|
|
2433
|
+
const current = await this.getById(id, ctx);
|
|
2434
|
+
if (!current) throw new NotFoundError("Account", id);
|
|
2435
|
+
return current;
|
|
2436
|
+
}
|
|
2437
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_ASSIGNED, {
|
|
2438
|
+
accountId: id,
|
|
2439
|
+
ownerId,
|
|
2440
|
+
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
2441
|
+
}, ctx));
|
|
2442
|
+
return await this.getById(id, ctx) ?? before;
|
|
2443
|
+
}
|
|
2444
|
+
async merge(input, ctx) {
|
|
2445
|
+
const { primaryId, secondaryId } = input;
|
|
2446
|
+
if (primaryId === secondaryId) throw new Error("[crm] cannot merge an account with itself");
|
|
2447
|
+
const strategy = input.strategy ?? "preferPrimary";
|
|
2448
|
+
const [primary, secondary] = await Promise.all([this.getById(primaryId, ctx), this.getById(secondaryId, ctx)]);
|
|
2449
|
+
if (!primary) throw new NotFoundError("Account", primaryId);
|
|
2450
|
+
if (!secondary) throw new NotFoundError("Account", secondaryId);
|
|
2451
|
+
const winner = strategy === "preferPrimary" ? primary : secondary;
|
|
2452
|
+
const loser = strategy === "preferPrimary" ? secondary : primary;
|
|
2453
|
+
const mergedTags = Array.from(new Set([...primary.tags ?? [], ...secondary.tags ?? []]));
|
|
2454
|
+
const patch = {
|
|
2455
|
+
name: winner.name || loser.name,
|
|
2456
|
+
tags: mergedTags,
|
|
2457
|
+
...firstDefined("domain", winner.domain, loser.domain),
|
|
2458
|
+
...firstDefined("industry", winner.industry, loser.industry),
|
|
2459
|
+
...firstDefined("sizeBucket", winner.sizeBucket, loser.sizeBucket),
|
|
2460
|
+
...firstDefined("annualRevenueAmount", winner.annualRevenueAmount, loser.annualRevenueAmount),
|
|
2461
|
+
...firstDefined("ownerId", winner.ownerId, loser.ownerId)
|
|
2462
|
+
};
|
|
2463
|
+
const updated = await this.update(primaryId, patch, ctx);
|
|
2464
|
+
if (!updated) throw new NotFoundError("Account", primaryId);
|
|
2465
|
+
await this.delete(secondaryId, ctx);
|
|
2466
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACCOUNT_MERGED, {
|
|
2467
|
+
primaryId,
|
|
2468
|
+
secondaryId,
|
|
2469
|
+
strategy
|
|
2470
|
+
}, ctx));
|
|
2471
|
+
return updated;
|
|
2472
|
+
}
|
|
1878
2473
|
};
|
|
2474
|
+
function firstDefined(key, a, b) {
|
|
2475
|
+
if (a !== void 0 && a !== null) return { [key]: a };
|
|
2476
|
+
if (b !== void 0 && b !== null) return { [key]: b };
|
|
2477
|
+
return {};
|
|
2478
|
+
}
|
|
1879
2479
|
//#endregion
|
|
1880
2480
|
//#region src/repositories/campaign.repository.ts
|
|
1881
2481
|
var CampaignRepository = class extends Repository {
|
|
@@ -1900,7 +2500,13 @@ var CampaignRepository = class extends Repository {
|
|
|
1900
2500
|
occurredAt: now
|
|
1901
2501
|
}]
|
|
1902
2502
|
};
|
|
1903
|
-
|
|
2503
|
+
let created;
|
|
2504
|
+
try {
|
|
2505
|
+
created = await this.create(draft, ctx);
|
|
2506
|
+
} catch (err) {
|
|
2507
|
+
if (this.rejectDuplicates && input.name && isDuplicateKeyError(err)) throw new DuplicateCampaignError(input.name);
|
|
2508
|
+
throw err;
|
|
2509
|
+
}
|
|
1904
2510
|
await this.events.publish(createEvent(CRM_EVENTS.CAMPAIGN_CREATED, {
|
|
1905
2511
|
campaignId: String(created._id),
|
|
1906
2512
|
name: created.name
|
|
@@ -2020,7 +2626,16 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2020
2626
|
...input.consentUserAgent !== void 0 ? { consentUserAgent: input.consentUserAgent } : {},
|
|
2021
2627
|
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
2022
2628
|
};
|
|
2023
|
-
|
|
2629
|
+
let sub;
|
|
2630
|
+
try {
|
|
2631
|
+
sub = await this.create(draft, ctx);
|
|
2632
|
+
} catch (err) {
|
|
2633
|
+
if (isDuplicateKeyError(err)) throw new DuplicateSubscriptionError({
|
|
2634
|
+
contactId: input.contactId,
|
|
2635
|
+
campaignId: input.campaignId
|
|
2636
|
+
});
|
|
2637
|
+
throw err;
|
|
2638
|
+
}
|
|
2024
2639
|
await this.events.publish(createEvent(CRM_EVENTS.SUBSCRIPTION_CREATED, {
|
|
2025
2640
|
subscriptionId: String(sub._id),
|
|
2026
2641
|
contactId: sub.contactId,
|
|
@@ -2164,6 +2779,55 @@ var ActivityRepository = class extends Repository {
|
|
|
2164
2779
|
}, ctx));
|
|
2165
2780
|
return updated;
|
|
2166
2781
|
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Push an activity's `dueDate`. Allowed on planned / in_progress only;
|
|
2784
|
+
* rescheduling a completed or cancelled activity makes no sense.
|
|
2785
|
+
* Emits `crm:activity.rescheduled` with the previous date for audit.
|
|
2786
|
+
*/
|
|
2787
|
+
async reschedule(id, newDueDate, extras = {}, ctx) {
|
|
2788
|
+
const existing = await this.getById(id, ctx);
|
|
2789
|
+
if (!existing) throw new NotFoundError("Activity", id);
|
|
2790
|
+
if (existing.status !== "planned" && existing.status !== "in_progress") throw new InvalidActivityTransitionError({
|
|
2791
|
+
from: existing.status,
|
|
2792
|
+
to: existing.status,
|
|
2793
|
+
activityId: id
|
|
2794
|
+
});
|
|
2795
|
+
const previous = existing.dueDate;
|
|
2796
|
+
const updated = await this.update(id, { dueDate: newDueDate }, ctx);
|
|
2797
|
+
if (!updated) throw new NotFoundError("Activity", id);
|
|
2798
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_RESCHEDULED, {
|
|
2799
|
+
activityId: id,
|
|
2800
|
+
dueDate: newDueDate.toISOString(),
|
|
2801
|
+
...previous ? { previousDueDate: previous.toISOString() } : {},
|
|
2802
|
+
...extras.reason !== void 0 ? { reason: extras.reason } : {},
|
|
2803
|
+
...extras.by !== void 0 ? { by: extras.by } : {}
|
|
2804
|
+
}, ctx));
|
|
2805
|
+
return updated;
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* Record the outcome of an activity (call result, email engagement,
|
|
2809
|
+
* meeting confirmation). Stored under `metadata.outcome` so it stays
|
|
2810
|
+
* polymorphic — the shape is the channel's concern, not this package's.
|
|
2811
|
+
*
|
|
2812
|
+
* Does NOT transition status — call `complete()` separately when the
|
|
2813
|
+
* outcome implies completion. This split lets agents log partial
|
|
2814
|
+
* outcomes (e.g. "left voicemail") without forcing terminal state.
|
|
2815
|
+
*/
|
|
2816
|
+
async recordOutcome(id, outcome, ctx) {
|
|
2817
|
+
const existing = await this.getById(id, ctx);
|
|
2818
|
+
if (!existing) throw new NotFoundError("Activity", id);
|
|
2819
|
+
const mergedMetadata = {
|
|
2820
|
+
...existing.metadata ?? {},
|
|
2821
|
+
outcome
|
|
2822
|
+
};
|
|
2823
|
+
const updated = await this.update(id, { metadata: mergedMetadata }, ctx);
|
|
2824
|
+
if (!updated) throw new NotFoundError("Activity", id);
|
|
2825
|
+
await this.events.publish(createEvent(CRM_EVENTS.ACTIVITY_OUTCOME_RECORDED, {
|
|
2826
|
+
activityId: id,
|
|
2827
|
+
outcome
|
|
2828
|
+
}, ctx));
|
|
2829
|
+
return updated;
|
|
2830
|
+
}
|
|
2167
2831
|
async _transition(id, target, patch, ctx) {
|
|
2168
2832
|
const existing = await this.getById(id, ctx);
|
|
2169
2833
|
if (!existing) throw new NotFoundError("Activity", id);
|
|
@@ -2181,6 +2845,29 @@ var ActivityRepository = class extends Repository {
|
|
|
2181
2845
|
});
|
|
2182
2846
|
return updated;
|
|
2183
2847
|
}
|
|
2848
|
+
/** Count `planned` activities with `dueDate` before `asOf`. Dashboard KPI. */
|
|
2849
|
+
async countOverduePlanned(asOf, ctx) {
|
|
2850
|
+
return this.count({
|
|
2851
|
+
status: "planned",
|
|
2852
|
+
dueDate: { $lt: asOf }
|
|
2853
|
+
}, ctx);
|
|
2854
|
+
}
|
|
2855
|
+
/** Latest `limit` activities — lightweight projection for the activity feed. */
|
|
2856
|
+
async findRecentForFeed(limit, ctx) {
|
|
2857
|
+
return this.findAll({}, {
|
|
2858
|
+
...ctx,
|
|
2859
|
+
sort: { updatedAt: -1 },
|
|
2860
|
+
limit,
|
|
2861
|
+
select: {
|
|
2862
|
+
_id: 1,
|
|
2863
|
+
title: 1,
|
|
2864
|
+
status: 1,
|
|
2865
|
+
updatedAt: 1,
|
|
2866
|
+
subjectKind: 1,
|
|
2867
|
+
subjectId: 1
|
|
2868
|
+
}
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2184
2871
|
};
|
|
2185
2872
|
//#endregion
|
|
2186
2873
|
//#region src/repositories/note.repository.ts
|
|
@@ -2269,6 +2956,161 @@ var EmailEventRepository = class extends Repository {
|
|
|
2269
2956
|
}
|
|
2270
2957
|
};
|
|
2271
2958
|
//#endregion
|
|
2959
|
+
//#region src/repositories/conversation.repository.ts
|
|
2960
|
+
var ConversationRepository = class extends Repository {
|
|
2961
|
+
events;
|
|
2962
|
+
constructor(model, plugins, deps, pagination) {
|
|
2963
|
+
super(model, plugins, pagination);
|
|
2964
|
+
this.events = deps.events;
|
|
2965
|
+
}
|
|
2966
|
+
async openConversation(input, ctx) {
|
|
2967
|
+
const draft = {
|
|
2968
|
+
channel: input.channel,
|
|
2969
|
+
participantKind: input.participantKind,
|
|
2970
|
+
participantId: input.participantId,
|
|
2971
|
+
status: "open",
|
|
2972
|
+
unreadCount: 0,
|
|
2973
|
+
...input.channelUserId !== void 0 ? { channelUserId: input.channelUserId } : {},
|
|
2974
|
+
...input.assigneeId !== void 0 ? { assigneeId: input.assigneeId } : {},
|
|
2975
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
2976
|
+
};
|
|
2977
|
+
const created = await this.create(draft, ctx);
|
|
2978
|
+
await this.events.publish(createEvent(CRM_EVENTS.CONVERSATION_CREATED, {
|
|
2979
|
+
conversationId: String(created._id),
|
|
2980
|
+
channel: created.channel,
|
|
2981
|
+
participantKind: created.participantKind,
|
|
2982
|
+
participantId: String(created.participantId)
|
|
2983
|
+
}, ctx));
|
|
2984
|
+
return created;
|
|
2985
|
+
}
|
|
2986
|
+
async assign(id, assigneeId, ctx) {
|
|
2987
|
+
const before = await this.findOneAndUpdate({
|
|
2988
|
+
_id: id,
|
|
2989
|
+
assigneeId: { $ne: assigneeId }
|
|
2990
|
+
}, { $set: { assigneeId } }, {
|
|
2991
|
+
...ctx,
|
|
2992
|
+
returnDocument: "before"
|
|
2993
|
+
});
|
|
2994
|
+
if (!before) {
|
|
2995
|
+
const current = await this.getById(id, ctx);
|
|
2996
|
+
if (!current) throw new NotFoundError("Conversation", id);
|
|
2997
|
+
return current;
|
|
2998
|
+
}
|
|
2999
|
+
await this.events.publish(createEvent(CRM_EVENTS.CONVERSATION_ASSIGNED, {
|
|
3000
|
+
conversationId: id,
|
|
3001
|
+
assigneeId,
|
|
3002
|
+
...before.assigneeId !== void 0 ? { previousAssigneeId: before.assigneeId } : {}
|
|
3003
|
+
}, ctx));
|
|
3004
|
+
return await this.getById(id, ctx) ?? before;
|
|
3005
|
+
}
|
|
3006
|
+
async resolve(id, ctx) {
|
|
3007
|
+
return this._setStatus(id, "resolved", CRM_EVENTS.CONVERSATION_RESOLVED, ctx);
|
|
3008
|
+
}
|
|
3009
|
+
async reopen(id, ctx) {
|
|
3010
|
+
return this._setStatus(id, "open", CRM_EVENTS.CONVERSATION_REOPENED, ctx);
|
|
3011
|
+
}
|
|
3012
|
+
/** Reset unread badge. Pure UI affordance, no event. */
|
|
3013
|
+
async markRead(id, ctx) {
|
|
3014
|
+
const updated = await this.update(id, { unreadCount: 0 }, ctx);
|
|
3015
|
+
if (!updated) throw new NotFoundError("Conversation", id);
|
|
3016
|
+
return updated;
|
|
3017
|
+
}
|
|
3018
|
+
/**
|
|
3019
|
+
* Bump `lastMessageAt` and (for inbound) increment `unreadCount`.
|
|
3020
|
+
* Called by `MessageRepository` when a message is recorded — not part
|
|
3021
|
+
* of the public surface. Returns the conversation for the caller's
|
|
3022
|
+
* follow-up writes.
|
|
3023
|
+
*/
|
|
3024
|
+
async touch(id, args, ctx) {
|
|
3025
|
+
const existing = await this.getById(id, ctx);
|
|
3026
|
+
if (!existing) return null;
|
|
3027
|
+
const patch = { lastMessageAt: args.sentAt };
|
|
3028
|
+
if (args.direction === "inbound") patch.unreadCount = (existing.unreadCount ?? 0) + 1;
|
|
3029
|
+
return await this.update(id, patch, ctx) ?? null;
|
|
3030
|
+
}
|
|
3031
|
+
async _setStatus(id, next, eventType, ctx) {
|
|
3032
|
+
const existing = await this.getById(id, ctx);
|
|
3033
|
+
if (!existing) throw new NotFoundError("Conversation", id);
|
|
3034
|
+
if (existing.status === next) return existing;
|
|
3035
|
+
const updated = await this.update(id, { status: next }, ctx);
|
|
3036
|
+
if (!updated) throw new NotFoundError("Conversation", id);
|
|
3037
|
+
await this.events.publish(createEvent(eventType, {
|
|
3038
|
+
conversationId: id,
|
|
3039
|
+
status: next,
|
|
3040
|
+
previousStatus: existing.status
|
|
3041
|
+
}, ctx));
|
|
3042
|
+
return updated;
|
|
3043
|
+
}
|
|
3044
|
+
};
|
|
3045
|
+
//#endregion
|
|
3046
|
+
//#region src/repositories/message.repository.ts
|
|
3047
|
+
var MessageRepository = class extends Repository {
|
|
3048
|
+
events;
|
|
3049
|
+
conversations;
|
|
3050
|
+
constructor(model, plugins, deps, pagination) {
|
|
3051
|
+
super(model, plugins, pagination);
|
|
3052
|
+
this.events = deps.events;
|
|
3053
|
+
this.conversations = deps.conversations;
|
|
3054
|
+
}
|
|
3055
|
+
async recordInbound(input, ctx) {
|
|
3056
|
+
return this._record(input, "inbound", "delivered", CRM_EVENTS.MESSAGE_RECEIVED, ctx);
|
|
3057
|
+
}
|
|
3058
|
+
async recordOutbound(input, ctx) {
|
|
3059
|
+
return this._record(input, "outbound", "sent", CRM_EVENTS.MESSAGE_SENT, ctx);
|
|
3060
|
+
}
|
|
3061
|
+
/** Wire delivery callback (e.g. WhatsApp `status` webhook). */
|
|
3062
|
+
async updateStatus(id, status, ctx) {
|
|
3063
|
+
const existing = await this.getById(id, ctx);
|
|
3064
|
+
if (!existing) throw new NotFoundError("Message", id);
|
|
3065
|
+
if (existing.status === status) return existing;
|
|
3066
|
+
const updated = await this.update(id, { status }, ctx);
|
|
3067
|
+
if (!updated) throw new NotFoundError("Message", id);
|
|
3068
|
+
return updated;
|
|
3069
|
+
}
|
|
3070
|
+
async _record(input, direction, defaultStatus, eventType, ctx) {
|
|
3071
|
+
const sentAt = input.sentAt ?? /* @__PURE__ */ new Date();
|
|
3072
|
+
const draft = {
|
|
3073
|
+
conversationId: input.conversationId,
|
|
3074
|
+
direction,
|
|
3075
|
+
body: input.body,
|
|
3076
|
+
attachments: input.attachments ?? [],
|
|
3077
|
+
status: defaultStatus,
|
|
3078
|
+
sentAt,
|
|
3079
|
+
...input.externalMessageId !== void 0 ? { externalMessageId: input.externalMessageId } : {},
|
|
3080
|
+
...input.senderRef !== void 0 ? { senderRef: input.senderRef } : {},
|
|
3081
|
+
...input.recipientRef !== void 0 ? { recipientRef: input.recipientRef } : {},
|
|
3082
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
3083
|
+
};
|
|
3084
|
+
const created = await this.create(draft, ctx);
|
|
3085
|
+
await this.conversations.touch(input.conversationId, {
|
|
3086
|
+
direction,
|
|
3087
|
+
sentAt
|
|
3088
|
+
}, ctx);
|
|
3089
|
+
await this.events.publish(createEvent(eventType, {
|
|
3090
|
+
messageId: String(created._id),
|
|
3091
|
+
conversationId: input.conversationId,
|
|
3092
|
+
direction,
|
|
3093
|
+
...input.externalMessageId !== void 0 ? { externalMessageId: input.externalMessageId } : {}
|
|
3094
|
+
}, ctx));
|
|
3095
|
+
return created;
|
|
3096
|
+
}
|
|
3097
|
+
/** Latest `limit` messages — lightweight projection for the activity feed. */
|
|
3098
|
+
async findRecentForFeed(limit, ctx) {
|
|
3099
|
+
return this.findAll({}, {
|
|
3100
|
+
...ctx,
|
|
3101
|
+
sort: { sentAt: -1 },
|
|
3102
|
+
limit,
|
|
3103
|
+
select: {
|
|
3104
|
+
_id: 1,
|
|
3105
|
+
body: 1,
|
|
3106
|
+
direction: 1,
|
|
3107
|
+
conversationId: 1,
|
|
3108
|
+
sentAt: 1
|
|
3109
|
+
}
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
3113
|
+
//#endregion
|
|
2272
3114
|
//#region src/repositories/create-repositories.ts
|
|
2273
3115
|
/**
|
|
2274
3116
|
* Repository factory — composes the shared mongokit plugin stack and
|
|
@@ -2306,6 +3148,11 @@ function createCrmRepositories(models, deps) {
|
|
|
2306
3148
|
const note = new NoteRepository(models.Note, plugins, { events }, pagination);
|
|
2307
3149
|
const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
|
|
2308
3150
|
const emailEvent = new EmailEventRepository(models.EmailEvent, plugins, { events }, pagination);
|
|
3151
|
+
const conversation = new ConversationRepository(models.Conversation, plugins, { events }, pagination);
|
|
3152
|
+
const message = new MessageRepository(models.Message, plugins, {
|
|
3153
|
+
events,
|
|
3154
|
+
conversations: conversation
|
|
3155
|
+
}, pagination);
|
|
2309
3156
|
return Object.freeze({
|
|
2310
3157
|
pipeline,
|
|
2311
3158
|
lead,
|
|
@@ -2317,46 +3164,29 @@ function createCrmRepositories(models, deps) {
|
|
|
2317
3164
|
activity,
|
|
2318
3165
|
note,
|
|
2319
3166
|
lossReason,
|
|
2320
|
-
emailEvent
|
|
3167
|
+
emailEvent,
|
|
3168
|
+
conversation,
|
|
3169
|
+
message
|
|
2321
3170
|
});
|
|
2322
3171
|
}
|
|
2323
3172
|
//#endregion
|
|
2324
|
-
//#region src/
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
async subscribe(pattern, handler) {
|
|
2347
|
-
const sub = {
|
|
2348
|
-
pattern,
|
|
2349
|
-
handler
|
|
2350
|
-
};
|
|
2351
|
-
this.subs.push(sub);
|
|
2352
|
-
return () => {
|
|
2353
|
-
this.subs = this.subs.filter((s) => s !== sub);
|
|
2354
|
-
};
|
|
2355
|
-
}
|
|
2356
|
-
async close() {
|
|
2357
|
-
this.subs = [];
|
|
3173
|
+
//#region src/engine/ensure-ready.ts
|
|
3174
|
+
const REQUIRED_CAPABILITIES = [
|
|
3175
|
+
"upsert",
|
|
3176
|
+
"getOrCreate",
|
|
3177
|
+
"duplicateKeyError"
|
|
3178
|
+
];
|
|
3179
|
+
function assertCrmCapabilities(probe, options = {}) {
|
|
3180
|
+
const caps = probe.capabilities;
|
|
3181
|
+
if (!caps) throw new Error("[crm] backend declares no `capabilities` descriptor (requires @classytic/repo-core 0.6 / mongokit 3.16+).");
|
|
3182
|
+
const missing = REQUIRED_CAPABILITIES.filter((flag) => caps[flag] !== true);
|
|
3183
|
+
if (missing.length > 0) throw new Error(`[crm] backend missing required capabilities: ${missing.join(", ")}. Lead dedup + conversion depend on them.`);
|
|
3184
|
+
if (caps.transactions !== true) {
|
|
3185
|
+
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.";
|
|
3186
|
+
if (options.allowNonTransactional) options.logger?.warn?.(msg);
|
|
3187
|
+
else throw new Error(msg);
|
|
2358
3188
|
}
|
|
2359
|
-
}
|
|
3189
|
+
}
|
|
2360
3190
|
//#endregion
|
|
2361
3191
|
//#region src/engine/create-crm.ts
|
|
2362
3192
|
async function createCrm(config) {
|
|
@@ -2365,6 +3195,8 @@ async function createCrm(config) {
|
|
|
2365
3195
|
connection: config.connection,
|
|
2366
3196
|
tenantFieldType: config.tenantFieldType ?? "objectId",
|
|
2367
3197
|
multiTenant: config.multiTenant ?? true,
|
|
3198
|
+
forceRecreate: config.forceRecreate ?? false,
|
|
3199
|
+
autoIndex: config.autoIndex ?? true,
|
|
2368
3200
|
...config.logger !== void 0 ? { logger: config.logger } : {},
|
|
2369
3201
|
...config.pagination !== void 0 ? { pagination: config.pagination } : {},
|
|
2370
3202
|
plugins: config.plugins ?? []
|
|
@@ -2375,11 +3207,18 @@ async function createCrm(config) {
|
|
|
2375
3207
|
events,
|
|
2376
3208
|
config: resolved
|
|
2377
3209
|
});
|
|
3210
|
+
assertCrmCapabilities(repositories.lead, {
|
|
3211
|
+
...config.allowNonTransactional !== void 0 ? { allowNonTransactional: config.allowNonTransactional } : {},
|
|
3212
|
+
...config.logger?.warn !== void 0 ? { logger: { warn: (m) => config.logger?.warn?.(m) } } : {}
|
|
3213
|
+
});
|
|
2378
3214
|
return Object.freeze({
|
|
2379
3215
|
models,
|
|
2380
3216
|
repositories,
|
|
2381
3217
|
events,
|
|
2382
3218
|
config: Object.freeze(resolved),
|
|
3219
|
+
async syncIndexes() {
|
|
3220
|
+
await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
|
|
3221
|
+
},
|
|
2383
3222
|
async dispose() {
|
|
2384
3223
|
await events.close?.();
|
|
2385
3224
|
}
|
|
@@ -2753,7 +3592,11 @@ const contactInfoSchema = z.object({
|
|
|
2753
3592
|
email: z.string().email().max(255).optional(),
|
|
2754
3593
|
personalEmail: z.string().email().max(255).optional(),
|
|
2755
3594
|
phone: z.string().max(50).optional(),
|
|
2756
|
-
alternatePhone: z.string().max(50).optional()
|
|
3595
|
+
alternatePhone: z.string().max(50).optional(),
|
|
3596
|
+
whatsapp: z.string().max(50).optional(),
|
|
3597
|
+
telegram: z.string().max(100).optional(),
|
|
3598
|
+
linkedin: z.string().max(255).optional(),
|
|
3599
|
+
instagram: z.string().max(100).optional()
|
|
2757
3600
|
});
|
|
2758
3601
|
const createContactSchema = z.object({
|
|
2759
3602
|
name: contactNameSchema,
|
|
@@ -2967,6 +3810,4 @@ var validators_exports = /* @__PURE__ */ __exportAll({
|
|
|
2967
3810
|
updatePipelineSchema: () => updatePipelineSchema
|
|
2968
3811
|
});
|
|
2969
3812
|
//#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
|
|
3813
|
+
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 };
|