@bash-app/bash-common 30.381.0 → 30.385.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.
Files changed (91) hide show
  1. package/dist/__tests__/accessScreening.test.js +39 -1
  2. package/dist/__tests__/accessScreening.test.js.map +1 -1
  3. package/dist/__tests__/applicationFunnel.test.d.ts +2 -0
  4. package/dist/__tests__/applicationFunnel.test.d.ts.map +1 -0
  5. package/dist/__tests__/applicationFunnel.test.js +126 -0
  6. package/dist/__tests__/applicationFunnel.test.js.map +1 -0
  7. package/dist/__tests__/definitionsHelpers.test.js +20 -1
  8. package/dist/__tests__/definitionsHelpers.test.js.map +1 -1
  9. package/dist/__tests__/fundingMeter.test.d.ts +2 -0
  10. package/dist/__tests__/fundingMeter.test.d.ts.map +1 -0
  11. package/dist/__tests__/fundingMeter.test.js +129 -0
  12. package/dist/__tests__/fundingMeter.test.js.map +1 -0
  13. package/dist/__tests__/giveaway.test.d.ts +2 -0
  14. package/dist/__tests__/giveaway.test.d.ts.map +1 -0
  15. package/dist/__tests__/giveaway.test.js +65 -0
  16. package/dist/__tests__/giveaway.test.js.map +1 -0
  17. package/dist/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
  18. package/dist/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
  19. package/dist/__tests__/promoterCompetitionUtils.test.js +262 -0
  20. package/dist/__tests__/promoterCompetitionUtils.test.js.map +1 -0
  21. package/dist/accessScreening.d.ts +18 -3
  22. package/dist/accessScreening.d.ts.map +1 -1
  23. package/dist/accessScreening.js +26 -0
  24. package/dist/accessScreening.js.map +1 -1
  25. package/dist/applicationFunnel.d.ts +58 -0
  26. package/dist/applicationFunnel.d.ts.map +1 -0
  27. package/dist/applicationFunnel.js +147 -0
  28. package/dist/applicationFunnel.js.map +1 -0
  29. package/dist/definitions.d.ts +145 -5
  30. package/dist/definitions.d.ts.map +1 -1
  31. package/dist/definitions.js +137 -5
  32. package/dist/definitions.js.map +1 -1
  33. package/dist/extendedSchemas.d.ts +166 -1
  34. package/dist/extendedSchemas.d.ts.map +1 -1
  35. package/dist/extendedSchemas.js +7 -1
  36. package/dist/extendedSchemas.js.map +1 -1
  37. package/dist/fundingMeter.d.ts +66 -0
  38. package/dist/fundingMeter.d.ts.map +1 -0
  39. package/dist/fundingMeter.js +99 -0
  40. package/dist/fundingMeter.js.map +1 -0
  41. package/dist/giveaway.d.ts +71 -0
  42. package/dist/giveaway.d.ts.map +1 -0
  43. package/dist/giveaway.js +117 -0
  44. package/dist/giveaway.js.map +1 -0
  45. package/dist/index.d.ts +5 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +6 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/utils/__tests__/bashHostIdentity.test.d.ts +2 -0
  50. package/dist/utils/__tests__/bashHostIdentity.test.d.ts.map +1 -0
  51. package/dist/utils/__tests__/bashHostIdentity.test.js +39 -0
  52. package/dist/utils/__tests__/bashHostIdentity.test.js.map +1 -0
  53. package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
  54. package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
  55. package/dist/utils/__tests__/promoterCompetitionUtils.test.js +104 -0
  56. package/dist/utils/__tests__/promoterCompetitionUtils.test.js.map +1 -0
  57. package/dist/utils/__tests__/slugUtils.test.js +29 -2
  58. package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
  59. package/dist/utils/bashHostIdentity.d.ts +18 -0
  60. package/dist/utils/bashHostIdentity.d.ts.map +1 -0
  61. package/dist/utils/bashHostIdentity.js +17 -0
  62. package/dist/utils/bashHostIdentity.js.map +1 -0
  63. package/dist/utils/promoterCompetitionUtils.d.ts +89 -0
  64. package/dist/utils/promoterCompetitionUtils.d.ts.map +1 -0
  65. package/dist/utils/promoterCompetitionUtils.js +158 -0
  66. package/dist/utils/promoterCompetitionUtils.js.map +1 -0
  67. package/dist/utils/slugUtils.d.ts +2 -0
  68. package/dist/utils/slugUtils.d.ts.map +1 -1
  69. package/dist/utils/slugUtils.js +5 -1
  70. package/dist/utils/slugUtils.js.map +1 -1
  71. package/package.json +1 -1
  72. package/prisma/schema.prisma +415 -25
  73. package/src/__tests__/accessScreening.test.ts +69 -1
  74. package/src/__tests__/applicationFunnel.test.ts +197 -0
  75. package/src/__tests__/definitionsHelpers.test.ts +24 -0
  76. package/src/__tests__/fundingMeter.test.ts +143 -0
  77. package/src/__tests__/giveaway.test.ts +111 -0
  78. package/src/__tests__/promoterCompetitionUtils.test.ts +334 -0
  79. package/src/accessScreening.ts +49 -0
  80. package/src/applicationFunnel.ts +225 -0
  81. package/src/definitions.ts +181 -5
  82. package/src/extendedSchemas.ts +127 -1
  83. package/src/fundingMeter.ts +188 -0
  84. package/src/giveaway.ts +182 -0
  85. package/src/index.ts +14 -0
  86. package/src/utils/__tests__/bashHostIdentity.test.ts +57 -0
  87. package/src/utils/__tests__/promoterCompetitionUtils.test.ts +128 -0
  88. package/src/utils/__tests__/slugUtils.test.ts +44 -0
  89. package/src/utils/bashHostIdentity.ts +30 -0
  90. package/src/utils/promoterCompetitionUtils.ts +235 -0
  91. package/src/utils/slugUtils.ts +11 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * bash-common promoterCompetitionUtils — pure function tests.
