@happyvertical/smrt-sales 0.54.2 → 0.54.3
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/AGENTS.md +2 -2
- package/README.md +21 -7
- package/agents/crm.md +41 -2
- package/agents/svelte.md +2 -2
- package/dist/agreements.js +2 -2
- package/dist/chunks/__smrt-register__-12v_iN-t.js +6 -0
- package/dist/chunks/__smrt-register__-12v_iN-t.js.map +1 -0
- package/dist/chunks/{agreements-CxHyIbZB.js → agreements-BB_tNU0U.js} +25 -1
- package/dist/chunks/agreements-BB_tNU0U.js.map +1 -0
- package/dist/chunks/{commissions-BlJpPXFC.js → commissions-Cs4MsxVg.js} +65 -1
- package/dist/chunks/commissions-Cs4MsxVg.js.map +1 -0
- package/dist/chunks/{crm-C2bD25yv.js → crm-DfRKKvWV.js} +850 -185
- package/dist/chunks/crm-DfRKKvWV.js.map +1 -0
- package/dist/chunks/{referrals-BsGHbnpW.js → referrals-CrFDsdq-.js} +82 -2
- package/dist/chunks/referrals-CrFDsdq-.js.map +1 -0
- package/dist/commissions.js +2 -2
- package/dist/crm/collections/LeadCollection.d.ts +20 -0
- package/dist/crm/collections/LeadCollection.d.ts.map +1 -1
- package/dist/crm/collections/OpportunityCollection.d.ts.map +1 -1
- package/dist/crm/collections/OpportunityConversionCollection.d.ts.map +1 -1
- package/dist/crm/index.d.ts +2 -1
- package/dist/crm/index.d.ts.map +1 -1
- package/dist/crm/services/LeadWorkflowService.d.ts +79 -5
- package/dist/crm/services/LeadWorkflowService.d.ts.map +1 -1
- package/dist/crm/services/lead-inbox.d.ts +54 -0
- package/dist/crm/services/lead-inbox.d.ts.map +1 -0
- package/dist/crm/types.d.ts +2 -0
- package/dist/crm/types.d.ts.map +1 -1
- package/dist/crm.js +2 -2
- package/dist/index.js +5 -5
- package/dist/manifest.json +17 -2
- package/dist/referrals.js +2 -2
- package/dist/smrt-knowledge.json +58 -9
- package/dist/svelte/components/LeadCreateForm.svelte +146 -0
- package/dist/svelte/components/LeadCreateForm.svelte.d.ts +22 -0
- package/dist/svelte/components/LeadCreateForm.svelte.d.ts.map +1 -0
- package/dist/svelte/components/LeadDetail.svelte +28 -0
- package/dist/svelte/components/LeadDetail.svelte.d.ts +6 -1
- package/dist/svelte/components/LeadDetail.svelte.d.ts.map +1 -1
- package/dist/svelte/components/LeadList.svelte +129 -3
- package/dist/svelte/components/LeadList.svelte.d.ts +29 -1
- package/dist/svelte/components/LeadList.svelte.d.ts.map +1 -1
- package/dist/svelte/index.d.ts +4 -2
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +2 -1
- package/dist/svelte/types.d.ts +49 -0
- package/dist/svelte/types.d.ts.map +1 -1
- package/package.json +10 -9
- package/dist/chunks/__smrt-register__-RRPKjeCe.js +0 -6
- package/dist/chunks/__smrt-register__-RRPKjeCe.js.map +0 -1
- package/dist/chunks/agreements-CxHyIbZB.js.map +0 -1
- package/dist/chunks/commissions-BlJpPXFC.js.map +0 -1
- package/dist/chunks/crm-C2bD25yv.js.map +0 -1
- package/dist/chunks/referrals-BsGHbnpW.js.map +0 -1
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
2
|
-
import { TenantContextError, TenantScoped, requireTenantId, tenantId } from "@happyvertical/smrt-tenancy";
|
|
2
|
+
import { TenantContextError, TenantScoped, getTenantId, requireTenantId, tenantId } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
//#region src/crm/models/Lead.ts
|
|
4
5
|
var __defProp$6 = Object.defineProperty;
|
|
5
6
|
var __getOwnPropDesc$6 = Object.getOwnPropertyDescriptor;
|
|
7
|
+
var __defNormalProp$6 = (obj, key, value) => key in obj ? __defProp$6(obj, key, {
|
|
8
|
+
enumerable: true,
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
value
|
|
12
|
+
}) : obj[key] = value;
|
|
6
13
|
var __decorateClass$6 = (decorators, target, key, kind) => {
|
|
7
14
|
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$6(target, key) : target;
|
|
8
15
|
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
9
16
|
if (kind && result) __defProp$6(target, key, result);
|
|
10
17
|
return result;
|
|
11
18
|
};
|
|
19
|
+
var __publicField$6 = (obj, key, value) => __defNormalProp$6(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
12
20
|
var LEAD_STATUS_TRANSITIONS = {
|
|
13
21
|
new: [
|
|
14
22
|
"working",
|
|
@@ -179,6 +187,7 @@ var Lead = class extends SmrtObject {
|
|
|
179
187
|
return loadedLeadStatus.get(this);
|
|
180
188
|
}
|
|
181
189
|
};
|
|
190
|
+
__publicField$6(Lead, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
182
191
|
__decorateClass$6([tenantId({ nullable: true })], Lead.prototype, "tenantId", 2);
|
|
183
192
|
__decorateClass$6([field({ required: true })], Lead.prototype, "name", 2);
|
|
184
193
|
__decorateClass$6([crossPackageRef("@happyvertical/smrt-profiles:Profile")], Lead.prototype, "profileId", 2);
|
|
@@ -199,15 +208,137 @@ Lead = __decorateClass$6([TenantScoped({ mode: "optional" }), smrt({
|
|
|
199
208
|
cli: { skipApiCheck: true }
|
|
200
209
|
})], Lead);
|
|
201
210
|
//#endregion
|
|
211
|
+
//#region src/crm/types.ts
|
|
212
|
+
var SALES_REPRESENTATIVE_STATUSES = [
|
|
213
|
+
"active",
|
|
214
|
+
"inactive",
|
|
215
|
+
"suspended"
|
|
216
|
+
];
|
|
217
|
+
var LEAD_STATUSES = [
|
|
218
|
+
"new",
|
|
219
|
+
"working",
|
|
220
|
+
"qualified",
|
|
221
|
+
"disqualified",
|
|
222
|
+
"merged"
|
|
223
|
+
];
|
|
224
|
+
var PIPELINE_STATUSES = ["active", "archived"];
|
|
225
|
+
var OPPORTUNITY_STATUSES = [
|
|
226
|
+
"open",
|
|
227
|
+
"won",
|
|
228
|
+
"lost"
|
|
229
|
+
];
|
|
230
|
+
var SALES_ACTIVITY_SUBJECT_KINDS = ["lead", "opportunity"];
|
|
231
|
+
var SALES_ACTIVITY_KINDS = [
|
|
232
|
+
"note",
|
|
233
|
+
"call",
|
|
234
|
+
"email",
|
|
235
|
+
"meeting",
|
|
236
|
+
"task",
|
|
237
|
+
"assignment",
|
|
238
|
+
"qualification",
|
|
239
|
+
"stage_change",
|
|
240
|
+
"merge",
|
|
241
|
+
"status_change"
|
|
242
|
+
];
|
|
243
|
+
var DEFAULT_PIPELINE_KEY = "default";
|
|
244
|
+
var DEFAULT_PIPELINE_STAGES = [
|
|
245
|
+
{
|
|
246
|
+
key: "new",
|
|
247
|
+
name: "New",
|
|
248
|
+
probability: .1
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
key: "qualified",
|
|
252
|
+
name: "Qualified",
|
|
253
|
+
probability: .25
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
key: "discovery",
|
|
257
|
+
name: "Discovery",
|
|
258
|
+
probability: .4
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
key: "proposal",
|
|
262
|
+
name: "Proposal",
|
|
263
|
+
probability: .6
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
key: "negotiation",
|
|
267
|
+
name: "Negotiation",
|
|
268
|
+
probability: .8
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
key: "closed_won",
|
|
272
|
+
name: "Closed Won",
|
|
273
|
+
probability: 1,
|
|
274
|
+
isWon: true
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
key: "closed_lost",
|
|
278
|
+
name: "Closed Lost",
|
|
279
|
+
probability: 0,
|
|
280
|
+
isLost: true
|
|
281
|
+
}
|
|
282
|
+
];
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/crm/services/lead-inbox.ts
|
|
285
|
+
var INBOX_SORTS = /* @__PURE__ */ new Set([
|
|
286
|
+
"created",
|
|
287
|
+
"next_action",
|
|
288
|
+
"name"
|
|
289
|
+
]);
|
|
290
|
+
function normalizeLeadInboxOptions(options) {
|
|
291
|
+
if (!options || typeof options !== "object") throw new Error("LeadCollection.listInbox: options are required");
|
|
292
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100) throw new Error("LeadCollection.listInbox: limit must be an integer between 1 and 100");
|
|
293
|
+
if (!Number.isSafeInteger(options.offset) || options.offset < 0) throw new Error("LeadCollection.listInbox: offset must be a non-negative integer");
|
|
294
|
+
if (options.status !== void 0 && !LEAD_STATUSES.includes(options.status)) throw new Error(`LeadCollection.listInbox: unsupported status '${String(options.status)}'`);
|
|
295
|
+
const sort = options.sort ?? "created";
|
|
296
|
+
if (!INBOX_SORTS.has(sort)) throw new Error(`LeadCollection.listInbox: unsupported sort '${String(sort)}'`);
|
|
297
|
+
if (options.ownerRepId !== void 0 && (typeof options.ownerRepId !== "string" || !options.ownerRepId.trim())) throw new Error("LeadCollection.listInbox: ownerRepId must be a non-empty string");
|
|
298
|
+
if (options.search !== void 0 && typeof options.search !== "string") throw new Error("LeadCollection.listInbox: search must be a string");
|
|
299
|
+
if (options.now !== void 0 && (!(options.now instanceof Date) || !Number.isFinite(options.now.getTime()))) throw new Error("LeadCollection.listInbox: now must be a valid Date");
|
|
300
|
+
const search = options.search?.trim();
|
|
301
|
+
return {
|
|
302
|
+
...options.status ? { status: options.status } : {},
|
|
303
|
+
...options.ownerRepId?.trim() ? { ownerRepId: options.ownerRepId.trim() } : {},
|
|
304
|
+
...search ? { search } : {},
|
|
305
|
+
unassigned: options.unassigned === true,
|
|
306
|
+
overdue: options.overdue === true,
|
|
307
|
+
sort,
|
|
308
|
+
limit: options.limit,
|
|
309
|
+
offset: options.offset,
|
|
310
|
+
now: options.now ?? /* @__PURE__ */ new Date()
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function emptyLeadInboxStatusCounts() {
|
|
314
|
+
return {
|
|
315
|
+
all: 0,
|
|
316
|
+
new: 0,
|
|
317
|
+
working: 0,
|
|
318
|
+
qualified: 0,
|
|
319
|
+
disqualified: 0,
|
|
320
|
+
merged: 0,
|
|
321
|
+
unassigned: 0,
|
|
322
|
+
overdue: 0
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
202
326
|
//#region src/crm/models/Opportunity.ts
|
|
203
327
|
var __defProp$5 = Object.defineProperty;
|
|
204
328
|
var __getOwnPropDesc$5 = Object.getOwnPropertyDescriptor;
|
|
329
|
+
var __defNormalProp$5 = (obj, key, value) => key in obj ? __defProp$5(obj, key, {
|
|
330
|
+
enumerable: true,
|
|
331
|
+
configurable: true,
|
|
332
|
+
writable: true,
|
|
333
|
+
value
|
|
334
|
+
}) : obj[key] = value;
|
|
205
335
|
var __decorateClass$5 = (decorators, target, key, kind) => {
|
|
206
336
|
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$5(target, key) : target;
|
|
207
337
|
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
208
338
|
if (kind && result) __defProp$5(target, key, result);
|
|
209
339
|
return result;
|
|
210
340
|
};
|
|
341
|
+
var __publicField$5 = (obj, key, value) => __defNormalProp$5(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
211
342
|
var OPPORTUNITY_STATUS_TRANSITIONS = {
|
|
212
343
|
open: ["won", "lost"],
|
|
213
344
|
won: [],
|
|
@@ -343,6 +474,7 @@ var Opportunity = class extends SmrtObject {
|
|
|
343
474
|
return loadedOpportunityStatus.get(this);
|
|
344
475
|
}
|
|
345
476
|
};
|
|
477
|
+
__publicField$5(Opportunity, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
346
478
|
__decorateClass$5([tenantId({ nullable: true })], Opportunity.prototype, "tenantId", 2);
|
|
347
479
|
__decorateClass$5([field({ required: true })], Opportunity.prototype, "name", 2);
|
|
348
480
|
__decorateClass$5([foreignKey("Lead")], Opportunity.prototype, "leadId", 2);
|
|
@@ -363,12 +495,19 @@ Opportunity = __decorateClass$5([TenantScoped({ mode: "optional" }), smrt({
|
|
|
363
495
|
//#region src/crm/models/PipelineStage.ts
|
|
364
496
|
var __defProp$4 = Object.defineProperty;
|
|
365
497
|
var __getOwnPropDesc$4 = Object.getOwnPropertyDescriptor;
|
|
498
|
+
var __defNormalProp$4 = (obj, key, value) => key in obj ? __defProp$4(obj, key, {
|
|
499
|
+
enumerable: true,
|
|
500
|
+
configurable: true,
|
|
501
|
+
writable: true,
|
|
502
|
+
value
|
|
503
|
+
}) : obj[key] = value;
|
|
366
504
|
var __decorateClass$4 = (decorators, target, key, kind) => {
|
|
367
505
|
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$4(target, key) : target;
|
|
368
506
|
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
369
507
|
if (kind && result) __defProp$4(target, key, result);
|
|
370
508
|
return result;
|
|
371
509
|
};
|
|
510
|
+
var __publicField$4 = (obj, key, value) => __defNormalProp$4(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
372
511
|
var PipelineStage = class extends SmrtObject {
|
|
373
512
|
tenantId = null;
|
|
374
513
|
pipelineId = "";
|
|
@@ -422,6 +561,7 @@ var PipelineStage = class extends SmrtObject {
|
|
|
422
561
|
this.metadata = JSON.stringify(metadata);
|
|
423
562
|
}
|
|
424
563
|
};
|
|
564
|
+
__publicField$4(PipelineStage, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
425
565
|
__decorateClass$4([tenantId({ nullable: true })], PipelineStage.prototype, "tenantId", 2);
|
|
426
566
|
__decorateClass$4([foreignKey("PipelineDefinition", { required: true })], PipelineStage.prototype, "pipelineId", 2);
|
|
427
567
|
__decorateClass$4([field({ required: true })], PipelineStage.prototype, "key", 2);
|
|
@@ -473,12 +613,19 @@ var PipelineStageCollection = class extends SmrtCollection {
|
|
|
473
613
|
//#region src/crm/models/SalesActivity.ts
|
|
474
614
|
var __defProp$3 = Object.defineProperty;
|
|
475
615
|
var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
|
|
616
|
+
var __defNormalProp$3 = (obj, key, value) => key in obj ? __defProp$3(obj, key, {
|
|
617
|
+
enumerable: true,
|
|
618
|
+
configurable: true,
|
|
619
|
+
writable: true,
|
|
620
|
+
value
|
|
621
|
+
}) : obj[key] = value;
|
|
476
622
|
var __decorateClass$3 = (decorators, target, key, kind) => {
|
|
477
623
|
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
|
|
478
624
|
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
479
625
|
if (kind && result) __defProp$3(target, key, result);
|
|
480
626
|
return result;
|
|
481
627
|
};
|
|
628
|
+
var __publicField$3 = (obj, key, value) => __defNormalProp$3(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
482
629
|
var workflowCompletionPermits = /* @__PURE__ */ new WeakSet();
|
|
483
630
|
function permitSalesActivityWorkflowCompletion(activity) {
|
|
484
631
|
workflowCompletionPermits.add(activity);
|
|
@@ -573,6 +720,7 @@ var SalesActivity = class extends SmrtObject {
|
|
|
573
720
|
}
|
|
574
721
|
}
|
|
575
722
|
};
|
|
723
|
+
__publicField$3(SalesActivity, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
576
724
|
__decorateClass$3([tenantId({ nullable: true })], SalesActivity.prototype, "tenantId", 2);
|
|
577
725
|
__decorateClass$3([field({ required: true })], SalesActivity.prototype, "subjectId", 2);
|
|
578
726
|
__decorateClass$3([crossPackageRef("@happyvertical/smrt-profiles:Profile")], SalesActivity.prototype, "actorProfileId", 2);
|
|
@@ -639,12 +787,20 @@ var OpportunityCollection = class extends SmrtCollection {
|
|
|
639
787
|
activityCollectionPromise = null;
|
|
640
788
|
/** Sibling stage collection sharing this collection's DB connection. */
|
|
641
789
|
async getStageCollection() {
|
|
642
|
-
if (!this.stageCollectionPromise) this.stageCollectionPromise = PipelineStageCollection.create({
|
|
790
|
+
if (!this.stageCollectionPromise) this.stageCollectionPromise = PipelineStageCollection.create({
|
|
791
|
+
db: this.db,
|
|
792
|
+
_reuseInitializedDb: true,
|
|
793
|
+
_deferRuntimeInitialization: true
|
|
794
|
+
});
|
|
643
795
|
return this.stageCollectionPromise;
|
|
644
796
|
}
|
|
645
797
|
/** Sibling activity collection sharing this collection's DB connection. */
|
|
646
798
|
async getActivityCollection() {
|
|
647
|
-
if (!this.activityCollectionPromise) this.activityCollectionPromise = SalesActivityCollection.create({
|
|
799
|
+
if (!this.activityCollectionPromise) this.activityCollectionPromise = SalesActivityCollection.create({
|
|
800
|
+
db: this.db,
|
|
801
|
+
_reuseInitializedDb: true,
|
|
802
|
+
_deferRuntimeInitialization: true
|
|
803
|
+
});
|
|
648
804
|
return this.activityCollectionPromise;
|
|
649
805
|
}
|
|
650
806
|
/**
|
|
@@ -732,12 +888,19 @@ var OpportunityCollection = class extends SmrtCollection {
|
|
|
732
888
|
//#region src/crm/models/PipelineDefinition.ts
|
|
733
889
|
var __defProp$2 = Object.defineProperty;
|
|
734
890
|
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
891
|
+
var __defNormalProp$2 = (obj, key, value) => key in obj ? __defProp$2(obj, key, {
|
|
892
|
+
enumerable: true,
|
|
893
|
+
configurable: true,
|
|
894
|
+
writable: true,
|
|
895
|
+
value
|
|
896
|
+
}) : obj[key] = value;
|
|
735
897
|
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
736
898
|
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
737
899
|
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
738
900
|
if (kind && result) __defProp$2(target, key, result);
|
|
739
901
|
return result;
|
|
740
902
|
};
|
|
903
|
+
var __publicField$2 = (obj, key, value) => __defNormalProp$2(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
741
904
|
var PipelineDefinition = class extends SmrtObject {
|
|
742
905
|
tenantId = null;
|
|
743
906
|
key = "";
|
|
@@ -783,6 +946,7 @@ var PipelineDefinition = class extends SmrtObject {
|
|
|
783
946
|
this.metadata = JSON.stringify(metadata);
|
|
784
947
|
}
|
|
785
948
|
};
|
|
949
|
+
__publicField$2(PipelineDefinition, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
786
950
|
__decorateClass$2([tenantId({ nullable: true })], PipelineDefinition.prototype, "tenantId", 2);
|
|
787
951
|
__decorateClass$2([field({ required: true })], PipelineDefinition.prototype, "key", 2);
|
|
788
952
|
PipelineDefinition = __decorateClass$2([TenantScoped({ mode: "optional" }), smrt({
|
|
@@ -797,79 +961,6 @@ PipelineDefinition = __decorateClass$2([TenantScoped({ mode: "optional" }), smrt
|
|
|
797
961
|
conflictColumns: ["tenant_id", "key"]
|
|
798
962
|
})], PipelineDefinition);
|
|
799
963
|
//#endregion
|
|
800
|
-
//#region src/crm/types.ts
|
|
801
|
-
var SALES_REPRESENTATIVE_STATUSES = [
|
|
802
|
-
"active",
|
|
803
|
-
"inactive",
|
|
804
|
-
"suspended"
|
|
805
|
-
];
|
|
806
|
-
var LEAD_STATUSES = [
|
|
807
|
-
"new",
|
|
808
|
-
"working",
|
|
809
|
-
"qualified",
|
|
810
|
-
"disqualified",
|
|
811
|
-
"merged"
|
|
812
|
-
];
|
|
813
|
-
var PIPELINE_STATUSES = ["active", "archived"];
|
|
814
|
-
var OPPORTUNITY_STATUSES = [
|
|
815
|
-
"open",
|
|
816
|
-
"won",
|
|
817
|
-
"lost"
|
|
818
|
-
];
|
|
819
|
-
var SALES_ACTIVITY_SUBJECT_KINDS = ["lead", "opportunity"];
|
|
820
|
-
var SALES_ACTIVITY_KINDS = [
|
|
821
|
-
"note",
|
|
822
|
-
"call",
|
|
823
|
-
"email",
|
|
824
|
-
"meeting",
|
|
825
|
-
"task",
|
|
826
|
-
"assignment",
|
|
827
|
-
"qualification",
|
|
828
|
-
"stage_change",
|
|
829
|
-
"merge",
|
|
830
|
-
"status_change"
|
|
831
|
-
];
|
|
832
|
-
var DEFAULT_PIPELINE_KEY = "default";
|
|
833
|
-
var DEFAULT_PIPELINE_STAGES = [
|
|
834
|
-
{
|
|
835
|
-
key: "new",
|
|
836
|
-
name: "New",
|
|
837
|
-
probability: .1
|
|
838
|
-
},
|
|
839
|
-
{
|
|
840
|
-
key: "qualified",
|
|
841
|
-
name: "Qualified",
|
|
842
|
-
probability: .25
|
|
843
|
-
},
|
|
844
|
-
{
|
|
845
|
-
key: "discovery",
|
|
846
|
-
name: "Discovery",
|
|
847
|
-
probability: .4
|
|
848
|
-
},
|
|
849
|
-
{
|
|
850
|
-
key: "proposal",
|
|
851
|
-
name: "Proposal",
|
|
852
|
-
probability: .6
|
|
853
|
-
},
|
|
854
|
-
{
|
|
855
|
-
key: "negotiation",
|
|
856
|
-
name: "Negotiation",
|
|
857
|
-
probability: .8
|
|
858
|
-
},
|
|
859
|
-
{
|
|
860
|
-
key: "closed_won",
|
|
861
|
-
name: "Closed Won",
|
|
862
|
-
probability: 1,
|
|
863
|
-
isWon: true
|
|
864
|
-
},
|
|
865
|
-
{
|
|
866
|
-
key: "closed_lost",
|
|
867
|
-
name: "Closed Lost",
|
|
868
|
-
probability: 0,
|
|
869
|
-
isLost: true
|
|
870
|
-
}
|
|
871
|
-
];
|
|
872
|
-
//#endregion
|
|
873
964
|
//#region src/crm/collections/PipelineDefinitionCollection.ts
|
|
874
965
|
function requireId$1(value, what) {
|
|
875
966
|
if (!value) throw new Error(`PipelineDefinitionCollection: ${what} has no id`);
|
|
@@ -971,6 +1062,105 @@ var PipelineDefinitionCollection = class extends SmrtCollection {
|
|
|
971
1062
|
}
|
|
972
1063
|
};
|
|
973
1064
|
//#endregion
|
|
1065
|
+
//#region src/crm/models/SalesRepresentative.ts
|
|
1066
|
+
var __defProp$1 = Object.defineProperty;
|
|
1067
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
1068
|
+
var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, {
|
|
1069
|
+
enumerable: true,
|
|
1070
|
+
configurable: true,
|
|
1071
|
+
writable: true,
|
|
1072
|
+
value
|
|
1073
|
+
}) : obj[key] = value;
|
|
1074
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
1075
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
1076
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1077
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
1078
|
+
return result;
|
|
1079
|
+
};
|
|
1080
|
+
var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
1081
|
+
var SalesRepresentative = class extends SmrtObject {
|
|
1082
|
+
tenantId = null;
|
|
1083
|
+
profileId = "";
|
|
1084
|
+
earnerId = "";
|
|
1085
|
+
/** Role status — only `active` reps should receive new assignments. */
|
|
1086
|
+
status = "active";
|
|
1087
|
+
/** Job title shown on CRM surfaces (e.g. `'Account Executive'`). */
|
|
1088
|
+
title = "";
|
|
1089
|
+
/**
|
|
1090
|
+
* Free-form JSON object stored as a string. Use
|
|
1091
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
1092
|
+
*/
|
|
1093
|
+
metadata = "{}";
|
|
1094
|
+
constructor(options = {}) {
|
|
1095
|
+
super(options);
|
|
1096
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
1097
|
+
if (options.profileId !== void 0) this.profileId = options.profileId;
|
|
1098
|
+
if (options.earnerId !== void 0) this.earnerId = options.earnerId;
|
|
1099
|
+
if (options.status !== void 0) this.status = options.status;
|
|
1100
|
+
if (options.title !== void 0) this.title = options.title;
|
|
1101
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
1102
|
+
}
|
|
1103
|
+
/** Whether the rep may receive new lead/opportunity assignments. */
|
|
1104
|
+
isActive() {
|
|
1105
|
+
return this.status === "active";
|
|
1106
|
+
}
|
|
1107
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
1108
|
+
getMetadata() {
|
|
1109
|
+
try {
|
|
1110
|
+
const parsed = JSON.parse(this.metadata);
|
|
1111
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1112
|
+
return {};
|
|
1113
|
+
} catch {
|
|
1114
|
+
return {};
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/** Serialize and store the metadata object. */
|
|
1118
|
+
setMetadata(metadata) {
|
|
1119
|
+
this.metadata = JSON.stringify(metadata);
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
__publicField$1(SalesRepresentative, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
1123
|
+
__decorateClass$1([tenantId({ nullable: true })], SalesRepresentative.prototype, "tenantId", 2);
|
|
1124
|
+
__decorateClass$1([crossPackageRef("@happyvertical/smrt-profiles:Profile")], SalesRepresentative.prototype, "profileId", 2);
|
|
1125
|
+
__decorateClass$1([foreignKey("Earner")], SalesRepresentative.prototype, "earnerId", 2);
|
|
1126
|
+
SalesRepresentative = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
|
|
1127
|
+
api: { include: [
|
|
1128
|
+
"list",
|
|
1129
|
+
"get",
|
|
1130
|
+
"create",
|
|
1131
|
+
"update"
|
|
1132
|
+
] },
|
|
1133
|
+
mcp: { include: ["list", "get"] },
|
|
1134
|
+
cli: { skipApiCheck: true }
|
|
1135
|
+
})], SalesRepresentative);
|
|
1136
|
+
//#endregion
|
|
1137
|
+
//#region src/crm/collections/SalesRepresentativeCollection.ts
|
|
1138
|
+
var SalesRepresentativeCollection = class extends SmrtCollection {
|
|
1139
|
+
static _itemClass = SalesRepresentative;
|
|
1140
|
+
/**
|
|
1141
|
+
* Find rep roles held by a given profile (a profile may hold the role in
|
|
1142
|
+
* several tenants/contexts).
|
|
1143
|
+
*
|
|
1144
|
+
* @param profileId - smrt-profiles Profile id
|
|
1145
|
+
* @returns Reps for that profile, newest first
|
|
1146
|
+
*/
|
|
1147
|
+
async findByProfile(profileId) {
|
|
1148
|
+
return await this.list({
|
|
1149
|
+
where: { profileId },
|
|
1150
|
+
orderBy: "created_at DESC"
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Find all active reps (eligible for new lead/opportunity assignment).
|
|
1155
|
+
*/
|
|
1156
|
+
async findActive() {
|
|
1157
|
+
return await this.list({
|
|
1158
|
+
where: { status: "active" },
|
|
1159
|
+
orderBy: "created_at DESC"
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
//#endregion
|
|
974
1164
|
//#region src/crm/collections/LeadCollection.ts
|
|
975
1165
|
var MERGE_FILL_CONTACT_FIELDS = [
|
|
976
1166
|
"contactName",
|
|
@@ -987,21 +1177,43 @@ var LeadCollection = class extends SmrtCollection {
|
|
|
987
1177
|
opportunityCollectionPromise = null;
|
|
988
1178
|
pipelineCollectionPromise = null;
|
|
989
1179
|
activityCollectionPromise = null;
|
|
1180
|
+
representativeCollectionPromise = null;
|
|
990
1181
|
/** Sibling opportunity collection sharing this collection's DB connection. */
|
|
991
1182
|
async getOpportunityCollection() {
|
|
992
|
-
if (!this.opportunityCollectionPromise) this.opportunityCollectionPromise = OpportunityCollection.create({
|
|
1183
|
+
if (!this.opportunityCollectionPromise) this.opportunityCollectionPromise = OpportunityCollection.create({
|
|
1184
|
+
db: this.db,
|
|
1185
|
+
_reuseInitializedDb: true,
|
|
1186
|
+
_deferRuntimeInitialization: true
|
|
1187
|
+
});
|
|
993
1188
|
return this.opportunityCollectionPromise;
|
|
994
1189
|
}
|
|
995
1190
|
/** Sibling pipeline collection sharing this collection's DB connection. */
|
|
996
1191
|
async getPipelineCollection() {
|
|
997
|
-
if (!this.pipelineCollectionPromise) this.pipelineCollectionPromise = PipelineDefinitionCollection.create({
|
|
1192
|
+
if (!this.pipelineCollectionPromise) this.pipelineCollectionPromise = PipelineDefinitionCollection.create({
|
|
1193
|
+
db: this.db,
|
|
1194
|
+
_reuseInitializedDb: true,
|
|
1195
|
+
_deferRuntimeInitialization: true
|
|
1196
|
+
});
|
|
998
1197
|
return this.pipelineCollectionPromise;
|
|
999
1198
|
}
|
|
1000
1199
|
/** Sibling activity collection sharing this collection's DB connection. */
|
|
1001
1200
|
async getActivityCollection() {
|
|
1002
|
-
if (!this.activityCollectionPromise) this.activityCollectionPromise = SalesActivityCollection.create({
|
|
1201
|
+
if (!this.activityCollectionPromise) this.activityCollectionPromise = SalesActivityCollection.create({
|
|
1202
|
+
db: this.db,
|
|
1203
|
+
_reuseInitializedDb: true,
|
|
1204
|
+
_deferRuntimeInitialization: true
|
|
1205
|
+
});
|
|
1003
1206
|
return this.activityCollectionPromise;
|
|
1004
1207
|
}
|
|
1208
|
+
/** Sibling representative collection sharing this collection's DB connection. */
|
|
1209
|
+
async getRepresentativeCollection() {
|
|
1210
|
+
if (!this.representativeCollectionPromise) this.representativeCollectionPromise = SalesRepresentativeCollection.create({
|
|
1211
|
+
db: this.db,
|
|
1212
|
+
_reuseInitializedDb: true,
|
|
1213
|
+
_deferRuntimeInitialization: true
|
|
1214
|
+
});
|
|
1215
|
+
return this.representativeCollectionPromise;
|
|
1216
|
+
}
|
|
1005
1217
|
/** Leads in a given lifecycle status, newest first. */
|
|
1006
1218
|
async findByStatus(status) {
|
|
1007
1219
|
return await this.list({
|
|
@@ -1017,6 +1229,107 @@ var LeadCollection = class extends SmrtCollection {
|
|
|
1017
1229
|
});
|
|
1018
1230
|
}
|
|
1019
1231
|
/**
|
|
1232
|
+
* Return one tenant's Lead inbox with exact pagination and tab counts.
|
|
1233
|
+
*
|
|
1234
|
+
* `total` applies every requested filter. `statusCounts` applies the same
|
|
1235
|
+
* owner and search scope, but deliberately ignores status/unassigned/overdue
|
|
1236
|
+
* so a host can switch tabs without issuing a second request. Search is a
|
|
1237
|
+
* trimmed, case-insensitive literal substring across name, contact name,
|
|
1238
|
+
* email, phone, and organization. `next_action` sorts the earliest open task
|
|
1239
|
+
* first and places Leads without an open task last. Every order has an id
|
|
1240
|
+
* tie-breaker, so offset pages remain stable while the underlying data does.
|
|
1241
|
+
*
|
|
1242
|
+
* The read performs a constant number of queries: page, counts, earliest
|
|
1243
|
+
* open tasks, and owners. It requires ambient tenancy and applies an explicit
|
|
1244
|
+
* tenant predicate to both Lead and activity SQL.
|
|
1245
|
+
*/
|
|
1246
|
+
async listInbox(options) {
|
|
1247
|
+
const tenantId = getTenantId();
|
|
1248
|
+
if (!tenantId) throw new TenantContextError("LeadCollection.listInbox: an ambient tenant context is required");
|
|
1249
|
+
const normalized = normalizeLeadInboxOptions(options);
|
|
1250
|
+
const now = normalized.now.toISOString();
|
|
1251
|
+
const readScope = await this.resolveListReadPredicate();
|
|
1252
|
+
const base = buildInboxBasePredicate(normalized, tenantId, readScope);
|
|
1253
|
+
const tab = buildInboxTabPredicate(normalized, now);
|
|
1254
|
+
const nextActionsCte = inboxNextActionsCte();
|
|
1255
|
+
const orderBy = inboxOrderBy(normalized.sort);
|
|
1256
|
+
const leads = await this.query(`${nextActionsCte.sql}
|
|
1257
|
+
SELECT l.*
|
|
1258
|
+
FROM ${this.tableName} l
|
|
1259
|
+
LEFT JOIN inbox_next_actions na ON na.subject_id = CAST(l.id AS TEXT)
|
|
1260
|
+
WHERE (${base.sql}) AND (${tab.sql})
|
|
1261
|
+
ORDER BY ${orderBy}
|
|
1262
|
+
LIMIT ? OFFSET ?`, [
|
|
1263
|
+
tenantId,
|
|
1264
|
+
...base.values,
|
|
1265
|
+
...tab.values,
|
|
1266
|
+
normalized.limit,
|
|
1267
|
+
normalized.offset
|
|
1268
|
+
], { allowRawOnTenantScoped: true });
|
|
1269
|
+
const visibleLeads = await readScope.finish(leads);
|
|
1270
|
+
const countResult = await this.db.query(`${nextActionsCte.sql}
|
|
1271
|
+
SELECT l.status,
|
|
1272
|
+
COUNT(*) AS status_count,
|
|
1273
|
+
SUM(CASE WHEN l.owner_rep_id IS NULL OR CAST(l.owner_rep_id AS TEXT) = '' THEN 1 ELSE 0 END) AS unassigned_count,
|
|
1274
|
+
SUM(CASE WHEN na.due_at < ? THEN 1 ELSE 0 END) AS overdue_count,
|
|
1275
|
+
SUM(CASE WHEN ${tab.sql} THEN 1 ELSE 0 END) AS filtered_count
|
|
1276
|
+
FROM ${this.tableName} l
|
|
1277
|
+
LEFT JOIN inbox_next_actions na ON na.subject_id = CAST(l.id AS TEXT)
|
|
1278
|
+
WHERE ${base.sql}
|
|
1279
|
+
GROUP BY l.status`, tenantId, now, ...tab.values, ...base.values);
|
|
1280
|
+
const statusCounts = emptyLeadInboxStatusCounts();
|
|
1281
|
+
let total = 0;
|
|
1282
|
+
for (const row of countResult.rows) {
|
|
1283
|
+
const status = String(row.status);
|
|
1284
|
+
if (status in statusCounts && status !== "all") statusCounts[status] = toInboxCount(row.status_count);
|
|
1285
|
+
statusCounts.all += toInboxCount(row.status_count);
|
|
1286
|
+
statusCounts.unassigned += toInboxCount(row.unassigned_count);
|
|
1287
|
+
statusCounts.overdue += toInboxCount(row.overdue_count);
|
|
1288
|
+
total += toInboxCount(row.filtered_count);
|
|
1289
|
+
}
|
|
1290
|
+
if (visibleLeads.length === 0) return {
|
|
1291
|
+
leads: visibleLeads,
|
|
1292
|
+
items: [],
|
|
1293
|
+
total,
|
|
1294
|
+
statusCounts
|
|
1295
|
+
};
|
|
1296
|
+
const leadIds = visibleLeads.map((lead) => requireId(lead.id, "inbox lead"));
|
|
1297
|
+
const activities = await this.getActivityCollection();
|
|
1298
|
+
const idPlaceholders = leadIds.map(() => "?").join(", ");
|
|
1299
|
+
const nextActions = await activities.query(`SELECT a.*
|
|
1300
|
+
FROM sales_activities a
|
|
1301
|
+
WHERE a.tenant_id = ?
|
|
1302
|
+
AND a.subject_kind = 'lead'
|
|
1303
|
+
AND a.activity_kind = 'task'
|
|
1304
|
+
AND a.completed_at IS NULL
|
|
1305
|
+
AND a.due_at IS NOT NULL
|
|
1306
|
+
AND a.subject_id IN (${idPlaceholders})
|
|
1307
|
+
AND NOT EXISTS (
|
|
1308
|
+
SELECT 1 FROM sales_activities earlier
|
|
1309
|
+
WHERE earlier.tenant_id = a.tenant_id
|
|
1310
|
+
AND earlier.subject_kind = a.subject_kind
|
|
1311
|
+
AND earlier.subject_id = a.subject_id
|
|
1312
|
+
AND earlier.activity_kind = 'task'
|
|
1313
|
+
AND earlier.completed_at IS NULL
|
|
1314
|
+
AND earlier.due_at IS NOT NULL
|
|
1315
|
+
AND (earlier.due_at < a.due_at OR (earlier.due_at = a.due_at AND earlier.id < a.id))
|
|
1316
|
+
)`, [tenantId, ...leadIds], { allowRawOnTenantScoped: true });
|
|
1317
|
+
const nextActionByLead = new Map(nextActions.map((activity) => [activity.subjectId, activity]));
|
|
1318
|
+
const ownerIds = [...new Set(visibleLeads.map((lead) => lead.ownerRepId).filter((id) => id !== ""))];
|
|
1319
|
+
const owners = ownerIds.length ? await (await this.getRepresentativeCollection()).listByIds(ownerIds) : [];
|
|
1320
|
+
const ownerById = new Map(owners.map((owner) => [requireId(owner.id, "inbox owner"), owner]));
|
|
1321
|
+
return {
|
|
1322
|
+
leads: visibleLeads,
|
|
1323
|
+
items: visibleLeads.map((lead) => ({
|
|
1324
|
+
lead,
|
|
1325
|
+
owner: ownerById.get(lead.ownerRepId) ?? null,
|
|
1326
|
+
nextAction: nextActionByLead.get(requireId(lead.id, "inbox lead")) ?? null
|
|
1327
|
+
})),
|
|
1328
|
+
total,
|
|
1329
|
+
statusCounts
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1020
1333
|
* Qualify a lead into an Opportunity.
|
|
1021
1334
|
*
|
|
1022
1335
|
* Transitions the lead to `qualified` (stamping `qualifiedAt` once) and
|
|
@@ -1086,6 +1399,7 @@ var LeadCollection = class extends SmrtCollection {
|
|
|
1086
1399
|
subjectId: leadId,
|
|
1087
1400
|
activityKind: "qualification",
|
|
1088
1401
|
summary: `Qualified into opportunity '${opportunity.name}'`,
|
|
1402
|
+
actorProfileId: params.actorProfileId ?? "",
|
|
1089
1403
|
metadata: JSON.stringify({
|
|
1090
1404
|
opportunityId,
|
|
1091
1405
|
pipelineId: pipeline.id ?? "",
|
|
@@ -1099,6 +1413,7 @@ var LeadCollection = class extends SmrtCollection {
|
|
|
1099
1413
|
subjectId: opportunityId,
|
|
1100
1414
|
activityKind: "qualification",
|
|
1101
1415
|
summary: `Created from qualified lead '${lead.name}'`,
|
|
1416
|
+
actorProfileId: params.actorProfileId ?? "",
|
|
1102
1417
|
metadata: JSON.stringify({
|
|
1103
1418
|
leadId,
|
|
1104
1419
|
pipelineId: pipeline.id ?? "",
|
|
@@ -1237,16 +1552,90 @@ var LeadCollection = class extends SmrtCollection {
|
|
|
1237
1552
|
});
|
|
1238
1553
|
}
|
|
1239
1554
|
};
|
|
1555
|
+
function inboxNextActionsCte() {
|
|
1556
|
+
return {
|
|
1557
|
+
sql: `WITH inbox_next_actions AS (
|
|
1558
|
+
SELECT subject_id, MIN(due_at) AS due_at
|
|
1559
|
+
FROM sales_activities
|
|
1560
|
+
WHERE tenant_id = ?
|
|
1561
|
+
AND subject_kind = 'lead'
|
|
1562
|
+
AND activity_kind = 'task'
|
|
1563
|
+
AND completed_at IS NULL
|
|
1564
|
+
AND due_at IS NOT NULL
|
|
1565
|
+
GROUP BY subject_id
|
|
1566
|
+
)`,
|
|
1567
|
+
values: []
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
function buildInboxBasePredicate(options, tenantId, readScope) {
|
|
1571
|
+
const clauses = ["l.tenant_id = ?", `(${readScope.sql})`];
|
|
1572
|
+
const values = [tenantId, ...readScope.values];
|
|
1573
|
+
if (options.ownerRepId) {
|
|
1574
|
+
clauses.push("l.owner_rep_id = ?");
|
|
1575
|
+
values.push(options.ownerRepId);
|
|
1576
|
+
}
|
|
1577
|
+
if (options.search) {
|
|
1578
|
+
const match = `%${options.search.toLowerCase().replace(/[!%_]/g, "!$&")}%`;
|
|
1579
|
+
clauses.push(`(
|
|
1580
|
+
LOWER(COALESCE(l.name, '')) LIKE ? ESCAPE '!'
|
|
1581
|
+
OR LOWER(COALESCE(l.contact_name, '')) LIKE ? ESCAPE '!'
|
|
1582
|
+
OR LOWER(COALESCE(l.email, '')) LIKE ? ESCAPE '!'
|
|
1583
|
+
OR LOWER(COALESCE(l.phone, '')) LIKE ? ESCAPE '!'
|
|
1584
|
+
OR LOWER(COALESCE(l.organization_name, '')) LIKE ? ESCAPE '!'
|
|
1585
|
+
)`);
|
|
1586
|
+
values.push(match, match, match, match, match);
|
|
1587
|
+
}
|
|
1588
|
+
return {
|
|
1589
|
+
sql: clauses.join(" AND "),
|
|
1590
|
+
values
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
function buildInboxTabPredicate(options, now) {
|
|
1594
|
+
const clauses = [];
|
|
1595
|
+
const values = [];
|
|
1596
|
+
if (options.status) {
|
|
1597
|
+
clauses.push("l.status = ?");
|
|
1598
|
+
values.push(options.status);
|
|
1599
|
+
}
|
|
1600
|
+
if (options.unassigned) clauses.push("(l.owner_rep_id IS NULL OR CAST(l.owner_rep_id AS TEXT) = '')");
|
|
1601
|
+
if (options.overdue) {
|
|
1602
|
+
clauses.push("na.due_at < ?");
|
|
1603
|
+
values.push(now);
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
sql: clauses.join(" AND ") || "1 = 1",
|
|
1607
|
+
values
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
function inboxOrderBy(sort) {
|
|
1611
|
+
switch (sort) {
|
|
1612
|
+
case "name": return "LOWER(l.name) ASC, l.id ASC";
|
|
1613
|
+
case "next_action": return "CASE WHEN na.due_at IS NULL THEN 1 ELSE 0 END ASC, na.due_at ASC, l.id ASC";
|
|
1614
|
+
default: return "l.created_at DESC, l.id ASC";
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
function toInboxCount(value) {
|
|
1618
|
+
const count = Number(value ?? 0);
|
|
1619
|
+
if (!Number.isSafeInteger(count) || count < 0) throw new Error(`LeadCollection.listInbox: database returned invalid count '${String(value)}'`);
|
|
1620
|
+
return count;
|
|
1621
|
+
}
|
|
1240
1622
|
//#endregion
|
|
1241
1623
|
//#region src/crm/models/OpportunityConversion.ts
|
|
1242
|
-
var __defProp
|
|
1243
|
-
var __getOwnPropDesc
|
|
1244
|
-
var
|
|
1245
|
-
|
|
1624
|
+
var __defProp = Object.defineProperty;
|
|
1625
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
1626
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
1627
|
+
enumerable: true,
|
|
1628
|
+
configurable: true,
|
|
1629
|
+
writable: true,
|
|
1630
|
+
value
|
|
1631
|
+
}) : obj[key] = value;
|
|
1632
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
1633
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
1246
1634
|
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1247
|
-
if (kind && result) __defProp
|
|
1635
|
+
if (kind && result) __defProp(target, key, result);
|
|
1248
1636
|
return result;
|
|
1249
1637
|
};
|
|
1638
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
1250
1639
|
var OpportunityConversion = class extends SmrtObject {
|
|
1251
1640
|
tenantId = null;
|
|
1252
1641
|
opportunityId = "";
|
|
@@ -1301,11 +1690,12 @@ var OpportunityConversion = class extends SmrtObject {
|
|
|
1301
1690
|
return await super.save();
|
|
1302
1691
|
}
|
|
1303
1692
|
};
|
|
1304
|
-
|
|
1305
|
-
__decorateClass
|
|
1306
|
-
__decorateClass
|
|
1307
|
-
__decorateClass
|
|
1308
|
-
|
|
1693
|
+
__publicField(OpportunityConversion, "__smrtPackage__", "@happyvertical/smrt-sales");
|
|
1694
|
+
__decorateClass([tenantId({ nullable: true })], OpportunityConversion.prototype, "tenantId", 2);
|
|
1695
|
+
__decorateClass([foreignKey("Opportunity", { required: true })], OpportunityConversion.prototype, "opportunityId", 2);
|
|
1696
|
+
__decorateClass([field({ required: true })], OpportunityConversion.prototype, "targetKind", 2);
|
|
1697
|
+
__decorateClass([field({ required: true })], OpportunityConversion.prototype, "targetId", 2);
|
|
1698
|
+
OpportunityConversion = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
1309
1699
|
api: { include: [
|
|
1310
1700
|
"list",
|
|
1311
1701
|
"get",
|
|
@@ -1326,7 +1716,11 @@ var OpportunityConversionCollection = class extends SmrtCollection {
|
|
|
1326
1716
|
opportunityCollectionPromise = null;
|
|
1327
1717
|
/** Sibling opportunity collection sharing this collection's DB connection. */
|
|
1328
1718
|
async getOpportunityCollection() {
|
|
1329
|
-
if (!this.opportunityCollectionPromise) this.opportunityCollectionPromise = OpportunityCollection.create({
|
|
1719
|
+
if (!this.opportunityCollectionPromise) this.opportunityCollectionPromise = OpportunityCollection.create({
|
|
1720
|
+
db: this.db,
|
|
1721
|
+
_reuseInitializedDb: true,
|
|
1722
|
+
_deferRuntimeInitialization: true
|
|
1723
|
+
});
|
|
1330
1724
|
return this.opportunityCollectionPromise;
|
|
1331
1725
|
}
|
|
1332
1726
|
/** All conversion links recorded for one opportunity, oldest first. */
|
|
@@ -1377,97 +1771,6 @@ var OpportunityConversionCollection = class extends SmrtCollection {
|
|
|
1377
1771
|
}
|
|
1378
1772
|
};
|
|
1379
1773
|
//#endregion
|
|
1380
|
-
//#region src/crm/models/SalesRepresentative.ts
|
|
1381
|
-
var __defProp = Object.defineProperty;
|
|
1382
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
1383
|
-
var __decorateClass = (decorators, target, key, kind) => {
|
|
1384
|
-
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
1385
|
-
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1386
|
-
if (kind && result) __defProp(target, key, result);
|
|
1387
|
-
return result;
|
|
1388
|
-
};
|
|
1389
|
-
var SalesRepresentative = class extends SmrtObject {
|
|
1390
|
-
tenantId = null;
|
|
1391
|
-
profileId = "";
|
|
1392
|
-
earnerId = "";
|
|
1393
|
-
/** Role status — only `active` reps should receive new assignments. */
|
|
1394
|
-
status = "active";
|
|
1395
|
-
/** Job title shown on CRM surfaces (e.g. `'Account Executive'`). */
|
|
1396
|
-
title = "";
|
|
1397
|
-
/**
|
|
1398
|
-
* Free-form JSON object stored as a string. Use
|
|
1399
|
-
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
1400
|
-
*/
|
|
1401
|
-
metadata = "{}";
|
|
1402
|
-
constructor(options = {}) {
|
|
1403
|
-
super(options);
|
|
1404
|
-
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
1405
|
-
if (options.profileId !== void 0) this.profileId = options.profileId;
|
|
1406
|
-
if (options.earnerId !== void 0) this.earnerId = options.earnerId;
|
|
1407
|
-
if (options.status !== void 0) this.status = options.status;
|
|
1408
|
-
if (options.title !== void 0) this.title = options.title;
|
|
1409
|
-
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
1410
|
-
}
|
|
1411
|
-
/** Whether the rep may receive new lead/opportunity assignments. */
|
|
1412
|
-
isActive() {
|
|
1413
|
-
return this.status === "active";
|
|
1414
|
-
}
|
|
1415
|
-
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
1416
|
-
getMetadata() {
|
|
1417
|
-
try {
|
|
1418
|
-
const parsed = JSON.parse(this.metadata);
|
|
1419
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1420
|
-
return {};
|
|
1421
|
-
} catch {
|
|
1422
|
-
return {};
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
/** Serialize and store the metadata object. */
|
|
1426
|
-
setMetadata(metadata) {
|
|
1427
|
-
this.metadata = JSON.stringify(metadata);
|
|
1428
|
-
}
|
|
1429
|
-
};
|
|
1430
|
-
__decorateClass([tenantId({ nullable: true })], SalesRepresentative.prototype, "tenantId", 2);
|
|
1431
|
-
__decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], SalesRepresentative.prototype, "profileId", 2);
|
|
1432
|
-
__decorateClass([foreignKey("Earner")], SalesRepresentative.prototype, "earnerId", 2);
|
|
1433
|
-
SalesRepresentative = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
1434
|
-
api: { include: [
|
|
1435
|
-
"list",
|
|
1436
|
-
"get",
|
|
1437
|
-
"create",
|
|
1438
|
-
"update"
|
|
1439
|
-
] },
|
|
1440
|
-
mcp: { include: ["list", "get"] },
|
|
1441
|
-
cli: { skipApiCheck: true }
|
|
1442
|
-
})], SalesRepresentative);
|
|
1443
|
-
//#endregion
|
|
1444
|
-
//#region src/crm/collections/SalesRepresentativeCollection.ts
|
|
1445
|
-
var SalesRepresentativeCollection = class extends SmrtCollection {
|
|
1446
|
-
static _itemClass = SalesRepresentative;
|
|
1447
|
-
/**
|
|
1448
|
-
* Find rep roles held by a given profile (a profile may hold the role in
|
|
1449
|
-
* several tenants/contexts).
|
|
1450
|
-
*
|
|
1451
|
-
* @param profileId - smrt-profiles Profile id
|
|
1452
|
-
* @returns Reps for that profile, newest first
|
|
1453
|
-
*/
|
|
1454
|
-
async findByProfile(profileId) {
|
|
1455
|
-
return await this.list({
|
|
1456
|
-
where: { profileId },
|
|
1457
|
-
orderBy: "created_at DESC"
|
|
1458
|
-
});
|
|
1459
|
-
}
|
|
1460
|
-
/**
|
|
1461
|
-
* Find all active reps (eligible for new lead/opportunity assignment).
|
|
1462
|
-
*/
|
|
1463
|
-
async findActive() {
|
|
1464
|
-
return await this.list({
|
|
1465
|
-
where: { status: "active" },
|
|
1466
|
-
orderBy: "created_at DESC"
|
|
1467
|
-
});
|
|
1468
|
-
}
|
|
1469
|
-
};
|
|
1470
|
-
//#endregion
|
|
1471
1774
|
//#region src/crm/services/LeadWorkflowService.ts
|
|
1472
1775
|
var LEAD_HUMAN_ACTIVITY_KINDS = [
|
|
1473
1776
|
"note",
|
|
@@ -1511,8 +1814,219 @@ var LeadWorkflowService = class LeadWorkflowService {
|
|
|
1511
1814
|
return new LeadWorkflowService({
|
|
1512
1815
|
leads: await LeadCollection.create(options),
|
|
1513
1816
|
activities: await SalesActivityCollection.create(options),
|
|
1514
|
-
representatives: await SalesRepresentativeCollection.create(options)
|
|
1817
|
+
representatives: await SalesRepresentativeCollection.create(options),
|
|
1818
|
+
opportunities: await OpportunityCollection.create(options),
|
|
1819
|
+
conversions: await OpportunityConversionCollection.create(options),
|
|
1820
|
+
pipelines: await PipelineDefinitionCollection.create(options),
|
|
1821
|
+
stages: await PipelineStageCollection.create(options)
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
/** Create or explicitly report a tenant-local duplicate Lead. */
|
|
1825
|
+
async createLead(input) {
|
|
1826
|
+
const normalized = this.normalizeCreateLeadInput(input);
|
|
1827
|
+
const tenantId = this.requireActiveTenant();
|
|
1828
|
+
const intentHash = sha256(stableStringify(normalized.intent));
|
|
1829
|
+
const operationId = normalized.idempotencyKey || normalized.dedupe !== "none" ? uuidFromHash(sha256(`lead-intake:${tenantId}:${normalized.idempotencyKey ? `key:${normalized.idempotencyKey}` : `intent:${intentHash}`}`)) : void 0;
|
|
1830
|
+
const result = await this.runMutation(async (deps, activeTenantId) => {
|
|
1831
|
+
if (operationId) {
|
|
1832
|
+
await this.lockOperationFence(deps, `lead-intake:${operationId}`);
|
|
1833
|
+
const replay = await deps.activities.get({ id: operationId }, { cache: false });
|
|
1834
|
+
if (replay) {
|
|
1835
|
+
const metadata = replay.getMetadata();
|
|
1836
|
+
if (replay.activityKind !== "lead_intake" && replay.activityKind !== "inbound") throw this.refusal("idempotency_conflict", "Lead intake operation key is already used by another activity");
|
|
1837
|
+
if (metadata.intentHash !== intentHash) throw this.refusal("idempotency_conflict", "Lead intake idempotency key was already used with different input");
|
|
1838
|
+
return {
|
|
1839
|
+
leadId: replay.subjectId,
|
|
1840
|
+
created: false
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (normalized.ownerRepId) {
|
|
1845
|
+
const representative = await deps.representatives.get({ id: normalized.ownerRepId }, { cache: false });
|
|
1846
|
+
if (!representative) throw this.refusal("representative_unavailable", "Representative is unavailable in the active tenant");
|
|
1847
|
+
if (!representative.isActive()) throw this.refusal("representative_inactive", "Representative is not active");
|
|
1848
|
+
}
|
|
1849
|
+
const matches = await this.findDedupeMatches(deps, activeTenantId, normalized);
|
|
1850
|
+
const active = matches.find((candidate) => candidate.status === "new" || candidate.status === "working");
|
|
1851
|
+
if (active) {
|
|
1852
|
+
const leadId2 = this.requireLeadId(active);
|
|
1853
|
+
await deps.activities.create({
|
|
1854
|
+
...operationId ? { id: operationId } : {},
|
|
1855
|
+
tenantId: activeTenantId,
|
|
1856
|
+
subjectKind: "lead",
|
|
1857
|
+
subjectId: leadId2,
|
|
1858
|
+
activityKind: "inbound",
|
|
1859
|
+
summary: "Received duplicate lead intake",
|
|
1860
|
+
actorProfileId: normalized.actorProfileId,
|
|
1861
|
+
metadata: JSON.stringify({
|
|
1862
|
+
intentHash,
|
|
1863
|
+
created: false,
|
|
1864
|
+
dedupe: normalized.dedupe,
|
|
1865
|
+
receivedAt: normalized.now.toISOString(),
|
|
1866
|
+
sourceKind: normalized.sourceKind,
|
|
1867
|
+
sourceId: normalized.sourceId
|
|
1868
|
+
})
|
|
1869
|
+
});
|
|
1870
|
+
return {
|
|
1871
|
+
leadId: leadId2,
|
|
1872
|
+
created: false
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
const terminal = matches[0];
|
|
1876
|
+
if (terminal) {
|
|
1877
|
+
const duplicateOfId = this.requireLeadId(terminal);
|
|
1878
|
+
return {
|
|
1879
|
+
leadId: duplicateOfId,
|
|
1880
|
+
created: false,
|
|
1881
|
+
duplicateOfId
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
const lead2 = await deps.leads.create({
|
|
1885
|
+
tenantId: activeTenantId,
|
|
1886
|
+
name: normalized.name,
|
|
1887
|
+
email: normalized.email,
|
|
1888
|
+
contactName: normalized.contactName,
|
|
1889
|
+
phone: normalized.phone,
|
|
1890
|
+
organizationName: normalized.organizationName,
|
|
1891
|
+
sourceKind: normalized.sourceKind,
|
|
1892
|
+
sourceId: normalized.sourceId,
|
|
1893
|
+
acquisitionContext: stableStringify(normalized.acquisitionContext),
|
|
1894
|
+
ownerRepId: normalized.ownerRepId,
|
|
1895
|
+
status: "new"
|
|
1896
|
+
});
|
|
1897
|
+
const leadId = this.requireLeadId(lead2);
|
|
1898
|
+
await deps.activities.create({
|
|
1899
|
+
...operationId ? { id: operationId } : {},
|
|
1900
|
+
tenantId: activeTenantId,
|
|
1901
|
+
subjectKind: "lead",
|
|
1902
|
+
subjectId: leadId,
|
|
1903
|
+
activityKind: "lead_intake",
|
|
1904
|
+
summary: "Created lead from intake",
|
|
1905
|
+
actorProfileId: normalized.actorProfileId,
|
|
1906
|
+
metadata: JSON.stringify({
|
|
1907
|
+
intentHash,
|
|
1908
|
+
created: true,
|
|
1909
|
+
dedupe: normalized.dedupe,
|
|
1910
|
+
receivedAt: normalized.now.toISOString(),
|
|
1911
|
+
sourceKind: normalized.sourceKind,
|
|
1912
|
+
sourceId: normalized.sourceId
|
|
1913
|
+
})
|
|
1914
|
+
});
|
|
1915
|
+
return {
|
|
1916
|
+
leadId,
|
|
1917
|
+
created: true
|
|
1918
|
+
};
|
|
1515
1919
|
});
|
|
1920
|
+
const lead = await this.readLead(this.deps.leads, result.leadId, tenantId);
|
|
1921
|
+
return {
|
|
1922
|
+
lead,
|
|
1923
|
+
created: result.created,
|
|
1924
|
+
...result.duplicateOfId ? { duplicateOf: lead } : {}
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
/** Qualify a Lead through the collection lifecycle in one transaction. */
|
|
1928
|
+
async qualifyLead(input) {
|
|
1929
|
+
const actorProfileId = this.requireIdentifier(input.actorProfileId, "actorProfileId");
|
|
1930
|
+
if (input.pipelineId && !UUID_RE.test(input.pipelineId)) throw this.refusal("pipeline_unavailable", "Pipeline is unavailable in the active tenant");
|
|
1931
|
+
if (input.expectedValueCents !== void 0 && (!Number.isSafeInteger(input.expectedValueCents) || input.expectedValueCents < 0)) throw this.refusal("invalid_value", "Expected value must be a non-negative safe integer number of cents");
|
|
1932
|
+
const tenantId = this.requireActiveTenant();
|
|
1933
|
+
const result = await this.runMutation(async (deps, activeTenantId) => {
|
|
1934
|
+
const lead2 = await this.lockLead(deps, input.leadId, activeTenantId);
|
|
1935
|
+
if (lead2.status === "disqualified" || lead2.status === "merged") throw this.refusal("invalid_transition", `Lead cannot be qualified from '${lead2.status}'`);
|
|
1936
|
+
if (input.pipelineId) {
|
|
1937
|
+
const pipeline = await deps.pipelines.get({ id: input.pipelineId }, { cache: false });
|
|
1938
|
+
if (!pipeline || pipeline.tenantId !== activeTenantId) throw this.refusal("pipeline_unavailable", "Pipeline is unavailable in the active tenant");
|
|
1939
|
+
}
|
|
1940
|
+
const leadId = this.requireLeadId(lead2);
|
|
1941
|
+
const prior = await deps.opportunities.findByLead(leadId);
|
|
1942
|
+
let opportunity2;
|
|
1943
|
+
try {
|
|
1944
|
+
opportunity2 = await deps.leads.qualify({
|
|
1945
|
+
leadId,
|
|
1946
|
+
actorProfileId,
|
|
1947
|
+
pipelineId: input.pipelineId,
|
|
1948
|
+
opportunityName: input.opportunityName,
|
|
1949
|
+
expectedValueCents: input.expectedValueCents,
|
|
1950
|
+
currency: input.currency,
|
|
1951
|
+
expectedCloseAt: input.expectedCloseAt,
|
|
1952
|
+
now: input.now
|
|
1953
|
+
});
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
if (error instanceof Error && (error.message.includes("pipeline") || error.message.includes("stages"))) throw this.refusal("pipeline_unavailable", error.message);
|
|
1956
|
+
throw error;
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
leadId,
|
|
1960
|
+
opportunityId: this.requireOpportunityId(opportunity2),
|
|
1961
|
+
created: prior.length === 0
|
|
1962
|
+
};
|
|
1963
|
+
});
|
|
1964
|
+
return {
|
|
1965
|
+
lead: await this.readLead(this.deps.leads, result.leadId, tenantId),
|
|
1966
|
+
opportunity: await this.readOpportunity(result.opportunityId, tenantId),
|
|
1967
|
+
created: result.created
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
/** Close an opportunity at its configured terminal stage and optionally link conversion. */
|
|
1971
|
+
async closeOpportunity(input) {
|
|
1972
|
+
const actorProfileId = this.requireIdentifier(input.actorProfileId, "actorProfileId");
|
|
1973
|
+
if (!UUID_RE.test(input.opportunityId)) throw this.refusal("opportunity_unavailable", "Opportunity is unavailable in the active tenant");
|
|
1974
|
+
if (input.outcome !== "won" && input.outcome !== "lost") throw this.refusal("invalid_transition", "Opportunity outcome must be 'won' or 'lost'");
|
|
1975
|
+
if (input.conversion && input.outcome !== "won") throw this.refusal("invalid_conversion", "A conversion can only be recorded for a won opportunity");
|
|
1976
|
+
const conversion = input.conversion ? {
|
|
1977
|
+
targetKind: this.requireOpenIdentifier(input.conversion.targetKind, "conversion targetKind"),
|
|
1978
|
+
targetId: this.requireOpenIdentifier(input.conversion.targetId, "conversion targetId"),
|
|
1979
|
+
note: input.conversion.note?.trim() ?? ""
|
|
1980
|
+
} : void 0;
|
|
1981
|
+
const reason = input.reason?.trim();
|
|
1982
|
+
if (reason && reason.length > 1e3) throw this.refusal("reason_too_long", `Lead workflow reason must be at most ${MAX_LEAD_WORKFLOW_TEXT_LENGTH} characters`);
|
|
1983
|
+
const tenantId = this.requireActiveTenant();
|
|
1984
|
+
const result = await this.runMutation(async (deps, activeTenantId) => {
|
|
1985
|
+
const opportunity2 = await this.lockOpportunity(deps, input.opportunityId, activeTenantId);
|
|
1986
|
+
let changed = false;
|
|
1987
|
+
if (opportunity2.status !== "open") {
|
|
1988
|
+
if (opportunity2.status !== input.outcome) throw this.refusal("opportunity_replay_conflict", `Opportunity is already closed as '${opportunity2.status}'`);
|
|
1989
|
+
if (reason && opportunity2.outcomeReason !== reason) throw this.refusal("opportunity_replay_conflict", "Opportunity was already closed with a different reason");
|
|
1990
|
+
} else {
|
|
1991
|
+
const terminal = (await deps.stages.list({
|
|
1992
|
+
where: { pipelineId: opportunity2.pipelineId },
|
|
1993
|
+
orderBy: "sort_order ASC"
|
|
1994
|
+
})).find((stage) => input.outcome === "won" ? stage.isWon : stage.isLost);
|
|
1995
|
+
if (!terminal?.id) throw this.refusal("pipeline_has_no_terminal_stage", `Opportunity pipeline has no '${input.outcome}' terminal stage`);
|
|
1996
|
+
await deps.opportunities.moveToStage({
|
|
1997
|
+
opportunityId: input.opportunityId,
|
|
1998
|
+
stageId: terminal.id,
|
|
1999
|
+
actorProfileId,
|
|
2000
|
+
outcomeReason: reason,
|
|
2001
|
+
now: input.now
|
|
2002
|
+
});
|
|
2003
|
+
changed = true;
|
|
2004
|
+
}
|
|
2005
|
+
let conversionId;
|
|
2006
|
+
let conversionCreated;
|
|
2007
|
+
if (conversion) {
|
|
2008
|
+
const recorded = await deps.conversions.recordConversion({
|
|
2009
|
+
opportunityId: input.opportunityId,
|
|
2010
|
+
...conversion
|
|
2011
|
+
});
|
|
2012
|
+
conversionId = this.requireConversionId(recorded.conversion);
|
|
2013
|
+
conversionCreated = recorded.created;
|
|
2014
|
+
}
|
|
2015
|
+
return {
|
|
2016
|
+
opportunityId: input.opportunityId,
|
|
2017
|
+
changed,
|
|
2018
|
+
conversionId,
|
|
2019
|
+
conversionCreated
|
|
2020
|
+
};
|
|
2021
|
+
});
|
|
2022
|
+
const opportunity = await this.readOpportunity(result.opportunityId, tenantId);
|
|
2023
|
+
const persistedConversion = result.conversionId ? await this.deps.conversions.get({ id: result.conversionId }, { cache: false }) : void 0;
|
|
2024
|
+
return {
|
|
2025
|
+
opportunity,
|
|
2026
|
+
changed: result.changed,
|
|
2027
|
+
...persistedConversion ? { conversion: persistedConversion } : {},
|
|
2028
|
+
...result.conversionCreated !== void 0 ? { conversionCreated: result.conversionCreated } : {}
|
|
2029
|
+
};
|
|
1516
2030
|
}
|
|
1517
2031
|
/** Assign or reassign an active representative, with one audit row per change. */
|
|
1518
2032
|
async assignLead(input) {
|
|
@@ -1683,12 +2197,17 @@ var LeadWorkflowService = class LeadWorkflowService {
|
|
|
1683
2197
|
const tenantId = this.requireActiveTenant();
|
|
1684
2198
|
const lead = await this.readLead(this.deps.leads, input.leadId, tenantId);
|
|
1685
2199
|
const leadId = this.requireLeadId(lead);
|
|
1686
|
-
const [owner, openTasks] = await Promise.all([
|
|
2200
|
+
const [owner, openTasks, linkedOpportunities] = await Promise.all([
|
|
2201
|
+
lead.ownerRepId ? this.deps.representatives.get({ id: lead.ownerRepId }, { cache: false }) : Promise.resolve(null),
|
|
2202
|
+
this.deps.activities.findOpenTasks("lead", leadId),
|
|
2203
|
+
this.deps.opportunities.findByLead(leadId)
|
|
2204
|
+
]);
|
|
1687
2205
|
const earliestOpenTask = openTasks[0] ?? null;
|
|
1688
2206
|
return {
|
|
1689
2207
|
lead,
|
|
1690
2208
|
owner,
|
|
1691
2209
|
earliestOpenTask,
|
|
2210
|
+
opportunity: linkedOpportunities[0] ?? null,
|
|
1692
2211
|
queue: projectLeadWorkQueue({
|
|
1693
2212
|
status: lead.status,
|
|
1694
2213
|
ownerRepId: lead.ownerRepId,
|
|
@@ -1720,6 +2239,26 @@ var LeadWorkflowService = class LeadWorkflowService {
|
|
|
1720
2239
|
_reuseInitializedDb: true,
|
|
1721
2240
|
_deferRuntimeInitialization: true
|
|
1722
2241
|
}),
|
|
2242
|
+
opportunities: await OpportunityCollection.create({
|
|
2243
|
+
db: tx,
|
|
2244
|
+
_reuseInitializedDb: true,
|
|
2245
|
+
_deferRuntimeInitialization: true
|
|
2246
|
+
}),
|
|
2247
|
+
conversions: await OpportunityConversionCollection.create({
|
|
2248
|
+
db: tx,
|
|
2249
|
+
_reuseInitializedDb: true,
|
|
2250
|
+
_deferRuntimeInitialization: true
|
|
2251
|
+
}),
|
|
2252
|
+
pipelines: await PipelineDefinitionCollection.create({
|
|
2253
|
+
db: tx,
|
|
2254
|
+
_reuseInitializedDb: true,
|
|
2255
|
+
_deferRuntimeInitialization: true
|
|
2256
|
+
}),
|
|
2257
|
+
stages: await PipelineStageCollection.create({
|
|
2258
|
+
db: tx,
|
|
2259
|
+
_reuseInitializedDb: true,
|
|
2260
|
+
_deferRuntimeInitialization: true
|
|
2261
|
+
}),
|
|
1723
2262
|
supportsRowLocks
|
|
1724
2263
|
}, tenantId));
|
|
1725
2264
|
if (supportsRowLocks) return await runTransaction();
|
|
@@ -1759,12 +2298,110 @@ var LeadWorkflowService = class LeadWorkflowService {
|
|
|
1759
2298
|
if (!task || task.tenantId !== tenantId || task.subjectKind !== "lead" || task.subjectId !== leadId) throw this.refusal("task_unavailable", "Task is unavailable for this lead");
|
|
1760
2299
|
return task;
|
|
1761
2300
|
}
|
|
2301
|
+
async lockOpportunity(deps, opportunityId, tenantId) {
|
|
2302
|
+
if (deps.supportsRowLocks) {
|
|
2303
|
+
const opportunity = (await deps.opportunities.query(`SELECT * FROM ${deps.opportunities.tableName}
|
|
2304
|
+
WHERE id = $1 AND tenant_id = $2
|
|
2305
|
+
FOR UPDATE`, [opportunityId, tenantId], { allowRawOnTenantScoped: true }))[0];
|
|
2306
|
+
if (opportunity && opportunity.tenantId === tenantId) return opportunity;
|
|
2307
|
+
throw this.refusal("opportunity_unavailable", "Opportunity is unavailable in the active tenant");
|
|
2308
|
+
}
|
|
2309
|
+
return await this.readOpportunityFrom(deps.opportunities, opportunityId, tenantId);
|
|
2310
|
+
}
|
|
2311
|
+
async lockOperationFence(deps, key) {
|
|
2312
|
+
if (!deps.supportsRowLocks) return;
|
|
2313
|
+
await deps.leads.db.query("SELECT pg_advisory_xact_lock(hashtext($1))", key);
|
|
2314
|
+
}
|
|
2315
|
+
async findDedupeMatches(deps, tenantId, input) {
|
|
2316
|
+
if (input.dedupe === "none") return [];
|
|
2317
|
+
const clauses = [];
|
|
2318
|
+
const identityLocks = [];
|
|
2319
|
+
const params = [tenantId];
|
|
2320
|
+
if (input.email) {
|
|
2321
|
+
params.push(input.email);
|
|
2322
|
+
clauses.push(`LOWER(TRIM(email)) = $${params.length}`);
|
|
2323
|
+
identityLocks.push(`lead-dedupe:${tenantId}:email:${input.email}`);
|
|
2324
|
+
}
|
|
2325
|
+
if (input.dedupe === "email_or_org" && input.organizationName) {
|
|
2326
|
+
const organizationName = input.organizationName.toLowerCase();
|
|
2327
|
+
params.push(organizationName);
|
|
2328
|
+
clauses.push(`LOWER(TRIM(organization_name)) = $${params.length}`);
|
|
2329
|
+
identityLocks.push(`lead-dedupe:${tenantId}:organization:${organizationName}`);
|
|
2330
|
+
}
|
|
2331
|
+
if (clauses.length === 0) return [];
|
|
2332
|
+
if (deps.supportsRowLocks) for (const identityLock of identityLocks.sort()) await this.lockOperationFence(deps, identityLock);
|
|
2333
|
+
return await deps.leads.query(`SELECT * FROM ${deps.leads.tableName}
|
|
2334
|
+
WHERE tenant_id = $1 AND (${clauses.join(" OR ")})
|
|
2335
|
+
ORDER BY created_at DESC${deps.supportsRowLocks ? " FOR UPDATE" : ""}`, params, { allowRawOnTenantScoped: true });
|
|
2336
|
+
}
|
|
1762
2337
|
async readLead(leads, leadId, tenantId) {
|
|
1763
2338
|
if (!UUID_RE.test(leadId)) throw this.refusal("lead_unavailable", "Lead is unavailable in the active tenant");
|
|
1764
2339
|
const lead = await leads.get({ id: leadId }, { cache: false });
|
|
1765
2340
|
if (!lead || lead.tenantId !== tenantId) throw this.refusal("lead_unavailable", "Lead is unavailable in the active tenant");
|
|
1766
2341
|
return lead;
|
|
1767
2342
|
}
|
|
2343
|
+
async readOpportunity(opportunityId, tenantId) {
|
|
2344
|
+
return await this.readOpportunityFrom(this.deps.opportunities, opportunityId, tenantId);
|
|
2345
|
+
}
|
|
2346
|
+
async readOpportunityFrom(opportunities, opportunityId, tenantId) {
|
|
2347
|
+
const opportunity = await opportunities.get({ id: opportunityId }, { cache: false });
|
|
2348
|
+
if (!opportunity || opportunity.tenantId !== tenantId) throw this.refusal("opportunity_unavailable", "Opportunity is unavailable in the active tenant");
|
|
2349
|
+
return opportunity;
|
|
2350
|
+
}
|
|
2351
|
+
normalizeCreateLeadInput(input) {
|
|
2352
|
+
const name = typeof input.name === "string" ? input.name.trim() : "";
|
|
2353
|
+
if (!name) throw this.refusal("name_required", "Lead name is required");
|
|
2354
|
+
const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
|
|
2355
|
+
const phone = typeof input.phone === "string" ? input.phone.trim() : "";
|
|
2356
|
+
if (!email && !phone) throw this.refusal("contact_required", "Lead intake requires at least one of email or phone");
|
|
2357
|
+
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) throw this.refusal("invalid_email", "Lead email is invalid");
|
|
2358
|
+
const sourceKind = typeof input.sourceKind === "string" ? input.sourceKind.trim() : "";
|
|
2359
|
+
if (!sourceKind) throw this.refusal("source_kind_required", "Lead sourceKind is required");
|
|
2360
|
+
const dedupe = input.dedupe ?? "email";
|
|
2361
|
+
if (![
|
|
2362
|
+
"none",
|
|
2363
|
+
"email",
|
|
2364
|
+
"email_or_org"
|
|
2365
|
+
].includes(dedupe)) throw this.refusal("invalid_dedupe_policy", "Lead dedupe policy is invalid");
|
|
2366
|
+
const idempotencyKey = input.idempotencyKey?.trim() ?? "";
|
|
2367
|
+
if (idempotencyKey.length > 255) throw this.refusal("invalid_idempotency_key", "Lead idempotency key must be at most 255 characters");
|
|
2368
|
+
const acquisitionContext = input.acquisitionContext ?? {};
|
|
2369
|
+
if (!isPlainJsonObject(acquisitionContext) || !isJsonValue(acquisitionContext, /* @__PURE__ */ new Set())) throw this.refusal("invalid_metadata", "Lead acquisitionContext must be a plain JSON object with JSON values");
|
|
2370
|
+
const ownerRepId = input.ownerRepId ? this.requireIdentifier(input.ownerRepId, "ownerRepId") : "";
|
|
2371
|
+
const actorProfileId = input.actorProfileId ? this.requireIdentifier(input.actorProfileId, "actorProfileId") : "";
|
|
2372
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
2373
|
+
if (!Number.isFinite(now.getTime())) throw this.refusal("invalid_due_at", "Lead intake now must be valid");
|
|
2374
|
+
const normalized = {
|
|
2375
|
+
name,
|
|
2376
|
+
email,
|
|
2377
|
+
contactName: input.contactName?.trim() ?? "",
|
|
2378
|
+
phone,
|
|
2379
|
+
organizationName: input.organizationName?.trim() ?? "",
|
|
2380
|
+
sourceKind,
|
|
2381
|
+
sourceId: input.sourceId?.trim() ?? "",
|
|
2382
|
+
acquisitionContext,
|
|
2383
|
+
ownerRepId,
|
|
2384
|
+
actorProfileId,
|
|
2385
|
+
dedupe,
|
|
2386
|
+
idempotencyKey,
|
|
2387
|
+
now
|
|
2388
|
+
};
|
|
2389
|
+
return {
|
|
2390
|
+
...normalized,
|
|
2391
|
+
intent: {
|
|
2392
|
+
name: normalized.name,
|
|
2393
|
+
email: normalized.email,
|
|
2394
|
+
contactName: normalized.contactName,
|
|
2395
|
+
phone: normalized.phone,
|
|
2396
|
+
organizationName: normalized.organizationName,
|
|
2397
|
+
sourceKind: normalized.sourceKind,
|
|
2398
|
+
sourceId: normalized.sourceId,
|
|
2399
|
+
acquisitionContext: normalized.acquisitionContext,
|
|
2400
|
+
ownerRepId: normalized.ownerRepId,
|
|
2401
|
+
dedupe: normalized.dedupe
|
|
2402
|
+
}
|
|
2403
|
+
};
|
|
2404
|
+
}
|
|
1768
2405
|
assertActiveFollowUpLead(lead) {
|
|
1769
2406
|
if (lead.status !== "new" && lead.status !== "working") throw this.refusal("lead_not_actionable", `Lead is not actionable for ordinary follow-up while '${lead.status}'`);
|
|
1770
2407
|
}
|
|
@@ -1818,6 +2455,19 @@ var LeadWorkflowService = class LeadWorkflowService {
|
|
|
1818
2455
|
if (!activity.id) throw this.refusal("task_unavailable", "Task is unavailable for this lead");
|
|
1819
2456
|
return activity.id;
|
|
1820
2457
|
}
|
|
2458
|
+
requireOpportunityId(opportunity) {
|
|
2459
|
+
if (!opportunity.id) throw this.refusal("opportunity_unavailable", "Opportunity is unavailable in the active tenant");
|
|
2460
|
+
return opportunity.id;
|
|
2461
|
+
}
|
|
2462
|
+
requireConversionId(conversion) {
|
|
2463
|
+
if (!conversion.id) throw this.refusal("invalid_conversion", "Opportunity conversion could not be persisted");
|
|
2464
|
+
return conversion.id;
|
|
2465
|
+
}
|
|
2466
|
+
requireOpenIdentifier(value, field) {
|
|
2467
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
2468
|
+
if (!normalized) throw this.refusal("invalid_conversion", `${field} is required`);
|
|
2469
|
+
return normalized;
|
|
2470
|
+
}
|
|
1821
2471
|
now(value) {
|
|
1822
2472
|
return value ?? /* @__PURE__ */ new Date();
|
|
1823
2473
|
}
|
|
@@ -1887,7 +2537,22 @@ function isJsonValue(value, ancestors) {
|
|
|
1887
2537
|
}
|
|
1888
2538
|
return false;
|
|
1889
2539
|
}
|
|
2540
|
+
function stableStringify(value) {
|
|
2541
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
2542
|
+
if (isPlainJsonObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
2543
|
+
return JSON.stringify(value);
|
|
2544
|
+
}
|
|
2545
|
+
function sha256(value) {
|
|
2546
|
+
return createHash("sha256").update(value).digest("hex");
|
|
2547
|
+
}
|
|
2548
|
+
function uuidFromHash(hash) {
|
|
2549
|
+
const chars = hash.slice(0, 32).split("");
|
|
2550
|
+
chars[12] = "5";
|
|
2551
|
+
chars[16] = (Number.parseInt(chars[16] ?? "0", 16) & 3 | 8).toString(16);
|
|
2552
|
+
const value = chars.join("");
|
|
2553
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
2554
|
+
}
|
|
1890
2555
|
//#endregion
|
|
1891
|
-
export {
|
|
2556
|
+
export { PIPELINE_STATUSES as C, Lead as D, SALES_REPRESENTATIVE_STATUSES as E, OPPORTUNITY_STATUSES as S, SALES_ACTIVITY_SUBJECT_KINDS as T, PipelineStage as _, projectLeadWorkQueue as a, DEFAULT_PIPELINE_STAGES as b, LeadCollection as c, PipelineDefinitionCollection as d, PipelineDefinition as f, PipelineStageCollection as g, SalesActivity as h, MAX_LEAD_WORKFLOW_TEXT_LENGTH as i, SalesRepresentativeCollection as l, SalesActivityCollection as m, LeadWorkflowService as n, OpportunityConversionCollection as o, OpportunityCollection as p, LeadWorkflowValidationError as r, OpportunityConversion as s, LEAD_HUMAN_ACTIVITY_KINDS as t, SalesRepresentative as u, Opportunity as v, SALES_ACTIVITY_KINDS as w, LEAD_STATUSES as x, DEFAULT_PIPELINE_KEY as y };
|
|
1892
2557
|
|
|
1893
|
-
//# sourceMappingURL=crm-
|
|
2558
|
+
//# sourceMappingURL=crm-DfRKKvWV.js.map
|