@bash-app/bash-common 30.314.0 → 30.316.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/dist/__tests__/hostCrmSegments.test.d.ts +2 -0
- package/dist/__tests__/hostCrmSegments.test.d.ts.map +1 -0
- package/dist/__tests__/hostCrmSegments.test.js +188 -0
- package/dist/__tests__/hostCrmSegments.test.js.map +1 -0
- package/dist/__tests__/relationshipHealth.test.d.ts +2 -0
- package/dist/__tests__/relationshipHealth.test.d.ts.map +1 -0
- package/dist/__tests__/relationshipHealth.test.js +265 -0
- package/dist/__tests__/relationshipHealth.test.js.map +1 -0
- package/dist/hostCrmAutomation.d.ts +64 -0
- package/dist/hostCrmAutomation.d.ts.map +1 -0
- package/dist/hostCrmAutomation.js +21 -0
- package/dist/hostCrmAutomation.js.map +1 -0
- package/dist/hostCrmInsights.d.ts +39 -0
- package/dist/hostCrmInsights.d.ts.map +1 -0
- package/dist/hostCrmInsights.js +2 -0
- package/dist/hostCrmInsights.js.map +1 -0
- package/dist/hostCrmSegments.d.ts +80 -2
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js +84 -3
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/membershipDefinitions.d.ts +2 -0
- package/dist/membershipDefinitions.d.ts.map +1 -1
- package/dist/membershipDefinitions.js +3 -0
- package/dist/membershipDefinitions.js.map +1 -1
- package/dist/relationshipHealth.d.ts +28 -0
- package/dist/relationshipHealth.d.ts.map +1 -0
- package/dist/relationshipHealth.js +91 -0
- package/dist/relationshipHealth.js.map +1 -0
- package/dist/utils/__tests__/slugUtils.test.js +9 -1
- package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
- package/dist/utils/slugUtils.d.ts +7 -0
- package/dist/utils/slugUtils.d.ts.map +1 -1
- package/dist/utils/slugUtils.js +9 -0
- package/dist/utils/slugUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +126 -1
- package/src/__tests__/hostCrmSegments.test.ts +234 -0
- package/src/__tests__/relationshipHealth.test.ts +370 -0
- package/src/hostCrmAutomation.ts +79 -0
- package/src/hostCrmInsights.ts +39 -0
- package/src/hostCrmSegments.ts +196 -7
- package/src/index.ts +3 -0
- package/src/membershipDefinitions.ts +3 -0
- package/src/relationshipHealth.ts +134 -0
- package/src/utils/__tests__/slugUtils.test.ts +15 -0
- package/src/utils/slugUtils.ts +13 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { computeRelationshipHealth, RELATIONSHIP_HEALTH_LABELS } from "../relationshipHealth.js";
|
|
2
|
+
|
|
3
|
+
const NOW = new Date("2026-07-13T00:00:00Z");
|
|
4
|
+
|
|
5
|
+
describe("computeRelationshipHealth", () => {
|
|
6
|
+
it("returns New with no attendance history", () => {
|
|
7
|
+
const result = computeRelationshipHealth(
|
|
8
|
+
{ eventCount: 0, lastAttendedAt: null, firstAttendedAt: null, tags: [], lastOutreachAt: null },
|
|
9
|
+
NOW
|
|
10
|
+
);
|
|
11
|
+
expect(result.status).toBe("New");
|
|
12
|
+
expect(result.reasons.join(" ")).toMatch(/no attended bashes/i);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns New for a single recent attendance", () => {
|
|
16
|
+
const result = computeRelationshipHealth(
|
|
17
|
+
{
|
|
18
|
+
eventCount: 1,
|
|
19
|
+
lastAttendedAt: new Date("2026-07-01T00:00:00Z").toISOString(),
|
|
20
|
+
firstAttendedAt: new Date("2026-07-01T00:00:00Z").toISOString(),
|
|
21
|
+
tags: [],
|
|
22
|
+
lastOutreachAt: null,
|
|
23
|
+
},
|
|
24
|
+
NOW
|
|
25
|
+
);
|
|
26
|
+
expect(result.status).toBe("New");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns Cooling for a single attendance that lapsed a few months", () => {
|
|
30
|
+
const result = computeRelationshipHealth(
|
|
31
|
+
{
|
|
32
|
+
eventCount: 1,
|
|
33
|
+
lastAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
34
|
+
firstAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
35
|
+
tags: [],
|
|
36
|
+
lastOutreachAt: null,
|
|
37
|
+
},
|
|
38
|
+
NOW
|
|
39
|
+
);
|
|
40
|
+
expect(result.status).toBe("Cooling");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns AtRisk for a single attendance that lapsed a long time ago", () => {
|
|
44
|
+
const result = computeRelationshipHealth(
|
|
45
|
+
{
|
|
46
|
+
eventCount: 1,
|
|
47
|
+
lastAttendedAt: new Date("2025-01-01T00:00:00Z").toISOString(),
|
|
48
|
+
firstAttendedAt: new Date("2025-01-01T00:00:00Z").toISOString(),
|
|
49
|
+
tags: [],
|
|
50
|
+
lastOutreachAt: null,
|
|
51
|
+
},
|
|
52
|
+
NOW
|
|
53
|
+
);
|
|
54
|
+
expect(result.status).toBe("AtRisk");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns Steady for a regular attendee within their own cadence", () => {
|
|
58
|
+
const result = computeRelationshipHealth(
|
|
59
|
+
{
|
|
60
|
+
eventCount: 5,
|
|
61
|
+
firstAttendedAt: new Date("2026-01-13T00:00:00Z").toISOString(),
|
|
62
|
+
lastAttendedAt: new Date("2026-06-23T00:00:00Z").toISOString(),
|
|
63
|
+
tags: [],
|
|
64
|
+
lastOutreachAt: null,
|
|
65
|
+
},
|
|
66
|
+
NOW
|
|
67
|
+
);
|
|
68
|
+
expect(result.status).toBe("Steady");
|
|
69
|
+
expect(result.expectedGapDays).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns Cooling then AtRisk as the gap relative to cadence grows", () => {
|
|
73
|
+
const base = {
|
|
74
|
+
eventCount: 4,
|
|
75
|
+
firstAttendedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
|
|
76
|
+
tags: [] as string[],
|
|
77
|
+
lastOutreachAt: null,
|
|
78
|
+
};
|
|
79
|
+
// span Jan1→May1 = 120 days over 3 gaps → expectedGapDays = 40;
|
|
80
|
+
// daysSince (NOW=Jul13) = 73 → ratio ~1.83 → Cooling
|
|
81
|
+
const cooling = computeRelationshipHealth(
|
|
82
|
+
{ ...base, lastAttendedAt: new Date("2026-05-01T00:00:00Z").toISOString() },
|
|
83
|
+
NOW
|
|
84
|
+
);
|
|
85
|
+
expect(cooling.status).toBe("Cooling");
|
|
86
|
+
|
|
87
|
+
// span Jan1→Feb1 = 31 days over 3 gaps → expectedGapDays clamps to the 14-day
|
|
88
|
+
// floor; daysSince = 162 → ratio ~11.6 → AtRisk
|
|
89
|
+
const atRisk = computeRelationshipHealth(
|
|
90
|
+
{ ...base, lastAttendedAt: new Date("2026-02-01T00:00:00Z").toISOString() },
|
|
91
|
+
NOW
|
|
92
|
+
);
|
|
93
|
+
expect(atRisk.status).toBe("AtRisk");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("escalates a Cooling VIP to AtRisk and records why", () => {
|
|
97
|
+
const withoutVip = computeRelationshipHealth(
|
|
98
|
+
{
|
|
99
|
+
eventCount: 1,
|
|
100
|
+
lastAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
101
|
+
firstAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
102
|
+
tags: [],
|
|
103
|
+
lastOutreachAt: null,
|
|
104
|
+
},
|
|
105
|
+
NOW
|
|
106
|
+
);
|
|
107
|
+
expect(withoutVip.status).toBe("Cooling");
|
|
108
|
+
|
|
109
|
+
const withVip = computeRelationshipHealth(
|
|
110
|
+
{
|
|
111
|
+
eventCount: 1,
|
|
112
|
+
lastAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
113
|
+
firstAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
114
|
+
tags: ["VIP"],
|
|
115
|
+
lastOutreachAt: null,
|
|
116
|
+
},
|
|
117
|
+
NOW
|
|
118
|
+
);
|
|
119
|
+
expect(withVip.status).toBe("AtRisk");
|
|
120
|
+
expect(withVip.reasons.some((r) => /vip/i.test(r))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("includes outreach recency in the inspectable reasons without changing the bucket", () => {
|
|
124
|
+
const result = computeRelationshipHealth(
|
|
125
|
+
{
|
|
126
|
+
eventCount: 5,
|
|
127
|
+
firstAttendedAt: new Date("2026-01-13T00:00:00Z").toISOString(),
|
|
128
|
+
lastAttendedAt: new Date("2026-06-23T00:00:00Z").toISOString(),
|
|
129
|
+
tags: [],
|
|
130
|
+
lastOutreachAt: new Date("2026-07-10T00:00:00Z").toISOString(),
|
|
131
|
+
},
|
|
132
|
+
NOW
|
|
133
|
+
);
|
|
134
|
+
expect(result.status).toBe("Steady");
|
|
135
|
+
expect(result.reasons.some((r) => /contacted/i.test(r))).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("treats a single attendance with no lastAttendedAt as New", () => {
|
|
139
|
+
const result = computeRelationshipHealth(
|
|
140
|
+
{
|
|
141
|
+
eventCount: 1,
|
|
142
|
+
lastAttendedAt: null,
|
|
143
|
+
firstAttendedAt: null,
|
|
144
|
+
tags: [],
|
|
145
|
+
lastOutreachAt: null,
|
|
146
|
+
},
|
|
147
|
+
NOW
|
|
148
|
+
);
|
|
149
|
+
expect(result.status).toBe("New");
|
|
150
|
+
expect(result.daysSinceLastAttended).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses the default cadence when firstAttendedAt is missing for multi-event guests", () => {
|
|
154
|
+
const result = computeRelationshipHealth(
|
|
155
|
+
{
|
|
156
|
+
eventCount: 3,
|
|
157
|
+
firstAttendedAt: null,
|
|
158
|
+
lastAttendedAt: new Date("2026-07-01T00:00:00Z").toISOString(),
|
|
159
|
+
tags: [],
|
|
160
|
+
lastOutreachAt: null,
|
|
161
|
+
},
|
|
162
|
+
NOW
|
|
163
|
+
);
|
|
164
|
+
expect(result.status).toBe("Steady");
|
|
165
|
+
expect(result.expectedGapDays).toBe(60);
|
|
166
|
+
expect(result.reasons.some((r) => /default 60-day cadence/i.test(r))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("uses the default cadence when first and last attendance are the same day", () => {
|
|
170
|
+
const same = new Date("2026-06-01T00:00:00Z").toISOString();
|
|
171
|
+
const result = computeRelationshipHealth(
|
|
172
|
+
{
|
|
173
|
+
eventCount: 3,
|
|
174
|
+
firstAttendedAt: same,
|
|
175
|
+
lastAttendedAt: same,
|
|
176
|
+
tags: [],
|
|
177
|
+
lastOutreachAt: null,
|
|
178
|
+
},
|
|
179
|
+
NOW
|
|
180
|
+
);
|
|
181
|
+
expect(result.expectedGapDays).toBe(60);
|
|
182
|
+
expect(result.reasons.some((r) => /default 60-day cadence/i.test(r))).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("clamps personalized cadence to a 14-day minimum", () => {
|
|
186
|
+
// 3 bashes over 10 days → raw gap 5, clamped to 14
|
|
187
|
+
const result = computeRelationshipHealth(
|
|
188
|
+
{
|
|
189
|
+
eventCount: 3,
|
|
190
|
+
firstAttendedAt: new Date("2026-07-01T00:00:00Z").toISOString(),
|
|
191
|
+
lastAttendedAt: new Date("2026-07-11T00:00:00Z").toISOString(),
|
|
192
|
+
tags: [],
|
|
193
|
+
lastOutreachAt: null,
|
|
194
|
+
},
|
|
195
|
+
NOW
|
|
196
|
+
);
|
|
197
|
+
expect(result.expectedGapDays).toBe(14);
|
|
198
|
+
expect(result.status).toBe("Steady");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not escalate a Steady VIP", () => {
|
|
202
|
+
const result = computeRelationshipHealth(
|
|
203
|
+
{
|
|
204
|
+
eventCount: 5,
|
|
205
|
+
firstAttendedAt: new Date("2026-01-13T00:00:00Z").toISOString(),
|
|
206
|
+
lastAttendedAt: new Date("2026-06-23T00:00:00Z").toISOString(),
|
|
207
|
+
tags: [" VIP "],
|
|
208
|
+
lastOutreachAt: null,
|
|
209
|
+
},
|
|
210
|
+
NOW
|
|
211
|
+
);
|
|
212
|
+
expect(result.status).toBe("Steady");
|
|
213
|
+
expect(result.reasons.some((r) => /vip/i.test(r))).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("notes missing outreach for Cooling and AtRisk without lastOutreachAt", () => {
|
|
217
|
+
const cooling = computeRelationshipHealth(
|
|
218
|
+
{
|
|
219
|
+
eventCount: 1,
|
|
220
|
+
lastAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
221
|
+
firstAttendedAt: new Date("2026-04-01T00:00:00Z").toISOString(),
|
|
222
|
+
tags: [],
|
|
223
|
+
lastOutreachAt: null,
|
|
224
|
+
},
|
|
225
|
+
NOW
|
|
226
|
+
);
|
|
227
|
+
expect(cooling.status).toBe("Cooling");
|
|
228
|
+
expect(cooling.reasons.some((r) => /no outreach/i.test(r))).toBe(true);
|
|
229
|
+
|
|
230
|
+
const atRisk = computeRelationshipHealth(
|
|
231
|
+
{
|
|
232
|
+
eventCount: 1,
|
|
233
|
+
lastAttendedAt: new Date("2025-01-01T00:00:00Z").toISOString(),
|
|
234
|
+
firstAttendedAt: new Date("2025-01-01T00:00:00Z").toISOString(),
|
|
235
|
+
tags: [],
|
|
236
|
+
lastOutreachAt: null,
|
|
237
|
+
},
|
|
238
|
+
NOW
|
|
239
|
+
);
|
|
240
|
+
expect(atRisk.status).toBe("AtRisk");
|
|
241
|
+
expect(atRisk.reasons.some((r) => /no outreach/i.test(r))).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("uses singular day wording for a one-day gap", () => {
|
|
245
|
+
const result = computeRelationshipHealth(
|
|
246
|
+
{
|
|
247
|
+
eventCount: 1,
|
|
248
|
+
lastAttendedAt: new Date("2026-07-12T00:00:00Z").toISOString(),
|
|
249
|
+
firstAttendedAt: new Date("2026-07-12T00:00:00Z").toISOString(),
|
|
250
|
+
tags: [],
|
|
251
|
+
lastOutreachAt: new Date("2026-07-12T00:00:00Z").toISOString(),
|
|
252
|
+
},
|
|
253
|
+
NOW
|
|
254
|
+
);
|
|
255
|
+
expect(result.reasons.some((r) => r.includes("1 day ago"))).toBe(true);
|
|
256
|
+
expect(result.reasons.some((r) => r.includes("1 days ago"))).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("exposes human labels for every status", () => {
|
|
260
|
+
expect(RELATIONSHIP_HEALTH_LABELS.New).toBe("New");
|
|
261
|
+
expect(RELATIONSHIP_HEALTH_LABELS.Steady).toBe("Steady");
|
|
262
|
+
expect(RELATIONSHIP_HEALTH_LABELS.Cooling).toBe("Cooling");
|
|
263
|
+
expect(RELATIONSHIP_HEALTH_LABELS.AtRisk).toBe("At risk");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("treats multi-event guests with no lastAttendedAt as Steady (ratio 0)", () => {
|
|
267
|
+
const result = computeRelationshipHealth(
|
|
268
|
+
{
|
|
269
|
+
eventCount: 2,
|
|
270
|
+
firstAttendedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
|
|
271
|
+
lastAttendedAt: null,
|
|
272
|
+
tags: [],
|
|
273
|
+
lastOutreachAt: null,
|
|
274
|
+
},
|
|
275
|
+
NOW
|
|
276
|
+
);
|
|
277
|
+
expect(result.status).toBe("Steady");
|
|
278
|
+
expect(result.daysSinceLastAttended).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("uses exact day boundaries for the single-attendance windows", () => {
|
|
282
|
+
const stillNew = computeRelationshipHealth(
|
|
283
|
+
{
|
|
284
|
+
eventCount: 1,
|
|
285
|
+
lastAttendedAt: new Date("2026-05-29T00:00:00Z").toISOString(), // 45 days before NOW
|
|
286
|
+
firstAttendedAt: new Date("2026-05-29T00:00:00Z").toISOString(),
|
|
287
|
+
tags: [],
|
|
288
|
+
lastOutreachAt: null,
|
|
289
|
+
},
|
|
290
|
+
NOW
|
|
291
|
+
);
|
|
292
|
+
expect(stillNew.status).toBe("New");
|
|
293
|
+
|
|
294
|
+
const coolingEdge = computeRelationshipHealth(
|
|
295
|
+
{
|
|
296
|
+
eventCount: 1,
|
|
297
|
+
lastAttendedAt: new Date("2026-05-28T00:00:00Z").toISOString(), // 46 days
|
|
298
|
+
firstAttendedAt: new Date("2026-05-28T00:00:00Z").toISOString(),
|
|
299
|
+
tags: [],
|
|
300
|
+
lastOutreachAt: null,
|
|
301
|
+
},
|
|
302
|
+
NOW
|
|
303
|
+
);
|
|
304
|
+
expect(coolingEdge.status).toBe("Cooling");
|
|
305
|
+
|
|
306
|
+
const stillCooling = computeRelationshipHealth(
|
|
307
|
+
{
|
|
308
|
+
eventCount: 1,
|
|
309
|
+
lastAttendedAt: new Date("2026-03-15T00:00:00Z").toISOString(), // 120 days
|
|
310
|
+
firstAttendedAt: new Date("2026-03-15T00:00:00Z").toISOString(),
|
|
311
|
+
tags: [],
|
|
312
|
+
lastOutreachAt: null,
|
|
313
|
+
},
|
|
314
|
+
NOW
|
|
315
|
+
);
|
|
316
|
+
expect(stillCooling.status).toBe("Cooling");
|
|
317
|
+
|
|
318
|
+
const atRiskEdge = computeRelationshipHealth(
|
|
319
|
+
{
|
|
320
|
+
eventCount: 1,
|
|
321
|
+
lastAttendedAt: new Date("2026-03-14T00:00:00Z").toISOString(), // 121 days
|
|
322
|
+
firstAttendedAt: new Date("2026-03-14T00:00:00Z").toISOString(),
|
|
323
|
+
tags: [],
|
|
324
|
+
lastOutreachAt: null,
|
|
325
|
+
},
|
|
326
|
+
NOW
|
|
327
|
+
);
|
|
328
|
+
expect(atRiskEdge.status).toBe("AtRisk");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("keeps Steady at the 1.5x cadence ratio and Cooling just above it", () => {
|
|
332
|
+
// span Jan14→May14 = 120 days over 3 gaps → expectedGapDays = 40
|
|
333
|
+
const base = {
|
|
334
|
+
eventCount: 4,
|
|
335
|
+
firstAttendedAt: new Date("2026-01-14T00:00:00Z").toISOString(),
|
|
336
|
+
tags: [] as string[],
|
|
337
|
+
lastOutreachAt: null,
|
|
338
|
+
};
|
|
339
|
+
// daysSince (NOW=Jul13) = 60 → ratio exactly 1.5 → Steady
|
|
340
|
+
const steady = computeRelationshipHealth(
|
|
341
|
+
{ ...base, lastAttendedAt: new Date("2026-05-14T00:00:00Z").toISOString() },
|
|
342
|
+
NOW
|
|
343
|
+
);
|
|
344
|
+
expect(steady.expectedGapDays).toBe(40);
|
|
345
|
+
expect(steady.status).toBe("Steady");
|
|
346
|
+
|
|
347
|
+
// one day later lapse → daysSince = 61, span drops to 119 (still rounds to
|
|
348
|
+
// the same 40-day gap) → ratio just above 1.5 → Cooling
|
|
349
|
+
const cooling = computeRelationshipHealth(
|
|
350
|
+
{ ...base, lastAttendedAt: new Date("2026-05-13T00:00:00Z").toISOString() },
|
|
351
|
+
NOW
|
|
352
|
+
);
|
|
353
|
+
expect(cooling.status).toBe("Cooling");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("does not re-escalate an AtRisk VIP (already past Cooling)", () => {
|
|
357
|
+
const result = computeRelationshipHealth(
|
|
358
|
+
{
|
|
359
|
+
eventCount: 1,
|
|
360
|
+
lastAttendedAt: new Date("2025-01-01T00:00:00Z").toISOString(),
|
|
361
|
+
firstAttendedAt: new Date("2025-01-01T00:00:00Z").toISOString(),
|
|
362
|
+
tags: ["vip"],
|
|
363
|
+
lastOutreachAt: null,
|
|
364
|
+
},
|
|
365
|
+
NOW
|
|
366
|
+
);
|
|
367
|
+
expect(result.status).toBe("AtRisk");
|
|
368
|
+
expect(result.reasons.some((r) => /escalated from Cooling/i.test(r))).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HostCrmAutomationAudienceType as HostCrmAutomationAudienceTypeEnum,
|
|
3
|
+
HostCrmAutomationChannel as HostCrmAutomationChannelEnum,
|
|
4
|
+
HostCrmAutomationTrigger as HostCrmAutomationTriggerEnum,
|
|
5
|
+
} from "@prisma/client";
|
|
6
|
+
|
|
7
|
+
/** Mirrors Prisma `HostCrmAutomationTrigger` — `@prisma/client` browser bundle omits runtime enum exports. */
|
|
8
|
+
export const HostCrmAutomationTrigger = {
|
|
9
|
+
Recurring: "Recurring",
|
|
10
|
+
NewlyAtRisk: "NewlyAtRisk",
|
|
11
|
+
PostEvent: "PostEvent",
|
|
12
|
+
} as const satisfies Record<HostCrmAutomationTriggerEnum, HostCrmAutomationTriggerEnum>;
|
|
13
|
+
|
|
14
|
+
export type HostCrmAutomationTrigger = HostCrmAutomationTriggerEnum;
|
|
15
|
+
|
|
16
|
+
/** Mirrors Prisma `HostCrmAutomationChannel` — `@prisma/client` browser bundle omits runtime enum exports. */
|
|
17
|
+
export const HostCrmAutomationChannel = {
|
|
18
|
+
Email: "Email",
|
|
19
|
+
Sms: "Sms",
|
|
20
|
+
} as const satisfies Record<HostCrmAutomationChannelEnum, HostCrmAutomationChannelEnum>;
|
|
21
|
+
|
|
22
|
+
export type HostCrmAutomationChannel = HostCrmAutomationChannelEnum;
|
|
23
|
+
|
|
24
|
+
/** Mirrors Prisma `HostCrmAutomationAudienceType` — `@prisma/client` browser bundle omits runtime enum exports. */
|
|
25
|
+
export const HostCrmAutomationAudienceType = {
|
|
26
|
+
Preset: "Preset",
|
|
27
|
+
Segment: "Segment",
|
|
28
|
+
} as const satisfies Record<
|
|
29
|
+
HostCrmAutomationAudienceTypeEnum,
|
|
30
|
+
HostCrmAutomationAudienceTypeEnum
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
export type HostCrmAutomationAudienceType = HostCrmAutomationAudienceTypeEnum;
|
|
34
|
+
|
|
35
|
+
/** Minimum days between two runs of a Recurring automation. */
|
|
36
|
+
export const HOST_CRM_AUTOMATION_MIN_CADENCE_DAYS = 1;
|
|
37
|
+
/** Default cadence for a newly created Recurring automation, matching the legacy win-back cron interval. */
|
|
38
|
+
export const HOST_CRM_AUTOMATION_DEFAULT_CADENCE_DAYS = 30;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* API/client shape of a `HostCrmAutomation` row — dates are ISO strings (as
|
|
42
|
+
* returned by the JSON API), not the raw Prisma `Date` type. See
|
|
43
|
+
* `api/src/api/user/privateUser/hostCrmAutomationApi.ts` for the endpoints
|
|
44
|
+
* that produce this shape and `bash-app/src/dataLayer/user/attendeeAudienceFetcher.ts`
|
|
45
|
+
* for the client fetchers.
|
|
46
|
+
*/
|
|
47
|
+
export type HostCrmAutomation = {
|
|
48
|
+
id: string;
|
|
49
|
+
hostUserId: string;
|
|
50
|
+
name: string;
|
|
51
|
+
trigger: HostCrmAutomationTrigger;
|
|
52
|
+
channel: HostCrmAutomationChannel;
|
|
53
|
+
/** Ignored for PostEvent — audience there is implicit (that event's ticket holders). */
|
|
54
|
+
audienceType: HostCrmAutomationAudienceType | null;
|
|
55
|
+
audiencePresetId: string | null;
|
|
56
|
+
audienceSegmentId: string | null;
|
|
57
|
+
subject: string | null;
|
|
58
|
+
body: string;
|
|
59
|
+
/** Only used by the Recurring trigger. */
|
|
60
|
+
cadenceDays: number | null;
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
lastRunAt: string | null;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
updatedAt: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Payload for creating or fully updating a `HostCrmAutomation`. */
|
|
68
|
+
export type HostCrmAutomationInput = {
|
|
69
|
+
name: string;
|
|
70
|
+
trigger: HostCrmAutomationTrigger;
|
|
71
|
+
channel: HostCrmAutomationChannel;
|
|
72
|
+
audienceType?: HostCrmAutomationAudienceType | null;
|
|
73
|
+
audiencePresetId?: string | null;
|
|
74
|
+
audienceSegmentId?: string | null;
|
|
75
|
+
subject?: string | null;
|
|
76
|
+
body: string;
|
|
77
|
+
cadenceDays?: number | null;
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MembershipTier } from "./membershipDefinitions.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One curated predictive-insight observation for the Attendee CRM's
|
|
5
|
+
* Recommended-actions accordion. Deliberately a discriminated union of
|
|
6
|
+
* discrete, computed facts (not prose) — same design choice already made for
|
|
7
|
+
* `HostCrmTimelineEntry` — so the drawer/card renders each fact directly and
|
|
8
|
+
* a future AI layer can consume the same structured data without re-deriving it.
|
|
9
|
+
*/
|
|
10
|
+
export type HostCrmInsight =
|
|
11
|
+
| {
|
|
12
|
+
type: "MostLikelyToReturn";
|
|
13
|
+
nextEventTitle: string;
|
|
14
|
+
nextEventStartsAt: string;
|
|
15
|
+
attendeeCount: number;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
type: "AttendanceTrend";
|
|
19
|
+
direction: "up" | "down" | "flat";
|
|
20
|
+
percent: number;
|
|
21
|
+
recentCount: number;
|
|
22
|
+
priorCount: number;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
type: "DayOfWeekRetention";
|
|
26
|
+
bestDay: string;
|
|
27
|
+
worstDay: string;
|
|
28
|
+
bestRepeatRate: number;
|
|
29
|
+
worstRepeatRate: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Response shape for the Predictive Insights card. Gated server-side —
|
|
34
|
+
* `locked: true` hosts never receive computed `insights`, so a Basic-tier
|
|
35
|
+
* host can't see real data even by inspecting the network response.
|
|
36
|
+
*/
|
|
37
|
+
export type HostCrmInsightsResult =
|
|
38
|
+
| { locked: true; requiredTier: MembershipTier }
|
|
39
|
+
| { locked: false; insights: HostCrmInsight[] };
|