@classytic/crm 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/dist/events.d.ts +64 -2
- package/dist/events.js +123 -2
- package/dist/index.d.ts +595 -20
- package/dist/index.js +1070 -68
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { t as __exportAll } from "./chunk.js";
|
|
2
2
|
import { Schema } from "mongoose";
|
|
3
|
-
import { Repository, aggregateHelpersPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin } from "@classytic/mongokit";
|
|
3
|
+
import { Repository, aggregateHelpersPlugin, isDuplicateKeyError, methodRegistryPlugin, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
|
|
4
4
|
import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
|
|
5
|
-
import { assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
|
|
5
|
+
import { IllegalTransitionError, assertAndClaim, defineStateMachine } from "@classytic/primitives/state-machine";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { civilDateOf } from "@classytic/primitives/timezone";
|
|
9
|
+
import { CURRENCY_PATTERN } from "@classytic/primitives/currency";
|
|
8
10
|
import { appendStatus, lastTransitionTo, latestEntry, timeInStatus } from "@classytic/primitives/status-history";
|
|
9
11
|
//#region src/core/errors.ts
|
|
10
12
|
/**
|
|
@@ -58,6 +60,11 @@ var DuplicateLeadError = class extends CrmError {
|
|
|
58
60
|
super("DUPLICATE_LEAD", `A lead with this email / phone already exists`, details);
|
|
59
61
|
}
|
|
60
62
|
};
|
|
63
|
+
var UnmanagedSessionError = class extends CrmError {
|
|
64
|
+
constructor(eventType) {
|
|
65
|
+
super("UNMANAGED_SESSION", "ctx.session was provided but no event delivery path exists — inside a host-owned transaction the event would either leak to subscribers before commit or be lost on rollback. Use engine.withTransaction(ctx, fn), wire CrmConfig.outbox, or attach a ctx[PENDING_EVENTS] queue and call flushPending() after commit (PACKAGE_RULES §P8.1).", eventType !== void 0 ? { eventType } : void 0);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
61
68
|
var CrmModelCollisionError = class extends CrmError {
|
|
62
69
|
constructor(modelName) {
|
|
63
70
|
super("CRM_MODEL_COLLISION", `Mongoose model '${modelName}' is already registered on this connection. Another engine (or a hot reload) owns it. Pass { forceRecreate: true } to createCrm() for test/hot-reload fixtures, or use a separate mongoose connection per engine in production (PACKAGE_RULES §21).`, { modelName });
|
|
@@ -98,6 +105,21 @@ var InvalidUnsubscribeTokenError = class extends CrmError {
|
|
|
98
105
|
super("INVALID_UNSUBSCRIBE_TOKEN", "No subscription matches the supplied unsubscribe token");
|
|
99
106
|
}
|
|
100
107
|
};
|
|
108
|
+
var InvalidSequenceTransitionError = class extends CrmError {
|
|
109
|
+
constructor(details) {
|
|
110
|
+
super("INVALID_SEQUENCE_TRANSITION", `Sequence ${details.sequenceId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
var InvalidEnrollmentTransitionError = class extends CrmError {
|
|
114
|
+
constructor(details) {
|
|
115
|
+
super("INVALID_ENROLLMENT_TRANSITION", `Enrollment ${details.enrollmentId} cannot move from '${details.from}' to '${details.to}'`, details);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
var AlreadyEnrolledError = class extends CrmError {
|
|
119
|
+
constructor(details) {
|
|
120
|
+
super("ALREADY_ENROLLED", `Subject '${details.subjectId}' already has an active enrollment in sequence '${details.sequenceId}'`, details);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
101
123
|
//#endregion
|
|
102
124
|
//#region src/models/tenant-field.ts
|
|
103
125
|
/**
|
|
@@ -257,7 +279,7 @@ function canTransitionLead(from, to) {
|
|
|
257
279
|
/**
|
|
258
280
|
* Lead — top-of-funnel inquiry, pre-qualification.
|
|
259
281
|
*/
|
|
260
|
-
const statusEntrySchema$
|
|
282
|
+
const statusEntrySchema$5 = new Schema({
|
|
261
283
|
status: {
|
|
262
284
|
type: String,
|
|
263
285
|
enum: LEAD_STATUSES,
|
|
@@ -331,7 +353,7 @@ function buildLeadSchema(config) {
|
|
|
331
353
|
index: true
|
|
332
354
|
},
|
|
333
355
|
statusHistory: {
|
|
334
|
-
type: [statusEntrySchema$
|
|
356
|
+
type: [statusEntrySchema$5],
|
|
335
357
|
default: []
|
|
336
358
|
},
|
|
337
359
|
ownerId: {
|
|
@@ -418,7 +440,7 @@ function canTransitionOpportunity(from, to) {
|
|
|
418
440
|
* Money amount lives flat (`amount` + `currency`) so `$sum` aggregates work; the repo
|
|
419
441
|
* surface returns it that way too (no Money translation layer per PACKAGE_RULES §4).
|
|
420
442
|
*/
|
|
421
|
-
const statusEntrySchema$
|
|
443
|
+
const statusEntrySchema$4 = new Schema({
|
|
422
444
|
status: {
|
|
423
445
|
type: String,
|
|
424
446
|
enum: OPPORTUNITY_STATUSES,
|
|
@@ -466,7 +488,7 @@ function buildOpportunitySchema(config) {
|
|
|
466
488
|
index: true
|
|
467
489
|
},
|
|
468
490
|
statusHistory: {
|
|
469
|
-
type: [statusEntrySchema$
|
|
491
|
+
type: [statusEntrySchema$4],
|
|
470
492
|
default: []
|
|
471
493
|
},
|
|
472
494
|
amount: { type: Number },
|
|
@@ -779,7 +801,7 @@ function canTransitionCampaign(from, to) {
|
|
|
779
801
|
* Campaign — a marketing/sales motion targeting a contact list.
|
|
780
802
|
* Money fields stored flat (amount + currency) for $sum aggregations.
|
|
781
803
|
*/
|
|
782
|
-
const statusEntrySchema$
|
|
804
|
+
const statusEntrySchema$3 = new Schema({
|
|
783
805
|
status: {
|
|
784
806
|
type: String,
|
|
785
807
|
enum: CAMPAIGN_STATUSES,
|
|
@@ -813,7 +835,7 @@ function buildCampaignSchema(config) {
|
|
|
813
835
|
index: true
|
|
814
836
|
},
|
|
815
837
|
statusHistory: {
|
|
816
|
-
type: [statusEntrySchema$
|
|
838
|
+
type: [statusEntrySchema$3],
|
|
817
839
|
default: []
|
|
818
840
|
},
|
|
819
841
|
utmSource: {
|
|
@@ -928,7 +950,7 @@ function canTransitionSubscription(from, to) {
|
|
|
928
950
|
* same connection. TypeScript class + file names retain `Subscription` for
|
|
929
951
|
* source-level continuity — only the Mongo identity changed in v0.4.0.
|
|
930
952
|
*/
|
|
931
|
-
const statusEntrySchema = new Schema({
|
|
953
|
+
const statusEntrySchema$2 = new Schema({
|
|
932
954
|
status: {
|
|
933
955
|
type: String,
|
|
934
956
|
enum: SUBSCRIPTION_STATUSES,
|
|
@@ -961,7 +983,7 @@ function buildSubscriptionSchema(config) {
|
|
|
961
983
|
index: true
|
|
962
984
|
},
|
|
963
985
|
statusHistory: {
|
|
964
|
-
type: [statusEntrySchema],
|
|
986
|
+
type: [statusEntrySchema$2],
|
|
965
987
|
default: []
|
|
966
988
|
},
|
|
967
989
|
source: {
|
|
@@ -1036,6 +1058,83 @@ function createSubscriptionModel(connection, config) {
|
|
|
1036
1058
|
return connection.model("EmailSubscription", buildSubscriptionSchema(config));
|
|
1037
1059
|
}
|
|
1038
1060
|
//#endregion
|
|
1061
|
+
//#region src/enums/suppression-reason.ts
|
|
1062
|
+
/** Reasons derived 1:1 from an email engagement event kind. */
|
|
1063
|
+
const EVENT_DERIVED_SUPPRESSION_REASONS = [
|
|
1064
|
+
"bounce",
|
|
1065
|
+
"complaint",
|
|
1066
|
+
"unsubscribe"
|
|
1067
|
+
];
|
|
1068
|
+
const SUPPRESSION_REASONS = [...EVENT_DERIVED_SUPPRESSION_REASONS, "manual"];
|
|
1069
|
+
//#endregion
|
|
1070
|
+
//#region src/models/suppression.schema.ts
|
|
1071
|
+
/**
|
|
1072
|
+
* Suppression — org-wide "never email this address again" list.
|
|
1073
|
+
*
|
|
1074
|
+
* Subscription unsubscribes are per-campaign; a Suppression row is the
|
|
1075
|
+
* GLOBAL gate a host consults before EVERY outbound email (campaign
|
|
1076
|
+
* fanout, sequence step sends, one-off notifications). One row per
|
|
1077
|
+
* (org, email) — `suppress()` is getOrCreate-idempotent against the
|
|
1078
|
+
* unique index below.
|
|
1079
|
+
*
|
|
1080
|
+
* `subjectKind`/`subjectId` are optional String external refs (rule 7 —
|
|
1081
|
+
* polymorphic across Contact/Lead/host-owned models, so no
|
|
1082
|
+
* ObjectId+refPath). `source` is a free-form origin marker, e.g.
|
|
1083
|
+
* `'campaign:<id>'`, `'sequence:<id>'`, `'api'`, `'import'`.
|
|
1084
|
+
*/
|
|
1085
|
+
function buildSuppressionSchema(config) {
|
|
1086
|
+
const schema = new Schema({
|
|
1087
|
+
organizationId: tenantFieldDef(config),
|
|
1088
|
+
email: {
|
|
1089
|
+
type: String,
|
|
1090
|
+
required: true,
|
|
1091
|
+
trim: true,
|
|
1092
|
+
lowercase: true,
|
|
1093
|
+
maxlength: 320
|
|
1094
|
+
},
|
|
1095
|
+
reason: {
|
|
1096
|
+
type: String,
|
|
1097
|
+
enum: SUPPRESSION_REASONS,
|
|
1098
|
+
required: true
|
|
1099
|
+
},
|
|
1100
|
+
source: {
|
|
1101
|
+
type: String,
|
|
1102
|
+
trim: true,
|
|
1103
|
+
maxlength: 200
|
|
1104
|
+
},
|
|
1105
|
+
subjectKind: {
|
|
1106
|
+
type: String,
|
|
1107
|
+
trim: true,
|
|
1108
|
+
maxlength: 50
|
|
1109
|
+
},
|
|
1110
|
+
subjectId: {
|
|
1111
|
+
type: String,
|
|
1112
|
+
trim: true,
|
|
1113
|
+
maxlength: 100
|
|
1114
|
+
},
|
|
1115
|
+
suppressedAt: {
|
|
1116
|
+
type: Date,
|
|
1117
|
+
required: true,
|
|
1118
|
+
default: () => /* @__PURE__ */ new Date()
|
|
1119
|
+
},
|
|
1120
|
+
metadata: { type: Schema.Types.Mixed }
|
|
1121
|
+
}, { timestamps: true });
|
|
1122
|
+
schema.index({
|
|
1123
|
+
organizationId: 1,
|
|
1124
|
+
email: 1
|
|
1125
|
+
}, { unique: true });
|
|
1126
|
+
schema.index({
|
|
1127
|
+
organizationId: 1,
|
|
1128
|
+
reason: 1,
|
|
1129
|
+
suppressedAt: -1
|
|
1130
|
+
});
|
|
1131
|
+
schema.index({
|
|
1132
|
+
organizationId: 1,
|
|
1133
|
+
suppressedAt: -1
|
|
1134
|
+
});
|
|
1135
|
+
return schema;
|
|
1136
|
+
}
|
|
1137
|
+
//#endregion
|
|
1039
1138
|
//#region src/enums/activity.ts
|
|
1040
1139
|
/**
|
|
1041
1140
|
* Activity type vocabulary. The package stores `type` as free-form
|
|
@@ -1608,6 +1707,263 @@ function createMessageModel(connection, config) {
|
|
|
1608
1707
|
return connection.model("Message", buildMessageSchema(config));
|
|
1609
1708
|
}
|
|
1610
1709
|
//#endregion
|
|
1710
|
+
//#region src/enums/sequence-status.ts
|
|
1711
|
+
/**
|
|
1712
|
+
* Email sequence lifecycle.
|
|
1713
|
+
*
|
|
1714
|
+
* draft — being built; no enrollments can be created
|
|
1715
|
+
* active — accepting new enrollments; steps are executing
|
|
1716
|
+
* paused — no new enrollments; running enrollments pause at next step
|
|
1717
|
+
* archived — soft-hidden; historical data preserved
|
|
1718
|
+
*/
|
|
1719
|
+
const SEQUENCE_STATUSES = [
|
|
1720
|
+
"draft",
|
|
1721
|
+
"active",
|
|
1722
|
+
"paused",
|
|
1723
|
+
"archived"
|
|
1724
|
+
];
|
|
1725
|
+
const SEQUENCE_TRANSITIONS = {
|
|
1726
|
+
draft: ["active", "archived"],
|
|
1727
|
+
active: ["paused", "archived"],
|
|
1728
|
+
paused: ["active", "archived"],
|
|
1729
|
+
archived: []
|
|
1730
|
+
};
|
|
1731
|
+
function canTransitionSequence(from, to) {
|
|
1732
|
+
return SEQUENCE_TRANSITIONS[from].includes(to);
|
|
1733
|
+
}
|
|
1734
|
+
//#endregion
|
|
1735
|
+
//#region src/models/sequence.schema.ts
|
|
1736
|
+
/**
|
|
1737
|
+
* Sequence — ordered cadence of SEND_EMAIL and DELAY steps.
|
|
1738
|
+
*
|
|
1739
|
+
* Steps are stored as a JSON array (Mixed) so the host can add new step
|
|
1740
|
+
* types without a schema migration. The discriminator is `step.type`.
|
|
1741
|
+
*/
|
|
1742
|
+
const statusEntrySchema$1 = new Schema({
|
|
1743
|
+
status: {
|
|
1744
|
+
type: String,
|
|
1745
|
+
enum: SEQUENCE_STATUSES,
|
|
1746
|
+
required: true
|
|
1747
|
+
},
|
|
1748
|
+
occurredAt: {
|
|
1749
|
+
type: Date,
|
|
1750
|
+
required: true
|
|
1751
|
+
},
|
|
1752
|
+
by: { type: String },
|
|
1753
|
+
note: { type: String }
|
|
1754
|
+
}, { _id: false });
|
|
1755
|
+
function buildSequenceSchema(config) {
|
|
1756
|
+
const schema = new Schema({
|
|
1757
|
+
organizationId: tenantFieldDef(config),
|
|
1758
|
+
name: {
|
|
1759
|
+
type: String,
|
|
1760
|
+
required: true,
|
|
1761
|
+
trim: true,
|
|
1762
|
+
maxlength: 200
|
|
1763
|
+
},
|
|
1764
|
+
description: {
|
|
1765
|
+
type: String,
|
|
1766
|
+
trim: true,
|
|
1767
|
+
maxlength: 1e3
|
|
1768
|
+
},
|
|
1769
|
+
status: {
|
|
1770
|
+
type: String,
|
|
1771
|
+
enum: SEQUENCE_STATUSES,
|
|
1772
|
+
default: "draft",
|
|
1773
|
+
required: true
|
|
1774
|
+
},
|
|
1775
|
+
fromName: {
|
|
1776
|
+
type: String,
|
|
1777
|
+
trim: true,
|
|
1778
|
+
maxlength: 100
|
|
1779
|
+
},
|
|
1780
|
+
fromEmail: {
|
|
1781
|
+
type: String,
|
|
1782
|
+
trim: true,
|
|
1783
|
+
maxlength: 200
|
|
1784
|
+
},
|
|
1785
|
+
steps: {
|
|
1786
|
+
type: [Schema.Types.Mixed],
|
|
1787
|
+
default: []
|
|
1788
|
+
},
|
|
1789
|
+
replyDetectionEnabled: {
|
|
1790
|
+
type: Boolean,
|
|
1791
|
+
default: true
|
|
1792
|
+
},
|
|
1793
|
+
tags: {
|
|
1794
|
+
type: [String],
|
|
1795
|
+
default: []
|
|
1796
|
+
},
|
|
1797
|
+
ownerId: { type: String },
|
|
1798
|
+
metadata: { type: Schema.Types.Mixed },
|
|
1799
|
+
statusHistory: {
|
|
1800
|
+
type: [statusEntrySchema$1],
|
|
1801
|
+
default: []
|
|
1802
|
+
}
|
|
1803
|
+
}, { timestamps: true });
|
|
1804
|
+
schema.index({
|
|
1805
|
+
organizationId: 1,
|
|
1806
|
+
status: 1
|
|
1807
|
+
});
|
|
1808
|
+
schema.index({
|
|
1809
|
+
organizationId: 1,
|
|
1810
|
+
createdAt: -1
|
|
1811
|
+
});
|
|
1812
|
+
return schema;
|
|
1813
|
+
}
|
|
1814
|
+
//#endregion
|
|
1815
|
+
//#region src/enums/enrollment-status.ts
|
|
1816
|
+
/**
|
|
1817
|
+
* Enrollment lifecycle.
|
|
1818
|
+
*
|
|
1819
|
+
* active — steps are executing (or waiting on a delay)
|
|
1820
|
+
* paused — execution halted; can be resumed
|
|
1821
|
+
* completed — all steps ran to completion
|
|
1822
|
+
* exited — removed early (reply, unsubscribe, bounce, manual)
|
|
1823
|
+
*/
|
|
1824
|
+
const ENROLLMENT_STATUSES = [
|
|
1825
|
+
"active",
|
|
1826
|
+
"paused",
|
|
1827
|
+
"completed",
|
|
1828
|
+
"exited"
|
|
1829
|
+
];
|
|
1830
|
+
const ENROLLMENT_TRANSITIONS = {
|
|
1831
|
+
active: [
|
|
1832
|
+
"paused",
|
|
1833
|
+
"completed",
|
|
1834
|
+
"exited"
|
|
1835
|
+
],
|
|
1836
|
+
paused: ["active", "exited"],
|
|
1837
|
+
completed: [],
|
|
1838
|
+
exited: []
|
|
1839
|
+
};
|
|
1840
|
+
function canTransitionEnrollment(from, to) {
|
|
1841
|
+
return ENROLLMENT_TRANSITIONS[from].includes(to);
|
|
1842
|
+
}
|
|
1843
|
+
//#endregion
|
|
1844
|
+
//#region src/models/enrollment.schema.ts
|
|
1845
|
+
/**
|
|
1846
|
+
* Enrollment — a single subject (lead or contact) progressing through a Sequence.
|
|
1847
|
+
*
|
|
1848
|
+
* `currentStepIndex` tracks which step to execute next. The sequence workflow
|
|
1849
|
+
* reads this field at each iteration so crashes / pauses resume from the
|
|
1850
|
+
* correct position without re-running completed steps.
|
|
1851
|
+
*/
|
|
1852
|
+
const ENROLLMENT_SUBJECT_KINDS = ["lead", "contact"];
|
|
1853
|
+
const stepResultSchema = new Schema({
|
|
1854
|
+
stepId: {
|
|
1855
|
+
type: String,
|
|
1856
|
+
required: true
|
|
1857
|
+
},
|
|
1858
|
+
stepType: {
|
|
1859
|
+
type: String,
|
|
1860
|
+
required: true
|
|
1861
|
+
},
|
|
1862
|
+
status: {
|
|
1863
|
+
type: String,
|
|
1864
|
+
enum: [
|
|
1865
|
+
"pending",
|
|
1866
|
+
"sent",
|
|
1867
|
+
"failed",
|
|
1868
|
+
"skipped"
|
|
1869
|
+
],
|
|
1870
|
+
required: true
|
|
1871
|
+
},
|
|
1872
|
+
sentAt: { type: Date },
|
|
1873
|
+
error: { type: String }
|
|
1874
|
+
}, { _id: false });
|
|
1875
|
+
const statusEntrySchema = new Schema({
|
|
1876
|
+
status: {
|
|
1877
|
+
type: String,
|
|
1878
|
+
enum: ENROLLMENT_STATUSES,
|
|
1879
|
+
required: true
|
|
1880
|
+
},
|
|
1881
|
+
occurredAt: {
|
|
1882
|
+
type: Date,
|
|
1883
|
+
required: true
|
|
1884
|
+
},
|
|
1885
|
+
by: { type: String }
|
|
1886
|
+
}, { _id: false });
|
|
1887
|
+
function buildEnrollmentSchema(config) {
|
|
1888
|
+
const schema = new Schema({
|
|
1889
|
+
organizationId: tenantFieldDef(config),
|
|
1890
|
+
sequenceId: {
|
|
1891
|
+
type: config.tenantFieldType === "objectId" ? Schema.Types.ObjectId : String,
|
|
1892
|
+
required: true,
|
|
1893
|
+
ref: "Sequence"
|
|
1894
|
+
},
|
|
1895
|
+
subjectKind: {
|
|
1896
|
+
type: String,
|
|
1897
|
+
enum: ENROLLMENT_SUBJECT_KINDS,
|
|
1898
|
+
required: true
|
|
1899
|
+
},
|
|
1900
|
+
subjectId: {
|
|
1901
|
+
type: config.tenantFieldType === "objectId" ? Schema.Types.ObjectId : String,
|
|
1902
|
+
required: true
|
|
1903
|
+
},
|
|
1904
|
+
status: {
|
|
1905
|
+
type: String,
|
|
1906
|
+
enum: ENROLLMENT_STATUSES,
|
|
1907
|
+
default: "active",
|
|
1908
|
+
required: true
|
|
1909
|
+
},
|
|
1910
|
+
currentStepIndex: {
|
|
1911
|
+
type: Number,
|
|
1912
|
+
default: 0,
|
|
1913
|
+
min: 0
|
|
1914
|
+
},
|
|
1915
|
+
stepResults: {
|
|
1916
|
+
type: [stepResultSchema],
|
|
1917
|
+
default: []
|
|
1918
|
+
},
|
|
1919
|
+
resumeAt: { type: Date },
|
|
1920
|
+
workflowRunId: { type: String },
|
|
1921
|
+
exitReason: {
|
|
1922
|
+
type: String,
|
|
1923
|
+
enum: [
|
|
1924
|
+
"completed",
|
|
1925
|
+
"reply",
|
|
1926
|
+
"unsubscribe",
|
|
1927
|
+
"bounce",
|
|
1928
|
+
"manual"
|
|
1929
|
+
]
|
|
1930
|
+
},
|
|
1931
|
+
enrolledAt: {
|
|
1932
|
+
type: Date,
|
|
1933
|
+
required: true,
|
|
1934
|
+
default: () => /* @__PURE__ */ new Date()
|
|
1935
|
+
},
|
|
1936
|
+
completedAt: { type: Date },
|
|
1937
|
+
ownerId: { type: String },
|
|
1938
|
+
metadata: { type: Schema.Types.Mixed },
|
|
1939
|
+
statusHistory: {
|
|
1940
|
+
type: [statusEntrySchema],
|
|
1941
|
+
default: []
|
|
1942
|
+
}
|
|
1943
|
+
}, { timestamps: true });
|
|
1944
|
+
schema.index({
|
|
1945
|
+
organizationId: 1,
|
|
1946
|
+
sequenceId: 1,
|
|
1947
|
+
subjectId: 1,
|
|
1948
|
+
status: 1
|
|
1949
|
+
}, { unique: false });
|
|
1950
|
+
schema.index({
|
|
1951
|
+
organizationId: 1,
|
|
1952
|
+
sequenceId: 1,
|
|
1953
|
+
status: 1
|
|
1954
|
+
});
|
|
1955
|
+
schema.index({
|
|
1956
|
+
organizationId: 1,
|
|
1957
|
+
subjectKind: 1,
|
|
1958
|
+
subjectId: 1
|
|
1959
|
+
});
|
|
1960
|
+
schema.index({
|
|
1961
|
+
organizationId: 1,
|
|
1962
|
+
createdAt: -1
|
|
1963
|
+
});
|
|
1964
|
+
return schema;
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1611
1967
|
//#region src/models/create-models.ts
|
|
1612
1968
|
/** Mongoose model names this package registers — drives the §21 collision check. */
|
|
1613
1969
|
const CRM_MODEL_NAMES = [
|
|
@@ -1618,12 +1974,15 @@ const CRM_MODEL_NAMES = [
|
|
|
1618
1974
|
"Account",
|
|
1619
1975
|
"Campaign",
|
|
1620
1976
|
"EmailSubscription",
|
|
1977
|
+
"Suppression",
|
|
1621
1978
|
"Activity",
|
|
1622
1979
|
"Note",
|
|
1623
1980
|
"LossReason",
|
|
1624
1981
|
"EmailEvent",
|
|
1625
1982
|
"Conversation",
|
|
1626
|
-
"Message"
|
|
1983
|
+
"Message",
|
|
1984
|
+
"Sequence",
|
|
1985
|
+
"Enrollment"
|
|
1627
1986
|
];
|
|
1628
1987
|
function createCrmModels(connection, config) {
|
|
1629
1988
|
const forceRecreate = config.forceRecreate ?? false;
|
|
@@ -1644,12 +2003,15 @@ function createCrmModels(connection, config) {
|
|
|
1644
2003
|
Account: register("Account", buildAccountSchema(config)),
|
|
1645
2004
|
Campaign: register("Campaign", buildCampaignSchema(config)),
|
|
1646
2005
|
Subscription: register("EmailSubscription", buildSubscriptionSchema(config)),
|
|
2006
|
+
Suppression: register("Suppression", buildSuppressionSchema(config)),
|
|
1647
2007
|
Activity: register("Activity", buildActivitySchema(config)),
|
|
1648
2008
|
Note: register("Note", buildNoteSchema(config)),
|
|
1649
2009
|
LossReason: register("LossReason", buildLossReasonSchema(config)),
|
|
1650
2010
|
EmailEvent: register("EmailEvent", buildEmailEventSchema(config)),
|
|
1651
2011
|
Conversation: register("Conversation", buildConversationSchema(config)),
|
|
1652
|
-
Message: register("Message", buildMessageSchema(config))
|
|
2012
|
+
Message: register("Message", buildMessageSchema(config)),
|
|
2013
|
+
Sequence: register("Sequence", buildSequenceSchema(config)),
|
|
2014
|
+
Enrollment: register("Enrollment", buildEnrollmentSchema(config))
|
|
1653
2015
|
});
|
|
1654
2016
|
}
|
|
1655
2017
|
//#endregion
|
|
@@ -1688,6 +2050,16 @@ const CRM_EVENTS = {
|
|
|
1688
2050
|
SUBSCRIPTION_UNSUBSCRIBED: "crm:subscription.unsubscribed",
|
|
1689
2051
|
SUBSCRIPTION_BOUNCED: "crm:subscription.bounced",
|
|
1690
2052
|
SUBSCRIPTION_COMPLAINED: "crm:subscription.complained",
|
|
2053
|
+
SEQUENCE_CREATED: "crm:sequence.created",
|
|
2054
|
+
SEQUENCE_ACTIVATED: "crm:sequence.active",
|
|
2055
|
+
SEQUENCE_PAUSED: "crm:sequence.paused",
|
|
2056
|
+
SEQUENCE_ARCHIVED: "crm:sequence.archived",
|
|
2057
|
+
ENROLLMENT_CREATED: "crm:enrollment.created",
|
|
2058
|
+
ENROLLMENT_STEP_SENT: "crm:enrollment.step_sent",
|
|
2059
|
+
ENROLLMENT_PAUSED: "crm:enrollment.paused",
|
|
2060
|
+
ENROLLMENT_RESUMED: "crm:enrollment.resumed",
|
|
2061
|
+
ENROLLMENT_COMPLETED: "crm:enrollment.completed",
|
|
2062
|
+
ENROLLMENT_EXITED: "crm:enrollment.exited",
|
|
1691
2063
|
EMAIL_EVENT_OPENED: "crm:email_event.opened",
|
|
1692
2064
|
EMAIL_EVENT_CLICKED: "crm:email_event.clicked",
|
|
1693
2065
|
EMAIL_EVENT_BOUNCED: "crm:email_event.bounced",
|
|
@@ -1706,6 +2078,8 @@ const CRM_EVENTS = {
|
|
|
1706
2078
|
OPPORTUNITY_EXTENDED: "crm:opportunity.extended",
|
|
1707
2079
|
ACTIVITY_RESCHEDULED: "crm:activity.rescheduled",
|
|
1708
2080
|
ACTIVITY_OUTCOME_RECORDED: "crm:activity.outcome_recorded",
|
|
2081
|
+
SUPPRESSION_ADDED: "crm:suppression.added",
|
|
2082
|
+
SUPPRESSION_REMOVED: "crm:suppression.removed",
|
|
1709
2083
|
CONVERSATION_CREATED: "crm:conversation.created",
|
|
1710
2084
|
CONVERSATION_ASSIGNED: "crm:conversation.assigned",
|
|
1711
2085
|
CONVERSATION_RESOLVED: "crm:conversation.resolved",
|
|
@@ -1778,7 +2152,7 @@ var PipelineRepository = class extends Repository {
|
|
|
1778
2152
|
async archive(id, ctx) {
|
|
1779
2153
|
const updated = await this.update(id, { isArchived: true }, ctx);
|
|
1780
2154
|
if (!updated) throw new NotFoundError("Pipeline", id);
|
|
1781
|
-
await this.events.
|
|
2155
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.PIPELINE_ARCHIVED, { pipelineId: id }, ctx));
|
|
1782
2156
|
return updated;
|
|
1783
2157
|
}
|
|
1784
2158
|
};
|
|
@@ -1844,6 +2218,24 @@ const ACTIVITY_MACHINE = defineStateMachine({
|
|
|
1844
2218
|
activityId: entityId
|
|
1845
2219
|
})
|
|
1846
2220
|
});
|
|
2221
|
+
const SEQUENCE_MACHINE = defineStateMachine({
|
|
2222
|
+
name: "Sequence",
|
|
2223
|
+
transitions: SEQUENCE_TRANSITIONS,
|
|
2224
|
+
errorFactory: ({ entityId, from, to }) => new InvalidSequenceTransitionError({
|
|
2225
|
+
from,
|
|
2226
|
+
to,
|
|
2227
|
+
sequenceId: entityId
|
|
2228
|
+
})
|
|
2229
|
+
});
|
|
2230
|
+
const ENROLLMENT_MACHINE = defineStateMachine({
|
|
2231
|
+
name: "Enrollment",
|
|
2232
|
+
transitions: ENROLLMENT_TRANSITIONS,
|
|
2233
|
+
errorFactory: ({ entityId, from, to }) => new InvalidEnrollmentTransitionError({
|
|
2234
|
+
from,
|
|
2235
|
+
to,
|
|
2236
|
+
enrollmentId: entityId
|
|
2237
|
+
})
|
|
2238
|
+
});
|
|
1847
2239
|
//#endregion
|
|
1848
2240
|
//#region src/shared/calculators/scoring.ts
|
|
1849
2241
|
/**
|
|
@@ -1913,12 +2305,12 @@ var LeadRepository = class extends Repository {
|
|
|
1913
2305
|
let result = created;
|
|
1914
2306
|
if (score !== 0) {
|
|
1915
2307
|
result = await this.update(String(created._id), { score }, ctx) ?? created;
|
|
1916
|
-
await this.events.
|
|
2308
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_SCORED, {
|
|
1917
2309
|
leadId: String(created._id),
|
|
1918
2310
|
score
|
|
1919
2311
|
}, ctx));
|
|
1920
2312
|
}
|
|
1921
|
-
await this.events.
|
|
2313
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_CREATED, {
|
|
1922
2314
|
leadId: String(result._id),
|
|
1923
2315
|
email: result.email,
|
|
1924
2316
|
score: result.score
|
|
@@ -1937,7 +2329,7 @@ var LeadRepository = class extends Repository {
|
|
|
1937
2329
|
note: reason,
|
|
1938
2330
|
patch: { disqualifyReason: reason }
|
|
1939
2331
|
}, ctx);
|
|
1940
|
-
await this.events.
|
|
2332
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_DISQUALIFIED, {
|
|
1941
2333
|
leadId: id,
|
|
1942
2334
|
reason
|
|
1943
2335
|
}, ctx));
|
|
@@ -1959,7 +2351,7 @@ var LeadRepository = class extends Repository {
|
|
|
1959
2351
|
if (!await this.getById(id, ctx)) throw new NotFoundError("Lead", id);
|
|
1960
2352
|
if (!await this.update(id, patch, ctx)) throw new NotFoundError("Lead", id);
|
|
1961
2353
|
const fields = Object.keys(patch);
|
|
1962
|
-
await this.events.
|
|
2354
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_ENRICHED, {
|
|
1963
2355
|
leadId: id,
|
|
1964
2356
|
fields,
|
|
1965
2357
|
...extras.provider !== void 0 ? { provider: extras.provider } : {}
|
|
@@ -1983,7 +2375,7 @@ var LeadRepository = class extends Repository {
|
|
|
1983
2375
|
if (!current) throw new NotFoundError("Lead", id);
|
|
1984
2376
|
return current;
|
|
1985
2377
|
}
|
|
1986
|
-
await this.events.
|
|
2378
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_ASSIGNED, {
|
|
1987
2379
|
leadId: id,
|
|
1988
2380
|
ownerId,
|
|
1989
2381
|
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
@@ -2001,7 +2393,7 @@ var LeadRepository = class extends Repository {
|
|
|
2001
2393
|
if (score === lead.score) return lead;
|
|
2002
2394
|
const updated = await this.update(id, { score }, ctx);
|
|
2003
2395
|
if (!updated) throw new NotFoundError("Lead", id);
|
|
2004
|
-
await this.events.
|
|
2396
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_SCORED, {
|
|
2005
2397
|
leadId: id,
|
|
2006
2398
|
score
|
|
2007
2399
|
}, ctx));
|
|
@@ -2031,7 +2423,7 @@ var LeadRepository = class extends Repository {
|
|
|
2031
2423
|
to: target,
|
|
2032
2424
|
leadId: id
|
|
2033
2425
|
});
|
|
2034
|
-
await this.events.
|
|
2426
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_STATUS_CHANGED, {
|
|
2035
2427
|
leadId: id,
|
|
2036
2428
|
from: existing.status,
|
|
2037
2429
|
to: target
|
|
@@ -2097,7 +2489,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2097
2489
|
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
2098
2490
|
};
|
|
2099
2491
|
const created = await this.create(draft, ctx);
|
|
2100
|
-
await this.events.
|
|
2492
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_CREATED, {
|
|
2101
2493
|
opportunityId: String(created._id),
|
|
2102
2494
|
pipelineId: input.pipelineId,
|
|
2103
2495
|
stageId: String(stage._id)
|
|
@@ -2141,7 +2533,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2141
2533
|
stageId: String(target._id)
|
|
2142
2534
|
});
|
|
2143
2535
|
const event = target.sequence > current.sequence ? CRM_EVENTS.OPPORTUNITY_STAGE_ADVANCED : CRM_EVENTS.OPPORTUNITY_STAGE_REGRESSED;
|
|
2144
|
-
await this.events.
|
|
2536
|
+
await this.events.dispatch(ctx, createEvent(event, {
|
|
2145
2537
|
opportunityId: id,
|
|
2146
2538
|
from: {
|
|
2147
2539
|
stageId: String(current._id),
|
|
@@ -2163,7 +2555,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2163
2555
|
...input.by !== void 0 ? { by: input.by } : {},
|
|
2164
2556
|
...input.note !== void 0 ? { note: input.note } : {}
|
|
2165
2557
|
}, ctx);
|
|
2166
|
-
await this.events.
|
|
2558
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_WON, {
|
|
2167
2559
|
opportunityId: id,
|
|
2168
2560
|
amount: updated.amount
|
|
2169
2561
|
}, ctx));
|
|
@@ -2178,7 +2570,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2178
2570
|
...input.by !== void 0 ? { by: input.by } : {},
|
|
2179
2571
|
...input.note !== void 0 ? { note: input.note } : {}
|
|
2180
2572
|
}, ctx);
|
|
2181
|
-
await this.events.
|
|
2573
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_LOST, {
|
|
2182
2574
|
opportunityId: id,
|
|
2183
2575
|
lostReasonId: updated.lostReasonId
|
|
2184
2576
|
}, ctx));
|
|
@@ -2201,7 +2593,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2201
2593
|
if (!current) throw new NotFoundError("Opportunity", id);
|
|
2202
2594
|
return current;
|
|
2203
2595
|
}
|
|
2204
|
-
await this.events.
|
|
2596
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_ASSIGNED, {
|
|
2205
2597
|
opportunityId: id,
|
|
2206
2598
|
ownerId,
|
|
2207
2599
|
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
@@ -2225,7 +2617,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2225
2617
|
if (amount === existing.amount) return existing;
|
|
2226
2618
|
const updated = await this.update(id, { amount }, ctx);
|
|
2227
2619
|
if (!updated) throw new NotFoundError("Opportunity", id);
|
|
2228
|
-
await this.events.
|
|
2620
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_AMOUNT_CHANGED, {
|
|
2229
2621
|
opportunityId: id,
|
|
2230
2622
|
amount,
|
|
2231
2623
|
...existing.amount !== void 0 ? { previousAmount: existing.amount } : {},
|
|
@@ -2252,7 +2644,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2252
2644
|
if (previous && newDate <= previous) return existing;
|
|
2253
2645
|
const updated = await this.update(id, { expectedCloseAt: newDate }, ctx);
|
|
2254
2646
|
if (!updated) throw new NotFoundError("Opportunity", id);
|
|
2255
|
-
await this.events.
|
|
2647
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_EXTENDED, {
|
|
2256
2648
|
opportunityId: id,
|
|
2257
2649
|
expectedCloseAt: newDate.toISOString(),
|
|
2258
2650
|
...previous ? { previousExpectedCloseAt: previous.toISOString() } : {},
|
|
@@ -2269,7 +2661,7 @@ var OpportunityRepository = class extends Repository {
|
|
|
2269
2661
|
...by !== void 0 ? { by } : {},
|
|
2270
2662
|
...note !== void 0 ? { note } : {}
|
|
2271
2663
|
}, ctx);
|
|
2272
|
-
await this.events.
|
|
2664
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.OPPORTUNITY_ABANDONED, { opportunityId: id }, ctx));
|
|
2273
2665
|
return updated;
|
|
2274
2666
|
}
|
|
2275
2667
|
async _closeStatus(id, target, patch, extras, ctx) {
|
|
@@ -2366,7 +2758,7 @@ var ContactRepository = class extends Repository {
|
|
|
2366
2758
|
*/
|
|
2367
2759
|
async createContact(input, ctx) {
|
|
2368
2760
|
const created = await this.create(input, ctx);
|
|
2369
|
-
await this.events.
|
|
2761
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_CREATED, {
|
|
2370
2762
|
contactId: String(created._id),
|
|
2371
2763
|
email: created.contact?.email
|
|
2372
2764
|
}, ctx));
|
|
@@ -2381,7 +2773,7 @@ var ContactRepository = class extends Repository {
|
|
|
2381
2773
|
if (!await this.getById(id, ctx)) throw new NotFoundError("Contact", id);
|
|
2382
2774
|
const updated = await this.update(id, patch, ctx);
|
|
2383
2775
|
if (!updated) throw new NotFoundError("Contact", id);
|
|
2384
|
-
await this.events.
|
|
2776
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_ENRICHED, {
|
|
2385
2777
|
contactId: id,
|
|
2386
2778
|
fields: Object.keys(patch),
|
|
2387
2779
|
...extras.provider !== void 0 ? { provider: extras.provider } : {}
|
|
@@ -2402,7 +2794,7 @@ var ContactRepository = class extends Repository {
|
|
|
2402
2794
|
if (!current) throw new NotFoundError("Contact", id);
|
|
2403
2795
|
return current;
|
|
2404
2796
|
}
|
|
2405
|
-
await this.events.
|
|
2797
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_ASSIGNED, {
|
|
2406
2798
|
contactId: id,
|
|
2407
2799
|
ownerId,
|
|
2408
2800
|
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
@@ -2452,7 +2844,7 @@ var ContactRepository = class extends Repository {
|
|
|
2452
2844
|
const updated = await this.update(primaryId, patch, ctx);
|
|
2453
2845
|
if (!updated) throw new NotFoundError("Contact", primaryId);
|
|
2454
2846
|
await this.delete(secondaryId, ctx);
|
|
2455
|
-
await this.events.
|
|
2847
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONTACT_MERGED, {
|
|
2456
2848
|
primaryId,
|
|
2457
2849
|
secondaryId,
|
|
2458
2850
|
strategy
|
|
@@ -2484,7 +2876,7 @@ var AccountRepository = class extends Repository {
|
|
|
2484
2876
|
}
|
|
2485
2877
|
async createAccount(input, ctx) {
|
|
2486
2878
|
const created = await this.create(input, ctx);
|
|
2487
|
-
await this.events.
|
|
2879
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACCOUNT_CREATED, {
|
|
2488
2880
|
accountId: String(created._id),
|
|
2489
2881
|
name: created.name
|
|
2490
2882
|
}, ctx));
|
|
@@ -2494,7 +2886,7 @@ var AccountRepository = class extends Repository {
|
|
|
2494
2886
|
if (!await this.getById(id, ctx)) throw new NotFoundError("Account", id);
|
|
2495
2887
|
const updated = await this.update(id, patch, ctx);
|
|
2496
2888
|
if (!updated) throw new NotFoundError("Account", id);
|
|
2497
|
-
await this.events.
|
|
2889
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACCOUNT_ENRICHED, {
|
|
2498
2890
|
accountId: id,
|
|
2499
2891
|
fields: Object.keys(patch),
|
|
2500
2892
|
...extras.provider !== void 0 ? { provider: extras.provider } : {}
|
|
@@ -2514,7 +2906,7 @@ var AccountRepository = class extends Repository {
|
|
|
2514
2906
|
if (!current) throw new NotFoundError("Account", id);
|
|
2515
2907
|
return current;
|
|
2516
2908
|
}
|
|
2517
|
-
await this.events.
|
|
2909
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACCOUNT_ASSIGNED, {
|
|
2518
2910
|
accountId: id,
|
|
2519
2911
|
ownerId,
|
|
2520
2912
|
...before.ownerId !== void 0 ? { previousOwnerId: before.ownerId } : {}
|
|
@@ -2543,7 +2935,7 @@ var AccountRepository = class extends Repository {
|
|
|
2543
2935
|
const updated = await this.update(primaryId, patch, ctx);
|
|
2544
2936
|
if (!updated) throw new NotFoundError("Account", primaryId);
|
|
2545
2937
|
await this.delete(secondaryId, ctx);
|
|
2546
|
-
await this.events.
|
|
2938
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACCOUNT_MERGED, {
|
|
2547
2939
|
primaryId,
|
|
2548
2940
|
secondaryId,
|
|
2549
2941
|
strategy
|
|
@@ -2587,7 +2979,7 @@ var CampaignRepository = class extends Repository {
|
|
|
2587
2979
|
if (this.rejectDuplicates && input.name && isDuplicateKeyError(err)) throw new DuplicateCampaignError(input.name);
|
|
2588
2980
|
throw err;
|
|
2589
2981
|
}
|
|
2590
|
-
await this.events.
|
|
2982
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CAMPAIGN_CREATED, {
|
|
2591
2983
|
campaignId: String(created._id),
|
|
2592
2984
|
name: created.name
|
|
2593
2985
|
}, ctx));
|
|
@@ -2595,7 +2987,7 @@ var CampaignRepository = class extends Repository {
|
|
|
2595
2987
|
}
|
|
2596
2988
|
async activate(id, by, ctx) {
|
|
2597
2989
|
const updated = await this._transition(id, "active", { ...by !== void 0 ? { by } : {} }, ctx);
|
|
2598
|
-
await this.events.
|
|
2990
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CAMPAIGN_ACTIVATED, { campaignId: id }, ctx));
|
|
2599
2991
|
return updated;
|
|
2600
2992
|
}
|
|
2601
2993
|
async pause(id, reason, by, ctx) {
|
|
@@ -2603,7 +2995,7 @@ var CampaignRepository = class extends Repository {
|
|
|
2603
2995
|
...by !== void 0 ? { by } : {},
|
|
2604
2996
|
...reason !== void 0 ? { note: reason } : {}
|
|
2605
2997
|
}, ctx);
|
|
2606
|
-
await this.events.
|
|
2998
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CAMPAIGN_PAUSED, {
|
|
2607
2999
|
campaignId: id,
|
|
2608
3000
|
reason
|
|
2609
3001
|
}, ctx));
|
|
@@ -2611,13 +3003,13 @@ var CampaignRepository = class extends Repository {
|
|
|
2611
3003
|
}
|
|
2612
3004
|
async resume(id, by, ctx) {
|
|
2613
3005
|
const updated = await this._transition(id, "active", { ...by !== void 0 ? { by } : {} }, ctx);
|
|
2614
|
-
await this.events.
|
|
3006
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CAMPAIGN_RESUMED, { campaignId: id }, ctx));
|
|
2615
3007
|
return updated;
|
|
2616
3008
|
}
|
|
2617
3009
|
async complete(id, extras = {}, ctx) {
|
|
2618
3010
|
const patch = extras.actualRevenueAmount !== void 0 ? { actualRevenueAmount: extras.actualRevenueAmount } : {};
|
|
2619
3011
|
const updated = await this._transition(id, "completed", extras, ctx, patch);
|
|
2620
|
-
await this.events.
|
|
3012
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CAMPAIGN_COMPLETED, {
|
|
2621
3013
|
campaignId: id,
|
|
2622
3014
|
actualRevenueAmount: updated.actualRevenueAmount
|
|
2623
3015
|
}, ctx));
|
|
@@ -2625,7 +3017,7 @@ var CampaignRepository = class extends Repository {
|
|
|
2625
3017
|
}
|
|
2626
3018
|
async archive(id, by, ctx) {
|
|
2627
3019
|
const updated = await this._transition(id, "archived", { ...by !== void 0 ? { by } : {} }, ctx);
|
|
2628
|
-
await this.events.
|
|
3020
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CAMPAIGN_ARCHIVED, { campaignId: id }, ctx));
|
|
2629
3021
|
return updated;
|
|
2630
3022
|
}
|
|
2631
3023
|
async _transition(id, target, extras, ctx, patch = {}) {
|
|
@@ -2716,7 +3108,7 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2716
3108
|
});
|
|
2717
3109
|
throw err;
|
|
2718
3110
|
}
|
|
2719
|
-
await this.events.
|
|
3111
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUBSCRIPTION_CREATED, {
|
|
2720
3112
|
subscriptionId: String(sub._id),
|
|
2721
3113
|
contactId: sub.contactId,
|
|
2722
3114
|
campaignId: sub.campaignId,
|
|
@@ -2729,7 +3121,7 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2729
3121
|
if (!sub || sub.status !== "pending") throw new InvalidConfirmationTokenError();
|
|
2730
3122
|
const confirmedAt = /* @__PURE__ */ new Date();
|
|
2731
3123
|
const updated = await this._transition(String(sub._id), "confirmed", { patch: { confirmedAt } }, ctx);
|
|
2732
|
-
await this.events.
|
|
3124
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUBSCRIPTION_CONFIRMED, {
|
|
2733
3125
|
subscriptionId: String(sub._id),
|
|
2734
3126
|
contactId: sub.contactId,
|
|
2735
3127
|
campaignId: sub.campaignId,
|
|
@@ -2749,7 +3141,7 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2749
3141
|
...reason !== void 0 ? { unsubscribeReason: reason } : {}
|
|
2750
3142
|
}
|
|
2751
3143
|
}, ctx);
|
|
2752
|
-
await this.events.
|
|
3144
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUBSCRIPTION_UNSUBSCRIBED, {
|
|
2753
3145
|
subscriptionId: String(sub._id),
|
|
2754
3146
|
contactId: sub.contactId,
|
|
2755
3147
|
campaignId: sub.campaignId,
|
|
@@ -2769,7 +3161,7 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2769
3161
|
...reason !== void 0 ? { bounceReason: reason } : {}
|
|
2770
3162
|
}
|
|
2771
3163
|
}, ctx);
|
|
2772
|
-
await this.events.
|
|
3164
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUBSCRIPTION_BOUNCED, {
|
|
2773
3165
|
subscriptionId: id,
|
|
2774
3166
|
contactId: existing.contactId,
|
|
2775
3167
|
campaignId: existing.campaignId,
|
|
@@ -2783,7 +3175,7 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2783
3175
|
if (existing.status === "complained") return existing;
|
|
2784
3176
|
const complainedAt = /* @__PURE__ */ new Date();
|
|
2785
3177
|
const updated = await this._transition(id, "complained", { patch: { complainedAt } }, ctx);
|
|
2786
|
-
await this.events.
|
|
3178
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUBSCRIPTION_COMPLAINED, {
|
|
2787
3179
|
subscriptionId: id,
|
|
2788
3180
|
contactId: existing.contactId,
|
|
2789
3181
|
campaignId: existing.campaignId
|
|
@@ -2818,6 +3210,78 @@ var SubscriptionRepository = class extends Repository {
|
|
|
2818
3210
|
}
|
|
2819
3211
|
};
|
|
2820
3212
|
//#endregion
|
|
3213
|
+
//#region src/repositories/suppression.repository.ts
|
|
3214
|
+
function normalizeEmail(email) {
|
|
3215
|
+
return email.trim().toLowerCase();
|
|
3216
|
+
}
|
|
3217
|
+
var SuppressionRepository = class extends Repository {
|
|
3218
|
+
events;
|
|
3219
|
+
constructor(model, plugins, deps, pagination) {
|
|
3220
|
+
super(model, plugins, pagination);
|
|
3221
|
+
this.events = deps.events;
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* Idempotently add `email` to the org's suppression list. Atomic
|
|
3225
|
+
* `getOrCreate` on the unique (org, email) index — of N concurrent
|
|
3226
|
+
* calls exactly one inserts. Emits `crm:suppression.added` only for
|
|
3227
|
+
* the inserting call; an existing row is returned unchanged, no event.
|
|
3228
|
+
*/
|
|
3229
|
+
async suppress(email, input, ctx) {
|
|
3230
|
+
const normalized = normalizeEmail(email);
|
|
3231
|
+
const draft = {
|
|
3232
|
+
email: normalized,
|
|
3233
|
+
reason: input.reason,
|
|
3234
|
+
suppressedAt: /* @__PURE__ */ new Date(),
|
|
3235
|
+
...input.source !== void 0 ? { source: input.source } : {},
|
|
3236
|
+
...input.subjectKind !== void 0 ? { subjectKind: input.subjectKind } : {},
|
|
3237
|
+
...input.subjectId !== void 0 ? { subjectId: input.subjectId } : {},
|
|
3238
|
+
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
3239
|
+
};
|
|
3240
|
+
const { doc, created } = await this.getOrCreate({ email: normalized }, draft, ctx);
|
|
3241
|
+
if (created) await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUPPRESSION_ADDED, {
|
|
3242
|
+
email: normalized,
|
|
3243
|
+
reason: input.reason,
|
|
3244
|
+
...input.source !== void 0 ? { source: input.source } : {}
|
|
3245
|
+
}, ctx));
|
|
3246
|
+
return doc;
|
|
3247
|
+
}
|
|
3248
|
+
/** True when `email` is on the org's suppression list. */
|
|
3249
|
+
async isSuppressed(email, ctx) {
|
|
3250
|
+
return await this.exists({ email: normalizeEmail(email) }, ctx) !== null;
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
3253
|
+
* Return the suppressed subset of `emails` (normalized) as a Set —
|
|
3254
|
+
* ONE `$in` query, built for the campaign-fanout hot path:
|
|
3255
|
+
*
|
|
3256
|
+
* ```ts
|
|
3257
|
+
* const blocked = await suppression.filterSuppressed(recipients, ctx);
|
|
3258
|
+
* const sendable = recipients.filter((e) => !blocked.has(e.toLowerCase()));
|
|
3259
|
+
* ```
|
|
3260
|
+
*/
|
|
3261
|
+
async filterSuppressed(emails, ctx) {
|
|
3262
|
+
if (emails.length === 0) return /* @__PURE__ */ new Set();
|
|
3263
|
+
const normalized = [...new Set(emails.map(normalizeEmail))];
|
|
3264
|
+
const rows = await this.findAll({ email: { $in: normalized } }, {
|
|
3265
|
+
...ctx ?? {},
|
|
3266
|
+
select: "email",
|
|
3267
|
+
lean: true
|
|
3268
|
+
});
|
|
3269
|
+
return new Set(rows.map((row) => row.email));
|
|
3270
|
+
}
|
|
3271
|
+
/**
|
|
3272
|
+
* Remove `email` from the org's suppression list. Single atomic
|
|
3273
|
+
* `deleteMany` on the (unique) email key — emits
|
|
3274
|
+
* `crm:suppression.removed` only when a row was actually removed.
|
|
3275
|
+
* Returns whether a row was removed.
|
|
3276
|
+
*/
|
|
3277
|
+
async unsuppress(email, ctx) {
|
|
3278
|
+
const normalized = normalizeEmail(email);
|
|
3279
|
+
const removed = (await this.deleteMany({ email: normalized }, ctx ?? {})).deletedCount > 0;
|
|
3280
|
+
if (removed) await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SUPPRESSION_REMOVED, { email: normalized }, ctx));
|
|
3281
|
+
return removed;
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
//#endregion
|
|
2821
3285
|
//#region src/repositories/activity.repository.ts
|
|
2822
3286
|
var ActivityRepository = class extends Repository {
|
|
2823
3287
|
events;
|
|
@@ -2831,7 +3295,7 @@ var ActivityRepository = class extends Repository {
|
|
|
2831
3295
|
status: "planned"
|
|
2832
3296
|
};
|
|
2833
3297
|
const created = await this.create(draft, ctx);
|
|
2834
|
-
await this.events.
|
|
3298
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACTIVITY_PLANNED, {
|
|
2835
3299
|
activityId: String(created._id),
|
|
2836
3300
|
type: created.type,
|
|
2837
3301
|
subjectKind: created.subjectKind,
|
|
@@ -2845,7 +3309,7 @@ var ActivityRepository = class extends Repository {
|
|
|
2845
3309
|
async complete(id, completedAt, ctx) {
|
|
2846
3310
|
const ts = completedAt ?? /* @__PURE__ */ new Date();
|
|
2847
3311
|
const updated = await this._transition(id, "completed", { completedAt: ts }, ctx);
|
|
2848
|
-
await this.events.
|
|
3312
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACTIVITY_COMPLETED, {
|
|
2849
3313
|
activityId: id,
|
|
2850
3314
|
completedAt: ts.toISOString()
|
|
2851
3315
|
}, ctx));
|
|
@@ -2853,7 +3317,7 @@ var ActivityRepository = class extends Repository {
|
|
|
2853
3317
|
}
|
|
2854
3318
|
async cancel(id, reason, ctx) {
|
|
2855
3319
|
const updated = await this._transition(id, "cancelled", reason ? { metadata: { cancelReason: reason } } : {}, ctx);
|
|
2856
|
-
await this.events.
|
|
3320
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACTIVITY_CANCELLED, {
|
|
2857
3321
|
activityId: id,
|
|
2858
3322
|
...reason !== void 0 ? { reason } : {}
|
|
2859
3323
|
}, ctx));
|
|
@@ -2875,7 +3339,7 @@ var ActivityRepository = class extends Repository {
|
|
|
2875
3339
|
const previous = existing.dueDate;
|
|
2876
3340
|
const updated = await this.update(id, { dueDate: newDueDate }, ctx);
|
|
2877
3341
|
if (!updated) throw new NotFoundError("Activity", id);
|
|
2878
|
-
await this.events.
|
|
3342
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACTIVITY_RESCHEDULED, {
|
|
2879
3343
|
activityId: id,
|
|
2880
3344
|
dueDate: newDueDate.toISOString(),
|
|
2881
3345
|
...previous ? { previousDueDate: previous.toISOString() } : {},
|
|
@@ -2902,7 +3366,7 @@ var ActivityRepository = class extends Repository {
|
|
|
2902
3366
|
};
|
|
2903
3367
|
const updated = await this.update(id, { metadata: mergedMetadata }, ctx);
|
|
2904
3368
|
if (!updated) throw new NotFoundError("Activity", id);
|
|
2905
|
-
await this.events.
|
|
3369
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ACTIVITY_OUTCOME_RECORDED, {
|
|
2906
3370
|
activityId: id,
|
|
2907
3371
|
outcome
|
|
2908
3372
|
}, ctx));
|
|
@@ -2959,7 +3423,7 @@ var NoteRepository = class extends Repository {
|
|
|
2959
3423
|
}
|
|
2960
3424
|
async addNote(input, ctx) {
|
|
2961
3425
|
const created = await this.create(input, ctx);
|
|
2962
|
-
await this.events.
|
|
3426
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.NOTE_ADDED, {
|
|
2963
3427
|
noteId: String(created._id),
|
|
2964
3428
|
subjectKind: created.subjectKind,
|
|
2965
3429
|
subjectId: String(created.subjectId)
|
|
@@ -3025,7 +3489,7 @@ var EmailEventRepository = class extends Repository {
|
|
|
3025
3489
|
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
3026
3490
|
};
|
|
3027
3491
|
const created = await this.create(draft, ctx);
|
|
3028
|
-
await this.events.
|
|
3492
|
+
await this.events.dispatch(ctx, createEvent(KIND_TO_EVENT[kind], {
|
|
3029
3493
|
eventId: String(created._id),
|
|
3030
3494
|
kind,
|
|
3031
3495
|
subjectKind: input.subjectKind,
|
|
@@ -3055,7 +3519,7 @@ var ConversationRepository = class extends Repository {
|
|
|
3055
3519
|
...input.metadata !== void 0 ? { metadata: input.metadata } : {}
|
|
3056
3520
|
};
|
|
3057
3521
|
const created = await this.create(draft, ctx);
|
|
3058
|
-
await this.events.
|
|
3522
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONVERSATION_CREATED, {
|
|
3059
3523
|
conversationId: String(created._id),
|
|
3060
3524
|
channel: created.channel,
|
|
3061
3525
|
participantKind: created.participantKind,
|
|
@@ -3076,7 +3540,7 @@ var ConversationRepository = class extends Repository {
|
|
|
3076
3540
|
if (!current) throw new NotFoundError("Conversation", id);
|
|
3077
3541
|
return current;
|
|
3078
3542
|
}
|
|
3079
|
-
await this.events.
|
|
3543
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.CONVERSATION_ASSIGNED, {
|
|
3080
3544
|
conversationId: id,
|
|
3081
3545
|
assigneeId,
|
|
3082
3546
|
...before.assigneeId !== void 0 ? { previousAssigneeId: before.assigneeId } : {}
|
|
@@ -3114,7 +3578,7 @@ var ConversationRepository = class extends Repository {
|
|
|
3114
3578
|
if (existing.status === next) return existing;
|
|
3115
3579
|
const updated = await this.update(id, { status: next }, ctx);
|
|
3116
3580
|
if (!updated) throw new NotFoundError("Conversation", id);
|
|
3117
|
-
await this.events.
|
|
3581
|
+
await this.events.dispatch(ctx, createEvent(eventType, {
|
|
3118
3582
|
conversationId: id,
|
|
3119
3583
|
status: next,
|
|
3120
3584
|
previousStatus: existing.status
|
|
@@ -3166,7 +3630,7 @@ var MessageRepository = class extends Repository {
|
|
|
3166
3630
|
direction,
|
|
3167
3631
|
sentAt
|
|
3168
3632
|
}, ctx);
|
|
3169
|
-
await this.events.
|
|
3633
|
+
await this.events.dispatch(ctx, createEvent(eventType, {
|
|
3170
3634
|
messageId: String(created._id),
|
|
3171
3635
|
conversationId: input.conversationId,
|
|
3172
3636
|
direction,
|
|
@@ -3191,6 +3655,230 @@ var MessageRepository = class extends Repository {
|
|
|
3191
3655
|
}
|
|
3192
3656
|
};
|
|
3193
3657
|
//#endregion
|
|
3658
|
+
//#region src/repositories/sequence.repository.ts
|
|
3659
|
+
var SequenceRepository = class extends Repository {
|
|
3660
|
+
events;
|
|
3661
|
+
constructor(model, plugins, deps, pagination) {
|
|
3662
|
+
super(model, plugins, pagination);
|
|
3663
|
+
this.events = deps.events;
|
|
3664
|
+
}
|
|
3665
|
+
async activate(id, ctx) {
|
|
3666
|
+
const doc = await this._transition(id, "active", ctx);
|
|
3667
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SEQUENCE_ACTIVATED, { sequenceId: id }, ctx));
|
|
3668
|
+
return doc;
|
|
3669
|
+
}
|
|
3670
|
+
async activateFromPaused(id, ctx) {
|
|
3671
|
+
return this.activate(id, ctx);
|
|
3672
|
+
}
|
|
3673
|
+
async pause(id, ctx) {
|
|
3674
|
+
const doc = await this._transition(id, "paused", ctx);
|
|
3675
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SEQUENCE_PAUSED, { sequenceId: id }, ctx));
|
|
3676
|
+
return doc;
|
|
3677
|
+
}
|
|
3678
|
+
async archive(id, ctx) {
|
|
3679
|
+
const doc = await this._transition(id, "archived", ctx);
|
|
3680
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.SEQUENCE_ARCHIVED, { sequenceId: id }, ctx));
|
|
3681
|
+
return doc;
|
|
3682
|
+
}
|
|
3683
|
+
async _transition(id, target, ctx) {
|
|
3684
|
+
const existing = await this.getById(id, ctx);
|
|
3685
|
+
if (!existing) throw new NotFoundError("Sequence", id);
|
|
3686
|
+
SEQUENCE_MACHINE.assertTransition(id, existing.status, target);
|
|
3687
|
+
const entry = {
|
|
3688
|
+
status: target,
|
|
3689
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
3690
|
+
...ctx?.actorId !== void 0 ? { by: String(ctx.actorId) } : {}
|
|
3691
|
+
};
|
|
3692
|
+
const updated = await assertAndClaim(SEQUENCE_MACHINE, this, id, {
|
|
3693
|
+
from: existing.status,
|
|
3694
|
+
to: target,
|
|
3695
|
+
patch: { statusHistory: [...existing.statusHistory ?? [], entry] },
|
|
3696
|
+
options: ctx
|
|
3697
|
+
});
|
|
3698
|
+
if (!updated) throw new InvalidSequenceTransitionError({
|
|
3699
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
3700
|
+
to: target,
|
|
3701
|
+
sequenceId: id
|
|
3702
|
+
});
|
|
3703
|
+
return updated;
|
|
3704
|
+
}
|
|
3705
|
+
};
|
|
3706
|
+
//#endregion
|
|
3707
|
+
//#region src/repositories/enrollment.repository.ts
|
|
3708
|
+
var EnrollmentRepository = class extends Repository {
|
|
3709
|
+
events;
|
|
3710
|
+
sequences;
|
|
3711
|
+
constructor(model, plugins, deps, pagination) {
|
|
3712
|
+
super(model, plugins, pagination);
|
|
3713
|
+
this.events = deps.events;
|
|
3714
|
+
this.sequences = deps.sequences;
|
|
3715
|
+
}
|
|
3716
|
+
/** Create a new enrollment — throws AlreadyEnrolledError if an active one exists. */
|
|
3717
|
+
async enroll(input, ctx) {
|
|
3718
|
+
const { sequenceId, subjectKind, subjectId, workflowRunId, ownerId, metadata } = input;
|
|
3719
|
+
if (await this.getOne({
|
|
3720
|
+
sequenceId,
|
|
3721
|
+
subjectId,
|
|
3722
|
+
status: { $in: ["active", "paused"] }
|
|
3723
|
+
}, ctx)) throw new AlreadyEnrolledError({
|
|
3724
|
+
sequenceId,
|
|
3725
|
+
subjectId
|
|
3726
|
+
});
|
|
3727
|
+
const doc = await this.create({
|
|
3728
|
+
organizationId: ctx?.organizationId,
|
|
3729
|
+
sequenceId,
|
|
3730
|
+
subjectKind,
|
|
3731
|
+
subjectId,
|
|
3732
|
+
status: "active",
|
|
3733
|
+
currentStepIndex: 0,
|
|
3734
|
+
stepResults: [],
|
|
3735
|
+
enrolledAt: /* @__PURE__ */ new Date(),
|
|
3736
|
+
...workflowRunId ? { workflowRunId } : {},
|
|
3737
|
+
...ownerId ? { ownerId } : {},
|
|
3738
|
+
...metadata ? { metadata } : {},
|
|
3739
|
+
statusHistory: [{
|
|
3740
|
+
status: "active",
|
|
3741
|
+
occurredAt: /* @__PURE__ */ new Date()
|
|
3742
|
+
}]
|
|
3743
|
+
}, ctx);
|
|
3744
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_CREATED, {
|
|
3745
|
+
enrollmentId: String(doc._id),
|
|
3746
|
+
sequenceId,
|
|
3747
|
+
subjectId,
|
|
3748
|
+
subjectKind
|
|
3749
|
+
}, ctx));
|
|
3750
|
+
return doc;
|
|
3751
|
+
}
|
|
3752
|
+
/** Set workflowRunId after the workflow has started. */
|
|
3753
|
+
async setWorkflowRunId(id, workflowRunId, ctx) {
|
|
3754
|
+
await this.update(id, { workflowRunId }, ctx);
|
|
3755
|
+
}
|
|
3756
|
+
/** Atomically advance to the next step and record the result (CAS). */
|
|
3757
|
+
async advanceStep(id, nextStepIndex, stepResult, ctx) {
|
|
3758
|
+
return this.claim(id, {
|
|
3759
|
+
field: "currentStepIndex",
|
|
3760
|
+
from: nextStepIndex - 1,
|
|
3761
|
+
to: nextStepIndex,
|
|
3762
|
+
where: { status: "active" }
|
|
3763
|
+
}, stepResult ? { $push: { stepResults: stepResult } } : {}, ctx);
|
|
3764
|
+
}
|
|
3765
|
+
async pause(id, ctx) {
|
|
3766
|
+
const doc = await this._transition(id, "paused", ctx);
|
|
3767
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_PAUSED, { enrollmentId: id }, ctx));
|
|
3768
|
+
return doc;
|
|
3769
|
+
}
|
|
3770
|
+
async resume(id, ctx) {
|
|
3771
|
+
const doc = await this._transition(id, "active", ctx);
|
|
3772
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_RESUMED, { enrollmentId: id }, ctx));
|
|
3773
|
+
return doc;
|
|
3774
|
+
}
|
|
3775
|
+
async complete(id, ctx) {
|
|
3776
|
+
const doc = await this._transition(id, "completed", ctx, { completedAt: /* @__PURE__ */ new Date() });
|
|
3777
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_COMPLETED, { enrollmentId: id }, ctx));
|
|
3778
|
+
return doc;
|
|
3779
|
+
}
|
|
3780
|
+
async exit(id, exitReason, ctx) {
|
|
3781
|
+
const existing = await this.getById(id, ctx);
|
|
3782
|
+
if (!existing) throw new NotFoundError("Enrollment", id);
|
|
3783
|
+
const from = existing.status;
|
|
3784
|
+
ENROLLMENT_MACHINE.assertTransition(id, from, "exited");
|
|
3785
|
+
const entry = {
|
|
3786
|
+
status: "exited",
|
|
3787
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
3788
|
+
...ctx?.actorId ? { by: String(ctx.actorId) } : {}
|
|
3789
|
+
};
|
|
3790
|
+
const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
|
|
3791
|
+
from,
|
|
3792
|
+
to: "exited",
|
|
3793
|
+
patch: {
|
|
3794
|
+
exitReason,
|
|
3795
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
3796
|
+
},
|
|
3797
|
+
options: ctx
|
|
3798
|
+
});
|
|
3799
|
+
if (!updated) throw new NotFoundError("Enrollment", id);
|
|
3800
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.ENROLLMENT_EXITED, {
|
|
3801
|
+
enrollmentId: id,
|
|
3802
|
+
exitReason
|
|
3803
|
+
}, ctx));
|
|
3804
|
+
return updated;
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* Reply detection: exit every ACTIVE enrollment of the given subjects
|
|
3808
|
+
* with reason `'reply'` — the one method a host's
|
|
3809
|
+
* `crm:message.received` subscriber calls:
|
|
3810
|
+
*
|
|
3811
|
+
* ```ts
|
|
3812
|
+
* crm.events.subscribe('crm:message.received', async (e) => {
|
|
3813
|
+
* await crm.repositories.enrollment.exitActiveOnReply(
|
|
3814
|
+
* [{ subjectKind: 'contact', subjectId: e.payload.participantId }],
|
|
3815
|
+
* { organizationId: e.meta.organizationId },
|
|
3816
|
+
* );
|
|
3817
|
+
* });
|
|
3818
|
+
* ```
|
|
3819
|
+
*
|
|
3820
|
+
* Sequences are batch-loaded in ONE `$in` query; enrollments whose
|
|
3821
|
+
* sequence has `replyDetectionEnabled === false` are skipped (a missing
|
|
3822
|
+
* sequence — e.g. deleted — does NOT skip: the default is to exit).
|
|
3823
|
+
* Each exit goes through `exit()` (state-machine CAS + statusHistory +
|
|
3824
|
+
* ENROLLMENT_EXITED event). An enrollment that a concurrent transition
|
|
3825
|
+
* already moved out of `active` loses the CAS and is skipped — the
|
|
3826
|
+
* desired end state ("not active") already holds.
|
|
3827
|
+
*
|
|
3828
|
+
* Returns the enrollments this call actually exited.
|
|
3829
|
+
*/
|
|
3830
|
+
async exitActiveOnReply(subjects, ctx) {
|
|
3831
|
+
if (subjects.length === 0) return [];
|
|
3832
|
+
const active = await this.findAll({
|
|
3833
|
+
status: "active",
|
|
3834
|
+
$or: subjects.map((s) => ({
|
|
3835
|
+
subjectKind: s.subjectKind,
|
|
3836
|
+
subjectId: s.subjectId
|
|
3837
|
+
}))
|
|
3838
|
+
}, ctx);
|
|
3839
|
+
if (active.length === 0) return [];
|
|
3840
|
+
const sequenceIds = [...new Set(active.map((e) => String(e.sequenceId)))];
|
|
3841
|
+
const sequences = await this.sequences.findAll({ _id: { $in: sequenceIds } }, ctx);
|
|
3842
|
+
const sequencesById = new Map(sequences.map((s) => [String(s._id), s]));
|
|
3843
|
+
const exited = [];
|
|
3844
|
+
for (const enrollment of active) {
|
|
3845
|
+
if (sequencesById.get(String(enrollment.sequenceId))?.replyDetectionEnabled === false) continue;
|
|
3846
|
+
try {
|
|
3847
|
+
exited.push(await this.exit(String(enrollment._id), "reply", ctx));
|
|
3848
|
+
} catch (err) {
|
|
3849
|
+
if (err instanceof IllegalTransitionError || err instanceof InvalidEnrollmentTransitionError || err instanceof NotFoundError) continue;
|
|
3850
|
+
throw err;
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
return exited;
|
|
3854
|
+
}
|
|
3855
|
+
async _transition(id, target, ctx, patch = {}) {
|
|
3856
|
+
const existing = await this.getById(id, ctx);
|
|
3857
|
+
if (!existing) throw new NotFoundError("Enrollment", id);
|
|
3858
|
+
ENROLLMENT_MACHINE.assertTransition(id, existing.status, target);
|
|
3859
|
+
const entry = {
|
|
3860
|
+
status: target,
|
|
3861
|
+
occurredAt: /* @__PURE__ */ new Date(),
|
|
3862
|
+
...ctx?.actorId ? { by: String(ctx.actorId) } : {}
|
|
3863
|
+
};
|
|
3864
|
+
const updated = await assertAndClaim(ENROLLMENT_MACHINE, this, id, {
|
|
3865
|
+
from: existing.status,
|
|
3866
|
+
to: target,
|
|
3867
|
+
patch: {
|
|
3868
|
+
...patch,
|
|
3869
|
+
statusHistory: [...existing.statusHistory ?? [], entry]
|
|
3870
|
+
},
|
|
3871
|
+
options: ctx
|
|
3872
|
+
});
|
|
3873
|
+
if (!updated) throw new InvalidEnrollmentTransitionError({
|
|
3874
|
+
from: (await this.getById(id, ctx))?.status ?? existing.status,
|
|
3875
|
+
to: target,
|
|
3876
|
+
enrollmentId: id
|
|
3877
|
+
});
|
|
3878
|
+
return updated;
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3881
|
+
//#endregion
|
|
3194
3882
|
//#region src/repositories/create-repositories.ts
|
|
3195
3883
|
/**
|
|
3196
3884
|
* Repository factory — composes the shared mongokit plugin stack and
|
|
@@ -3224,6 +3912,7 @@ function createCrmRepositories(models, deps) {
|
|
|
3224
3912
|
const account = new AccountRepository(models.Account, plugins, { events }, pagination);
|
|
3225
3913
|
const campaign = new CampaignRepository(models.Campaign, plugins, { events }, pagination);
|
|
3226
3914
|
const subscription = new SubscriptionRepository(models.Subscription, plugins, { events }, pagination);
|
|
3915
|
+
const suppression = new SuppressionRepository(models.Suppression, plugins, { events }, pagination);
|
|
3227
3916
|
const activity = new ActivityRepository(models.Activity, plugins, { events }, pagination);
|
|
3228
3917
|
const note = new NoteRepository(models.Note, plugins, { events }, pagination);
|
|
3229
3918
|
const lossReason = new LossReasonRepository(models.LossReason, plugins, { events }, pagination);
|
|
@@ -3233,6 +3922,11 @@ function createCrmRepositories(models, deps) {
|
|
|
3233
3922
|
events,
|
|
3234
3923
|
conversations: conversation
|
|
3235
3924
|
}, pagination);
|
|
3925
|
+
const sequence = new SequenceRepository(models.Sequence, plugins, { events }, pagination);
|
|
3926
|
+
const enrollment = new EnrollmentRepository(models.Enrollment, plugins, {
|
|
3927
|
+
events,
|
|
3928
|
+
sequences: sequence
|
|
3929
|
+
}, pagination);
|
|
3236
3930
|
return Object.freeze({
|
|
3237
3931
|
pipeline,
|
|
3238
3932
|
lead,
|
|
@@ -3241,15 +3935,90 @@ function createCrmRepositories(models, deps) {
|
|
|
3241
3935
|
account,
|
|
3242
3936
|
campaign,
|
|
3243
3937
|
subscription,
|
|
3938
|
+
suppression,
|
|
3244
3939
|
activity,
|
|
3245
3940
|
note,
|
|
3246
3941
|
lossReason,
|
|
3247
3942
|
emailEvent,
|
|
3248
3943
|
conversation,
|
|
3249
|
-
message
|
|
3944
|
+
message,
|
|
3945
|
+
sequence,
|
|
3946
|
+
enrollment
|
|
3250
3947
|
});
|
|
3251
3948
|
}
|
|
3252
3949
|
//#endregion
|
|
3950
|
+
//#region src/events/dispatch.ts
|
|
3951
|
+
/**
|
|
3952
|
+
* Context key for the post-commit event queue. `Symbol.for` so the host
|
|
3953
|
+
* and the package agree on the key even across duplicated installs.
|
|
3954
|
+
*/
|
|
3955
|
+
const PENDING_EVENTS = Symbol.for("@classytic/crm:pendingEvents");
|
|
3956
|
+
/**
|
|
3957
|
+
* EventTransport-compatible wrapper the engine hands to repositories.
|
|
3958
|
+
* `publish`/`subscribe` delegate straight to the underlying transport
|
|
3959
|
+
* (host-facing surface unchanged); domain verbs call `dispatch(ctx, event)`
|
|
3960
|
+
* so session/outbox/queue routing happens in ONE place.
|
|
3961
|
+
*/
|
|
3962
|
+
var CrmEventDispatcher = class {
|
|
3963
|
+
transport;
|
|
3964
|
+
outbox;
|
|
3965
|
+
logger;
|
|
3966
|
+
constructor(options) {
|
|
3967
|
+
this.transport = options.transport;
|
|
3968
|
+
this.outbox = options.outbox;
|
|
3969
|
+
this.logger = options.logger;
|
|
3970
|
+
}
|
|
3971
|
+
get name() {
|
|
3972
|
+
return this.transport.name;
|
|
3973
|
+
}
|
|
3974
|
+
/** Raw immediate publish — post-commit flushes and host-side use only. */
|
|
3975
|
+
publish(event) {
|
|
3976
|
+
return this.transport.publish(event);
|
|
3977
|
+
}
|
|
3978
|
+
subscribe(pattern, handler) {
|
|
3979
|
+
return this.transport.subscribe(pattern, handler);
|
|
3980
|
+
}
|
|
3981
|
+
async close() {
|
|
3982
|
+
await this.transport.close?.();
|
|
3983
|
+
}
|
|
3984
|
+
/**
|
|
3985
|
+
* Session-bound dispatch (see module doc for the full contract).
|
|
3986
|
+
* `ctx` comes first so call sites read `dispatch(ctx, createEvent(...))`.
|
|
3987
|
+
*/
|
|
3988
|
+
async dispatch(ctx, event) {
|
|
3989
|
+
const pending = ctx?.[PENDING_EVENTS];
|
|
3990
|
+
const session = ctx?.session;
|
|
3991
|
+
if (this.outbox) {
|
|
3992
|
+
const saveOptions = {};
|
|
3993
|
+
if (session !== void 0) saveOptions.session = session;
|
|
3994
|
+
await this.outbox.save(event, saveOptions);
|
|
3995
|
+
} else if (session && !pending) throw new UnmanagedSessionError(event.type);
|
|
3996
|
+
if (pending) {
|
|
3997
|
+
pending.push(event);
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
if (!session) try {
|
|
4001
|
+
await this.transport.publish(event);
|
|
4002
|
+
} catch (err) {
|
|
4003
|
+
this.logger?.error?.(`[crm] publish failed for ${event.type}`, { err });
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
};
|
|
4007
|
+
/**
|
|
4008
|
+
* Drain a `[PENDING_EVENTS]` queue through a transport after commit,
|
|
4009
|
+
* swallowing per-event errors (durable delivery is the outbox's job).
|
|
4010
|
+
* Hosts composing their own outer transaction call this after commit;
|
|
4011
|
+
* most should use `engine.withTransaction(ctx, fn)` instead.
|
|
4012
|
+
*/
|
|
4013
|
+
async function flushPending(transport, events, logger) {
|
|
4014
|
+
if (!transport || events.length === 0) return;
|
|
4015
|
+
for (const event of events) try {
|
|
4016
|
+
await transport.publish(event);
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
(logger?.error ?? console.error)(`[crm] publish failed for ${event.type}`, { err });
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
//#endregion
|
|
3253
4022
|
//#region src/events/in-process-bus.ts
|
|
3254
4023
|
/**
|
|
3255
4024
|
* In-process event bus — default fallback when no `eventTransport` is
|
|
@@ -3305,6 +4074,42 @@ function assertCrmCapabilities(probe, options = {}) {
|
|
|
3305
4074
|
}
|
|
3306
4075
|
}
|
|
3307
4076
|
//#endregion
|
|
4077
|
+
//#region src/engine/run-unit.ts
|
|
4078
|
+
/**
|
|
4079
|
+
* runUnit — transaction + post-commit-publish discipline for CRM verbs
|
|
4080
|
+
* composed into one atomic unit (the canonical case: `LeadService.convert`
|
|
4081
|
+
* = Account + Contact + Opportunity + lead transition). Same contract as
|
|
4082
|
+
* `@classytic/refit` / `@classytic/review` / `@classytic/cart` /
|
|
4083
|
+
* `@classytic/invoice` (PACKAGE_RULES §P8.1):
|
|
4084
|
+
*
|
|
4085
|
+
* 1. Events dispatched inside the unit are QUEUED (`ctx[PENDING_EVENTS]`)
|
|
4086
|
+
* and flushed only after the transaction commits (queue resets on
|
|
4087
|
+
* retry attempts) — a rollback can't leak ghost events to in-process
|
|
4088
|
+
* subscribers. Outbox rows stay inside the transaction — the relay
|
|
4089
|
+
* reads only committed rows.
|
|
4090
|
+
* 2. A caller-owned `ctx.session` must arrive WITH a queue or an outbox —
|
|
4091
|
+
* `dispatch()` throws `UnmanagedSessionError` otherwise.
|
|
4092
|
+
*/
|
|
4093
|
+
async function runUnit(config, ctx, body) {
|
|
4094
|
+
const internalCtx = ctx;
|
|
4095
|
+
const externalQueue = internalCtx[PENDING_EVENTS];
|
|
4096
|
+
if (ctx.session) return body(externalQueue ? {
|
|
4097
|
+
...internalCtx,
|
|
4098
|
+
[PENDING_EVENTS]: externalQueue
|
|
4099
|
+
} : internalCtx);
|
|
4100
|
+
const pending = [];
|
|
4101
|
+
const result = await withTransaction(config.connection, async (session) => {
|
|
4102
|
+
pending.length = 0;
|
|
4103
|
+
return body({
|
|
4104
|
+
...internalCtx,
|
|
4105
|
+
session,
|
|
4106
|
+
[PENDING_EVENTS]: pending
|
|
4107
|
+
});
|
|
4108
|
+
}, config.allowNonTransactional ? { allowFallback: true } : {});
|
|
4109
|
+
await flushPending(config.events, pending, config.logger);
|
|
4110
|
+
return result;
|
|
4111
|
+
}
|
|
4112
|
+
//#endregion
|
|
3308
4113
|
//#region src/engine/create-crm.ts
|
|
3309
4114
|
async function createCrm(config) {
|
|
3310
4115
|
if (!config.connection) throw new Error("[crm] CrmConfig.connection is required");
|
|
@@ -3318,7 +4123,11 @@ async function createCrm(config) {
|
|
|
3318
4123
|
...config.pagination !== void 0 ? { pagination: config.pagination } : {},
|
|
3319
4124
|
plugins: config.plugins ?? []
|
|
3320
4125
|
};
|
|
3321
|
-
const events =
|
|
4126
|
+
const events = new CrmEventDispatcher({
|
|
4127
|
+
transport: config.eventTransport ?? new InProcessCrmBus(config.logger ? { logger: config.logger } : void 0),
|
|
4128
|
+
outbox: config.outbox,
|
|
4129
|
+
logger: config.logger
|
|
4130
|
+
});
|
|
3322
4131
|
const models = createCrmModels(config.connection, resolved);
|
|
3323
4132
|
const repositories = createCrmRepositories(models, {
|
|
3324
4133
|
events,
|
|
@@ -3333,11 +4142,19 @@ async function createCrm(config) {
|
|
|
3333
4142
|
repositories,
|
|
3334
4143
|
events,
|
|
3335
4144
|
config: Object.freeze(resolved),
|
|
4145
|
+
withTransaction(ctx, body) {
|
|
4146
|
+
return runUnit({
|
|
4147
|
+
connection: config.connection,
|
|
4148
|
+
events,
|
|
4149
|
+
logger: config.logger,
|
|
4150
|
+
allowNonTransactional: config.allowNonTransactional ?? false
|
|
4151
|
+
}, ctx, body);
|
|
4152
|
+
},
|
|
3336
4153
|
async syncIndexes() {
|
|
3337
4154
|
await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
|
|
3338
4155
|
},
|
|
3339
4156
|
async dispose() {
|
|
3340
|
-
await events.close
|
|
4157
|
+
await events.close();
|
|
3341
4158
|
}
|
|
3342
4159
|
});
|
|
3343
4160
|
}
|
|
@@ -3429,7 +4246,7 @@ var LeadService = class LeadService {
|
|
|
3429
4246
|
opportunityId: String(opportunity._id),
|
|
3430
4247
|
...input.by !== void 0 ? { by: input.by } : {}
|
|
3431
4248
|
}, ctx);
|
|
3432
|
-
await this.events.
|
|
4249
|
+
await this.events.dispatch(ctx, createEvent(CRM_EVENTS.LEAD_CONVERTED, {
|
|
3433
4250
|
leadId,
|
|
3434
4251
|
contactId: String(contact._id),
|
|
3435
4252
|
accountId: account ? String(account._id) : void 0,
|
|
@@ -3954,6 +4771,22 @@ const SubscriptionComplained = defineCrmEvent({
|
|
|
3954
4771
|
campaignId: z.string()
|
|
3955
4772
|
})
|
|
3956
4773
|
});
|
|
4774
|
+
const SuppressionAdded = defineCrmEvent({
|
|
4775
|
+
name: CRM_EVENTS.SUPPRESSION_ADDED,
|
|
4776
|
+
resource: "suppression",
|
|
4777
|
+
description: "suppress() inserted a NEW row on the org-wide suppression list (idempotent re-suppress of an existing email emits nothing). organizationId rides event meta.",
|
|
4778
|
+
zodSchema: z.object({
|
|
4779
|
+
email: z.string(),
|
|
4780
|
+
reason: z.enum(SUPPRESSION_REASONS),
|
|
4781
|
+
source: z.string().optional()
|
|
4782
|
+
})
|
|
4783
|
+
});
|
|
4784
|
+
const SuppressionRemoved = defineCrmEvent({
|
|
4785
|
+
name: CRM_EVENTS.SUPPRESSION_REMOVED,
|
|
4786
|
+
resource: "suppression",
|
|
4787
|
+
description: "unsuppress() removed an email from the org-wide suppression list.",
|
|
4788
|
+
zodSchema: z.object({ email: z.string() })
|
|
4789
|
+
});
|
|
3957
4790
|
const emailEventSchema = z.object({
|
|
3958
4791
|
eventId: z.string(),
|
|
3959
4792
|
kind: z.string(),
|
|
@@ -4054,6 +4887,82 @@ const MessageSent = defineCrmEvent({
|
|
|
4054
4887
|
externalMessageId: z.string().optional()
|
|
4055
4888
|
})
|
|
4056
4889
|
});
|
|
4890
|
+
const SequenceCreated = defineCrmEvent({
|
|
4891
|
+
name: CRM_EVENTS.SEQUENCE_CREATED,
|
|
4892
|
+
resource: "sequence",
|
|
4893
|
+
description: "A sequence draft was created. Emitted by the HOST after repo.create() (the package ships no createSequence verb) — defined here so registries validate the payload.",
|
|
4894
|
+
zodSchema: z.object({
|
|
4895
|
+
sequenceId: z.string(),
|
|
4896
|
+
name: z.string().optional()
|
|
4897
|
+
})
|
|
4898
|
+
});
|
|
4899
|
+
const SequenceActivated = defineCrmEvent({
|
|
4900
|
+
name: CRM_EVENTS.SEQUENCE_ACTIVATED,
|
|
4901
|
+
resource: "sequence",
|
|
4902
|
+
description: "activate() moved a sequence draft/paused → active.",
|
|
4903
|
+
zodSchema: z.object({ sequenceId: z.string() })
|
|
4904
|
+
});
|
|
4905
|
+
const SequencePaused = defineCrmEvent({
|
|
4906
|
+
name: CRM_EVENTS.SEQUENCE_PAUSED,
|
|
4907
|
+
resource: "sequence",
|
|
4908
|
+
description: "pause() suspended an active sequence (existing enrollments hold).",
|
|
4909
|
+
zodSchema: z.object({ sequenceId: z.string() })
|
|
4910
|
+
});
|
|
4911
|
+
const SequenceArchived = defineCrmEvent({
|
|
4912
|
+
name: CRM_EVENTS.SEQUENCE_ARCHIVED,
|
|
4913
|
+
resource: "sequence",
|
|
4914
|
+
description: "archive() retired a sequence — no new enrollments.",
|
|
4915
|
+
zodSchema: z.object({ sequenceId: z.string() })
|
|
4916
|
+
});
|
|
4917
|
+
const EnrollmentCreated = defineCrmEvent({
|
|
4918
|
+
name: CRM_EVENTS.ENROLLMENT_CREATED,
|
|
4919
|
+
resource: "enrollment",
|
|
4920
|
+
description: "enroll() attached a subject to a sequence.",
|
|
4921
|
+
zodSchema: z.object({
|
|
4922
|
+
enrollmentId: z.string(),
|
|
4923
|
+
sequenceId: z.string(),
|
|
4924
|
+
subjectId: z.string(),
|
|
4925
|
+
subjectKind: z.string()
|
|
4926
|
+
})
|
|
4927
|
+
});
|
|
4928
|
+
const EnrollmentStepSent = defineCrmEvent({
|
|
4929
|
+
name: CRM_EVENTS.ENROLLMENT_STEP_SENT,
|
|
4930
|
+
resource: "enrollment",
|
|
4931
|
+
description: "A sequence step was delivered. Emitted by the HOST outreach layer after its send adapter succeeds + advanceStep() commits (Buffer-style pattern in CLAUDE.md) — defined here so registries validate the payload.",
|
|
4932
|
+
zodSchema: z.object({
|
|
4933
|
+
enrollmentId: z.string(),
|
|
4934
|
+
sequenceId: z.string().optional(),
|
|
4935
|
+
stepIndex: z.number(),
|
|
4936
|
+
channel: z.string().optional()
|
|
4937
|
+
})
|
|
4938
|
+
});
|
|
4939
|
+
const EnrollmentPaused = defineCrmEvent({
|
|
4940
|
+
name: CRM_EVENTS.ENROLLMENT_PAUSED,
|
|
4941
|
+
resource: "enrollment",
|
|
4942
|
+
description: "pause() held an active enrollment.",
|
|
4943
|
+
zodSchema: z.object({ enrollmentId: z.string() })
|
|
4944
|
+
});
|
|
4945
|
+
const EnrollmentResumed = defineCrmEvent({
|
|
4946
|
+
name: CRM_EVENTS.ENROLLMENT_RESUMED,
|
|
4947
|
+
resource: "enrollment",
|
|
4948
|
+
description: "resume() reactivated a paused enrollment.",
|
|
4949
|
+
zodSchema: z.object({ enrollmentId: z.string() })
|
|
4950
|
+
});
|
|
4951
|
+
const EnrollmentCompleted = defineCrmEvent({
|
|
4952
|
+
name: CRM_EVENTS.ENROLLMENT_COMPLETED,
|
|
4953
|
+
resource: "enrollment",
|
|
4954
|
+
description: "complete() finished the final step of an enrollment.",
|
|
4955
|
+
zodSchema: z.object({ enrollmentId: z.string() })
|
|
4956
|
+
});
|
|
4957
|
+
const EnrollmentExited = defineCrmEvent({
|
|
4958
|
+
name: CRM_EVENTS.ENROLLMENT_EXITED,
|
|
4959
|
+
resource: "enrollment",
|
|
4960
|
+
description: "exit() removed a subject before completion (reply/unsubscribe/manual/goal).",
|
|
4961
|
+
zodSchema: z.object({
|
|
4962
|
+
enrollmentId: z.string(),
|
|
4963
|
+
exitReason: z.string().optional()
|
|
4964
|
+
})
|
|
4965
|
+
});
|
|
4057
4966
|
const crmEventDefinitions = [
|
|
4058
4967
|
LeadCreated,
|
|
4059
4968
|
LeadStatusChanged,
|
|
@@ -4100,6 +5009,8 @@ const crmEventDefinitions = [
|
|
|
4100
5009
|
SubscriptionUnsubscribed,
|
|
4101
5010
|
SubscriptionBounced,
|
|
4102
5011
|
SubscriptionComplained,
|
|
5012
|
+
SuppressionAdded,
|
|
5013
|
+
SuppressionRemoved,
|
|
4103
5014
|
EmailEventOpened,
|
|
4104
5015
|
EmailEventClicked,
|
|
4105
5016
|
EmailEventBounced,
|
|
@@ -4110,17 +5021,34 @@ const crmEventDefinitions = [
|
|
|
4110
5021
|
ConversationResolved,
|
|
4111
5022
|
ConversationReopened,
|
|
4112
5023
|
MessageReceived,
|
|
4113
|
-
MessageSent
|
|
5024
|
+
MessageSent,
|
|
5025
|
+
SequenceCreated,
|
|
5026
|
+
SequenceActivated,
|
|
5027
|
+
SequencePaused,
|
|
5028
|
+
SequenceArchived,
|
|
5029
|
+
EnrollmentCreated,
|
|
5030
|
+
EnrollmentStepSent,
|
|
5031
|
+
EnrollmentPaused,
|
|
5032
|
+
EnrollmentResumed,
|
|
5033
|
+
EnrollmentCompleted,
|
|
5034
|
+
EnrollmentExited
|
|
4114
5035
|
];
|
|
4115
5036
|
//#endregion
|
|
4116
5037
|
//#region src/shared/calculators/forecast.ts
|
|
4117
5038
|
/**
|
|
5039
|
+
* Pure forecast calculators — no I/O, no Mongo. Hosts call these after
|
|
5040
|
+
* loading opportunities via `repositories.opportunity.list(...)`.
|
|
5041
|
+
*
|
|
5042
|
+
* Amount is flat (`amount` Number + `currency` String per PACKAGE_RULES
|
|
5043
|
+
* §4) — same shape the opportunity schema stores.
|
|
5044
|
+
*/
|
|
5045
|
+
/**
|
|
4118
5046
|
* Aggregate opportunities into a forecast table — one row per bucket.
|
|
4119
5047
|
* Currency mismatch within a bucket throws — partition by currency
|
|
4120
5048
|
* upstream when multi-currency.
|
|
4121
5049
|
*/
|
|
4122
5050
|
function forecast(opportunities, options = {}) {
|
|
4123
|
-
const bucketFn = options.bucket ?? defaultBucket;
|
|
5051
|
+
const bucketFn = options.bucket ?? (options.timeZone !== void 0 ? zoneBucket(options.timeZone) : defaultBucket);
|
|
4124
5052
|
const include = options.include ?? defaultInclude;
|
|
4125
5053
|
const rows = /* @__PURE__ */ new Map();
|
|
4126
5054
|
for (const o of opportunities) {
|
|
@@ -4153,6 +5081,10 @@ function defaultBucket(o) {
|
|
|
4153
5081
|
const d = o.expectedCloseAt;
|
|
4154
5082
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
4155
5083
|
}
|
|
5084
|
+
/** Month bucket cut at the business's local midnight, not UTC. */
|
|
5085
|
+
function zoneBucket(timeZone) {
|
|
5086
|
+
return (o) => civilDateOf(o.expectedCloseAt, timeZone).slice(0, 7);
|
|
5087
|
+
}
|
|
4156
5088
|
/**
|
|
4157
5089
|
* "Where do deals die?" summary — opportunities partitioned across
|
|
4158
5090
|
* stages with current count + won/lost-from-here.
|
|
@@ -4189,7 +5121,7 @@ const objectIdLike = z.union([z.string().min(1), z.string()]);
|
|
|
4189
5121
|
const metadataSchema = z.record(z.string(), z.unknown()).optional();
|
|
4190
5122
|
const tagsSchema = z.array(z.string().min(1).max(64)).max(50).optional();
|
|
4191
5123
|
const moneyAmountSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
4192
|
-
const currencyCodeSchema = z.string().regex(
|
|
5124
|
+
const currencyCodeSchema = z.string().regex(CURRENCY_PATTERN, "currency must be an ISO 4217 code (3 uppercase letters)");
|
|
4193
5125
|
//#endregion
|
|
4194
5126
|
//#region src/validators/pipeline.schema.ts
|
|
4195
5127
|
const stageInputSchema = z.object({
|
|
@@ -4243,7 +5175,7 @@ const convertLeadSchema = z.object({
|
|
|
4243
5175
|
pipelineId: objectIdLike,
|
|
4244
5176
|
opportunityName: z.string().min(1).max(200).optional(),
|
|
4245
5177
|
amount: z.number().nonnegative().optional(),
|
|
4246
|
-
currency:
|
|
5178
|
+
currency: currencyCodeSchema.optional(),
|
|
4247
5179
|
expectedCloseAt: z.coerce.date().optional(),
|
|
4248
5180
|
by: z.string().optional()
|
|
4249
5181
|
});
|
|
@@ -4399,6 +5331,21 @@ const subscriptionFilterSchema = z.object({
|
|
|
4399
5331
|
campaignId: objectIdLike.optional()
|
|
4400
5332
|
});
|
|
4401
5333
|
//#endregion
|
|
5334
|
+
//#region src/validators/suppression.schema.ts
|
|
5335
|
+
const suppressSchema = z.object({
|
|
5336
|
+
email: z.string().email().max(320),
|
|
5337
|
+
reason: z.enum(SUPPRESSION_REASONS),
|
|
5338
|
+
source: z.string().max(200).optional(),
|
|
5339
|
+
subjectKind: z.string().max(50).optional(),
|
|
5340
|
+
subjectId: z.string().max(100).optional(),
|
|
5341
|
+
metadata: metadataSchema
|
|
5342
|
+
});
|
|
5343
|
+
const unsuppressSchema = z.object({ email: z.string().email().max(320) });
|
|
5344
|
+
const suppressionFilterSchema = z.object({
|
|
5345
|
+
email: z.string().max(320).optional(),
|
|
5346
|
+
reason: z.enum(SUPPRESSION_REASONS).optional()
|
|
5347
|
+
});
|
|
5348
|
+
//#endregion
|
|
4402
5349
|
//#region src/validators/activity.schema.ts
|
|
4403
5350
|
const planActivitySchema = z.object({
|
|
4404
5351
|
type: z.enum(ACTIVITY_TYPES),
|
|
@@ -4475,6 +5422,54 @@ const emailEventFilterSchema = z.object({
|
|
|
4475
5422
|
messageId: z.string().optional()
|
|
4476
5423
|
});
|
|
4477
5424
|
//#endregion
|
|
5425
|
+
//#region src/validators/sequence.schema.ts
|
|
5426
|
+
const sendEmailStepSchema = z.object({
|
|
5427
|
+
id: z.string().min(1),
|
|
5428
|
+
type: z.literal("SEND_EMAIL"),
|
|
5429
|
+
name: z.string().max(100).optional(),
|
|
5430
|
+
subject: z.string().min(1).max(300),
|
|
5431
|
+
htmlBody: z.string().min(1),
|
|
5432
|
+
fromName: z.string().max(100).optional(),
|
|
5433
|
+
fromEmail: z.string().email().optional()
|
|
5434
|
+
});
|
|
5435
|
+
const delayStepSchema = z.object({
|
|
5436
|
+
id: z.string().min(1),
|
|
5437
|
+
type: z.literal("DELAY"),
|
|
5438
|
+
name: z.string().max(100).optional(),
|
|
5439
|
+
settings: z.object({
|
|
5440
|
+
days: z.number().int().min(0).optional(),
|
|
5441
|
+
hours: z.number().int().min(0).optional(),
|
|
5442
|
+
minutes: z.number().int().min(0).optional()
|
|
5443
|
+
})
|
|
5444
|
+
});
|
|
5445
|
+
const sequenceStepSchema = z.discriminatedUnion("type", [sendEmailStepSchema, delayStepSchema]);
|
|
5446
|
+
const createSequenceSchema = z.object({
|
|
5447
|
+
name: z.string().min(1).max(200),
|
|
5448
|
+
description: z.string().max(1e3).optional(),
|
|
5449
|
+
fromName: z.string().max(100).optional(),
|
|
5450
|
+
fromEmail: z.string().email().optional(),
|
|
5451
|
+
steps: z.array(sequenceStepSchema).default([]),
|
|
5452
|
+
replyDetectionEnabled: z.boolean().default(true),
|
|
5453
|
+
tags: tagsSchema,
|
|
5454
|
+
ownerId: z.string().optional(),
|
|
5455
|
+
metadata: metadataSchema
|
|
5456
|
+
});
|
|
5457
|
+
const updateSequenceSchema = createSequenceSchema.partial();
|
|
5458
|
+
const sequenceFilterSchema = z.object({
|
|
5459
|
+
status: z.enum([
|
|
5460
|
+
"draft",
|
|
5461
|
+
"active",
|
|
5462
|
+
"paused",
|
|
5463
|
+
"archived"
|
|
5464
|
+
]).optional(),
|
|
5465
|
+
ownerId: z.string().optional(),
|
|
5466
|
+
tags: z.string().optional(),
|
|
5467
|
+
search: z.string().optional(),
|
|
5468
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
5469
|
+
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
5470
|
+
sort: z.string().optional()
|
|
5471
|
+
});
|
|
5472
|
+
//#endregion
|
|
4478
5473
|
//#region src/validators/index.ts
|
|
4479
5474
|
var validators_exports = /* @__PURE__ */ __exportAll({
|
|
4480
5475
|
accountFilterSchema: () => accountFilterSchema,
|
|
@@ -4494,6 +5489,7 @@ var validators_exports = /* @__PURE__ */ __exportAll({
|
|
|
4494
5489
|
createLeadSchema: () => createLeadSchema,
|
|
4495
5490
|
createLossReasonSchema: () => createLossReasonSchema,
|
|
4496
5491
|
createPipelineSchema: () => createPipelineSchema,
|
|
5492
|
+
createSequenceSchema: () => createSequenceSchema,
|
|
4497
5493
|
currencyCodeSchema: () => currencyCodeSchema,
|
|
4498
5494
|
emailEventFilterSchema: () => emailEventFilterSchema,
|
|
4499
5495
|
leadFilterSchema: () => leadFilterSchema,
|
|
@@ -4508,11 +5504,16 @@ var validators_exports = /* @__PURE__ */ __exportAll({
|
|
|
4508
5504
|
pipelineFilterSchema: () => pipelineFilterSchema,
|
|
4509
5505
|
planActivitySchema: () => planActivitySchema,
|
|
4510
5506
|
recordEmailEventSchema: () => recordEmailEventSchema,
|
|
5507
|
+
sequenceFilterSchema: () => sequenceFilterSchema,
|
|
5508
|
+
sequenceStepSchema: () => sequenceStepSchema,
|
|
4511
5509
|
stageInputSchemaExport: () => stageInputSchemaExport,
|
|
4512
5510
|
subscribeSchema: () => subscribeSchema,
|
|
4513
5511
|
subscriptionFilterSchema: () => subscriptionFilterSchema,
|
|
5512
|
+
suppressSchema: () => suppressSchema,
|
|
5513
|
+
suppressionFilterSchema: () => suppressionFilterSchema,
|
|
4514
5514
|
tagsSchema: () => tagsSchema,
|
|
4515
5515
|
unsubscribeSchema: () => unsubscribeSchema,
|
|
5516
|
+
unsuppressSchema: () => unsuppressSchema,
|
|
4516
5517
|
updateAccountSchema: () => updateAccountSchema,
|
|
4517
5518
|
updateActivitySchema: () => updateActivitySchema,
|
|
4518
5519
|
updateCampaignSchema: () => updateCampaignSchema,
|
|
@@ -4520,7 +5521,8 @@ var validators_exports = /* @__PURE__ */ __exportAll({
|
|
|
4520
5521
|
updateLeadSchema: () => updateLeadSchema,
|
|
4521
5522
|
updateLossReasonSchema: () => updateLossReasonSchema,
|
|
4522
5523
|
updateNoteSchema: () => updateNoteSchema,
|
|
4523
|
-
updatePipelineSchema: () => updatePipelineSchema
|
|
5524
|
+
updatePipelineSchema: () => updatePipelineSchema,
|
|
5525
|
+
updateSequenceSchema: () => updateSequenceSchema
|
|
4524
5526
|
});
|
|
4525
5527
|
//#endregion
|
|
4526
|
-
export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PipelineArchived, PipelineCreated, PipelineRepository, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSubscriptionSchema, canTransitionActivity, canTransitionCampaign, canTransitionLead, canTransitionOpportunity, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, forecast, lastTransitionTo, latestEntry, pipelineConversion, tenantFieldDef, timeInStatus, validators_exports as validators };
|
|
5528
|
+
export { ACTIVITY_MACHINE, ACTIVITY_STATUSES, ACTIVITY_TRANSITIONS, ACTIVITY_TYPES, AccountAssigned, AccountCreated, AccountEnriched, AccountMerged, AccountRepository, AccountUpdated, ActivityCancelled, ActivityCompleted, ActivityOutcomeRecorded, ActivityPlanned, ActivityRepository, ActivityRescheduled, AlreadyEnrolledError, CAMPAIGN_MACHINE, CAMPAIGN_STATUSES, CAMPAIGN_TRANSITIONS, CONVERSATION_CHANNELS, CONVERSATION_PARTICIPANT_KINDS, CONVERSATION_STATUSES, CRM_EVENTS, CRM_MODEL_NAMES, CampaignActivated, CampaignArchived, CampaignCompleted, CampaignCreated, CampaignPaused, CampaignRepository, CampaignResumed, ContactAssigned, ContactCreated, ContactEnriched, ContactMerged, ContactRepository, ContactUpdated, ConversationAssigned, ConversationCreated, ConversationReopened, ConversationRepository, ConversationResolved, CrmError, CrmEventDispatcher, DuplicateCampaignError, DuplicateLeadError, DuplicateSubscriptionError, EMAIL_EVENT_KINDS, EMAIL_EVENT_SUBJECT_KINDS, ENROLLMENT_MACHINE, ENROLLMENT_STATUSES, ENROLLMENT_SUBJECT_KINDS, ENROLLMENT_TRANSITIONS, EVENT_DERIVED_SUPPRESSION_REASONS, EmailEventBounced, EmailEventClicked, EmailEventComplained, EmailEventOpened, EmailEventRepository, EmailEventUnsubscribed, EmptyPipelineError, EnrollmentCompleted, EnrollmentCreated, EnrollmentExited, EnrollmentPaused, EnrollmentRepository, EnrollmentResumed, EnrollmentStepSent, InProcessCrmBus, InvalidActivityTransitionError, InvalidCampaignTransitionError, InvalidConfirmationTokenError, InvalidEnrollmentTransitionError, InvalidLeadTransitionError, InvalidOpportunityTransitionError, InvalidSequenceTransitionError, InvalidSubscriptionTransitionError, InvalidUnsubscribeTokenError, LEAD_MACHINE, LEAD_STATUSES, LEAD_TRANSITIONS, LeadAlreadyConvertedError, LeadAssigned, LeadConverted, LeadCreated, LeadDisqualified, LeadEnriched, LeadRepository, LeadScored, LeadService, LeadStatusChanged, LossReasonRepository, MESSAGE_DELIVERY_STATUSES, MESSAGE_DIRECTIONS, MessageReceived, MessageRepository, MessageSent, NotFoundError, NoteAdded, NoteRepository, OPPORTUNITY_MACHINE, OPPORTUNITY_STATUSES, OPPORTUNITY_TRANSITIONS, OpportunityAbandoned, OpportunityAmountChanged, OpportunityAssigned, OpportunityCreated, OpportunityExtended, OpportunityLost, OpportunityRepository, OpportunityStageAdvanced, OpportunityStageRegressed, OpportunityWon, PENDING_EVENTS, PipelineArchived, PipelineCreated, PipelineRepository, SEQUENCE_MACHINE, SEQUENCE_STATUSES, SEQUENCE_TRANSITIONS, SUBJECT_KINDS, SUBSCRIPTION_MACHINE, SUBSCRIPTION_STATUSES, SUBSCRIPTION_TRANSITIONS, SUPPRESSION_REASONS, SequenceActivated, SequenceArchived, SequenceCreated, SequencePaused, SequenceRepository, StageNotInPipelineError, SubscriptionBounced, SubscriptionComplained, SubscriptionConfirmed, SubscriptionCreated, SubscriptionRepository, SubscriptionUnsubscribed, SuppressionAdded, SuppressionRemoved, SuppressionRepository, UnmanagedSessionError, appendStatus, buildAccountSchema, buildActivitySchema, buildCampaignSchema, buildContactSchema, buildConversationSchema, buildEmailEventSchema, buildEnrollmentSchema, buildLeadSchema, buildLossReasonSchema, buildMessageSchema, buildNoteSchema, buildOpportunitySchema, buildPipelineSchema, buildSequenceSchema, buildSubscriptionSchema, buildSuppressionSchema, canTransitionActivity, canTransitionCampaign, canTransitionEnrollment, canTransitionLead, canTransitionOpportunity, canTransitionSequence, canTransitionSubscription, composeScorers, createAccountModel, createActivityModel, createCampaignModel, createContactModel, createConversationModel, createCrm, createCrmModels, createCrmRepositories, createEmailEventModel, createEvent, createLeadModel, createLossReasonModel, createMessageModel, createNoteModel, createOpportunityModel, createPipelineModel, createSubscriptionModel, crmEventDefinitions, defaultLeadScorer, flushPending, forecast, lastTransitionTo, latestEntry, pipelineConversion, runUnit, tenantFieldDef, timeInStatus, validators_exports as validators };
|