3
+ *
4
+ * Covers: formatPromoterRewardLabel, computePromoterCompetitionEconomics,
5
+ * suggestPromoterSalesGoal, getPromoterReputationLabel.
6
+ *
7
+ * No mocks needed — all functions are side-effect free.
8
+ */
9
+ import {
10
+ formatPromoterRewardLabel,
11
+ computePromoterCompetitionEconomics,
12
+ suggestPromoterSalesGoal,
13
+ getPromoterReputationLabel,
14
+ } from "../utils/promoterCompetitionUtils.js";
15
+ import { PromoterRewardMode, PromoterRewardType } from "../definitions.js";
16
+
17
+ // ─── formatPromoterRewardLabel ────────────────────────────────────────────────
18
+
19
+ describe("formatPromoterRewardLabel", () => {
20
+ it("formats a threshold CASH milestone as 'N sales → $X'", () => {
21
+ expect(
22
+ formatPromoterRewardLabel({
23
+ threshold: 5,
24
+ rank: null,
25
+ rewardType: PromoterRewardType.CASH,
26
+ rewardAmountCents: 1500,
27
+ rewardDescription: null,
28
+ })
29
+ ).toBe("5 sales → $15");
30
+ });
31
+
32
+ it("formats a threshold CASH milestone with triple-digit amount", () => {
33
+ expect(
34
+ formatPromoterRewardLabel({
35
+ threshold: 35,
36
+ rank: null,
37
+ rewardType: PromoterRewardType.CASH,
38
+ rewardAmountCents: 25000,
39
+ rewardDescription: null,
40
+ })
41
+ ).toBe("35 sales → $250");
42
+ });
43
+
44
+ it("formats a threshold ACCESS milestone using rewardDescription", () => {
45
+ expect(
46
+ formatPromoterRewardLabel({
47
+ threshold: 10,
48
+ rank: null,
49
+ rewardType: PromoterRewardType.ACCESS,
50
+ rewardAmountCents: null,
51
+ rewardDescription: "Backstage access",
52
+ })
53
+ ).toBe("10 sales → Backstage access");
54
+ });
55
+
56
+ it("falls back to rewardType when description is missing and reward is non-cash", () => {
57
+ expect(
58
+ formatPromoterRewardLabel({
59
+ threshold: 10,
60
+ rank: null,
61
+ rewardType: PromoterRewardType.TICKET,
62
+ rewardAmountCents: null,
63
+ rewardDescription: null,
64
+ })
65
+ ).toBe("10 sales → TICKET");
66
+ });
67
+
68
+ it("formats rank=1 as 'Top promoter → ...'", () => {
69
+ expect(
70
+ formatPromoterRewardLabel({
71
+ threshold: null,
72
+ rank: 1,
73
+ rewardType: PromoterRewardType.CASH,
74
+ rewardAmountCents: 25000,
75
+ rewardDescription: null,
76
+ })
77
+ ).toBe("Top promoter → $250");
78
+ });
79
+
80
+ it("formats rank > 1 as 'Top N promoter → ...'", () => {
81
+ expect(
82
+ formatPromoterRewardLabel({
83
+ threshold: null,
84
+ rank: 3,
85
+ rewardType: PromoterRewardType.CASH,
86
+ rewardAmountCents: 10000,
87
+ rewardDescription: null,
88
+ })
89
+ ).toBe("Top 3 promoter → $100");
90
+ });
91
+
92
+ it("formats a CREDIT milestone the same as CASH (dollar sign, no cents when round)", () => {
93
+ expect(
94
+ formatPromoterRewardLabel({
95
+ threshold: 20,
96
+ rank: null,
97
+ rewardType: PromoterRewardType.CREDIT,
98
+ rewardAmountCents: 5000,
99
+ rewardDescription: null,
100
+ })
101
+ ).toBe("20 sales → $50");
102
+ });
103
+ });
104
+
105
+ // ─── computePromoterCompetitionEconomics (bash-common version) ────────────────
106
+
107
+ describe("computePromoterCompetitionEconomics (bash-common)", () => {
108
+ const baseMilestones = [
109
+ { threshold: 5, rank: null, rewardType: PromoterRewardType.CASH, rewardAmountCents: 1500, rewardDescription: null },
110
+ { threshold: 10, rank: null, rewardType: PromoterRewardType.CASH, rewardAmountCents: 4000, rewardDescription: null },
111
+ { threshold: 20, rank: null, rewardType: PromoterRewardType.ACCESS, rewardAmountCents: null, rewardDescription: "VIP access" },
112
+ { threshold: 35, rank: null, rewardType: PromoterRewardType.CASH, rewardAmountCents: 10000, rewardDescription: null },
113
+ ];
114
+
115
+ it("computes correct gross potential revenue (100 tickets × $40)", () => {
116
+ const result = computePromoterCompetitionEconomics({
117
+ ticketPriceCents: 4000,
118
+ salesGoal: 100,
119
+ milestones: baseMilestones,
120
+ rewardMode: PromoterRewardMode.TOTAL_AT_MILESTONE,
121
+ });
122
+
123
+ expect(result.grossPotentialRevenueCents).toBe(400000); // 4000 × 100
124
+ });
125
+
126
+ it("TOTAL_AT_MILESTONE: max cash exposure is the highest threshold CASH reward ($100)", () => {
127
+ const result = computePromoterCompetitionEconomics({
128
+ ticketPriceCents: 4000,
129
+ salesGoal: 100,
130
+ milestones: baseMilestones,
131
+ rewardMode: PromoterRewardMode.TOTAL_AT_MILESTONE,
132
+ });
133
+
134
+ expect(result.maxCashRewardExposureCents).toBe(10000); // $100 (not $15+$40+$100)
135
+ });
136
+
137
+ it("ADDITIVE: max cash exposure sums all threshold CASH rewards ($15 + $40 + $100 = $155)", () => {
138
+ const result = computePromoterCompetitionEconomics({
139
+ ticketPriceCents: 4000,
140
+ salesGoal: 100,
141
+ milestones: baseMilestones,
142
+ rewardMode: PromoterRewardMode.ADDITIVE,
143
+ });
144
+
145
+ expect(result.maxCashRewardExposureCents).toBe(15500); // 1500 + 4000 + 10000
146
+ });
147
+
148
+ it("rank=1 CASH reward adds to exposure on top of threshold rewards regardless of mode", () => {
149
+ const milestonesWithRank = [
150
+ ...baseMilestones,
151
+ { threshold: null, rank: 1, rewardType: PromoterRewardType.CASH, rewardAmountCents: 20000, rewardDescription: null },
152
+ ];
153
+
154
+ const totalAt = computePromoterCompetitionEconomics({
155
+ ticketPriceCents: 4000,
156
+ salesGoal: 100,
157
+ milestones: milestonesWithRank,
158
+ rewardMode: PromoterRewardMode.TOTAL_AT_MILESTONE,
159
+ });
160
+ // TOTAL_AT_MILESTONE: max threshold cash = $100 (10000) + rank cash = $200 (20000)
161
+ expect(totalAt.maxCashRewardExposureCents).toBe(10000 + 20000);
162
+
163
+ const additive = computePromoterCompetitionEconomics({
164
+ ticketPriceCents: 4000,
165
+ salesGoal: 100,
166
+ milestones: milestonesWithRank,
167
+ rewardMode: PromoterRewardMode.ADDITIVE,
168
+ });
169
+ // ADDITIVE: sum of threshold cash + rank cash
170
+ expect(additive.maxCashRewardExposureCents).toBe(15500 + 20000);
171
+ });
172
+
173
+ it("effectiveAcquisitionCostPct is expressed as a 0–100 percentage rounded to 1 decimal", () => {
174
+ // 10000 / 400000 = 0.025 → × 1000 = 25 → round → 25 → /10 = 2.5
175
+ const result = computePromoterCompetitionEconomics({
176
+ ticketPriceCents: 4000,
177
+ salesGoal: 100,
178
+ milestones: baseMilestones,
179
+ rewardMode: PromoterRewardMode.TOTAL_AT_MILESTONE,
180
+ });
181
+ expect(result.effectiveAcquisitionCostPct).toBe(2.5);
182
+ });
183
+
184
+ it("returns 0 for effectiveAcquisitionCostPct when salesGoal is 0 (no division-by-zero)", () => {
185
+ const result = computePromoterCompetitionEconomics({
186
+ ticketPriceCents: 4000,
187
+ salesGoal: 0,
188
+ milestones: baseMilestones,
189
+ rewardMode: PromoterRewardMode.ADDITIVE,
190
+ });
191
+ expect(result.effectiveAcquisitionCostPct).toBe(0);
192
+ expect(result.grossPotentialRevenueCents).toBe(0);
193
+ });
194
+
195
+ it("includes a rewardBreakdown array with threshold milestones sorted ascending before rank", () => {
196
+ const result = computePromoterCompetitionEconomics({
197
+ ticketPriceCents: 4000,
198
+ salesGoal: 100,
199
+ milestones: [
200
+ { threshold: 10, rank: null, rewardType: PromoterRewardType.CASH, rewardAmountCents: 4000, rewardDescription: null },
201
+ { threshold: null, rank: 1, rewardType: PromoterRewardType.CASH, rewardAmountCents: 20000, rewardDescription: null },
202
+ { threshold: 5, rank: null, rewardType: PromoterRewardType.CASH, rewardAmountCents: 1500, rewardDescription: null },
203
+ ],
204
+ rewardMode: PromoterRewardMode.ADDITIVE,
205
+ });
206
+
207
+ expect(result.rewardBreakdown).toHaveLength(3);
208
+ // threshold 5 first, threshold 10 second, rank 1 last
209
+ expect(result.rewardBreakdown[0].label).toBe("5 sales → $15");
210
+ expect(result.rewardBreakdown[1].label).toBe("10 sales → $40");
211
+ expect(result.rewardBreakdown[2].label).toBe("Top promoter → $200");
212
+ });
213
+ });
214
+
215
+ // ─── suggestPromoterSalesGoal ─────────────────────────────────────────────────
216
+
217
+ describe("suggestPromoterSalesGoal", () => {
218
+ it("returns null when capacity is null", () => {
219
+ expect(suggestPromoterSalesGoal({ capacity: null, ticketsSold: 50 })).toBeNull();
220
+ });
221
+
222
+ it("returns null when capacity is 0 or negative", () => {
223
+ expect(suggestPromoterSalesGoal({ capacity: 0, ticketsSold: 0 })).toBeNull();
224
+ expect(suggestPromoterSalesGoal({ capacity: -1, ticketsSold: 0 })).toBeNull();
225
+ });
226
+
227
+ it("returns null when venue is full (remaining = 0)", () => {
228
+ expect(suggestPromoterSalesGoal({ capacity: 200, ticketsSold: 200 })).toBeNull();
229
+ });
230
+
231
+ it("snaps to nearest benchmark: capacity=200, sold=27 → remaining=173 → target≈86 → 75", () => {
232
+ // target = 173 × 0.5 = 86.5
233
+ // distances: |86.5-75|=11.5, |86.5-100|=13.5 → 75 wins
234
+ const result = suggestPromoterSalesGoal({ capacity: 200, ticketsSold: 27 });
235
+ expect(result).toBe(75);
236
+ });
237
+
238
+ it("treats null ticketsSold as 0", () => {
239
+ // capacity=100, sold=null → remaining=100 → target=50 → snap to 50
240
+ const result = suggestPromoterSalesGoal({ capacity: 100, ticketsSold: null });
241
+ expect(result).toBe(50);
242
+ });
243
+
244
+ it("snaps to lowest benchmark (25) for very small remaining capacity", () => {
245
+ // capacity=50, sold=45 → remaining=5 → target=2.5 → nearest to 25 (all benchmarks > 5, so 25 wins)
246
+ const result = suggestPromoterSalesGoal({ capacity: 50, ticketsSold: 45 });
247
+ expect(result).toBe(25);
248
+ });
249
+ });
250
+
251
+ // ─── getPromoterReputationLabel ───────────────────────────────────────────────
252
+
253
+ describe("getPromoterReputationLabel", () => {
254
+ const base = {
255
+ lifetimeQualifiedTickets: 0,
256
+ totalCompetitionsWon: 0,
257
+ totalEventsPromoted: 0,
258
+ repeatOrganizerCount: 0,
259
+ totalMilestonesReached: 0,
260
+ };
261
+
262
+ it("returns null when totalEventsPromoted is 0 (no promoter track record yet)", () => {
263
+ expect(getPromoterReputationLabel({ ...base, totalEventsPromoted: 0 })).toBeNull();
264
+ });
265
+
266
+ it("returns 'Competition Winner' when at least one competition was won", () => {
267
+ expect(
268
+ getPromoterReputationLabel({
269
+ ...base,
270
+ totalEventsPromoted: 1,
271
+ totalCompetitionsWon: 1,
272
+ })
273
+ ).toBe("Competition Winner");
274
+ });
275
+
276
+ it("returns '100+ Tickets Sold' when lifetimeQualifiedTickets >= 100 (and no competition win)", () => {
277
+ expect(
278
+ getPromoterReputationLabel({
279
+ ...base,
280
+ totalEventsPromoted: 2,
281
+ lifetimeQualifiedTickets: 100,
282
+ })
283
+ ).toBe("100+ Tickets Sold");
284
+ });
285
+
286
+ it("returns 'Repeat Collaborator' when worked with 2+ organizers (and lower-priority conditions unmet)", () => {
287
+ expect(
288
+ getPromoterReputationLabel({
289
+ ...base,
290
+ totalEventsPromoted: 2,
291
+ repeatOrganizerCount: 2,
292
+ })
293
+ ).toBe("Repeat Collaborator");
294
+ });
295
+
296
+ it("returns 'Experienced Promoter' when 3+ events promoted and no higher-priority label applies", () => {
297
+ expect(
298
+ getPromoterReputationLabel({
299
+ ...base,
300
+ totalEventsPromoted: 3,
301
+ })
302
+ ).toBe("Experienced Promoter");
303
+ });
304
+
305
+ it("returns 'Top Seller' when at least one milestone was reached and no higher-priority label applies", () => {
306
+ expect(
307
+ getPromoterReputationLabel({
308
+ ...base,
309
+ totalEventsPromoted: 1,
310
+ totalMilestonesReached: 1,
311
+ })
312
+ ).toBe("Top Seller");
313
+ });
314
+
315
+ it("returns null when promoted 1 event but no notable achievements", () => {
316
+ expect(
317
+ getPromoterReputationLabel({
318
+ ...base,
319
+ totalEventsPromoted: 1,
320
+ })
321
+ ).toBeNull();
322
+ });
323
+
324
+ it("respects priority order: Competition Winner beats 100+ Tickets Sold", () => {
325
+ expect(
326
+ getPromoterReputationLabel({
327
+ ...base,
328
+ totalEventsPromoted: 5,
329
+ totalCompetitionsWon: 1,
330
+ lifetimeQualifiedTickets: 500,
331
+ })
332
+ ).toBe("Competition Winner");
333
+ });
334
+ });
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Event access screening — shared status values for API + app.
3
+ *
4
+ * Simple "Request access" (a note + host approve) is only for events that
5
+ * are not using the Application funnel. Application is the richer gate.
3
6
  */
