@bash-app/bash-common 30.310.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/definitions.d.ts +11 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +18 -1
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +214 -41
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +71 -22
- package/dist/extendedSchemas.js.map +1 -1
- 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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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/mirroredPrismaEnums.d.ts +6 -0
- package/dist/mirroredPrismaEnums.d.ts.map +1 -1
- package/dist/mirroredPrismaEnums.js +12 -0
- package/dist/mirroredPrismaEnums.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/serviceTypeLabels.d.ts +15 -0
- package/dist/serviceTypeLabels.d.ts.map +1 -0
- package/dist/serviceTypeLabels.js +24 -0
- package/dist/serviceTypeLabels.js.map +1 -0
- package/dist/targetAudienceLabels.d.ts +12 -0
- package/dist/targetAudienceLabels.d.ts.map +1 -0
- package/dist/targetAudienceLabels.js +50 -0
- package/dist/targetAudienceLabels.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/service/serviceBookingTypes.d.ts +1 -1
- package/dist/utils/service/serviceBookingTypes.d.ts.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 +131 -2
- package/src/__tests__/hostCrmSegments.test.ts +234 -0
- package/src/__tests__/relationshipHealth.test.ts +370 -0
- package/src/definitions.ts +27 -1
- package/src/extendedSchemas.ts +199 -58
- package/src/hostCrmAutomation.ts +79 -0
- package/src/hostCrmInsights.ts +39 -0
- package/src/hostCrmSegments.ts +196 -7
- package/src/index.ts +5 -0
- package/src/membershipDefinitions.ts +3 -0
- package/src/mirroredPrismaEnums.ts +16 -0
- package/src/relationshipHealth.ts +134 -0
- package/src/serviceTypeLabels.ts +24 -0
- package/src/targetAudienceLabels.ts +54 -0
- package/src/utils/__tests__/slugUtils.test.ts +15 -0
- package/src/utils/service/serviceBookingTypes.ts +1 -1
- 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
|
+
});
|
package/src/definitions.ts
CHANGED
|
@@ -57,6 +57,22 @@ export const COMPLEMENTARY_SERVICE_TYPE: Partial<
|
|
|
57
57
|
[ServiceTypes.EventServices]: ServiceTypes.Vendors,
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Human-readable label for a ServiceTypes value — the raw enum ("EventServices")
|
|
62
|
+
* is not fit for display copy. Only covers the values COMPLEMENTARY_SERVICE_TYPE
|
|
63
|
+
* can produce; extend as more callers need display labels for other types.
|
|
64
|
+
*/
|
|
65
|
+
export const SERVICE_TYPE_DISPLAY_LABEL: Partial<Record<ServiceTypes, string>> =
|
|
66
|
+
{
|
|
67
|
+
[ServiceTypes.Vendors]: "Vendor",
|
|
68
|
+
[ServiceTypes.EventServices]: "Event Service",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Picks "a"/"an" for the given word, e.g. for "a Vendor" vs "an Event Service". */
|
|
72
|
+
export function articleFor(word: string): "a" | "an" {
|
|
73
|
+
return /^[aeiou]/i.test(word) ? "an" : "a";
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
export const ServiceBookingOfferDirection = {
|
|
61
77
|
HostToProvider: "HostToProvider",
|
|
62
78
|
ProviderToHost: "ProviderToHost",
|
|
@@ -1174,7 +1190,17 @@ export const YearsOfExperienceToString: { [key in YearsOfExperienceEnum]: string
|
|
|
1174
1190
|
[YearsOfExperience.FivePlusYears]: "More than five years",
|
|
1175
1191
|
};
|
|
1176
1192
|
|
|
1177
|
-
export { BracketType } from "./mirroredPrismaEnums.js";
|
|
1193
|
+
export { BracketType, ServiceBookingSettlementStatusLabel } from "./mirroredPrismaEnums.js";
|
|
1194
|
+
|
|
1195
|
+
// Re-exported for backward compatibility during migration — see serviceTypeLabels.ts/targetAudienceLabels.ts
|
|
1196
|
+
// for the canonical, consolidated source (following the BracketType precedent above).
|
|
1197
|
+
export { SERVICE_TYPE_LABELS } from "./serviceTypeLabels.js";
|
|
1198
|
+
export {
|
|
1199
|
+
AgeRangeLabel,
|
|
1200
|
+
EducationLabel,
|
|
1201
|
+
GenderLabel,
|
|
1202
|
+
OccupationLabel,
|
|
1203
|
+
} from "./targetAudienceLabels.js";
|
|
1178
1204
|
|
|
1179
1205
|
// Export Prisma enums for use in frontend
|
|
1180
1206
|
export {
|