@bash-app/bash-common 30.333.0 → 30.335.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 (53) hide show
  1. package/dist/__tests__/causeTags.test.d.ts +2 -0
  2. package/dist/__tests__/causeTags.test.d.ts.map +1 -0
  3. package/dist/__tests__/causeTags.test.js +94 -0
  4. package/dist/__tests__/causeTags.test.js.map +1 -0
  5. package/dist/__tests__/hostCrmGrowthPack.test.js +12 -2
  6. package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -1
  7. package/dist/bashFeedTypes.d.ts +4 -0
  8. package/dist/bashFeedTypes.d.ts.map +1 -1
  9. package/dist/causeTags.d.ts +42 -0
  10. package/dist/causeTags.d.ts.map +1 -0
  11. package/dist/causeTags.js +173 -0
  12. package/dist/causeTags.js.map +1 -0
  13. package/dist/definitions.d.ts +23 -0
  14. package/dist/definitions.d.ts.map +1 -1
  15. package/dist/definitions.js +28 -0
  16. package/dist/definitions.js.map +1 -1
  17. package/dist/hostCrmAutomation.d.ts +12 -2
  18. package/dist/hostCrmAutomation.d.ts.map +1 -1
  19. package/dist/hostCrmAutomation.js +16 -2
  20. package/dist/hostCrmAutomation.js.map +1 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/sms/smsTemplates.d.ts +6 -1
  26. package/dist/sms/smsTemplates.d.ts.map +1 -1
  27. package/dist/sms/smsTemplates.js +1 -0
  28. package/dist/sms/smsTemplates.js.map +1 -1
  29. package/dist/utils/__tests__/coatCheckUtils.test.d.ts +2 -0
  30. package/dist/utils/__tests__/coatCheckUtils.test.d.ts.map +1 -0
  31. package/dist/utils/__tests__/coatCheckUtils.test.js +76 -0
  32. package/dist/utils/__tests__/coatCheckUtils.test.js.map +1 -0
  33. package/dist/utils/coatCheckUtils.d.ts +30 -0
  34. package/dist/utils/coatCheckUtils.d.ts.map +1 -0
  35. package/dist/utils/coatCheckUtils.js +85 -0
  36. package/dist/utils/coatCheckUtils.js.map +1 -0
  37. package/dist/utils/slugUtils.d.ts +3 -4
  38. package/dist/utils/slugUtils.d.ts.map +1 -1
  39. package/dist/utils/slugUtils.js +3 -4
  40. package/dist/utils/slugUtils.js.map +1 -1
  41. package/package.json +1 -1
  42. package/prisma/schema.prisma +429 -3
  43. package/src/__tests__/causeTags.test.ts +143 -0
  44. package/src/__tests__/hostCrmGrowthPack.test.ts +14 -1
  45. package/src/bashFeedTypes.ts +4 -0
  46. package/src/causeTags.ts +243 -0
  47. package/src/definitions.ts +46 -0
  48. package/src/hostCrmAutomation.ts +25 -2
  49. package/src/index.ts +2 -0
  50. package/src/sms/smsTemplates.ts +6 -0
  51. package/src/utils/__tests__/coatCheckUtils.test.ts +110 -0
  52. package/src/utils/coatCheckUtils.ts +134 -0
  53. package/src/utils/slugUtils.ts +3 -4
@@ -1,16 +1,19 @@
1
1
  import {
2
2
  hostCrmGrowthPackIsComplete,
3
+ hostCrmHasLateBuyerPackAutomation,
3
4
  hostCrmHasPostEventPackAutomation,
4
5
  hostCrmHasWinBackPackAutomation,
6
+ HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID,
5
7
  HOST_CRM_GROWTH_PACK_WINBACK_PRESET_ID,
6
8
  } from "../hostCrmAutomation.js";
7
9
 