7
+ import {
8
+ eventHasApplication,
9
+ rsvpApplicationEnabled,
10
+ } from "./applicationFunnel.js";
11
+
4
12
  export const ACCESS_REQUEST_STATUSES = [
5
13
  "None",
6
14
  "Pending",
@@ -11,6 +19,35 @@ export const ACCESS_REQUEST_STATUSES = [
11
19
  export type AccessRequestStatus =
12
20
  (typeof ACCESS_REQUEST_STATUSES)[number];
13
21
 
22
+ export type AccessScreeningEventFlags = {
23
+ accessScreeningEnabled?: boolean | null;
24
+ applicationEnabled?: boolean | null;
25
+ entryMode?: string | null;
26
+ ticketTiers?: Array<{ requiresApplication?: boolean | null }> | null;
27
+ };
28
+
29
+ /**
30
+ * Event-wide note-only screening. False when Application is on so guests
31
+ * apply once instead of requesting access and then filling a form.
32
+ */
33
+ export function accessScreeningApplies(
34
+ event: AccessScreeningEventFlags
35
+ ): boolean {
36
+ return Boolean(event.accessScreeningEnabled) && !eventHasApplication(event);
37
+ }
38
+
39
+ /**
40
+ * Host Access requests queue (ticketed). RSVP applications use Pending RSVPs.
41
+ */
42
+ export function eventNeedsAccessRequestQueue(
43
+ event: AccessScreeningEventFlags
44
+ ): boolean {
45
+ if (rsvpApplicationEnabled(event)) return false;
46
+ if (accessScreeningApplies(event)) return true;
47
+ if (event.entryMode === "Rsvp") return false;
48
+ return eventHasApplication(event);
49
+ }
50
+
14
51
  /** True when the viewer must complete access screening before tickets / affirmative RSVP. */
15
52
  export function accessScreeningBlocksAttendeeActions(
16
53
  enabled: boolean | undefined,
@@ -21,3 +58,15 @@ export function accessScreeningBlocksAttendeeActions(
21
58
  if (hasTicket) return false;
22
59
  return status !== "Approved";
23
60
  }
61
+
62
+ export function accessScreeningBlocksAttendeeActionsForEvent(
63
+ event: AccessScreeningEventFlags,
64
+ status: AccessRequestStatus | null | undefined,
65
+ hasTicket: boolean
66
+ ): boolean {
67
+ return accessScreeningBlocksAttendeeActions(
68
+ accessScreeningApplies(event),
69
+ status,
70
+ hasTicket
71
+ );
72
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Host application funnel — shared evaluation for API + app.
3
+ * Choice questions can auto-deny or flag a mismatch; free text is review-only.
4
+ *
5
+ * Later Posh gaps (tracked, not in this module): bash-page screened badge /
6
+ * cover aesthetics, stronger host identity on Meet the host, who’s-going
7
+ * mutuals before apply, application analytics, AI screening assist (never
8
+ * auto-deny on LLM alone), waitlist → next approved applicant.
9
+ */
10
+
11
+ export const QUESTION_MISMATCH_ACTIONS = [
12
+ "None",
13
+ "FlagForReview",
14
+ "AutoDeny",
15
+ ] as const;
16
+
17
+ export type QuestionMismatchAction = (typeof QUESTION_MISMATCH_ACTIONS)[number];
18
+
19
+ export const APPLICATION_QUESTION_TYPES = [
20
+ "ShortText",
21
+ "LongText",
22
+ "SingleChoice",
23
+ "MultiChoice",
24
+ "YesNo",
25
+ ] as const;
26
+
27
+ export type ApplicationQuestionType =
28
+ (typeof APPLICATION_QUESTION_TYPES)[number];
29
+
30
+ export type ApplicationFunnelQuestion = {
31
+ id: string;
32
+ type: ApplicationQuestionType;
33
+ required?: boolean;
34
+ ticketTierId?: string | null;
35
+ mismatchAction?: QuestionMismatchAction | null;
36
+ acceptableOptions?: string[] | null;
37
+ parentQuestionId?: string | null;
38
+ showWhenAnswer?: string | null;
39
+ };
40
+
41
+ export type ApplicationFunnelAnswer = {
42
+ questionId: string;
43
+ answer: string;
44
+ };
45
+
46
+ export type ApplicationFunnelEvaluation = {
47
+ outcome: "pass" | "flag" | "auto_deny";
48
+ flaggedQuestionIds: string[];
49
+ autoDenyQuestionIds: string[];
50
+ };
51
+
52
+ export function isChoiceQuestionType(
53
+ type: ApplicationQuestionType | string
54
+ ): boolean {
55
+ return (
56
+ type === "SingleChoice" || type === "MultiChoice" || type === "YesNo"
57
+ );
58
+ }
59
+
60
+ export function questionsForApplicationSurface<
61
+ T extends { ticketTierId?: string | null },
62
+ >(questions: T[], ticketTierId: string | null): T[] {
63
+ return questions.filter(
64
+ (question) =>
65
+ question.ticketTierId == null || question.ticketTierId === ticketTierId
66
+ );
67
+ }
68
+
69
+ export function applicationsAreClosed(
70
+ closesAt: Date | string | null | undefined,
71
+ now: Date = new Date()
72
+ ): boolean {
73
+ if (!closesAt) return false;
74
+ const at = closesAt instanceof Date ? closesAt : new Date(closesAt);
75
+ if (Number.isNaN(at.getTime())) return false;
76
+ return now.getTime() > at.getTime();
77
+ }
78
+
79
+ export function isApplicationQuestionVisible(
80
+ question: ApplicationFunnelQuestion,
81
+ answers: ApplicationFunnelAnswer[],
82
+ allQuestions: ApplicationFunnelQuestion[],
83
+ seen: Set<string> = new Set()
84
+ ): boolean {
85
+ const parentId = question.parentQuestionId?.trim();
86
+ const when = question.showWhenAnswer?.trim();
87
+ if (!parentId || !when) return true;
88
+ if (seen.has(question.id)) return false;
89
+ seen.add(question.id);
90
+ const parent = allQuestions.find((row) => row.id === parentId);
91
+ if (parent && !isApplicationQuestionVisible(parent, answers, allQuestions, seen)) {
92
+ return false;
93
+ }
94
+ const raw = answers.find((row) => row.questionId === parentId)?.answer ?? "";
95
+ const parentType = parent?.type ?? "ShortText";
96
+ return answerValues(raw, parentType).some(
97
+ (value) => normalizeOption(value) === normalizeOption(when)
98
+ );
99
+ }
100
+
101
+ export function visibleApplicationQuestions<T extends ApplicationFunnelQuestion>(
102
+ questions: T[],
103
+ answers: ApplicationFunnelAnswer[]
104
+ ): T[] {
105
+ return questions.filter((question) =>
106
+ isApplicationQuestionVisible(question, answers, questions)
107
+ );
108
+ }
109
+
110
+ function normalizeOption(value: string): string {
111
+ return value.trim().toLowerCase();
112
+ }
113
+
114
+ function answerValues(
115
+ answer: string,
116
+ type: ApplicationQuestionType
117
+ ): string[] {
118
+ if (type === "MultiChoice") {
119
+ return answer
120
+ .split(",")
121
+ .map((part) => part.trim())
122
+ .filter(Boolean);
123
+ }
124
+ const trimmed = answer.trim();
125
+ return trimmed ? [trimmed] : [];
126
+ }
127
+
128
+ export function missingRequiredQuestionIds(
129
+ questions: ApplicationFunnelQuestion[],
130
+ answers: ApplicationFunnelAnswer[]
131
+ ): string[] {
132
+ const visible = visibleApplicationQuestions(questions, answers);
133
+ const byId = new Map(answers.map((row) => [row.questionId, row.answer]));
134
+ return visible
135
+ .filter((question) => question.required)
136
+ .filter((question) => {
137
+ const raw = byId.get(question.id) ?? "";
138
+ return answerValues(raw, question.type).length === 0;
139
+ })
140
+ .map((question) => question.id);
141
+ }
142
+
143
+ export function evaluateApplicationFunnel(
144
+ questions: ApplicationFunnelQuestion[],
145
+ answers: ApplicationFunnelAnswer[]
146
+ ): ApplicationFunnelEvaluation {
147
+ const byId = new Map(answers.map((row) => [row.questionId, row.answer]));
148
+ const flaggedQuestionIds: string[] = [];
149
+ const autoDenyQuestionIds: string[] = [];
150
+
151
+ const visible = visibleApplicationQuestions(questions, answers);
152
+ for (const question of visible) {
153
+ const action = question.mismatchAction ?? "None";
154
+ if (action === "None") continue;
155
+ if (!isChoiceQuestionType(question.type)) continue;
156
+ const acceptable = (question.acceptableOptions ?? [])
157
+ .map(normalizeOption)
158
+ .filter(Boolean);
159
+ if (acceptable.length === 0) continue;
160
+
161
+ const values = answerValues(byId.get(question.id) ?? "", question.type);
162
+ if (values.length === 0) continue;
163
+
164
+ const acceptableSet = new Set(acceptable);
165
+ const mismatch = values.some((value) => !acceptableSet.has(normalizeOption(value)));
166
+ if (!mismatch) continue;
167
+
168
+ if (action === "AutoDeny") {
169
+ autoDenyQuestionIds.push(question.id);
170
+ } else if (action === "FlagForReview") {
171
+ flaggedQuestionIds.push(question.id);
172
+ }
173
+ }
174
+
175
+ if (autoDenyQuestionIds.length > 0) {
176
+ return {
177
+ outcome: "auto_deny",
178
+ flaggedQuestionIds,
179
+ autoDenyQuestionIds,
180
+ };
181
+ }
182
+ if (flaggedQuestionIds.length > 0) {
183
+ return {
184
+ outcome: "flag",
185
+ flaggedQuestionIds,
186
+ autoDenyQuestionIds,
187
+ };
188
+ }
189
+ return {
190
+ outcome: "pass",
191
+ flaggedQuestionIds: [],
192
+ autoDenyQuestionIds: [],
193
+ };
194
+ }
195
+
196
+ export function eventHasApplication(event: {
197
+ applicationEnabled?: boolean | null;
198
+ ticketTiers?: Array<{ requiresApplication?: boolean | null }> | null;
199
+ }): boolean {
200
+ if (event.applicationEnabled) return true;
201
+ return Boolean(event.ticketTiers?.some((tier) => tier.requiresApplication));
202
+ }
203
+
204
+ export function rsvpApplicationEnabled(event: {
205
+ applicationEnabled?: boolean | null;
206
+ entryMode?: string | null;
207
+ }): boolean {
208
+ return event.entryMode === "Rsvp" && Boolean(event.applicationEnabled);
209
+ }
210
+
211
+ export function applicationBlocksTierPurchase(
212
+ tier: { requiresApplication?: boolean | null } | null | undefined,
213
+ status: string | null | undefined,
214
+ hasTicket: boolean
215
+ ): boolean {
216
+ if (hasTicket) return false;
217
+ if (!tier?.requiresApplication) return false;
218
+ return status !== "Approved";
219
+ }
220
+
221
+ export function selectedTiersRequireApplication(
222
+ tiers: Array<{ requiresApplication?: boolean | null }> | null | undefined
223
+ ): boolean {
224
+ return Boolean(tiers?.some((tier) => tier.requiresApplication));
225
+ }