@bash-app/bash-common 30.314.0 → 30.318.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__/appleIapProducts.test.d.ts +2 -0
- package/dist/__tests__/appleIapProducts.test.d.ts.map +1 -0
- package/dist/__tests__/appleIapProducts.test.js +32 -0
- package/dist/__tests__/appleIapProducts.test.js.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.d.ts +2 -0
- package/dist/__tests__/hostCrmGrowthPack.test.d.ts.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js +25 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -0
- 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 +198 -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/appleIapProducts.d.ts +64 -0
- package/dist/appleIapProducts.d.ts.map +1 -0
- package/dist/appleIapProducts.js +113 -0
- package/dist/appleIapProducts.js.map +1 -0
- package/dist/definitions.d.ts +3 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +2 -0
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +27 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/hostCrmAutomation.d.ts +89 -0
- package/dist/hostCrmAutomation.d.ts.map +1 -0
- package/dist/hostCrmAutomation.js +50 -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 +82 -2
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js +93 -3
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -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/sms/smsTemplates.d.ts +13 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +3 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- 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 +154 -2
- package/src/__tests__/appleIapProducts.test.ts +52 -0
- package/src/__tests__/hostCrmGrowthPack.test.ts +33 -0
- package/src/__tests__/hostCrmSegments.test.ts +247 -0
- package/src/__tests__/relationshipHealth.test.ts +370 -0
- package/src/appleIapProducts.ts +203 -0
- package/src/definitions.ts +2 -0
- package/src/extendedSchemas.ts +30 -1
- package/src/hostCrmAutomation.ts +138 -0
- package/src/hostCrmInsights.ts +39 -0
- package/src/hostCrmSegments.ts +208 -7
- package/src/index.ts +4 -0
- package/src/membershipDefinitions.ts +3 -0
- package/src/relationshipHealth.ts +134 -0
- package/src/sms/smsTemplates.ts +6 -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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple App Store product catalog for iOS IAP.
|
|
3
|
+
* Product IDs must match App Store Connect exactly.
|
|
4
|
+
*
|
|
5
|
+
* Stripe remains the billing path on web/Android. On iOS native, these products
|
|
6
|
+
* unlock the same entitlements Stripe grants for digital goods (Guideline 3.1.1).
|
|
7
|
+
* Event tickets and real-world service bookings stay on Stripe (3.1.3(e)).
|
|
8
|
+
*/
|
|
9
|
+
import { BashPassTier, MembershipTier } from "@prisma/client";
|
|
10
|
+
|
|
11
|
+
import { FEATURE_BOOST_DURATIONS, MEMBERSHIP_PRICING } from "./membershipDefinitions.js";
|
|
12
|
+
import { SERVICE_SUBSCRIPTION_TIERS } from "./definitions.js";
|
|
13
|
+
|
|
14
|
+
export type AppleIapProductFamily =
|
|
15
|
+
| "membership"
|
|
16
|
+
| "listing"
|
|
17
|
+
| "bashpass"
|
|
18
|
+
| "feature_boost";
|
|
19
|
+
|
|
20
|
+
export type AppleMembershipInterval = "monthly" | "yearly" | "lifetime";
|
|
21
|
+
|
|
22
|
+
export type AppleListingTier = "Ally" | "Partner" | "Patron";
|
|
23
|
+
|
|
24
|
+
export interface AppleMembershipProduct {
|
|
25
|
+
productId: string;
|
|
26
|
+
family: "membership";
|
|
27
|
+
tier: Exclude<MembershipTier, "Basic" | "Guest">;
|
|
28
|
+
interval: AppleMembershipInterval;
|
|
29
|
+
/** Reference price in cents (ASC must match; Apple may regionalize). */
|
|
30
|
+
priceCents: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AppleListingProduct {
|
|
34
|
+
productId: string;
|
|
35
|
+
family: "listing";
|
|
36
|
+
tier: AppleListingTier;
|
|
37
|
+
interval: "monthly";
|
|
38
|
+
priceCents: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AppleBashPassProduct {
|
|
42
|
+
productId: string;
|
|
43
|
+
family: "bashpass";
|
|
44
|
+
tier: BashPassTier;
|
|
45
|
+
interval: "monthly";
|
|
46
|
+
priceCents: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AppleFeatureBoostProduct {
|
|
50
|
+
productId: string;
|
|
51
|
+
family: "feature_boost";
|
|
52
|
+
durationId: (typeof FEATURE_BOOST_DURATIONS)[number]["id"];
|
|
53
|
+
hours: number;
|
|
54
|
+
priceCents: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type AppleIapProduct =
|
|
58
|
+
| AppleMembershipProduct
|
|
59
|
+
| AppleListingProduct
|
|
60
|
+
| AppleBashPassProduct
|
|
61
|
+
| AppleFeatureBoostProduct;
|
|
62
|
+
|
|
63
|
+
const MEMBERSHIP_TIERS = [
|
|
64
|
+
MembershipTier.Premium,
|
|
65
|
+
MembershipTier.Pro,
|
|
66
|
+
MembershipTier.Elite,
|
|
67
|
+
MembershipTier.Legend,
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
function membershipProductId(
|
|
71
|
+
tier: (typeof MEMBERSHIP_TIERS)[number],
|
|
72
|
+
interval: AppleMembershipInterval
|
|
73
|
+
): string {
|
|
74
|
+
return `community.bash.us.membership.${tier.toLowerCase()}.${interval}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Lifetime price heuristic: 10× yearly (matches existing Stripe lifetime helpers). */
|
|
78
|
+
function lifetimePriceCents(tier: (typeof MEMBERSHIP_TIERS)[number]): number {
|
|
79
|
+
return MEMBERSHIP_PRICING[tier].yearly * 10;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const APPLE_MEMBERSHIP_PRODUCTS: readonly AppleMembershipProduct[] =
|
|
83
|
+
MEMBERSHIP_TIERS.flatMap((tier) => {
|
|
84
|
+
const monthly: AppleMembershipProduct = {
|
|
85
|
+
productId: membershipProductId(tier, "monthly"),
|
|
86
|
+
family: "membership",
|
|
87
|
+
tier,
|
|
88
|
+
interval: "monthly",
|
|
89
|
+
priceCents: MEMBERSHIP_PRICING[tier].monthly,
|
|
90
|
+
};
|
|
91
|
+
const yearly: AppleMembershipProduct = {
|
|
92
|
+
productId: membershipProductId(tier, "yearly"),
|
|
93
|
+
family: "membership",
|
|
94
|
+
tier,
|
|
95
|
+
interval: "yearly",
|
|
96
|
+
priceCents: MEMBERSHIP_PRICING[tier].yearly,
|
|
97
|
+
};
|
|
98
|
+
const lifetime: AppleMembershipProduct = {
|
|
99
|
+
productId: membershipProductId(tier, "lifetime"),
|
|
100
|
+
family: "membership",
|
|
101
|
+
tier,
|
|
102
|
+
interval: "lifetime",
|
|
103
|
+
priceCents: lifetimePriceCents(tier),
|
|
104
|
+
};
|
|
105
|
+
return [monthly, yearly, lifetime];
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** Listing seat subscriptions — dollars in SERVICE_SUBSCRIPTION_TIERS → cents. */
|
|
109
|
+
export const APPLE_LISTING_PRODUCTS: readonly AppleListingProduct[] = (
|
|
110
|
+
["Ally", "Partner", "Patron"] as const
|
|
111
|
+
).map((tier) => ({
|
|
112
|
+
productId: `community.bash.us.listing.${tier.toLowerCase()}.monthly`,
|
|
113
|
+
family: "listing" as const,
|
|
114
|
+
tier,
|
|
115
|
+
interval: "monthly" as const,
|
|
116
|
+
priceCents: Math.round(SERVICE_SUBSCRIPTION_TIERS[tier].price * 100),
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Standalone BashPass monthly prices (cents). Mirror Stripe catalog when ASC is created.
|
|
121
|
+
* LITE $19.99 · STANDARD $49.99 · UNLIMITED $99.99
|
|
122
|
+
*/
|
|
123
|
+
export const APPLE_BASHPASS_PRICE_CENTS: Record<BashPassTier, number> = {
|
|
124
|
+
[BashPassTier.LITE]: 1999,
|
|
125
|
+
[BashPassTier.STANDARD]: 4999,
|
|
126
|
+
[BashPassTier.UNLIMITED]: 9999,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const APPLE_BASHPASS_PRODUCTS: readonly AppleBashPassProduct[] = (
|
|
130
|
+
Object.values(BashPassTier) as BashPassTier[]
|
|
131
|
+
).map((tier) => ({
|
|
132
|
+
productId: `community.bash.us.bashpass.${tier.toLowerCase()}.monthly`,
|
|
133
|
+
family: "bashpass" as const,
|
|
134
|
+
tier,
|
|
135
|
+
interval: "monthly" as const,
|
|
136
|
+
priceCents: APPLE_BASHPASS_PRICE_CENTS[tier],
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
export const APPLE_FEATURE_BOOST_PRODUCTS: readonly AppleFeatureBoostProduct[] =
|
|
140
|
+
FEATURE_BOOST_DURATIONS.map((d) => ({
|
|
141
|
+
productId: `community.bash.us.boost.${d.id}`,
|
|
142
|
+
family: "feature_boost" as const,
|
|
143
|
+
durationId: d.id,
|
|
144
|
+
hours: d.hours,
|
|
145
|
+
priceCents: d.priceCents,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
export const APPLE_IAP_PRODUCTS: readonly AppleIapProduct[] = [
|
|
149
|
+
...APPLE_MEMBERSHIP_PRODUCTS,
|
|
150
|
+
...APPLE_LISTING_PRODUCTS,
|
|
151
|
+
...APPLE_BASHPASS_PRODUCTS,
|
|
152
|
+
...APPLE_FEATURE_BOOST_PRODUCTS,
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const BY_PRODUCT_ID: ReadonlyMap<string, AppleIapProduct> = new Map(
|
|
156
|
+
APPLE_IAP_PRODUCTS.map((p) => [p.productId, p])
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
export function getAppleIapProduct(productId: string): AppleIapProduct | undefined {
|
|
160
|
+
return BY_PRODUCT_ID.get(productId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isAppleIapProductId(productId: string): boolean {
|
|
164
|
+
return BY_PRODUCT_ID.has(productId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listAppleIapProductIds(family?: AppleIapProductFamily): string[] {
|
|
168
|
+
if (!family) {
|
|
169
|
+
return APPLE_IAP_PRODUCTS.map((p) => p.productId);
|
|
170
|
+
}
|
|
171
|
+
return APPLE_IAP_PRODUCTS.filter((p) => p.family === family).map((p) => p.productId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function findAppleMembershipProduct(
|
|
175
|
+
tier: Exclude<MembershipTier, "Basic" | "Guest">,
|
|
176
|
+
interval: AppleMembershipInterval
|
|
177
|
+
): AppleMembershipProduct | undefined {
|
|
178
|
+
return APPLE_MEMBERSHIP_PRODUCTS.find(
|
|
179
|
+
(p) => p.tier === tier && p.interval === interval
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function findAppleListingProduct(
|
|
184
|
+
tier: AppleListingTier
|
|
185
|
+
): AppleListingProduct | undefined {
|
|
186
|
+
return APPLE_LISTING_PRODUCTS.find((p) => p.tier === tier);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function findAppleBashPassProduct(
|
|
190
|
+
tier: BashPassTier
|
|
191
|
+
): AppleBashPassProduct | undefined {
|
|
192
|
+
return APPLE_BASHPASS_PRODUCTS.find((p) => p.tier === tier);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function findAppleFeatureBoostProduct(
|
|
196
|
+
durationId: (typeof FEATURE_BOOST_DURATIONS)[number]["id"]
|
|
197
|
+
): AppleFeatureBoostProduct | undefined {
|
|
198
|
+
return APPLE_FEATURE_BOOST_PRODUCTS.find((p) => p.durationId === durationId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Apple subscription management deep link (Settings → Subscriptions). */
|
|
202
|
+
export const APPLE_SUBSCRIPTIONS_MANAGEMENT_URL =
|
|
203
|
+
"https://apps.apple.com/account/subscriptions";
|
package/src/definitions.ts
CHANGED
|
@@ -909,6 +909,8 @@ export enum ApiErrorType {
|
|
|
909
909
|
payment_intent_creation_failed,
|
|
910
910
|
/** Host requires waiver / custom terms; buyer did not attest acceptance */
|
|
911
911
|
EventWaiverNotAccepted,
|
|
912
|
+
/** Bash is not Published/PreSale — ticket purchase and checkout creation are blocked */
|
|
913
|
+
EventNotAcceptingPurchases,
|
|
912
914
|
}
|
|
913
915
|
|
|
914
916
|
export type ErrorDataType = Record<RecordKey, unknown>;
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -417,7 +417,18 @@ export interface BashEventExt extends Override<
|
|
|
417
417
|
| "isAutoApprovable"
|
|
418
418
|
| "merchandiseItems"
|
|
419
419
|
>,
|
|
420
|
-
{
|
|
420
|
+
{
|
|
421
|
+
icebreakerPrompt: string | null;
|
|
422
|
+
/** Free-text cancellation / refund policy shown at checkout and on tickets. */
|
|
423
|
+
cancellationPolicy?: string | null;
|
|
424
|
+
/** When true (default), cancelling auto-refunds paid ticket holders. */
|
|
425
|
+
refundsGuaranteedOnCancellation?: boolean;
|
|
426
|
+
/** Host may postpone after tickets exist if enabled before first sale. */
|
|
427
|
+
postponementAllowed?: boolean;
|
|
428
|
+
isPostponed?: boolean;
|
|
429
|
+
postponedAt?: Date | string | null;
|
|
430
|
+
postponeResponseDeadline?: Date | string | null;
|
|
431
|
+
}
|
|
421
432
|
> {
|
|
422
433
|
/** Host-curated merch catalog (wizard); stored as JSON on BashEvent. Null = no merch configured. */
|
|
423
434
|
merchandiseItems: BashEventMerchCatalog | null;
|
|
@@ -467,6 +478,20 @@ export interface BashEventExt extends Override<
|
|
|
467
478
|
myPublicRsvpStatus?: string | null;
|
|
468
479
|
/** Host approval queue status for the viewer's PublicBashRsvp (Pending | Approved | Denied) */
|
|
469
480
|
myPublicRsvpApprovalStatus?: string | null;
|
|
481
|
+
/** When the viewer last kept/declined after a postponement (PublicBashRsvp) */
|
|
482
|
+
myPublicRsvpPostponeRespondedAt?: Date | string | null;
|
|
483
|
+
/**
|
|
484
|
+
* Creator-view only when `isPostponed`: keep / decline / pending counts for
|
|
485
|
+
* active tickets and approved Going RSVPs.
|
|
486
|
+
*/
|
|
487
|
+
postponeResponseSummary?: {
|
|
488
|
+
ticketsKept: number;
|
|
489
|
+
ticketsDeclined: number;
|
|
490
|
+
ticketsPending: number;
|
|
491
|
+
rsvpsKept: number;
|
|
492
|
+
rsvpsDeclined: number;
|
|
493
|
+
rsvpsPending: number;
|
|
494
|
+
};
|
|
470
495
|
/** GET /event/:id and GET /public-event/:id (authenticated) when accessScreeningEnabled — viewer's latest access request status */
|
|
471
496
|
myAccessRequestStatus?: "None" | "Pending" | "Approved" | "Denied" | null;
|
|
472
497
|
/** GET /event/:id when status=Idea — viewer's IdeaInterest row (matched by email) */
|
|
@@ -482,6 +507,8 @@ export interface BashEventExt extends Override<
|
|
|
482
507
|
// Event page visual customisation (stored as scalar columns on BashEvent)
|
|
483
508
|
backgroundImage: string | null;
|
|
484
509
|
themeColor: string | null;
|
|
510
|
+
/** Soft blur on theme photo backgrounds; hosts may turn off for a sharp image. */
|
|
511
|
+
backgroundBlurEnabled: boolean;
|
|
485
512
|
/**
|
|
486
513
|
* Computed at request time from the host Organization's nonprofit verification.
|
|
487
514
|
* Drives the buyer-facing fee breakdown ("Nonprofit — no Bash platform fee").
|
|
@@ -1585,6 +1612,8 @@ export type TicketFlexible = Ticket & {
|
|
|
1585
1612
|
TicketTransferInvite,
|
|
1586
1613
|
"status" | "expiresAt" | "token" | "toEmail" | "toUserId"
|
|
1587
1614
|
> | null;
|
|
1615
|
+
/** Present after schema publish; set when guest keeps/declines a postponement. */
|
|
1616
|
+
postponeRespondedAt?: Date | string | null;
|
|
1588
1617
|
};
|
|
1589
1618
|
|
|
1590
1619
|
export interface CheckoutExt extends Checkout {
|