@happyvertical/smrt-sales 0.39.2
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 +87 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/chunks/__smrt-register__-kWdT7LOt.js +6 -0
- package/dist/chunks/__smrt-register__-kWdT7LOt.js.map +1 -0
- package/dist/chunks/commissions-BCxqOKVM.js +2181 -0
- package/dist/chunks/commissions-BCxqOKVM.js.map +1 -0
- package/dist/chunks/crm-DwEz7E2r.js +1428 -0
- package/dist/chunks/crm-DwEz7E2r.js.map +1 -0
- package/dist/chunks/referrals-BY47p70T.js +2880 -0
- package/dist/chunks/referrals-BY47p70T.js.map +1 -0
- package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts +26 -0
- package/dist/commissions/collections/CommissionAdjustmentCollection.d.ts.map +1 -0
- package/dist/commissions/collections/CommissionCollection.d.ts +43 -0
- package/dist/commissions/collections/CommissionCollection.d.ts.map +1 -0
- package/dist/commissions/collections/CommissionPayoutCollection.d.ts +19 -0
- package/dist/commissions/collections/CommissionPayoutCollection.d.ts.map +1 -0
- package/dist/commissions/collections/CommissionPlanCollection.d.ts +46 -0
- package/dist/commissions/collections/CommissionPlanCollection.d.ts.map +1 -0
- package/dist/commissions/collections/EarnerCollection.d.ts +14 -0
- package/dist/commissions/collections/EarnerCollection.d.ts.map +1 -0
- package/dist/commissions/collections/EarningEventCollection.d.ts +27 -0
- package/dist/commissions/collections/EarningEventCollection.d.ts.map +1 -0
- package/dist/commissions/collections/index.d.ts +11 -0
- package/dist/commissions/collections/index.d.ts.map +1 -0
- package/dist/commissions/index.d.ts +20 -0
- package/dist/commissions/index.d.ts.map +1 -0
- package/dist/commissions/models/Commission.d.ts +150 -0
- package/dist/commissions/models/Commission.d.ts.map +1 -0
- package/dist/commissions/models/CommissionAdjustment.d.ts +62 -0
- package/dist/commissions/models/CommissionAdjustment.d.ts.map +1 -0
- package/dist/commissions/models/CommissionPayout.d.ts +119 -0
- package/dist/commissions/models/CommissionPayout.d.ts.map +1 -0
- package/dist/commissions/models/CommissionPlan.d.ts +117 -0
- package/dist/commissions/models/CommissionPlan.d.ts.map +1 -0
- package/dist/commissions/models/Earner.d.ts +49 -0
- package/dist/commissions/models/Earner.d.ts.map +1 -0
- package/dist/commissions/models/EarningEvent.d.ts +89 -0
- package/dist/commissions/models/EarningEvent.d.ts.map +1 -0
- package/dist/commissions/models/index.d.ts +11 -0
- package/dist/commissions/models/index.d.ts.map +1 -0
- package/dist/commissions/money.d.ts +42 -0
- package/dist/commissions/money.d.ts.map +1 -0
- package/dist/commissions/services/CommissionBalanceService.d.ts +14 -0
- package/dist/commissions/services/CommissionBalanceService.d.ts.map +1 -0
- package/dist/commissions/services/CommissionCalculationService.d.ts +142 -0
- package/dist/commissions/services/CommissionCalculationService.d.ts.map +1 -0
- package/dist/commissions/services/CommissionPayoutService.d.ts +134 -0
- package/dist/commissions/services/CommissionPayoutService.d.ts.map +1 -0
- package/dist/commissions/services/CommissionSettlementService.d.ts +42 -0
- package/dist/commissions/services/CommissionSettlementService.d.ts.map +1 -0
- package/dist/commissions/services/index.d.ts +9 -0
- package/dist/commissions/services/index.d.ts.map +1 -0
- package/dist/commissions/types.d.ts +243 -0
- package/dist/commissions/types.d.ts.map +1 -0
- package/dist/commissions.d.ts +2 -0
- package/dist/commissions.d.ts.map +1 -0
- package/dist/commissions.js +3 -0
- package/dist/crm/collections/LeadCollection.d.ts +79 -0
- package/dist/crm/collections/LeadCollection.d.ts.map +1 -0
- package/dist/crm/collections/OpportunityCollection.d.ts +42 -0
- package/dist/crm/collections/OpportunityCollection.d.ts.map +1 -0
- package/dist/crm/collections/OpportunityConversionCollection.d.ts +26 -0
- package/dist/crm/collections/OpportunityConversionCollection.d.ts.map +1 -0
- package/dist/crm/collections/PipelineDefinitionCollection.d.ts +46 -0
- package/dist/crm/collections/PipelineDefinitionCollection.d.ts.map +1 -0
- package/dist/crm/collections/PipelineStageCollection.d.ts +22 -0
- package/dist/crm/collections/PipelineStageCollection.d.ts.map +1 -0
- package/dist/crm/collections/SalesActivityCollection.d.ts +24 -0
- package/dist/crm/collections/SalesActivityCollection.d.ts.map +1 -0
- package/dist/crm/collections/SalesRepresentativeCollection.d.ts +19 -0
- package/dist/crm/collections/SalesRepresentativeCollection.d.ts.map +1 -0
- package/dist/crm/index.d.ts +27 -0
- package/dist/crm/index.d.ts.map +1 -0
- package/dist/crm/models/Lead.d.ts +135 -0
- package/dist/crm/models/Lead.d.ts.map +1 -0
- package/dist/crm/models/Opportunity.d.ts +111 -0
- package/dist/crm/models/Opportunity.d.ts.map +1 -0
- package/dist/crm/models/OpportunityConversion.d.ts +66 -0
- package/dist/crm/models/OpportunityConversion.d.ts.map +1 -0
- package/dist/crm/models/PipelineDefinition.d.ts +58 -0
- package/dist/crm/models/PipelineDefinition.d.ts.map +1 -0
- package/dist/crm/models/PipelineStage.d.ts +66 -0
- package/dist/crm/models/PipelineStage.d.ts.map +1 -0
- package/dist/crm/models/SalesActivity.d.ts +76 -0
- package/dist/crm/models/SalesActivity.d.ts.map +1 -0
- package/dist/crm/models/SalesRepresentative.d.ts +60 -0
- package/dist/crm/models/SalesRepresentative.d.ts.map +1 -0
- package/dist/crm/types.d.ts +228 -0
- package/dist/crm/types.d.ts.map +1 -0
- package/dist/crm.d.ts +2 -0
- package/dist/crm.d.ts.map +1 -0
- package/dist/crm.js +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/manifest.json +9340 -0
- package/dist/referrals/collections/AttributionExceptionCollection.d.ts +17 -0
- package/dist/referrals/collections/AttributionExceptionCollection.d.ts.map +1 -0
- package/dist/referrals/collections/AttributionPolicyCollection.d.ts +50 -0
- package/dist/referrals/collections/AttributionPolicyCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralAgreementCollection.d.ts +48 -0
- package/dist/referrals/collections/ReferralAgreementCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralCollection.d.ts +25 -0
- package/dist/referrals/collections/ReferralCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralLinkCollection.d.ts +96 -0
- package/dist/referrals/collections/ReferralLinkCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralProgramCollection.d.ts +18 -0
- package/dist/referrals/collections/ReferralProgramCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts +13 -0
- package/dist/referrals/collections/ReferralTermSnapshotCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferralTouchCollection.d.ts +30 -0
- package/dist/referrals/collections/ReferralTouchCollection.d.ts.map +1 -0
- package/dist/referrals/collections/ReferrerCollection.d.ts +17 -0
- package/dist/referrals/collections/ReferrerCollection.d.ts.map +1 -0
- package/dist/referrals/index.d.ts +38 -0
- package/dist/referrals/index.d.ts.map +1 -0
- package/dist/referrals/models/AttributionException.d.ts +85 -0
- package/dist/referrals/models/AttributionException.d.ts.map +1 -0
- package/dist/referrals/models/AttributionPolicy.d.ts +128 -0
- package/dist/referrals/models/AttributionPolicy.d.ts.map +1 -0
- package/dist/referrals/models/Referral.d.ts +112 -0
- package/dist/referrals/models/Referral.d.ts.map +1 -0
- package/dist/referrals/models/ReferralAgreement.d.ts +125 -0
- package/dist/referrals/models/ReferralAgreement.d.ts.map +1 -0
- package/dist/referrals/models/ReferralLink.d.ts +71 -0
- package/dist/referrals/models/ReferralLink.d.ts.map +1 -0
- package/dist/referrals/models/ReferralProgram.d.ts +57 -0
- package/dist/referrals/models/ReferralProgram.d.ts.map +1 -0
- package/dist/referrals/models/ReferralTermSnapshot.d.ts +61 -0
- package/dist/referrals/models/ReferralTermSnapshot.d.ts.map +1 -0
- package/dist/referrals/models/ReferralTouch.d.ts +55 -0
- package/dist/referrals/models/ReferralTouch.d.ts.map +1 -0
- package/dist/referrals/models/Referrer.d.ts +69 -0
- package/dist/referrals/models/Referrer.d.ts.map +1 -0
- package/dist/referrals/services/AttributionService.d.ts +248 -0
- package/dist/referrals/services/AttributionService.d.ts.map +1 -0
- package/dist/referrals/services/ReferralCommissionService.d.ts +91 -0
- package/dist/referrals/services/ReferralCommissionService.d.ts.map +1 -0
- package/dist/referrals/services/ReferralQualificationService.d.ts +135 -0
- package/dist/referrals/services/ReferralQualificationService.d.ts.map +1 -0
- package/dist/referrals/types.d.ts +226 -0
- package/dist/referrals/types.d.ts.map +1 -0
- package/dist/referrals.d.ts +2 -0
- package/dist/referrals.d.ts.map +1 -0
- package/dist/referrals.js +3 -0
- package/dist/smrt-knowledge.json +5501 -0
- package/dist/svelte/__tests__/format.test.js +68 -0
- package/dist/svelte/__tests__/types.test.js +349 -0
- package/dist/svelte/components/AttributionConflictQueue.svelte +361 -0
- package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts +15 -0
- package/dist/svelte/components/AttributionConflictQueue.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CommissionBreakdown.svelte +251 -0
- package/dist/svelte/components/CommissionBreakdown.svelte.d.ts +11 -0
- package/dist/svelte/components/CommissionBreakdown.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CommissionExpenseSummary.svelte +186 -0
- package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts +13 -0
- package/dist/svelte/components/CommissionExpenseSummary.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ExecutedAgreementsList.svelte +136 -0
- package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts +11 -0
- package/dist/svelte/components/ExecutedAgreementsList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/LeadList.svelte +224 -0
- package/dist/svelte/components/LeadList.svelte.d.ts +19 -0
- package/dist/svelte/components/LeadList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/OpportunityBoard.svelte +230 -0
- package/dist/svelte/components/OpportunityBoard.svelte.d.ts +17 -0
- package/dist/svelte/components/OpportunityBoard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/OpportunityDetail.svelte +472 -0
- package/dist/svelte/components/OpportunityDetail.svelte.d.ts +27 -0
- package/dist/svelte/components/OpportunityDetail.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PayoutBatchReview.svelte +285 -0
- package/dist/svelte/components/PayoutBatchReview.svelte.d.ts +21 -0
- package/dist/svelte/components/PayoutBatchReview.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PayoutHistoryList.svelte +245 -0
- package/dist/svelte/components/PayoutHistoryList.svelte.d.ts +11 -0
- package/dist/svelte/components/PayoutHistoryList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReferralLinkManager.svelte +216 -0
- package/dist/svelte/components/ReferralLinkManager.svelte.d.ts +17 -0
- package/dist/svelte/components/ReferralLinkManager.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReferralStatusList.svelte +118 -0
- package/dist/svelte/components/ReferralStatusList.svelte.d.ts +11 -0
- package/dist/svelte/components/ReferralStatusList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ReferrerEarningsSummary.svelte +106 -0
- package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts +11 -0
- package/dist/svelte/components/ReferrerEarningsSummary.svelte.d.ts.map +1 -0
- package/dist/svelte/components/SalesDashboard.svelte +192 -0
- package/dist/svelte/components/SalesDashboard.svelte.d.ts +29 -0
- package/dist/svelte/components/SalesDashboard.svelte.d.ts.map +1 -0
- package/dist/svelte/format.d.ts +46 -0
- package/dist/svelte/format.d.ts.map +1 -0
- package/dist/svelte/format.js +75 -0
- package/dist/svelte/index.d.ts +50 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +37 -0
- package/dist/svelte/types.d.ts +422 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +376 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1428 @@
|
|
|
1
|
+
import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
2
|
+
import { TenantScoped, tenantId } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
//#region src/crm/models/Lead.ts
|
|
4
|
+
var __defProp$6 = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc$6 = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __decorateClass$6 = (decorators, target, key, kind) => {
|
|
7
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$6(target, key) : target;
|
|
8
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
9
|
+
if (kind && result) __defProp$6(target, key, result);
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
var LEAD_STATUS_TRANSITIONS = {
|
|
13
|
+
new: [
|
|
14
|
+
"working",
|
|
15
|
+
"qualified",
|
|
16
|
+
"disqualified",
|
|
17
|
+
"merged"
|
|
18
|
+
],
|
|
19
|
+
working: [
|
|
20
|
+
"qualified",
|
|
21
|
+
"disqualified",
|
|
22
|
+
"merged"
|
|
23
|
+
],
|
|
24
|
+
qualified: ["merged"],
|
|
25
|
+
disqualified: ["working", "merged"],
|
|
26
|
+
merged: []
|
|
27
|
+
};
|
|
28
|
+
var loadedLeadStatus = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
var Lead = class extends SmrtObject {
|
|
30
|
+
tenantId = null;
|
|
31
|
+
name = "";
|
|
32
|
+
/** Primary human contact name. */
|
|
33
|
+
contactName = "";
|
|
34
|
+
/** Primary contact email. */
|
|
35
|
+
email = "";
|
|
36
|
+
/** Primary contact phone. */
|
|
37
|
+
phone = "";
|
|
38
|
+
/** Prospect organization/company name. */
|
|
39
|
+
organizationName = "";
|
|
40
|
+
profileId = "";
|
|
41
|
+
ownerRepId = "";
|
|
42
|
+
/** Lifecycle status; transitions are save-guarded (see module map). */
|
|
43
|
+
status = "new";
|
|
44
|
+
/**
|
|
45
|
+
* Generic acquisition-source kind (open string): `'campaign'`,
|
|
46
|
+
* `'referral'`, `'import'`, `'web_form'`, … CRM stores the pointer without
|
|
47
|
+
* interpreting it — referral attribution lives in the referrals module.
|
|
48
|
+
*/
|
|
49
|
+
sourceKind = "";
|
|
50
|
+
/** Identifier within the `sourceKind` namespace. */
|
|
51
|
+
sourceId = "";
|
|
52
|
+
/**
|
|
53
|
+
* Preserved acquisition history as a JSON object string. Use
|
|
54
|
+
* {@link getAcquisitionContext}/{@link setAcquisitionContext}. Merges append
|
|
55
|
+
* the losing lead's context under a `mergedSources` array — both sides'
|
|
56
|
+
* histories survive.
|
|
57
|
+
*/
|
|
58
|
+
acquisitionContext = "{}";
|
|
59
|
+
/**
|
|
60
|
+
* Generic external intake-draft reference (open string) — e.g. the id of a
|
|
61
|
+
* form submission, imported CSV row, or referral intake draft that this
|
|
62
|
+
* lead was materialized from.
|
|
63
|
+
*/
|
|
64
|
+
intakeRef = "";
|
|
65
|
+
mergedIntoId = "";
|
|
66
|
+
/** When the lead was qualified into an opportunity. */
|
|
67
|
+
qualifiedAt = null;
|
|
68
|
+
/**
|
|
69
|
+
* Free-form JSON object stored as a string. Use
|
|
70
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
71
|
+
*/
|
|
72
|
+
metadata = "{}";
|
|
73
|
+
constructor(options = {}) {
|
|
74
|
+
super(options);
|
|
75
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
76
|
+
if (options.name !== void 0) this.name = options.name;
|
|
77
|
+
if (options.contactName !== void 0) this.contactName = options.contactName;
|
|
78
|
+
if (options.email !== void 0) this.email = options.email;
|
|
79
|
+
if (options.phone !== void 0) this.phone = options.phone;
|
|
80
|
+
if (options.organizationName !== void 0) this.organizationName = options.organizationName;
|
|
81
|
+
if (options.profileId !== void 0) this.profileId = options.profileId;
|
|
82
|
+
if (options.ownerRepId !== void 0) this.ownerRepId = options.ownerRepId;
|
|
83
|
+
if (options.status !== void 0) this.status = options.status;
|
|
84
|
+
if (options.sourceKind !== void 0) this.sourceKind = options.sourceKind;
|
|
85
|
+
if (options.sourceId !== void 0) this.sourceId = options.sourceId;
|
|
86
|
+
if (options.acquisitionContext !== void 0) this.acquisitionContext = options.acquisitionContext;
|
|
87
|
+
if (options.intakeRef !== void 0) this.intakeRef = options.intakeRef;
|
|
88
|
+
if (options.mergedIntoId !== void 0) this.mergedIntoId = options.mergedIntoId;
|
|
89
|
+
if (options.qualifiedAt !== void 0) this.qualifiedAt = options.qualifiedAt;
|
|
90
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
91
|
+
}
|
|
92
|
+
/** Whether the lead has been folded into another lead (terminal). */
|
|
93
|
+
isMerged() {
|
|
94
|
+
return this.status === "merged";
|
|
95
|
+
}
|
|
96
|
+
/** Whether the lead has been qualified into an opportunity. */
|
|
97
|
+
isQualified() {
|
|
98
|
+
return this.status === "qualified";
|
|
99
|
+
}
|
|
100
|
+
/** Parse the acquisition-context JSON string; `{}` on malformed content. */
|
|
101
|
+
getAcquisitionContext() {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(this.acquisitionContext);
|
|
104
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
105
|
+
return {};
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Serialize and store the acquisition-context object. */
|
|
111
|
+
setAcquisitionContext(context) {
|
|
112
|
+
this.acquisitionContext = JSON.stringify(context);
|
|
113
|
+
}
|
|
114
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
115
|
+
getMetadata() {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(this.metadata);
|
|
118
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
119
|
+
return {};
|
|
120
|
+
} catch {
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Serialize and store the metadata object. */
|
|
125
|
+
setMetadata(metadata) {
|
|
126
|
+
this.metadata = JSON.stringify(metadata);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Capture the status the row was loaded with so the save-time transition
|
|
130
|
+
* guard can reject illegal status flips made via raw field assignment
|
|
131
|
+
* (mass-assignment on the generated update route, a stale caller, etc.).
|
|
132
|
+
* Only persisted rows carry a prior status.
|
|
133
|
+
*/
|
|
134
|
+
async initialize() {
|
|
135
|
+
await super.initialize();
|
|
136
|
+
if (await this.isSaved()) loadedLeadStatus.set(this, this.status);
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Validate the status transition before persisting, then save. A forged
|
|
141
|
+
* `status` (e.g. un-merging a merged lead) is rejected here regardless of
|
|
142
|
+
* how the instance was constructed.
|
|
143
|
+
*/
|
|
144
|
+
async save() {
|
|
145
|
+
const prior = await this.resolvePriorStatus();
|
|
146
|
+
this.assertLeadStatusTransition(prior);
|
|
147
|
+
const result = await super.save();
|
|
148
|
+
loadedLeadStatus.set(this, this.status);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Reject an illegal status flip done via raw assignment. No-op transitions
|
|
153
|
+
* and brand-new rows are always allowed.
|
|
154
|
+
*/
|
|
155
|
+
assertLeadStatusTransition(prior) {
|
|
156
|
+
if (prior === void 0) return;
|
|
157
|
+
if (prior === this.status) return;
|
|
158
|
+
if (!(LEAD_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`Lead ${this.name || this.id}: illegal status transition '${prior}' \u2192 '${this.status}'.`);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the AUTHORITATIVE prior status (commerce Contract pattern). The
|
|
162
|
+
* WeakMap is only populated when {@link initialize} loaded the row from the
|
|
163
|
+
* DB; it is empty for an instance built via
|
|
164
|
+
* `collection.create({ id: <existing>, _skipLoad: true })` — the upsert
|
|
165
|
+
* path that writes onto an existing row without hydrating it. Trusting an
|
|
166
|
+
* empty WeakMap there would treat the write as a brand-new row and skip the
|
|
167
|
+
* guard entirely (a poisonable prior-state).
|
|
168
|
+
*
|
|
169
|
+
* So when this instance carries an `id`, read the persisted row straight
|
|
170
|
+
* from the database and treat its `status` as the prior. Only when no row
|
|
171
|
+
* exists (truly new) do we fall back to the WeakMap. The raw row's `status`
|
|
172
|
+
* column is single-word (no snake_case transform).
|
|
173
|
+
*/
|
|
174
|
+
async resolvePriorStatus() {
|
|
175
|
+
if (this.id) try {
|
|
176
|
+
const row = await this.db.get(this.tableName, { id: this.id });
|
|
177
|
+
if (row && row.status != null) return row.status;
|
|
178
|
+
} catch {}
|
|
179
|
+
return loadedLeadStatus.get(this);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
__decorateClass$6([tenantId({ nullable: true })], Lead.prototype, "tenantId", 2);
|
|
183
|
+
__decorateClass$6([field({ required: true })], Lead.prototype, "name", 2);
|
|
184
|
+
__decorateClass$6([crossPackageRef("@happyvertical/smrt-profiles:Profile")], Lead.prototype, "profileId", 2);
|
|
185
|
+
__decorateClass$6([foreignKey("SalesRepresentative")], Lead.prototype, "ownerRepId", 2);
|
|
186
|
+
__decorateClass$6([foreignKey("Lead")], Lead.prototype, "mergedIntoId", 2);
|
|
187
|
+
Lead = __decorateClass$6([TenantScoped({ mode: "optional" }), smrt({
|
|
188
|
+
api: { include: [
|
|
189
|
+
"list",
|
|
190
|
+
"get",
|
|
191
|
+
"create",
|
|
192
|
+
"update"
|
|
193
|
+
] },
|
|
194
|
+
mcp: { include: [
|
|
195
|
+
"list",
|
|
196
|
+
"get",
|
|
197
|
+
"create"
|
|
198
|
+
] },
|
|
199
|
+
cli: true
|
|
200
|
+
})], Lead);
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/crm/models/Opportunity.ts
|
|
203
|
+
var __defProp$5 = Object.defineProperty;
|
|
204
|
+
var __getOwnPropDesc$5 = Object.getOwnPropertyDescriptor;
|
|
205
|
+
var __decorateClass$5 = (decorators, target, key, kind) => {
|
|
206
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$5(target, key) : target;
|
|
207
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
208
|
+
if (kind && result) __defProp$5(target, key, result);
|
|
209
|
+
return result;
|
|
210
|
+
};
|
|
211
|
+
var OPPORTUNITY_STATUS_TRANSITIONS = {
|
|
212
|
+
open: ["won", "lost"],
|
|
213
|
+
won: [],
|
|
214
|
+
lost: []
|
|
215
|
+
};
|
|
216
|
+
var loadedOpportunityStatus = /* @__PURE__ */ new WeakMap();
|
|
217
|
+
var Opportunity = class extends SmrtObject {
|
|
218
|
+
tenantId = null;
|
|
219
|
+
name = "";
|
|
220
|
+
leadId = "";
|
|
221
|
+
ownerRepId = "";
|
|
222
|
+
pipelineId = "";
|
|
223
|
+
stageId = "";
|
|
224
|
+
/** Expected deal value in integer cents (INTEGER column). */
|
|
225
|
+
expectedValueCents = 0;
|
|
226
|
+
/** ISO 4217 currency code for `expectedValueCents`. */
|
|
227
|
+
currency = "USD";
|
|
228
|
+
/**
|
|
229
|
+
* Win probability (`0`–`1`, DECIMAL). Adopted from the current stage's
|
|
230
|
+
* default on stage moves unless explicitly overridden.
|
|
231
|
+
*/
|
|
232
|
+
probability = 0;
|
|
233
|
+
/** Forecasted close date. */
|
|
234
|
+
expectedCloseAt = null;
|
|
235
|
+
/** Lifecycle status; `open → won|lost` is save-guarded, terminal after. */
|
|
236
|
+
status = "open";
|
|
237
|
+
/**
|
|
238
|
+
* Human-readable outcome note — conventionally the loss reason
|
|
239
|
+
* (`'budget cut'`) or a win annotation.
|
|
240
|
+
*/
|
|
241
|
+
outcomeReason = "";
|
|
242
|
+
/** When the deal closed as won. */
|
|
243
|
+
wonAt = null;
|
|
244
|
+
/** When the deal closed as lost. */
|
|
245
|
+
lostAt = null;
|
|
246
|
+
/**
|
|
247
|
+
* Acquisition source copied from the originating lead at qualification
|
|
248
|
+
* time (open string), kept denormalized for reporting.
|
|
249
|
+
*/
|
|
250
|
+
sourceKind = "";
|
|
251
|
+
/** Identifier within the `sourceKind` namespace (copied from the lead). */
|
|
252
|
+
sourceId = "";
|
|
253
|
+
/**
|
|
254
|
+
* Free-form JSON object stored as a string. Use
|
|
255
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
256
|
+
*/
|
|
257
|
+
metadata = "{}";
|
|
258
|
+
constructor(options = {}) {
|
|
259
|
+
super(options);
|
|
260
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
261
|
+
if (options.name !== void 0) this.name = options.name;
|
|
262
|
+
if (options.leadId !== void 0) this.leadId = options.leadId;
|
|
263
|
+
if (options.ownerRepId !== void 0) this.ownerRepId = options.ownerRepId;
|
|
264
|
+
if (options.pipelineId !== void 0) this.pipelineId = options.pipelineId;
|
|
265
|
+
if (options.stageId !== void 0) this.stageId = options.stageId;
|
|
266
|
+
if (options.expectedValueCents !== void 0) this.expectedValueCents = options.expectedValueCents;
|
|
267
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
268
|
+
if (options.probability !== void 0) this.probability = options.probability;
|
|
269
|
+
if (options.expectedCloseAt !== void 0) this.expectedCloseAt = options.expectedCloseAt;
|
|
270
|
+
if (options.status !== void 0) this.status = options.status;
|
|
271
|
+
if (options.outcomeReason !== void 0) this.outcomeReason = options.outcomeReason;
|
|
272
|
+
if (options.wonAt !== void 0) this.wonAt = options.wonAt;
|
|
273
|
+
if (options.lostAt !== void 0) this.lostAt = options.lostAt;
|
|
274
|
+
if (options.sourceKind !== void 0) this.sourceKind = options.sourceKind;
|
|
275
|
+
if (options.sourceId !== void 0) this.sourceId = options.sourceId;
|
|
276
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
277
|
+
}
|
|
278
|
+
/** Whether the deal is still in play. */
|
|
279
|
+
isOpen() {
|
|
280
|
+
return this.status === "open";
|
|
281
|
+
}
|
|
282
|
+
/** Whether the deal closed (won or lost). */
|
|
283
|
+
isClosed() {
|
|
284
|
+
return this.status !== "open";
|
|
285
|
+
}
|
|
286
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
287
|
+
getMetadata() {
|
|
288
|
+
try {
|
|
289
|
+
const parsed = JSON.parse(this.metadata);
|
|
290
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
291
|
+
return {};
|
|
292
|
+
} catch {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Serialize and store the metadata object. */
|
|
297
|
+
setMetadata(metadata) {
|
|
298
|
+
this.metadata = JSON.stringify(metadata);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Capture the status the row was loaded with so the save-time transition
|
|
302
|
+
* guard can reject illegal status flips made via raw field assignment.
|
|
303
|
+
* Only persisted rows carry a prior status.
|
|
304
|
+
*/
|
|
305
|
+
async initialize() {
|
|
306
|
+
await super.initialize();
|
|
307
|
+
if (await this.isSaved()) loadedOpportunityStatus.set(this, this.status);
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Validate the status transition before persisting, then save. A forged
|
|
312
|
+
* `status` (e.g. re-opening a lost deal) is rejected here regardless of
|
|
313
|
+
* how the instance was constructed.
|
|
314
|
+
*/
|
|
315
|
+
async save() {
|
|
316
|
+
const prior = await this.resolvePriorStatus();
|
|
317
|
+
this.assertOpportunityStatusTransition(prior);
|
|
318
|
+
const result = await super.save();
|
|
319
|
+
loadedOpportunityStatus.set(this, this.status);
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Reject an illegal status flip done via raw assignment. No-op transitions
|
|
324
|
+
* and brand-new rows are always allowed.
|
|
325
|
+
*/
|
|
326
|
+
assertOpportunityStatusTransition(prior) {
|
|
327
|
+
if (prior === void 0) return;
|
|
328
|
+
if (prior === this.status) return;
|
|
329
|
+
if (!(OPPORTUNITY_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`Opportunity ${this.name || this.id}: illegal status transition '${prior}' \u2192 '${this.status}'.`);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Resolve the AUTHORITATIVE prior status (commerce Contract pattern): when
|
|
333
|
+
* this instance carries an `id`, re-read the persisted row and use its
|
|
334
|
+
* `status` as the prior — a create-onto-existing is an update, and an
|
|
335
|
+
* un-hydrated instance must not bypass the guard. Falls back to the
|
|
336
|
+
* load-time WeakMap (empty for truly new rows → `undefined` = new).
|
|
337
|
+
*/
|
|
338
|
+
async resolvePriorStatus() {
|
|
339
|
+
if (this.id) try {
|
|
340
|
+
const row = await this.db.get(this.tableName, { id: this.id });
|
|
341
|
+
if (row && row.status != null) return row.status;
|
|
342
|
+
} catch {}
|
|
343
|
+
return loadedOpportunityStatus.get(this);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
__decorateClass$5([tenantId({ nullable: true })], Opportunity.prototype, "tenantId", 2);
|
|
347
|
+
__decorateClass$5([field({ required: true })], Opportunity.prototype, "name", 2);
|
|
348
|
+
__decorateClass$5([foreignKey("Lead")], Opportunity.prototype, "leadId", 2);
|
|
349
|
+
__decorateClass$5([foreignKey("SalesRepresentative")], Opportunity.prototype, "ownerRepId", 2);
|
|
350
|
+
__decorateClass$5([foreignKey("PipelineDefinition")], Opportunity.prototype, "pipelineId", 2);
|
|
351
|
+
__decorateClass$5([foreignKey("PipelineStage")], Opportunity.prototype, "stageId", 2);
|
|
352
|
+
Opportunity = __decorateClass$5([TenantScoped({ mode: "optional" }), smrt({
|
|
353
|
+
api: { include: [
|
|
354
|
+
"list",
|
|
355
|
+
"get",
|
|
356
|
+
"create",
|
|
357
|
+
"update"
|
|
358
|
+
] },
|
|
359
|
+
mcp: { include: ["list", "get"] },
|
|
360
|
+
cli: true
|
|
361
|
+
})], Opportunity);
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/crm/models/PipelineStage.ts
|
|
364
|
+
var __defProp$4 = Object.defineProperty;
|
|
365
|
+
var __getOwnPropDesc$4 = Object.getOwnPropertyDescriptor;
|
|
366
|
+
var __decorateClass$4 = (decorators, target, key, kind) => {
|
|
367
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$4(target, key) : target;
|
|
368
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
369
|
+
if (kind && result) __defProp$4(target, key, result);
|
|
370
|
+
return result;
|
|
371
|
+
};
|
|
372
|
+
var PipelineStage = class extends SmrtObject {
|
|
373
|
+
tenantId = null;
|
|
374
|
+
pipelineId = "";
|
|
375
|
+
key = "";
|
|
376
|
+
/** Human-readable stage name shown on board columns. */
|
|
377
|
+
name = "";
|
|
378
|
+
/** Ordering within the pipeline (ascending). Seeded in steps of 10. */
|
|
379
|
+
sortOrder = 0;
|
|
380
|
+
/**
|
|
381
|
+
* Default win probability (`0`–`1`, DECIMAL) adopted by an opportunity
|
|
382
|
+
* entering this stage unless the move overrides it.
|
|
383
|
+
*/
|
|
384
|
+
probability = 0;
|
|
385
|
+
/** Terminal flag: moving an opportunity here closes it as `won`. */
|
|
386
|
+
isWon = false;
|
|
387
|
+
/** Terminal flag: moving an opportunity here closes it as `lost`. */
|
|
388
|
+
isLost = false;
|
|
389
|
+
/**
|
|
390
|
+
* Free-form JSON object stored as a string. Use
|
|
391
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
392
|
+
*/
|
|
393
|
+
metadata = "{}";
|
|
394
|
+
constructor(options = {}) {
|
|
395
|
+
super(options);
|
|
396
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
397
|
+
if (options.pipelineId !== void 0) this.pipelineId = options.pipelineId;
|
|
398
|
+
if (options.key !== void 0) this.key = options.key;
|
|
399
|
+
if (options.name !== void 0) this.name = options.name;
|
|
400
|
+
if (options.sortOrder !== void 0) this.sortOrder = options.sortOrder;
|
|
401
|
+
if (options.probability !== void 0) this.probability = options.probability;
|
|
402
|
+
if (options.isWon !== void 0) this.isWon = options.isWon;
|
|
403
|
+
if (options.isLost !== void 0) this.isLost = options.isLost;
|
|
404
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
405
|
+
}
|
|
406
|
+
/** Whether entering this stage closes the opportunity (won or lost). */
|
|
407
|
+
isTerminal() {
|
|
408
|
+
return this.isWon || this.isLost;
|
|
409
|
+
}
|
|
410
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
411
|
+
getMetadata() {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(this.metadata);
|
|
414
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
415
|
+
return {};
|
|
416
|
+
} catch {
|
|
417
|
+
return {};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Serialize and store the metadata object. */
|
|
421
|
+
setMetadata(metadata) {
|
|
422
|
+
this.metadata = JSON.stringify(metadata);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
__decorateClass$4([tenantId({ nullable: true })], PipelineStage.prototype, "tenantId", 2);
|
|
426
|
+
__decorateClass$4([foreignKey("PipelineDefinition", { required: true })], PipelineStage.prototype, "pipelineId", 2);
|
|
427
|
+
__decorateClass$4([field({ required: true })], PipelineStage.prototype, "key", 2);
|
|
428
|
+
PipelineStage = __decorateClass$4([TenantScoped({ mode: "optional" }), smrt({
|
|
429
|
+
api: { include: [
|
|
430
|
+
"list",
|
|
431
|
+
"get",
|
|
432
|
+
"create",
|
|
433
|
+
"update"
|
|
434
|
+
] },
|
|
435
|
+
mcp: { include: ["list", "get"] },
|
|
436
|
+
cli: false,
|
|
437
|
+
conflictColumns: ["pipeline_id", "key"]
|
|
438
|
+
})], PipelineStage);
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/crm/collections/PipelineStageCollection.ts
|
|
441
|
+
var PipelineStageCollection = class extends SmrtCollection {
|
|
442
|
+
static _itemClass = PipelineStage;
|
|
443
|
+
/**
|
|
444
|
+
* List a pipeline's stages in board order.
|
|
445
|
+
*
|
|
446
|
+
* @param pipelineId - Owning PipelineDefinition id
|
|
447
|
+
* @returns Stages ordered by `sortOrder` ascending
|
|
448
|
+
*/
|
|
449
|
+
async findByPipeline(pipelineId) {
|
|
450
|
+
return await this.list({
|
|
451
|
+
where: { pipelineId },
|
|
452
|
+
orderBy: "sort_order ASC"
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Find one stage by its natural key within a pipeline.
|
|
457
|
+
*
|
|
458
|
+
* @param pipelineId - Owning PipelineDefinition id
|
|
459
|
+
* @param key - Stage machine key (e.g. `'proposal'`)
|
|
460
|
+
* @returns The stage, or null when the key does not exist in the pipeline
|
|
461
|
+
*/
|
|
462
|
+
async findByKey(pipelineId, key) {
|
|
463
|
+
return (await this.list({
|
|
464
|
+
where: {
|
|
465
|
+
pipelineId,
|
|
466
|
+
key
|
|
467
|
+
},
|
|
468
|
+
limit: 1
|
|
469
|
+
}))[0] ?? null;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/crm/models/SalesActivity.ts
|
|
474
|
+
var __defProp$3 = Object.defineProperty;
|
|
475
|
+
var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
|
|
476
|
+
var __decorateClass$3 = (decorators, target, key, kind) => {
|
|
477
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
|
|
478
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
479
|
+
if (kind && result) __defProp$3(target, key, result);
|
|
480
|
+
return result;
|
|
481
|
+
};
|
|
482
|
+
var SalesActivity = class extends SmrtObject {
|
|
483
|
+
tenantId = null;
|
|
484
|
+
/** Which model the activity attaches to: `'lead'` or `'opportunity'`. */
|
|
485
|
+
subjectKind = "lead";
|
|
486
|
+
subjectId = "";
|
|
487
|
+
/**
|
|
488
|
+
* Kind of activity — OPEN string so consumers can extend the vocabulary
|
|
489
|
+
* without schema changes. See `SALES_ACTIVITY_KINDS` for the recommended
|
|
490
|
+
* kinds (framework-written rows use `qualification`, `stage_change`,
|
|
491
|
+
* `merge`).
|
|
492
|
+
*/
|
|
493
|
+
activityKind = "note";
|
|
494
|
+
/** One-line human-readable description. */
|
|
495
|
+
summary = "";
|
|
496
|
+
/** Next-action due date — set for `task`-like activities. */
|
|
497
|
+
dueAt = null;
|
|
498
|
+
/** When the next action was completed; `null` while open. */
|
|
499
|
+
completedAt = null;
|
|
500
|
+
actorProfileId = "";
|
|
501
|
+
/**
|
|
502
|
+
* Free-form JSON object stored as a string (framework audit rows carry
|
|
503
|
+
* structured detail here, e.g. `stage_change` from/to ids or the full
|
|
504
|
+
* loser snapshot on `merge`). Use {@link getMetadata}/{@link setMetadata}.
|
|
505
|
+
*/
|
|
506
|
+
metadata = "{}";
|
|
507
|
+
constructor(options = {}) {
|
|
508
|
+
super(options);
|
|
509
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
510
|
+
if (options.subjectKind !== void 0) this.subjectKind = options.subjectKind;
|
|
511
|
+
if (options.subjectId !== void 0) this.subjectId = options.subjectId;
|
|
512
|
+
if (options.activityKind !== void 0) this.activityKind = options.activityKind;
|
|
513
|
+
if (options.summary !== void 0) this.summary = options.summary;
|
|
514
|
+
if (options.dueAt !== void 0) this.dueAt = options.dueAt;
|
|
515
|
+
if (options.completedAt !== void 0) this.completedAt = options.completedAt;
|
|
516
|
+
if (options.actorProfileId !== void 0) this.actorProfileId = options.actorProfileId;
|
|
517
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
518
|
+
}
|
|
519
|
+
/** Whether this is an open next action (due date set, not completed). */
|
|
520
|
+
isOpenTask() {
|
|
521
|
+
return this.dueAt !== null && this.completedAt === null;
|
|
522
|
+
}
|
|
523
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
524
|
+
getMetadata() {
|
|
525
|
+
try {
|
|
526
|
+
const parsed = JSON.parse(this.metadata);
|
|
527
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
528
|
+
return {};
|
|
529
|
+
} catch {
|
|
530
|
+
return {};
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** Serialize and store the metadata object. */
|
|
534
|
+
setMetadata(metadata) {
|
|
535
|
+
this.metadata = JSON.stringify(metadata);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
__decorateClass$3([tenantId({ nullable: true })], SalesActivity.prototype, "tenantId", 2);
|
|
539
|
+
__decorateClass$3([field({ required: true })], SalesActivity.prototype, "subjectId", 2);
|
|
540
|
+
__decorateClass$3([crossPackageRef("@happyvertical/smrt-profiles:Profile")], SalesActivity.prototype, "actorProfileId", 2);
|
|
541
|
+
SalesActivity = __decorateClass$3([TenantScoped({ mode: "optional" }), smrt({
|
|
542
|
+
api: { include: [
|
|
543
|
+
"create",
|
|
544
|
+
"list",
|
|
545
|
+
"get"
|
|
546
|
+
] },
|
|
547
|
+
mcp: { include: ["list", "create"] },
|
|
548
|
+
cli: false
|
|
549
|
+
})], SalesActivity);
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/crm/collections/SalesActivityCollection.ts
|
|
552
|
+
var SalesActivityCollection = class extends SmrtCollection {
|
|
553
|
+
static _itemClass = SalesActivity;
|
|
554
|
+
/**
|
|
555
|
+
* Full trail for one subject, oldest first (chronological reading order).
|
|
556
|
+
*
|
|
557
|
+
* @param subjectKind - `'lead'` or `'opportunity'`
|
|
558
|
+
* @param subjectId - Subject row id
|
|
559
|
+
*/
|
|
560
|
+
async findBySubject(subjectKind, subjectId) {
|
|
561
|
+
return await this.list({
|
|
562
|
+
where: {
|
|
563
|
+
subjectKind,
|
|
564
|
+
subjectId
|
|
565
|
+
},
|
|
566
|
+
orderBy: "created_at ASC"
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Open next actions for one subject: activities with a due date
|
|
571
|
+
* (`dueAt` set) that have not been completed (`completedAt` null),
|
|
572
|
+
* soonest due first.
|
|
573
|
+
*
|
|
574
|
+
* @param subjectKind - `'lead'` or `'opportunity'`
|
|
575
|
+
* @param subjectId - Subject row id
|
|
576
|
+
*/
|
|
577
|
+
async findOpenTasks(subjectKind, subjectId) {
|
|
578
|
+
return await this.list({
|
|
579
|
+
where: {
|
|
580
|
+
subjectKind,
|
|
581
|
+
subjectId,
|
|
582
|
+
"dueAt !=": null,
|
|
583
|
+
completedAt: null
|
|
584
|
+
},
|
|
585
|
+
orderBy: "due_at ASC"
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/crm/collections/OpportunityCollection.ts
|
|
591
|
+
var OpportunityCollection = class extends SmrtCollection {
|
|
592
|
+
static _itemClass = Opportunity;
|
|
593
|
+
stageCollectionPromise = null;
|
|
594
|
+
activityCollectionPromise = null;
|
|
595
|
+
/** Sibling stage collection sharing this collection's DB connection. */
|
|
596
|
+
async getStageCollection() {
|
|
597
|
+
if (!this.stageCollectionPromise) this.stageCollectionPromise = PipelineStageCollection.create({ db: this.db });
|
|
598
|
+
return this.stageCollectionPromise;
|
|
599
|
+
}
|
|
600
|
+
/** Sibling activity collection sharing this collection's DB connection. */
|
|
601
|
+
async getActivityCollection() {
|
|
602
|
+
if (!this.activityCollectionPromise) this.activityCollectionPromise = SalesActivityCollection.create({ db: this.db });
|
|
603
|
+
return this.activityCollectionPromise;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Opportunities originating from a given lead, oldest first (the first row
|
|
607
|
+
* is the one `LeadCollection.qualify()` treats as canonical).
|
|
608
|
+
*/
|
|
609
|
+
async findByLead(leadId) {
|
|
610
|
+
return await this.list({
|
|
611
|
+
where: { leadId },
|
|
612
|
+
orderBy: "created_at ASC"
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Open (still in play) opportunities, optionally narrowed to one pipeline.
|
|
617
|
+
*/
|
|
618
|
+
async findOpen(pipelineId) {
|
|
619
|
+
return await this.list({
|
|
620
|
+
where: {
|
|
621
|
+
status: "open",
|
|
622
|
+
...pipelineId ? { pipelineId } : {}
|
|
623
|
+
},
|
|
624
|
+
orderBy: "created_at ASC"
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Move an opportunity to another stage of ITS pipeline, with the full
|
|
629
|
+
* consequence set:
|
|
630
|
+
*
|
|
631
|
+
* - validates the target stage belongs to the opportunity's pipeline
|
|
632
|
+
* (an opportunity created without a pipeline adopts the stage's);
|
|
633
|
+
* - adopts the stage's default `probability` unless
|
|
634
|
+
* `probabilityOverride` is given;
|
|
635
|
+
* - terminal stages close the deal: `isWon` sets `status: 'won'` +
|
|
636
|
+
* `wonAt`; `isLost` sets `status: 'lost'` + `lostAt` (record the loss
|
|
637
|
+
* reason via `outcomeReason`);
|
|
638
|
+
* - writes a `stage_change` SalesActivity carrying from/to detail.
|
|
639
|
+
*
|
|
640
|
+
* Moves after a terminal status are rejected — a closed deal's history is
|
|
641
|
+
* settled; reopening is not supported (create a new opportunity instead).
|
|
642
|
+
*
|
|
643
|
+
* @returns The updated opportunity
|
|
644
|
+
*/
|
|
645
|
+
async moveToStage(params) {
|
|
646
|
+
const opportunity = await this.get({ id: params.opportunityId });
|
|
647
|
+
if (!opportunity) throw new Error(`OpportunityCollection.moveToStage: opportunity '${params.opportunityId}' not found`);
|
|
648
|
+
if (opportunity.status !== "open") throw new Error(`OpportunityCollection.moveToStage: opportunity '${params.opportunityId}' is '${opportunity.status}' \u2014 stage moves after a terminal status are not allowed`);
|
|
649
|
+
const stage = await (await this.getStageCollection()).get({ id: params.stageId });
|
|
650
|
+
if (!stage) throw new Error(`OpportunityCollection.moveToStage: stage '${params.stageId}' not found`);
|
|
651
|
+
if (opportunity.pipelineId && stage.pipelineId !== opportunity.pipelineId) throw new Error(`OpportunityCollection.moveToStage: stage '${stage.key || stage.id}' belongs to pipeline '${stage.pipelineId}', not the opportunity's pipeline '${opportunity.pipelineId}'`);
|
|
652
|
+
const fromStageId = opportunity.stageId;
|
|
653
|
+
const fromStatus = opportunity.status;
|
|
654
|
+
if (!opportunity.pipelineId) opportunity.pipelineId = stage.pipelineId;
|
|
655
|
+
opportunity.stageId = stage.id ?? "";
|
|
656
|
+
opportunity.probability = params.probabilityOverride ?? stage.probability;
|
|
657
|
+
if (params.outcomeReason !== void 0) opportunity.outcomeReason = params.outcomeReason;
|
|
658
|
+
if (stage.isWon) {
|
|
659
|
+
opportunity.status = "won";
|
|
660
|
+
opportunity.wonAt = params.now ?? /* @__PURE__ */ new Date();
|
|
661
|
+
} else if (stage.isLost) {
|
|
662
|
+
opportunity.status = "lost";
|
|
663
|
+
opportunity.lostAt = params.now ?? /* @__PURE__ */ new Date();
|
|
664
|
+
}
|
|
665
|
+
await opportunity.save();
|
|
666
|
+
await (await this.getActivityCollection()).create({
|
|
667
|
+
...opportunity.tenantId !== null ? { tenantId: opportunity.tenantId } : {},
|
|
668
|
+
subjectKind: "opportunity",
|
|
669
|
+
subjectId: opportunity.id ?? "",
|
|
670
|
+
activityKind: "stage_change",
|
|
671
|
+
summary: `Moved to stage '${stage.key}'`,
|
|
672
|
+
actorProfileId: params.actorProfileId ?? "",
|
|
673
|
+
metadata: JSON.stringify({
|
|
674
|
+
fromStageId,
|
|
675
|
+
toStageId: stage.id ?? "",
|
|
676
|
+
toStageKey: stage.key,
|
|
677
|
+
fromStatus,
|
|
678
|
+
toStatus: opportunity.status,
|
|
679
|
+
probability: opportunity.probability,
|
|
680
|
+
...params.outcomeReason !== void 0 ? { outcomeReason: params.outcomeReason } : {}
|
|
681
|
+
})
|
|
682
|
+
});
|
|
683
|
+
return opportunity;
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
//#endregion
|
|
687
|
+
//#region src/crm/models/PipelineDefinition.ts
|
|
688
|
+
var __defProp$2 = Object.defineProperty;
|
|
689
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
690
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
691
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
692
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
693
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
694
|
+
return result;
|
|
695
|
+
};
|
|
696
|
+
var PipelineDefinition = class extends SmrtObject {
|
|
697
|
+
tenantId = null;
|
|
698
|
+
key = "";
|
|
699
|
+
/** Human-readable pipeline name shown on CRM surfaces. */
|
|
700
|
+
name = "";
|
|
701
|
+
/**
|
|
702
|
+
* Marks the tenant's default pipeline. Informational for pickers/UI; the
|
|
703
|
+
* seeded default pipeline sets it `true`.
|
|
704
|
+
*/
|
|
705
|
+
isDefault = false;
|
|
706
|
+
/** `active` pipelines accept new opportunities; `archived` are read-only history. */
|
|
707
|
+
status = "active";
|
|
708
|
+
/**
|
|
709
|
+
* Free-form JSON object stored as a string. Use
|
|
710
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
711
|
+
*/
|
|
712
|
+
metadata = "{}";
|
|
713
|
+
constructor(options = {}) {
|
|
714
|
+
super(options);
|
|
715
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
716
|
+
if (options.key !== void 0) this.key = options.key;
|
|
717
|
+
if (options.name !== void 0) this.name = options.name;
|
|
718
|
+
if (options.isDefault !== void 0) this.isDefault = options.isDefault;
|
|
719
|
+
if (options.status !== void 0) this.status = options.status;
|
|
720
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
721
|
+
}
|
|
722
|
+
/** Whether the pipeline accepts new opportunities. */
|
|
723
|
+
isActive() {
|
|
724
|
+
return this.status === "active";
|
|
725
|
+
}
|
|
726
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
727
|
+
getMetadata() {
|
|
728
|
+
try {
|
|
729
|
+
const parsed = JSON.parse(this.metadata);
|
|
730
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
731
|
+
return {};
|
|
732
|
+
} catch {
|
|
733
|
+
return {};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** Serialize and store the metadata object. */
|
|
737
|
+
setMetadata(metadata) {
|
|
738
|
+
this.metadata = JSON.stringify(metadata);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
__decorateClass$2([tenantId({ nullable: true })], PipelineDefinition.prototype, "tenantId", 2);
|
|
742
|
+
__decorateClass$2([field({ required: true })], PipelineDefinition.prototype, "key", 2);
|
|
743
|
+
PipelineDefinition = __decorateClass$2([TenantScoped({ mode: "optional" }), smrt({
|
|
744
|
+
api: { include: [
|
|
745
|
+
"list",
|
|
746
|
+
"get",
|
|
747
|
+
"create",
|
|
748
|
+
"update"
|
|
749
|
+
] },
|
|
750
|
+
mcp: { include: ["list", "get"] },
|
|
751
|
+
cli: true,
|
|
752
|
+
conflictColumns: ["tenant_id", "key"]
|
|
753
|
+
})], PipelineDefinition);
|
|
754
|
+
//#endregion
|
|
755
|
+
//#region src/crm/types.ts
|
|
756
|
+
var SALES_REPRESENTATIVE_STATUSES = [
|
|
757
|
+
"active",
|
|
758
|
+
"inactive",
|
|
759
|
+
"suspended"
|
|
760
|
+
];
|
|
761
|
+
var LEAD_STATUSES = [
|
|
762
|
+
"new",
|
|
763
|
+
"working",
|
|
764
|
+
"qualified",
|
|
765
|
+
"disqualified",
|
|
766
|
+
"merged"
|
|
767
|
+
];
|
|
768
|
+
var PIPELINE_STATUSES = ["active", "archived"];
|
|
769
|
+
var OPPORTUNITY_STATUSES = [
|
|
770
|
+
"open",
|
|
771
|
+
"won",
|
|
772
|
+
"lost"
|
|
773
|
+
];
|
|
774
|
+
var SALES_ACTIVITY_SUBJECT_KINDS = ["lead", "opportunity"];
|
|
775
|
+
var SALES_ACTIVITY_KINDS = [
|
|
776
|
+
"note",
|
|
777
|
+
"call",
|
|
778
|
+
"email",
|
|
779
|
+
"meeting",
|
|
780
|
+
"task",
|
|
781
|
+
"assignment",
|
|
782
|
+
"qualification",
|
|
783
|
+
"stage_change",
|
|
784
|
+
"merge",
|
|
785
|
+
"status_change"
|
|
786
|
+
];
|
|
787
|
+
var DEFAULT_PIPELINE_KEY = "default";
|
|
788
|
+
var DEFAULT_PIPELINE_STAGES = [
|
|
789
|
+
{
|
|
790
|
+
key: "new",
|
|
791
|
+
name: "New",
|
|
792
|
+
probability: .1
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
key: "qualified",
|
|
796
|
+
name: "Qualified",
|
|
797
|
+
probability: .25
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
key: "discovery",
|
|
801
|
+
name: "Discovery",
|
|
802
|
+
probability: .4
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
key: "proposal",
|
|
806
|
+
name: "Proposal",
|
|
807
|
+
probability: .6
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
key: "negotiation",
|
|
811
|
+
name: "Negotiation",
|
|
812
|
+
probability: .8
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
key: "closed_won",
|
|
816
|
+
name: "Closed Won",
|
|
817
|
+
probability: 1,
|
|
818
|
+
isWon: true
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
key: "closed_lost",
|
|
822
|
+
name: "Closed Lost",
|
|
823
|
+
probability: 0,
|
|
824
|
+
isLost: true
|
|
825
|
+
}
|
|
826
|
+
];
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/crm/collections/PipelineDefinitionCollection.ts
|
|
829
|
+
function requireId$1(value, what) {
|
|
830
|
+
if (!value) throw new Error(`PipelineDefinitionCollection: ${what} has no id`);
|
|
831
|
+
return value;
|
|
832
|
+
}
|
|
833
|
+
var PipelineDefinitionCollection = class extends SmrtCollection {
|
|
834
|
+
static _itemClass = PipelineDefinition;
|
|
835
|
+
stageCollectionPromise = null;
|
|
836
|
+
/** Sibling stage collection sharing this collection's DB connection. */
|
|
837
|
+
async getStageCollection() {
|
|
838
|
+
if (!this.stageCollectionPromise) this.stageCollectionPromise = PipelineStageCollection.create({ db: this.db });
|
|
839
|
+
return this.stageCollectionPromise;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Find one pipeline by its machine key (natural key within the tenant
|
|
843
|
+
* scope — ambient tenancy auto-filters the lookup).
|
|
844
|
+
*/
|
|
845
|
+
async findByKey(key) {
|
|
846
|
+
return (await this.list({
|
|
847
|
+
where: { key },
|
|
848
|
+
limit: 1
|
|
849
|
+
}))[0] ?? null;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Find the pipeline flagged `isDefault` (informational designation).
|
|
853
|
+
*/
|
|
854
|
+
async findDefault() {
|
|
855
|
+
return (await this.list({
|
|
856
|
+
where: { isDefault: true },
|
|
857
|
+
orderBy: "created_at ASC",
|
|
858
|
+
limit: 1
|
|
859
|
+
}))[0] ?? null;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* A pipeline's stages in board order (`sortOrder` ascending).
|
|
863
|
+
*/
|
|
864
|
+
async getStages(pipelineId) {
|
|
865
|
+
return await (await this.getStageCollection()).findByPipeline(pipelineId);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Idempotently find-or-create the seeded default pipeline (key
|
|
869
|
+
* `'default'`, `isDefault: true`) and its 7 default stages:
|
|
870
|
+
* `new(0.1) → qualified(0.25) → discovery(0.4) → proposal(0.6) →
|
|
871
|
+
* negotiation(0.8) → closed_won(1.0, isWon) → closed_lost(0.0, isLost)`.
|
|
872
|
+
*
|
|
873
|
+
* Idempotency is per stage KEY: a second call creates nothing, and a
|
|
874
|
+
* partially-seeded pipeline (e.g. an interrupted first run) is healed by
|
|
875
|
+
* creating only the missing keys. Existing stages are never overwritten —
|
|
876
|
+
* renames, re-weights, reorders, and custom stages added via normal
|
|
877
|
+
* PipelineStage operations all survive re-runs.
|
|
878
|
+
*
|
|
879
|
+
* Tenancy: under an ambient `withTenant()` context the pipeline/stages
|
|
880
|
+
* auto-scope to that tenant; pass `tenantId` to seed for a specific tenant
|
|
881
|
+
* from a system/global context. Lookup is by natural key, so each tenant
|
|
882
|
+
* gets exactly one seeded default pipeline.
|
|
883
|
+
*
|
|
884
|
+
* @returns The pipeline plus ALL of its stages ordered by `sortOrder`
|
|
885
|
+
* (including any custom stages added since seeding)
|
|
886
|
+
*/
|
|
887
|
+
async ensureDefaultPipeline(params = {}) {
|
|
888
|
+
const tenantScope = params.tenantId !== void 0 ? { tenantId: params.tenantId } : {};
|
|
889
|
+
let pipeline = (await this.list({
|
|
890
|
+
where: {
|
|
891
|
+
key: DEFAULT_PIPELINE_KEY,
|
|
892
|
+
...tenantScope
|
|
893
|
+
},
|
|
894
|
+
limit: 1
|
|
895
|
+
}))[0];
|
|
896
|
+
if (!pipeline) pipeline = await this.create({
|
|
897
|
+
...tenantScope,
|
|
898
|
+
key: DEFAULT_PIPELINE_KEY,
|
|
899
|
+
name: "Default Sales Pipeline",
|
|
900
|
+
isDefault: true,
|
|
901
|
+
status: "active"
|
|
902
|
+
});
|
|
903
|
+
const pipelineId = requireId$1(pipeline.id, "default pipeline");
|
|
904
|
+
const stagesCollection = await this.getStageCollection();
|
|
905
|
+
const existing = await stagesCollection.findByPipeline(pipelineId);
|
|
906
|
+
const existingKeys = new Set(existing.map((stage) => stage.key));
|
|
907
|
+
const stageTenantScope = pipeline.tenantId !== null ? { tenantId: pipeline.tenantId } : tenantScope;
|
|
908
|
+
for (const [index, seed] of DEFAULT_PIPELINE_STAGES.entries()) {
|
|
909
|
+
if (existingKeys.has(seed.key)) continue;
|
|
910
|
+
await stagesCollection.create({
|
|
911
|
+
...stageTenantScope,
|
|
912
|
+
pipelineId,
|
|
913
|
+
key: seed.key,
|
|
914
|
+
name: seed.name,
|
|
915
|
+
sortOrder: (index + 1) * 10,
|
|
916
|
+
probability: seed.probability,
|
|
917
|
+
isWon: seed.isWon ?? false,
|
|
918
|
+
isLost: seed.isLost ?? false
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
const stages = await stagesCollection.findByPipeline(pipelineId);
|
|
922
|
+
return {
|
|
923
|
+
pipeline,
|
|
924
|
+
stages
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/crm/collections/LeadCollection.ts
|
|
930
|
+
var MERGE_FILL_CONTACT_FIELDS = [
|
|
931
|
+
"contactName",
|
|
932
|
+
"email",
|
|
933
|
+
"phone",
|
|
934
|
+
"organizationName"
|
|
935
|
+
];
|
|
936
|
+
function requireId(value, what) {
|
|
937
|
+
if (!value) throw new Error(`LeadCollection: ${what} has no id`);
|
|
938
|
+
return value;
|
|
939
|
+
}
|
|
940
|
+
var LeadCollection = class extends SmrtCollection {
|
|
941
|
+
static _itemClass = Lead;
|
|
942
|
+
opportunityCollectionPromise = null;
|
|
943
|
+
pipelineCollectionPromise = null;
|
|
944
|
+
activityCollectionPromise = null;
|
|
945
|
+
/** Sibling opportunity collection sharing this collection's DB connection. */
|
|
946
|
+
async getOpportunityCollection() {
|
|
947
|
+
if (!this.opportunityCollectionPromise) this.opportunityCollectionPromise = OpportunityCollection.create({ db: this.db });
|
|
948
|
+
return this.opportunityCollectionPromise;
|
|
949
|
+
}
|
|
950
|
+
/** Sibling pipeline collection sharing this collection's DB connection. */
|
|
951
|
+
async getPipelineCollection() {
|
|
952
|
+
if (!this.pipelineCollectionPromise) this.pipelineCollectionPromise = PipelineDefinitionCollection.create({ db: this.db });
|
|
953
|
+
return this.pipelineCollectionPromise;
|
|
954
|
+
}
|
|
955
|
+
/** Sibling activity collection sharing this collection's DB connection. */
|
|
956
|
+
async getActivityCollection() {
|
|
957
|
+
if (!this.activityCollectionPromise) this.activityCollectionPromise = SalesActivityCollection.create({ db: this.db });
|
|
958
|
+
return this.activityCollectionPromise;
|
|
959
|
+
}
|
|
960
|
+
/** Leads in a given lifecycle status, newest first. */
|
|
961
|
+
async findByStatus(status) {
|
|
962
|
+
return await this.list({
|
|
963
|
+
where: { status },
|
|
964
|
+
orderBy: "created_at DESC"
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
/** Leads owned by a given sales rep, newest first. */
|
|
968
|
+
async findByOwner(ownerRepId) {
|
|
969
|
+
return await this.list({
|
|
970
|
+
where: { ownerRepId },
|
|
971
|
+
orderBy: "created_at DESC"
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Qualify a lead into an Opportunity.
|
|
976
|
+
*
|
|
977
|
+
* Transitions the lead to `qualified` (stamping `qualifiedAt` once) and
|
|
978
|
+
* creates an Opportunity at the FIRST stage of the target pipeline —
|
|
979
|
+
* `pipelineId` when given, otherwise the seeded default pipeline
|
|
980
|
+
* (`ensureDefaultPipeline()`). The opportunity adopts the first stage's
|
|
981
|
+
* probability, copies the lead's `sourceKind`/`sourceId` for reporting,
|
|
982
|
+
* and defaults its name/owner from the lead. A `qualification`
|
|
983
|
+
* SalesActivity is written on BOTH the lead and the opportunity.
|
|
984
|
+
*
|
|
985
|
+
* IDEMPOTENT: when the lead is already `qualified` and an opportunity
|
|
986
|
+
* exists for it, that opportunity is returned unchanged (no new rows, no
|
|
987
|
+
* new activities). A qualified lead with no opportunity (interrupted
|
|
988
|
+
* earlier run) is healed by creating the missing opportunity.
|
|
989
|
+
*
|
|
990
|
+
* Illegal source statuses are rejected by the lead's save-time transition
|
|
991
|
+
* guard (`disqualified`/`merged` leads cannot be qualified).
|
|
992
|
+
*
|
|
993
|
+
* @returns The (existing or newly created) opportunity
|
|
994
|
+
*/
|
|
995
|
+
async qualify(params) {
|
|
996
|
+
const lead = await this.get({ id: params.leadId });
|
|
997
|
+
if (!lead) throw new Error(`LeadCollection.qualify: lead '${params.leadId}' not found`);
|
|
998
|
+
const leadId = requireId(lead.id, `lead '${params.leadId}'`);
|
|
999
|
+
const opportunities = await this.getOpportunityCollection();
|
|
1000
|
+
if (lead.status === "qualified") {
|
|
1001
|
+
const existing = await opportunities.findByLead(leadId);
|
|
1002
|
+
if (existing[0]) return existing[0];
|
|
1003
|
+
}
|
|
1004
|
+
const pipelines = await this.getPipelineCollection();
|
|
1005
|
+
let pipeline;
|
|
1006
|
+
let stages;
|
|
1007
|
+
if (params.pipelineId) {
|
|
1008
|
+
const found = await pipelines.get({ id: params.pipelineId });
|
|
1009
|
+
if (!found) throw new Error(`LeadCollection.qualify: pipeline '${params.pipelineId}' not found`);
|
|
1010
|
+
pipeline = found;
|
|
1011
|
+
stages = await pipelines.getStages(requireId(pipeline.id, "pipeline"));
|
|
1012
|
+
} else ({pipeline, stages} = await pipelines.ensureDefaultPipeline({ ...lead.tenantId !== null ? { tenantId: lead.tenantId } : {} }));
|
|
1013
|
+
const firstStage = stages[0];
|
|
1014
|
+
if (!firstStage) throw new Error(`LeadCollection.qualify: pipeline '${pipeline.key || pipeline.id}' has no stages`);
|
|
1015
|
+
if (lead.status !== "qualified") {
|
|
1016
|
+
lead.status = "qualified";
|
|
1017
|
+
if (!lead.qualifiedAt) lead.qualifiedAt = params.now ?? /* @__PURE__ */ new Date();
|
|
1018
|
+
await lead.save();
|
|
1019
|
+
}
|
|
1020
|
+
const opportunity = await opportunities.create({
|
|
1021
|
+
...lead.tenantId !== null ? { tenantId: lead.tenantId } : {},
|
|
1022
|
+
name: params.opportunityName ?? lead.name,
|
|
1023
|
+
leadId,
|
|
1024
|
+
ownerRepId: params.ownerRepId ?? lead.ownerRepId,
|
|
1025
|
+
pipelineId: pipeline.id ?? "",
|
|
1026
|
+
stageId: firstStage.id ?? "",
|
|
1027
|
+
probability: firstStage.probability,
|
|
1028
|
+
expectedValueCents: params.expectedValueCents ?? 0,
|
|
1029
|
+
currency: params.currency ?? "USD",
|
|
1030
|
+
expectedCloseAt: params.expectedCloseAt ?? null,
|
|
1031
|
+
status: "open",
|
|
1032
|
+
sourceKind: lead.sourceKind,
|
|
1033
|
+
sourceId: lead.sourceId
|
|
1034
|
+
});
|
|
1035
|
+
const opportunityId = requireId(opportunity.id, "created opportunity");
|
|
1036
|
+
const activities = await this.getActivityCollection();
|
|
1037
|
+
const tenantScope = lead.tenantId !== null ? { tenantId: lead.tenantId } : {};
|
|
1038
|
+
await activities.create({
|
|
1039
|
+
...tenantScope,
|
|
1040
|
+
subjectKind: "lead",
|
|
1041
|
+
subjectId: leadId,
|
|
1042
|
+
activityKind: "qualification",
|
|
1043
|
+
summary: `Qualified into opportunity '${opportunity.name}'`,
|
|
1044
|
+
metadata: JSON.stringify({
|
|
1045
|
+
opportunityId,
|
|
1046
|
+
pipelineId: pipeline.id ?? "",
|
|
1047
|
+
stageId: firstStage.id ?? "",
|
|
1048
|
+
stageKey: firstStage.key
|
|
1049
|
+
})
|
|
1050
|
+
});
|
|
1051
|
+
await activities.create({
|
|
1052
|
+
...tenantScope,
|
|
1053
|
+
subjectKind: "opportunity",
|
|
1054
|
+
subjectId: opportunityId,
|
|
1055
|
+
activityKind: "qualification",
|
|
1056
|
+
summary: `Created from qualified lead '${lead.name}'`,
|
|
1057
|
+
metadata: JSON.stringify({
|
|
1058
|
+
leadId,
|
|
1059
|
+
pipelineId: pipeline.id ?? "",
|
|
1060
|
+
stageId: firstStage.id ?? "",
|
|
1061
|
+
stageKey: firstStage.key
|
|
1062
|
+
})
|
|
1063
|
+
});
|
|
1064
|
+
return opportunity;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Audited duplicate merge: fold the `loserId` lead into the `winnerId`
|
|
1068
|
+
* lead.
|
|
1069
|
+
*
|
|
1070
|
+
* Validations: the ids must differ, both leads must exist, the loser must
|
|
1071
|
+
* not already be merged, the winner must not itself be merged (merge into
|
|
1072
|
+
* the chain head instead), and the merge must not create a
|
|
1073
|
+
* `mergedIntoId` cycle.
|
|
1074
|
+
*
|
|
1075
|
+
* Effects:
|
|
1076
|
+
* - EMPTY winner contact fields (`contactName`/`email`/`phone`/
|
|
1077
|
+
* `organizationName`) are filled from the loser; non-empty winner data
|
|
1078
|
+
* is never overwritten;
|
|
1079
|
+
* - the loser's acquisition context is appended into the winner's under a
|
|
1080
|
+
* `mergedSources` array (both histories preserved verbatim);
|
|
1081
|
+
* - the loser becomes terminal: `status: 'merged'` +
|
|
1082
|
+
* `mergedIntoId: winnerId`;
|
|
1083
|
+
* - a `merge` SalesActivity is written on BOTH leads, each carrying a full
|
|
1084
|
+
* pre-merge loser snapshot in metadata;
|
|
1085
|
+
* - the loser's existing activities STAY attached to the loser (history
|
|
1086
|
+
* preserved in place) — read the combined trail via
|
|
1087
|
+
* {@link activitiesIncludingMerged}.
|
|
1088
|
+
*/
|
|
1089
|
+
async mergeLeads(params) {
|
|
1090
|
+
const { winnerId, loserId } = params;
|
|
1091
|
+
if (winnerId === loserId) throw new Error(`LeadCollection.mergeLeads: winner and loser must be distinct leads (got '${winnerId}' twice)`);
|
|
1092
|
+
const winner = await this.get({ id: winnerId });
|
|
1093
|
+
if (!winner) throw new Error(`LeadCollection.mergeLeads: winner lead '${winnerId}' not found`);
|
|
1094
|
+
const loser = await this.get({ id: loserId });
|
|
1095
|
+
if (!loser) throw new Error(`LeadCollection.mergeLeads: loser lead '${loserId}' not found`);
|
|
1096
|
+
if (loser.status === "merged") throw new Error(`LeadCollection.mergeLeads: lead '${loserId}' is already merged into '${loser.mergedIntoId}'`);
|
|
1097
|
+
if (winner.status === "merged") throw new Error(`LeadCollection.mergeLeads: winner lead '${winnerId}' has itself been merged into '${winner.mergedIntoId}' \u2014 merge into the chain head instead`);
|
|
1098
|
+
const visited = /* @__PURE__ */ new Set([winnerId]);
|
|
1099
|
+
let cursor = winner.mergedIntoId;
|
|
1100
|
+
while (cursor) {
|
|
1101
|
+
if (cursor === loserId) throw new Error(`LeadCollection.mergeLeads: merging '${loserId}' into '${winnerId}' would create a merge cycle`);
|
|
1102
|
+
if (visited.has(cursor)) break;
|
|
1103
|
+
visited.add(cursor);
|
|
1104
|
+
cursor = (await this.get({ id: cursor }))?.mergedIntoId ?? "";
|
|
1105
|
+
}
|
|
1106
|
+
const loserSnapshot = loser.toJSON();
|
|
1107
|
+
const filledFields = [];
|
|
1108
|
+
for (const fieldName of MERGE_FILL_CONTACT_FIELDS) if (winner[fieldName] === "" && loser[fieldName] !== "") {
|
|
1109
|
+
winner[fieldName] = loser[fieldName];
|
|
1110
|
+
filledFields.push(fieldName);
|
|
1111
|
+
}
|
|
1112
|
+
const winnerContext = winner.getAcquisitionContext();
|
|
1113
|
+
const priorSources = winnerContext.mergedSources;
|
|
1114
|
+
const mergedSources = Array.isArray(priorSources) ? [...priorSources] : [];
|
|
1115
|
+
mergedSources.push({
|
|
1116
|
+
leadId: loserId,
|
|
1117
|
+
mergedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1118
|
+
sourceKind: loser.sourceKind,
|
|
1119
|
+
sourceId: loser.sourceId,
|
|
1120
|
+
acquisitionContext: loser.getAcquisitionContext()
|
|
1121
|
+
});
|
|
1122
|
+
winner.setAcquisitionContext({
|
|
1123
|
+
...winnerContext,
|
|
1124
|
+
mergedSources
|
|
1125
|
+
});
|
|
1126
|
+
await winner.save();
|
|
1127
|
+
loser.status = "merged";
|
|
1128
|
+
loser.mergedIntoId = winnerId;
|
|
1129
|
+
await loser.save();
|
|
1130
|
+
const activities = await this.getActivityCollection();
|
|
1131
|
+
const actorProfileId = params.actorProfileId ?? "";
|
|
1132
|
+
const auditDetail = {
|
|
1133
|
+
winnerId,
|
|
1134
|
+
loserId,
|
|
1135
|
+
reason: params.reason ?? "",
|
|
1136
|
+
filledFields,
|
|
1137
|
+
loserSnapshot
|
|
1138
|
+
};
|
|
1139
|
+
await activities.create({
|
|
1140
|
+
...winner.tenantId !== null ? { tenantId: winner.tenantId } : {},
|
|
1141
|
+
subjectKind: "lead",
|
|
1142
|
+
subjectId: winnerId,
|
|
1143
|
+
activityKind: "merge",
|
|
1144
|
+
summary: `Merged duplicate lead '${loser.name}' into this lead`,
|
|
1145
|
+
actorProfileId,
|
|
1146
|
+
metadata: JSON.stringify({
|
|
1147
|
+
...auditDetail,
|
|
1148
|
+
direction: "absorbed"
|
|
1149
|
+
})
|
|
1150
|
+
});
|
|
1151
|
+
await activities.create({
|
|
1152
|
+
...loser.tenantId !== null ? { tenantId: loser.tenantId } : {},
|
|
1153
|
+
subjectKind: "lead",
|
|
1154
|
+
subjectId: loserId,
|
|
1155
|
+
activityKind: "merge",
|
|
1156
|
+
summary: `Merged into lead '${winner.name}'`,
|
|
1157
|
+
actorProfileId,
|
|
1158
|
+
metadata: JSON.stringify({
|
|
1159
|
+
...auditDetail,
|
|
1160
|
+
direction: "merged_away"
|
|
1161
|
+
})
|
|
1162
|
+
});
|
|
1163
|
+
return {
|
|
1164
|
+
winner,
|
|
1165
|
+
loser
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* The lead's activity trail INCLUDING the trails of every lead merged into
|
|
1170
|
+
* it, transitively (losers keep their own activities; this read
|
|
1171
|
+
* re-assembles the full history across `mergedIntoId` chains).
|
|
1172
|
+
*
|
|
1173
|
+
* Traversal is breadth-first over merge children with a visited-set guard,
|
|
1174
|
+
* so malformed cyclic data cannot loop. Results are in chronological
|
|
1175
|
+
* order.
|
|
1176
|
+
*/
|
|
1177
|
+
async activitiesIncludingMerged(leadId) {
|
|
1178
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1179
|
+
let frontier = [leadId];
|
|
1180
|
+
while (frontier.length > 0) {
|
|
1181
|
+
const batch = frontier.filter((id) => id && !visited.has(id));
|
|
1182
|
+
if (batch.length === 0) break;
|
|
1183
|
+
for (const id of batch) visited.add(id);
|
|
1184
|
+
frontier = (await this.list({ where: { "mergedIntoId in": batch } })).map((child) => child.id ?? "").filter((id) => id !== "");
|
|
1185
|
+
}
|
|
1186
|
+
return await (await this.getActivityCollection()).list({
|
|
1187
|
+
where: {
|
|
1188
|
+
subjectKind: "lead",
|
|
1189
|
+
"subjectId in": Array.from(visited)
|
|
1190
|
+
},
|
|
1191
|
+
orderBy: "created_at ASC"
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
//#endregion
|
|
1196
|
+
//#region src/crm/models/OpportunityConversion.ts
|
|
1197
|
+
var __defProp$1 = Object.defineProperty;
|
|
1198
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
1199
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
1200
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
1201
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1202
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
1203
|
+
return result;
|
|
1204
|
+
};
|
|
1205
|
+
var OpportunityConversion = class extends SmrtObject {
|
|
1206
|
+
tenantId = null;
|
|
1207
|
+
opportunityId = "";
|
|
1208
|
+
targetKind = "";
|
|
1209
|
+
targetId = "";
|
|
1210
|
+
/** Optional human-readable note about the conversion. */
|
|
1211
|
+
note = "";
|
|
1212
|
+
/**
|
|
1213
|
+
* Free-form JSON object stored as a string. Use
|
|
1214
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
1215
|
+
*/
|
|
1216
|
+
metadata = "{}";
|
|
1217
|
+
constructor(options = {}) {
|
|
1218
|
+
super(options);
|
|
1219
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
1220
|
+
if (options.opportunityId !== void 0) this.opportunityId = options.opportunityId;
|
|
1221
|
+
if (options.targetKind !== void 0) this.targetKind = options.targetKind;
|
|
1222
|
+
if (options.targetId !== void 0) this.targetId = options.targetId;
|
|
1223
|
+
if (options.note !== void 0) this.note = options.note;
|
|
1224
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
1225
|
+
}
|
|
1226
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
1227
|
+
getMetadata() {
|
|
1228
|
+
try {
|
|
1229
|
+
const parsed = JSON.parse(this.metadata);
|
|
1230
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1231
|
+
return {};
|
|
1232
|
+
} catch {
|
|
1233
|
+
return {};
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
/** Serialize and store the metadata object. */
|
|
1237
|
+
setMetadata(metadata) {
|
|
1238
|
+
this.metadata = JSON.stringify(metadata);
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Save with the append-only guard: a fresh instance whose
|
|
1242
|
+
* `(opportunityId, targetKind, targetId)` natural key already belongs to
|
|
1243
|
+
* a DIFFERENT row is refused — the conflict-column upsert would replace
|
|
1244
|
+
* the existing conversion link (note and id) without the won-opportunity
|
|
1245
|
+
* validation. Idempotent linking goes through
|
|
1246
|
+
* `OpportunityConversionCollection.recordConversion()`, which finds
|
|
1247
|
+
* first and reports `{ created: false }`.
|
|
1248
|
+
*/
|
|
1249
|
+
async save() {
|
|
1250
|
+
if (this.opportunityId && this.targetKind && this.targetId) try {
|
|
1251
|
+
const res = await this.db.query(`SELECT id FROM ${this.tableName} WHERE opportunity_id = $1 AND target_kind = $2 AND target_id = $3`, this.opportunityId, this.targetKind, this.targetId);
|
|
1252
|
+
if ((Array.isArray(res) ? res : res.rows ?? []).find((row) => row.id !== this.id)) throw new Error(`OpportunityConversion ${this.opportunityId}/${this.targetKind}/${this.targetId}: this conversion link already exists \u2014 conversion links are append-only. Use OpportunityConversionCollection.recordConversion() for idempotent linking.`);
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
if (error instanceof Error && error.message.includes("append-only")) throw error;
|
|
1255
|
+
}
|
|
1256
|
+
return await super.save();
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
__decorateClass$1([tenantId({ nullable: true })], OpportunityConversion.prototype, "tenantId", 2);
|
|
1260
|
+
__decorateClass$1([foreignKey("Opportunity", { required: true })], OpportunityConversion.prototype, "opportunityId", 2);
|
|
1261
|
+
__decorateClass$1([field({ required: true })], OpportunityConversion.prototype, "targetKind", 2);
|
|
1262
|
+
__decorateClass$1([field({ required: true })], OpportunityConversion.prototype, "targetId", 2);
|
|
1263
|
+
OpportunityConversion = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
|
|
1264
|
+
api: { include: [
|
|
1265
|
+
"list",
|
|
1266
|
+
"get",
|
|
1267
|
+
"create"
|
|
1268
|
+
] },
|
|
1269
|
+
mcp: { include: ["list", "get"] },
|
|
1270
|
+
cli: false,
|
|
1271
|
+
conflictColumns: [
|
|
1272
|
+
"opportunity_id",
|
|
1273
|
+
"target_kind",
|
|
1274
|
+
"target_id"
|
|
1275
|
+
]
|
|
1276
|
+
})], OpportunityConversion);
|
|
1277
|
+
//#endregion
|
|
1278
|
+
//#region src/crm/collections/OpportunityConversionCollection.ts
|
|
1279
|
+
var OpportunityConversionCollection = class extends SmrtCollection {
|
|
1280
|
+
static _itemClass = OpportunityConversion;
|
|
1281
|
+
opportunityCollectionPromise = null;
|
|
1282
|
+
/** Sibling opportunity collection sharing this collection's DB connection. */
|
|
1283
|
+
async getOpportunityCollection() {
|
|
1284
|
+
if (!this.opportunityCollectionPromise) this.opportunityCollectionPromise = OpportunityCollection.create({ db: this.db });
|
|
1285
|
+
return this.opportunityCollectionPromise;
|
|
1286
|
+
}
|
|
1287
|
+
/** All conversion links recorded for one opportunity, oldest first. */
|
|
1288
|
+
async findByOpportunity(opportunityId) {
|
|
1289
|
+
return await this.list({
|
|
1290
|
+
where: { opportunityId },
|
|
1291
|
+
orderBy: "created_at ASC"
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Record that a WON opportunity materialized into a downstream record,
|
|
1296
|
+
* idempotently on the natural key `(opportunityId, targetKind, targetId)`.
|
|
1297
|
+
*
|
|
1298
|
+
* Preconditions: the opportunity must exist and its status must be `'won'`
|
|
1299
|
+
* — conversions from open or lost deals are rejected with a clear error.
|
|
1300
|
+
* CRM only records the link; creating the downstream record itself
|
|
1301
|
+
* (client, project, contract, subscription, …) is the caller's job.
|
|
1302
|
+
*
|
|
1303
|
+
* @returns `{ conversion, created }` — `created: false` when the link
|
|
1304
|
+
* already existed (the existing row is returned untouched)
|
|
1305
|
+
*/
|
|
1306
|
+
async recordConversion(params) {
|
|
1307
|
+
const opportunity = await (await this.getOpportunityCollection()).get({ id: params.opportunityId });
|
|
1308
|
+
if (!opportunity) throw new Error(`OpportunityConversionCollection.recordConversion: opportunity '${params.opportunityId}' not found`);
|
|
1309
|
+
if (opportunity.status !== "won") throw new Error(`OpportunityConversionCollection.recordConversion: opportunity '${params.opportunityId}' has status '${opportunity.status}' \u2014 conversions can only be recorded for won opportunities`);
|
|
1310
|
+
const existing = await this.list({
|
|
1311
|
+
where: {
|
|
1312
|
+
opportunityId: params.opportunityId,
|
|
1313
|
+
targetKind: params.targetKind,
|
|
1314
|
+
targetId: params.targetId
|
|
1315
|
+
},
|
|
1316
|
+
limit: 1
|
|
1317
|
+
});
|
|
1318
|
+
if (existing[0]) return {
|
|
1319
|
+
conversion: existing[0],
|
|
1320
|
+
created: false
|
|
1321
|
+
};
|
|
1322
|
+
return {
|
|
1323
|
+
conversion: await this.create({
|
|
1324
|
+
...opportunity.tenantId !== null ? { tenantId: opportunity.tenantId } : {},
|
|
1325
|
+
opportunityId: params.opportunityId,
|
|
1326
|
+
targetKind: params.targetKind,
|
|
1327
|
+
targetId: params.targetId,
|
|
1328
|
+
note: params.note ?? ""
|
|
1329
|
+
}),
|
|
1330
|
+
created: true
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
//#endregion
|
|
1335
|
+
//#region src/crm/models/SalesRepresentative.ts
|
|
1336
|
+
var __defProp = Object.defineProperty;
|
|
1337
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
1338
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
1339
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
1340
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
1341
|
+
if (kind && result) __defProp(target, key, result);
|
|
1342
|
+
return result;
|
|
1343
|
+
};
|
|
1344
|
+
var SalesRepresentative = class extends SmrtObject {
|
|
1345
|
+
tenantId = null;
|
|
1346
|
+
profileId = "";
|
|
1347
|
+
earnerId = "";
|
|
1348
|
+
/** Role status — only `active` reps should receive new assignments. */
|
|
1349
|
+
status = "active";
|
|
1350
|
+
/** Job title shown on CRM surfaces (e.g. `'Account Executive'`). */
|
|
1351
|
+
title = "";
|
|
1352
|
+
/**
|
|
1353
|
+
* Free-form JSON object stored as a string. Use
|
|
1354
|
+
* {@link getMetadata}/{@link setMetadata} instead of parsing manually.
|
|
1355
|
+
*/
|
|
1356
|
+
metadata = "{}";
|
|
1357
|
+
constructor(options = {}) {
|
|
1358
|
+
super(options);
|
|
1359
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
1360
|
+
if (options.profileId !== void 0) this.profileId = options.profileId;
|
|
1361
|
+
if (options.earnerId !== void 0) this.earnerId = options.earnerId;
|
|
1362
|
+
if (options.status !== void 0) this.status = options.status;
|
|
1363
|
+
if (options.title !== void 0) this.title = options.title;
|
|
1364
|
+
if (options.metadata !== void 0) this.metadata = options.metadata;
|
|
1365
|
+
}
|
|
1366
|
+
/** Whether the rep may receive new lead/opportunity assignments. */
|
|
1367
|
+
isActive() {
|
|
1368
|
+
return this.status === "active";
|
|
1369
|
+
}
|
|
1370
|
+
/** Parse the metadata JSON string; returns `{}` on malformed content. */
|
|
1371
|
+
getMetadata() {
|
|
1372
|
+
try {
|
|
1373
|
+
const parsed = JSON.parse(this.metadata);
|
|
1374
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
1375
|
+
return {};
|
|
1376
|
+
} catch {
|
|
1377
|
+
return {};
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
/** Serialize and store the metadata object. */
|
|
1381
|
+
setMetadata(metadata) {
|
|
1382
|
+
this.metadata = JSON.stringify(metadata);
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
__decorateClass([tenantId({ nullable: true })], SalesRepresentative.prototype, "tenantId", 2);
|
|
1386
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-profiles:Profile")], SalesRepresentative.prototype, "profileId", 2);
|
|
1387
|
+
__decorateClass([foreignKey("Earner")], SalesRepresentative.prototype, "earnerId", 2);
|
|
1388
|
+
SalesRepresentative = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
|
|
1389
|
+
api: { include: [
|
|
1390
|
+
"list",
|
|
1391
|
+
"get",
|
|
1392
|
+
"create",
|
|
1393
|
+
"update"
|
|
1394
|
+
] },
|
|
1395
|
+
mcp: { include: ["list", "get"] },
|
|
1396
|
+
cli: true
|
|
1397
|
+
})], SalesRepresentative);
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region src/crm/collections/SalesRepresentativeCollection.ts
|
|
1400
|
+
var SalesRepresentativeCollection = class extends SmrtCollection {
|
|
1401
|
+
static _itemClass = SalesRepresentative;
|
|
1402
|
+
/**
|
|
1403
|
+
* Find rep roles held by a given profile (a profile may hold the role in
|
|
1404
|
+
* several tenants/contexts).
|
|
1405
|
+
*
|
|
1406
|
+
* @param profileId - smrt-profiles Profile id
|
|
1407
|
+
* @returns Reps for that profile, newest first
|
|
1408
|
+
*/
|
|
1409
|
+
async findByProfile(profileId) {
|
|
1410
|
+
return await this.list({
|
|
1411
|
+
where: { profileId },
|
|
1412
|
+
orderBy: "created_at DESC"
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Find all active reps (eligible for new lead/opportunity assignment).
|
|
1417
|
+
*/
|
|
1418
|
+
async findActive() {
|
|
1419
|
+
return await this.list({
|
|
1420
|
+
where: { status: "active" },
|
|
1421
|
+
orderBy: "created_at DESC"
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
//#endregion
|
|
1426
|
+
export { Lead as S, SalesActivityCollection as _, LeadCollection as a, PipelineStage as b, DEFAULT_PIPELINE_STAGES as c, PIPELINE_STATUSES as d, SALES_ACTIVITY_KINDS as f, OpportunityCollection as g, PipelineDefinition as h, OpportunityConversion as i, LEAD_STATUSES as l, SALES_REPRESENTATIVE_STATUSES as m, SalesRepresentative as n, PipelineDefinitionCollection as o, SALES_ACTIVITY_SUBJECT_KINDS as p, OpportunityConversionCollection as r, DEFAULT_PIPELINE_KEY as s, SalesRepresentativeCollection as t, OPPORTUNITY_STATUSES as u, SalesActivity as v, Opportunity as x, PipelineStageCollection as y };
|
|
1427
|
+
|
|
1428
|
+
//# sourceMappingURL=crm-DwEz7E2r.js.map
|