8
10
  describe("host CRM growth pack detection", () => {
9
- it("detects win-back and post-event pack rows", () => {
11
+ it("detects win-back, post-event, and late-buyer pack rows", () => {
10
12
  const empty: Array<{ trigger: string; channel: string; audiencePresetId?: string | null }> =
11
13
  [];
12
14
  expect(hostCrmHasWinBackPackAutomation(empty)).toBe(false);
13
15
  expect(hostCrmHasPostEventPackAutomation(empty)).toBe(false);
16
+ expect(hostCrmHasLateBuyerPackAutomation(empty)).toBe(false);
14
17
  expect(hostCrmGrowthPackIsComplete(empty)).toBe(false);
15
18
 
16
19
  const winBack = [
@@ -29,5 +32,15 @@ describe("host CRM growth pack detection", () => {
29
32
  ];
30
33
  expect(hostCrmHasPostEventPackAutomation(both)).toBe(true);
31
34
  expect(hostCrmGrowthPackIsComplete(both)).toBe(true);
35
+
36
+ const withLateBuyer = [
37
+ ...both,
38
+ {
39
+ trigger: "Recurring",
40
+ channel: "Email",
41
+ audiencePresetId: HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID,
42
+ },
43
+ ];
44
+ expect(hostCrmHasLateBuyerPackAutomation(withLateBuyer)).toBe(true);
32
45
  });
33
46
  });
@@ -36,6 +36,8 @@ export interface BashFeedPostBashSummary {
36
36
  rsvpGoing?: number;
37
37
  /** `PublicBashRsvp` rows with status Maybe. */
38
38
  rsvpMaybe?: number;
39
+ /** Classic `Review` rows for the event (feed ratings modal / event reviews). */
40
+ reviews?: number;
39
41
  };
40
42
  activeOffers?: Array<{
41
43
  id: string;
@@ -158,6 +160,8 @@ export interface BashFeedPostBashSummaryWire {
158
160
  checkedIn?: number;
159
161
  rsvpGoing?: number;
160
162
  rsvpMaybe?: number;
163
+ /** Classic `Review` rows for the event (feed ratings modal / event reviews). */
164
+ reviews?: number;
161
165
  };
162
166
  }
163
167
 
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Curated cause / community tags for bash + service Why flows.
3
+ * Stored as stable string IDs on `causeTags` (Service / BashEvent) so search
4
+ * can filter later without parsing free text.
5
+ */
6
+ export type CauseTagId =
7
+ | "neighborhood"
8
+ | "mutual_aid"
9
+ | "arts_culture"
10
+ | "music_scene"
11
+ | "youth"
12
+ | "lgbtq"
13
+ | "environment"
14
+ | "mental_health"
15
+ | "education"
16
+ | "food_security"
17
+ | "animal_welfare"
18
+ | "sports_rec"
19
+ | "faith_spiritual"
20
+ | "civic"
21
+ | "fundraiser"
22
+ | "small_business";
23
+
24
+ export type CauseTag = {
25
+ id: CauseTagId;
26
+ label: string;
27
+ /** Short seed line for bash descriptions when a chip is selected. */
28
+ bashSeed: string;
29
+ };
30
+
31
+ export const CAUSE_TAGS: readonly CauseTag[] = [
32
+ {
33
+ id: "neighborhood",
34
+ label: "Neighborhood / block",
35
+ bashSeed: "This bash is for the neighbors — building our block crew.",
36
+ },
37
+ {
38
+ id: "mutual_aid",
39
+ label: "Mutual aid",
40
+ bashSeed: "We're gathering to support mutual aid in our community.",
41
+ },
42
+ {
43
+ id: "arts_culture",
44
+ label: "Arts & culture",
45
+ bashSeed: "This night celebrates local arts and culture.",
46
+ },
47
+ {
48
+ id: "music_scene",
49
+ label: "Music scene",
50
+ bashSeed: "Built for the local music scene — come through for the sound.",
51
+ },
52
+ {
53
+ id: "youth",
54
+ label: "Youth & kids",
55
+ bashSeed: "This bash supports youth and families in our area.",
56
+ },
57
+ {
58
+ id: "lgbtq",
59
+ label: "LGBTQ+",
60
+ bashSeed: "A queer-friendly bash — everyone who shows respect is welcome.",
61
+ },
62
+ {
63
+ id: "environment",
64
+ label: "Environment",
65
+ bashSeed: "We're tying this night to environmental care and climate action.",
66
+ },
67
+ {
68
+ id: "mental_health",
69
+ label: "Mental health",
70
+ bashSeed: "This gathering centers mental health, rest, and real connection.",
71
+ },
72
+ {
73
+ id: "education",
74
+ label: "Education",
75
+ bashSeed: "We're here for learning, mentoring, and education in our community.",
76
+ },
77
+ {
78
+ id: "food_security",
79
+ label: "Food security",
80
+ bashSeed: "Part of this night supports food access for people nearby.",
81
+ },
82
+ {
83
+ id: "animal_welfare",
84
+ label: "Animal welfare",
85
+ bashSeed: "We're throwing this with animal welfare in mind.",
86
+ },
87
+ {
88
+ id: "sports_rec",
89
+ label: "Sports & recreation",
90
+ bashSeed: "Come move, play, and build the local sports / rec crew.",
91
+ },
92
+ {
93
+ id: "faith_spiritual",
94
+ label: "Faith / spiritual",
95
+ bashSeed: "This bash brings our faith / spiritual community together.",
96
+ },
97
+ {
98
+ id: "civic",
99
+ label: "Civic / local politics",
100
+ bashSeed: "A civic hang — talk, organize, and show up for our city.",
101
+ },
102
+ {
103
+ id: "fundraiser",
104
+ label: "Fundraiser",
105
+ bashSeed: "This bash raises support for a cause we care about.",
106
+ },
107
+ {
108
+ id: "small_business",
109
+ label: "Small business / makers",
110
+ bashSeed: "Celebrating local small businesses and makers.",
111
+ },
112
+ ] as const;
113
+
114
+ export const CAUSE_TAG_BY_ID: Readonly<Record<CauseTagId, CauseTag>> =
115
+ CAUSE_TAGS.reduce(
116
+ (acc, tag) => {
117
+ acc[tag.id] = tag;
118
+ return acc;
119
+ },
120
+ {} as Record<CauseTagId, CauseTag>
121
+ );
122
+
123
+ export function isCauseTagId(value: string): value is CauseTagId {
124
+ return Object.prototype.hasOwnProperty.call(CAUSE_TAG_BY_ID, value);
125
+ }
126
+
127
+ export function normalizeCauseTags(ids: string[] | null | undefined): CauseTagId[] {
128
+ if (!ids?.length) return [];
129
+ const seen = new Set<CauseTagId>();
130
+ for (const id of ids) {
131
+ if (isCauseTagId(id)) seen.add(id);
132
+ }
133
+ return [...seen];
134
+ }
135
+
136
+ export function causeTagLabels(ids: string[] | null | undefined): string[] {
137
+ return normalizeCauseTags(ids).map((id) => CAUSE_TAG_BY_ID[id].label);
138
+ }
139
+
140
+ /**
141
+ * Cause tag IDs whose label or id matches a free-text search query.
142
+ * Used by keyword search (Prisma `hasSome`) and client-side filters.
143
+ */
144
+ export function causeTagIdsMatchingSearch(query: string): CauseTagId[] {
145
+ const term = query.trim().toLowerCase();
146
+ if (!term) return [];
147
+
148
+ const termAsId = term.replace(/\s+/g, "_");
149
+ return CAUSE_TAGS.filter((tag) => {
150
+ const idSpaced = tag.id.replace(/_/g, " ");
151
+ return (
152
+ tag.label.toLowerCase().includes(term) ||
153
+ tag.id.includes(termAsId) ||
154
+ idSpaced.includes(term)
155
+ );
156
+ }).map((tag) => tag.id);
157
+ }
158
+
159
+ /** True when any of the entity's cause tags match the search query. */
160
+ export function causeTagsMatchSearch(
161
+ causeTags: string[] | null | undefined,
162
+ query: string
163
+ ): boolean {
164
+ const matching = causeTagIdsMatchingSearch(query);
165
+ if (matching.length === 0) return false;
166
+ const selected = new Set(normalizeCauseTags(causeTags));
167
+ return matching.some((id) => selected.has(id));
168
+ }
169
+
170
+ export type BashDonationCauseFields = {
171
+ donationGoalCents?: number | null;
172
+ donationCausePercent?: number | null;
173
+ donationCauseTagId?: string | null;
174
+ donationCauseLabel?: string | null;
175
+ };
176
+
177
+ /**
178
+ * Resolve the public cause label for host-declared donation split.
179
+ * Prefers curated tag label over free-text.
180
+ */
181
+ export function resolveDonationCauseLabel(
182
+ fields: Pick<
183
+ BashDonationCauseFields,
184
+ "donationCauseTagId" | "donationCauseLabel"
185
+ >
186
+ ): string | null {
187
+ const tagId = fields.donationCauseTagId?.trim();
188
+ if (tagId && isCauseTagId(tagId)) {
189
+ return CAUSE_TAG_BY_ID[tagId].label;
190
+ }
191
+ const label = fields.donationCauseLabel?.trim();
192
+ return label ? label : null;
193
+ }
194
+
195
+ export type DonationCauseValidationError =
196
+ | "invalid_goal"
197
+ | "invalid_percent"
198
+ | "percent_requires_cause"
199
+ | "invalid_cause_tag";
200
+
201
+ /**
202
+ * Validate bash donation goal / cause-split fields before persist.
203
+ * Returns null when valid.
204
+ */
205
+ export function validateBashDonationCauseFields(
206
+ fields: BashDonationCauseFields
207
+ ): DonationCauseValidationError | null {
208
+ const goal = fields.donationGoalCents;
209
+ if (
210
+ goal != null &&
211
+ (typeof goal !== "number" ||
212
+ !Number.isFinite(goal) ||
213
+ goal <= 0 ||
214
+ !Number.isInteger(goal))
215
+ ) {
216
+ return "invalid_goal";
217
+ }
218
+
219
+ const rawTag = fields.donationCauseTagId?.trim() || null;
220
+ if (rawTag && !isCauseTagId(rawTag)) {
221
+ return "invalid_cause_tag";
222
+ }
223
+
224
+ const percent = fields.donationCausePercent;
225
+ if (percent != null) {
226
+ if (
227
+ typeof percent !== "number" ||
228
+ !Number.isFinite(percent) ||
229
+ !Number.isInteger(percent) ||
230
+ percent < 0 ||
231
+ percent > 100
232
+ ) {
233
+ return "invalid_percent";
234
+ }
235
+ const hasTag = !!rawTag && isCauseTagId(rawTag);
236
+ const hasLabel = !!fields.donationCauseLabel?.trim();
237
+ if (!hasTag && !hasLabel) {
238
+ return "percent_requires_cause";
239
+ }
240
+ }
241
+
242
+ return null;
243
+ }
@@ -229,6 +229,45 @@ export const CHECKOUT_PURPOSE_DOOR_SQUARE_SALE = "door_square_sale" as const;
229
229
  /** Walk-up / PAD door sale via Square Terminal (card present). */
230
230
  export const CHECKOUT_PURPOSE_DOOR_SQUARE_TERMINAL_SALE =
231
231
  "door_square_terminal_sale" as const;
232
+ /** Online ticket checkout line: coat/bag check add-on. */
233
+ export const CHECKOUT_PURPOSE_COAT_CHECK_ONLINE = "coat_check_online" as const;
234
+ /** Walk-up coat/bag check via Stripe Checkout (guest pays on phone). */
235
+ export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_SALE = "coat_check_door_sale" as const;
236
+ /** Coat/bag check via Stripe Terminal (card present). */
237
+ export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_TERMINAL_SALE =
238
+ "coat_check_door_terminal_sale" as const;
239
+ /** Walk-up coat/bag check via Square Payment Link. */
240
+ export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_SQUARE_SALE =
241
+ "coat_check_door_square_sale" as const;
242
+ /** Coat/bag check via Square Terminal. */
243
+ export const CHECKOUT_PURPOSE_COAT_CHECK_DOOR_SQUARE_TERMINAL_SALE =
244
+ "coat_check_door_square_terminal_sale" as const;
245
+
246
+ export const COAT_CHECK_ENTITLEMENT_STATUSES = [
247
+ "Paid",
248
+ "CheckedIn",
249
+ "Claimed",
250
+ "Refunded",
251
+ "Cancelled",
252
+ ] as const;
253
+ export type CoatCheckEntitlementStatusName =
254
+ (typeof COAT_CHECK_ENTITLEMENT_STATUSES)[number];
255
+
256
+ export const COAT_CHECK_QR_TYPE = "coat_check" as const;
257
+
258
+ export const COAT_CHECK_SCAN_OUTCOMES = [
259
+ "ready_for_dropoff",
260
+ "ready_for_claim",
261
+ "already_claimed",
262
+ "invalid_or_expired",
263
+ "wrong_event",
264
+ "cancelled_or_refunded",
265
+ ] as const;
266
+ export type CoatCheckScanOutcome = (typeof COAT_CHECK_SCAN_OUTCOMES)[number];
267
+
268
+ /** Statuses that occupy a coat-check slot. */
269
+ export const COAT_CHECK_SLOT_OCCUPYING_STATUSES: CoatCheckEntitlementStatusName[] =
270
+ ["Paid", "CheckedIn"];
232
271
 
233
272
  /** Which processor the door app uses for card capture (host preferred + ready). */
234
273
  export type DoorPaymentRail = "STRIPE" | "SQUARE";
@@ -1038,6 +1077,13 @@ export interface StripeCreateBashEventTicketsCheckoutSessionArgs extends UtmFiel
1038
1077
 
1039
1078
  /** EventGroup id when purchasing a group package for the crew */
1040
1079
  groupId?: string;
1080
+
1081
+ /** Optional coat/bag check units to buy with this ticket checkout (0 = none). */
1082
+ coatCheckQuantity?: number;
1083
+ /** When true, add oversized surcharge per coat unit (if host configured). */
1084
+ coatCheckOversized?: boolean;
1085
+ /** Optional tip cents for coat check (when host enables tips). */
1086
+ coatCheckTipCents?: number;
1041
1087
  }
1042
1088
 
1043
1089
  export interface StripeCreateBashEventDonationCheckoutSessionArgs extends UtmFields {
@@ -93,11 +93,22 @@ export const HOST_CRM_WINBACK_DEFAULT_SUBJECT =
93
93
  export const HOST_CRM_WINBACK_DEFAULT_BODY =
94
94
  "It's been a while since your last bash with us. We'd love to see you again.";
95
95
 
96
- /** Post-event cron substitutes {{eventTitle}}, {{firstName}}, {{bashFeedUrl}}. */
96
+ /**
97
+ * Post-event cron substitutes {{eventTitle}}, {{firstName}}, {{bashFeedUrl}},
98
+ * {{memoriesUrl}}.
99
+ */
97
100
  export const HOST_CRM_GROWTH_PACK_POST_EVENT_SUBJECT =
98
101
  "Thank you for coming to {{eventTitle}}";
99
102
  export const HOST_CRM_GROWTH_PACK_POST_EVENT_BODY =
100
- "Hi {{firstName}},\n\nThanks so much for being part of {{eventTitle}} — it wouldn't have been the same without you. Hope to see you at the next one!\n\n{{bashFeedUrl}}";
103
+ "Hi {{firstName}},\n\nThanks so much for being part of {{eventTitle}} — it wouldn't have been the same without you. Got photos from the night? Save them to Memories, then post on BashFeed or drop them in the album:\n\nMemories: {{memoriesUrl}}\nBashFeed: {{bashFeedUrl}}\n\nHope to see you at the next one!";
104
+
105
+ /** Recurring email to last-minute buyers (paid within 48h of start). */
106
+ export const HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID = "last_minute_buyer";
107
+ export const HOST_CRM_GROWTH_PACK_LATE_BUYER_NAME = "Late-buyer logistics nudge";
108
+ export const HOST_CRM_GROWTH_PACK_LATE_BUYER_SUBJECT =
109
+ "You're in — here's how to make door day smooth";
110
+ export const HOST_CRM_GROWTH_PACK_LATE_BUYER_BODY =
111
+ "Hi {{firstName}},\n\nGlad you're coming. Since you grabbed tickets close to showtime, leave a few extra minutes for parking and check-in — doors can get busy.\n\nSee you there!";
101
112
 
102
113
  type GrowthPackAutomationShape = {
103
114
  trigger: string;
@@ -128,6 +139,18 @@ export function hostCrmHasPostEventPackAutomation(
128
139
  );
129
140
  }
130
141
 
142
+ /** True when a Recurring Email + last_minute_buyer automation already exists. */
143
+ export function hostCrmHasLateBuyerPackAutomation(
144
+ automations: GrowthPackAutomationShape[]
145
+ ): boolean {
146
+ return automations.some(
147
+ (a) =>
148
+ a.trigger === HostCrmAutomationTrigger.Recurring &&
149
+ a.channel === HostCrmAutomationChannel.Email &&
150
+ a.audiencePresetId === HOST_CRM_GROWTH_PACK_LATE_BUYER_PRESET_ID
151
+ );
152
+ }
153
+
131
154
  export function hostCrmGrowthPackIsComplete(
132
155
  automations: GrowthPackAutomationShape[]
133
156
  ): boolean {
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./competitionIdeas.js";
10
10
  export * from "./sideQuestIdeas.js";
11
11
  export * from "./sideQuestTypes.js";
12
12
  export * from "./icebreakerPrompts.js";
13
+ export * from "./causeTags.js";
13
14
  export * from "./competitionValidation.js";
14
15
  export * from "./groupTicketUtils.js";
15
16
  export * from "./guestPartySize.js";
@@ -406,6 +407,7 @@ export * from "./utils/flyerUtils.js";
406
407
  export * from "./utils/promotionBlastUtils.js";
407
408
  export * from "./utils/orgPromotionalPackageUtils.js";
408
409
  export * from "./utils/ticketCheckInDisplay.js";
410
+ export * from "./utils/coatCheckUtils.js";
409
411
  export * from "./utils/ticketTierSchedule.js";
410
412
  export * from "./utils/lobFlyerUtils.js";
411
413
  export * from "./utils/generalDateTimeUtils.js";
@@ -10,6 +10,7 @@ export const SMS_TEMPLATE_KEYS = [
10
10
  "EVENT_VENUE_CHANGED",
11
11
  "EVENT_WAITLIST_APPROVED",
12
12
  "TICKET_CONFIRMED",
13
+ "COAT_CHECK_CLAIM",
13
14
  ] as const;
14
15
 
15
16
  export type SmsTemplateKey = (typeof SMS_TEMPLATE_KEYS)[number];
@@ -23,4 +24,9 @@ export type SmsTemplateContext = {
23
24
  EVENT_VENUE_CHANGED: { eventTitle: string; shortUrl: string };
24
25
  EVENT_WAITLIST_APPROVED: { eventTitle: string; shortUrl: string };
25
26
  TICKET_CONFIRMED: { eventTitle: string; ticketCount: number; ticketsUrl?: string };
27
+ COAT_CHECK_CLAIM: {
28
+ eventTitle: string;
29
+ claimLabel: string;
30
+ ticketsUrl?: string;
31
+ };
26
32
  };
@@ -0,0 +1,110 @@
1
+ import {
2
+ canTransitionCoatCheckStatus,
3
+ clampCoatCheckPurchaseQty,
4
+ coatCheckScanOutcomeForStatus,
5
+ encodeCoatCheckQrPayload,
6
+ parseCoatCheckQrPayload,
7
+ remainingCoatCheckSlots,
8
+ } from "../coatCheckUtils";
9
+
10
+ describe("coatCheckUtils", () => {
11
+ describe("encode/parse QR payload", () => {
12
+ it("round-trips claim QR fields", () => {
13
+ const raw = encodeCoatCheckQrPayload({
14
+ eventId: "evt1",
15
+ entitlementId: "ent1",
16
+ qrToken: "tok-abc",
17
+ });
18
+ const parsed = parseCoatCheckQrPayload(raw);
19
+ expect(parsed).toMatchObject({
20
+ type: "coat_check",
21
+ eventId: "evt1",
22
+ entitlementId: "ent1",
23
+ qrToken: "tok-abc",
24
+ });
25
+ expect(typeof parsed?.timestamp).toBe("number");
26
+ });
27
+
28
+ it("rejects ticket-style payloads", () => {
29
+ expect(
30
+ parseCoatCheckQrPayload(
31
+ JSON.stringify({ eventId: "e", ticketId: "t", qrToken: "x" })
32
+ )
33
+ ).toBeNull();
34
+ });
35
+
36
+ it("rejects invalid JSON", () => {
37
+ expect(parseCoatCheckQrPayload("not-json")).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe("state machine", () => {
42
+ it("allows Paid → CheckedIn → Claimed", () => {
43
+ expect(canTransitionCoatCheckStatus("Paid", "CheckedIn")).toBe(true);
44
+ expect(canTransitionCoatCheckStatus("CheckedIn", "Claimed")).toBe(true);
45
+ });
46
+
47
+ it("rejects illegal transitions", () => {
48
+ expect(canTransitionCoatCheckStatus("Paid", "Claimed")).toBe(false);
49
+ expect(canTransitionCoatCheckStatus("Claimed", "CheckedIn")).toBe(false);
50
+ expect(canTransitionCoatCheckStatus("CheckedIn", "Paid")).toBe(false);
51
+ expect(canTransitionCoatCheckStatus("Paid", "Paid")).toBe(false);
52
+ });
53
+
54
+ it("allows refund/cancel from Paid or CheckedIn", () => {
55
+ expect(canTransitionCoatCheckStatus("Paid", "Refunded")).toBe(true);
56
+ expect(canTransitionCoatCheckStatus("CheckedIn", "Cancelled")).toBe(true);
57
+ expect(canTransitionCoatCheckStatus("Claimed", "Refunded")).toBe(false);
58
+ });
59
+
60
+ it("maps status to scan outcomes", () => {
61
+ expect(coatCheckScanOutcomeForStatus("Paid")).toBe("ready_for_dropoff");
62
+ expect(coatCheckScanOutcomeForStatus("CheckedIn")).toBe("ready_for_claim");
63
+ expect(coatCheckScanOutcomeForStatus("Claimed")).toBe("already_claimed");
64
+ expect(coatCheckScanOutcomeForStatus("Refunded")).toBe(
65
+ "cancelled_or_refunded"
66
+ );
67
+ });
68
+ });
69
+
70
+ describe("inventory helpers", () => {
71
+ it("treats null capacity as unlimited remaining", () => {
72
+ expect(
73
+ remainingCoatCheckSlots({ capacity: null, occupiedCount: 50 })
74
+ ).toBeNull();
75
+ });
76
+
77
+ it("computes remaining slots", () => {
78
+ expect(
79
+ remainingCoatCheckSlots({ capacity: 100, occupiedCount: 40 })
80
+ ).toBe(60);
81
+ expect(
82
+ remainingCoatCheckSlots({ capacity: 10, occupiedCount: 12 })
83
+ ).toBe(0);
84
+ });
85
+
86
+ it("clamps purchase qty by max and remaining", () => {
87
+ expect(
88
+ clampCoatCheckPurchaseQty({
89
+ requested: 5,
90
+ maxPerPurchase: 2,
91
+ remainingSlots: 10,
92
+ })
93
+ ).toBe(2);
94
+ expect(
95
+ clampCoatCheckPurchaseQty({
96
+ requested: 5,
97
+ maxPerPurchase: 10,
98
+ remainingSlots: 1,
99
+ })
100
+ ).toBe(1);
101
+ expect(
102
+ clampCoatCheckPurchaseQty({
103
+ requested: -3,
104
+ maxPerPurchase: 2,
105
+ remainingSlots: null,
106
+ })
107
+ ).toBe(0);
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,134 @@
1
+ import {
2
+ COAT_CHECK_ENTITLEMENT_STATUSES,
3
+ COAT_CHECK_QR_TYPE,
4
+ COAT_CHECK_SCAN_OUTCOMES,
5
+ COAT_CHECK_SLOT_OCCUPYING_STATUSES,
6
+ type CoatCheckEntitlementStatusName,
7
+ type CoatCheckScanOutcome,
8
+ } from "../definitions.js";
9
+
10
+ export type CoatCheckQrPayload = {
11
+ type: typeof COAT_CHECK_QR_TYPE;
12
+ eventId: string;
13
+ entitlementId: string;
14
+ qrToken: string;
15
+ timestamp: number;
16
+ };
17
+
18
+ export function encodeCoatCheckQrPayload(args: {
19
+ eventId: string;
20
+ entitlementId: string;
21
+ qrToken: string;
22
+ }): string {
23
+ const payload: CoatCheckQrPayload = {
24
+ type: COAT_CHECK_QR_TYPE,
25
+ eventId: args.eventId,
26
+ entitlementId: args.entitlementId,
27
+ qrToken: args.qrToken,
28
+ timestamp: Date.now(),
29
+ };
30
+ return JSON.stringify(payload);
31
+ }
32
+
33
+ export function parseCoatCheckQrPayload(
34
+ raw: string
35
+ ): CoatCheckQrPayload | null {
36
+ try {
37
+ const parsed = JSON.parse(raw) as Partial<CoatCheckQrPayload>;
38
+ if (
39
+ parsed?.type !== COAT_CHECK_QR_TYPE ||
40
+ typeof parsed.eventId !== "string" ||
41
+ typeof parsed.entitlementId !== "string" ||
42
+ typeof parsed.qrToken !== "string"
43
+ ) {
44
+ return null;
45
+ }
46
+ return {
47
+ type: COAT_CHECK_QR_TYPE,
48
+ eventId: parsed.eventId,
49
+ entitlementId: parsed.entitlementId,
50
+ qrToken: parsed.qrToken,
51
+ timestamp:
52
+ typeof parsed.timestamp === "number" ? parsed.timestamp : Date.now(),
53
+ };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export function coatCheckScanOutcomeForStatus(
60
+ status: CoatCheckEntitlementStatusName
61
+ ): CoatCheckScanOutcome {
62
+ switch (status) {
63
+ case "Paid":
64
+ return "ready_for_dropoff";
65
+ case "CheckedIn":
66
+ return "ready_for_claim";
67
+ case "Claimed":
68
+ return "already_claimed";
69
+ case "Refunded":
70
+ case "Cancelled":
71
+ return "cancelled_or_refunded";
72
+ default:
73
+ return "invalid_or_expired";
74
+ }
75
+ }
76
+
77
+ export function canTransitionCoatCheckStatus(
78
+ from: CoatCheckEntitlementStatusName,
79
+ to: CoatCheckEntitlementStatusName
80
+ ): boolean {
81
+ if (from === to) return false;
82
+ if (from === "Paid" && to === "CheckedIn") return true;
83
+ if (from === "CheckedIn" && to === "Claimed") return true;
84
+ if (
85
+ (from === "Paid" || from === "CheckedIn") &&
86
+ (to === "Refunded" || to === "Cancelled")
87
+ ) {
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ export function isCoatCheckSlotOccupyingStatus(
94
+ status: string
95
+ ): status is CoatCheckEntitlementStatusName {
96
+ return (COAT_CHECK_SLOT_OCCUPYING_STATUSES as readonly string[]).includes(
97
+ status
98
+ );
99
+ }
100
+
101
+ export function isCoatCheckEntitlementStatus(
102
+ status: string
103
+ ): status is CoatCheckEntitlementStatusName {
104
+ return (COAT_CHECK_ENTITLEMENT_STATUSES as readonly string[]).includes(status);
105
+ }
106
+
107
+ export function assertCoatCheckScanOutcome(
108
+ outcome: string
109
+ ): outcome is CoatCheckScanOutcome {
110
+ return (COAT_CHECK_SCAN_OUTCOMES as readonly string[]).includes(outcome);
111
+ }
112
+
113
+ /** Remaining slots; null capacity = unlimited (Infinity for math). */
114
+ export function remainingCoatCheckSlots(args: {
115
+ capacity: number | null | undefined;
116
+ occupiedCount: number;
117
+ }): number | null {
118
+ if (args.capacity == null) return null;
119
+ return Math.max(0, args.capacity - args.occupiedCount);
120
+ }
121
+
122
+ export function clampCoatCheckPurchaseQty(args: {
123
+ requested: number;
124
+ maxPerPurchase: number;
125
+ remainingSlots: number | null;
126
+ }): number {
127
+ const maxPer = Math.max(1, args.maxPerPurchase || 1);
128
+ let qty = Math.max(0, Math.floor(args.requested));
129
+ qty = Math.min(qty, maxPer);
130
+ if (args.remainingSlots != null) {
131
+ qty = Math.min(qty, args.remainingSlots);
132
+ }
133
+ return qty;
134
+ }
@@ -53,10 +53,9 @@ export function getPublicBashDetailPath(
53
53
  }
54
54
 
55
55
  /**
56
- * Public, shareable Memories recap path for a bash — nested under the bash
57
- * detail path so it inherits the same id/slug parsing (`/bash/{id}-{slug}/memories`).
58
- * No-login-required by design: the whole point of Memories is that a shared
59
- * link works for people who aren't signed in yet.
56
+ * Path to Your Memories (private vault) for a bash — nested under bash detail
57
+ * (`/bash/{id}-{slug}/memories`). Requires sign-in + eligibility; not a public
58
+ * unauthenticated recap page.
60
59
  */
61
60
  export function getPublicBashMemoriesPath(
62
61
  bashEventId: string,