@bash-app/bash-common 30.320.0 → 30.322.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 (37) hide show
  1. package/dist/__tests__/bashEventCancellationPolicy.test.d.ts +2 -0
  2. package/dist/__tests__/bashEventCancellationPolicy.test.d.ts.map +1 -0
  3. package/dist/__tests__/bashEventCancellationPolicy.test.js +91 -0
  4. package/dist/__tests__/bashEventCancellationPolicy.test.js.map +1 -0
  5. package/dist/__tests__/sideQuestIdeas.test.js +5 -1
  6. package/dist/__tests__/sideQuestIdeas.test.js.map +1 -1
  7. package/dist/__tests__/sideQuestTypes.test.js +33 -1
  8. package/dist/__tests__/sideQuestTypes.test.js.map +1 -1
  9. package/dist/bashEventCancellationPolicy.d.ts +58 -0
  10. package/dist/bashEventCancellationPolicy.d.ts.map +1 -0
  11. package/dist/bashEventCancellationPolicy.js +150 -0
  12. package/dist/bashEventCancellationPolicy.js.map +1 -0
  13. package/dist/definitions.d.ts +5 -0
  14. package/dist/definitions.d.ts.map +1 -1
  15. package/dist/definitions.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/sideQuestIdeas.d.ts +2 -2
  21. package/dist/sideQuestIdeas.d.ts.map +1 -1
  22. package/dist/sideQuestIdeas.js +18 -2
  23. package/dist/sideQuestIdeas.js.map +1 -1
  24. package/dist/sideQuestTypes.d.ts +76 -0
  25. package/dist/sideQuestTypes.d.ts.map +1 -1
  26. package/dist/sideQuestTypes.js +35 -0
  27. package/dist/sideQuestTypes.js.map +1 -1
  28. package/package.json +1 -1
  29. package/prisma/schema.prisma +80 -1
  30. package/src/__tests__/bashEventCancellationPolicy.test.ts +148 -0
  31. package/src/__tests__/sideQuestIdeas.test.ts +5 -1
  32. package/src/__tests__/sideQuestTypes.test.ts +41 -0
  33. package/src/bashEventCancellationPolicy.ts +231 -0
  34. package/src/definitions.ts +5 -0
  35. package/src/index.ts +1 -0
  36. package/src/sideQuestIdeas.ts +22 -3
  37. package/src/sideQuestTypes.ts +111 -0
@@ -2043,7 +2043,7 @@ enum SideQuestCategory {
2043
2043
  GiveBack
2044
2044
  }
2045
2045
 
2046
- /// How the guest proves completion. Phase 1: Qr + Code. Others reserved.
2046
+ /// How the guest proves completion. Phase 1+: Qr, Code, Vote, Circuit, Connect. Others reserved.
2047
2047
  enum SideQuestType {
2048
2048
  Qr
2049
2049
  Code
@@ -2055,6 +2055,10 @@ enum SideQuestType {
2055
2055
  Sponsor
2056
2056
  Social
2057
2057
  Trivia
2058
+ /// Multi-stop booth tour — guest scans N checkpoint QRs
2059
+ Circuit
2060
+ /// Meet N people — scan their ticket QR after the icebreaker (requiredTicks)
2061
+ Connect
2058
2062
  }
2059
2063
 
2060
2064
  enum EventBadgeAwardSource {
@@ -2089,6 +2093,12 @@ model SideQuest {
2089
2093
  sponsorServiceId String?
2090
2094
  /// When questType is Vote: the competition casting a vote completes this quest
2091
2095
  competitionId String?
2096
+ /// Ticks needed for this guest to complete (1 = single-shot; Circuit/Connect use N)
2097
+ requiredTicks Int @default(1)
2098
+ /// Quest-local icebreaker when bash icebreaker is off (Connect quests)
2099
+ icebreakerPrompt String?
2100
+ /// Set when timed-window-open push has been sent (cron)
2101
+ windowOpenedNotifiedAt DateTime?
2092
2102
  createdAt DateTime @default(now())
2093
2103
  updatedAt DateTime @updatedAt
2094
2104
 
@@ -2097,11 +2107,70 @@ model SideQuest {
2097
2107
  competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
2098
2108
  completions SideQuestCompletion[]
2099
2109
  milestonesUnlocking SideQuestMilestone[] @relation("MilestoneUnlocksQuest")
2110
+ checkpoints SideQuestCheckpoint[]
2111
+ progressHits SideQuestProgressHit[]
2100
2112
 
2101
2113
  @@index([bashEventId])
2102
2114
  @@index([bashEventId, sortOrder])
2103
2115
  @@index([eventBadgeId])
2104
2116
  @@index([competitionId])
2117
+ @@index([startsAt, windowOpenedNotifiedAt])
2118
+ }
2119
+
2120
+ model SideQuestCheckpoint {
2121
+ id String @id @default(cuid())
2122
+ sideQuestId String
2123
+ label String
2124
+ sortOrder Int @default(0)
2125
+ code String
2126
+ codeHash String
2127
+ /// Platform hire (Vendors / Exhibitors) — Pending, Approved, or Confirmed.
2128
+ serviceBookingId String?
2129
+ serviceId String?
2130
+ /// Partner request flows (vendor or exhibitor) before/at confirm.
2131
+ vendorBookingRequestId String?
2132
+ exhibitorBookingRequestId String?
2133
+ /// Off-platform vendor — QR emailed here; optional display name.
2134
+ contactEmail String?
2135
+ contactName String?
2136
+ createdAt DateTime @default(now())
2137
+ updatedAt DateTime @updatedAt
2138
+
2139
+ sideQuest SideQuest @relation(fields: [sideQuestId], references: [id], onDelete: Cascade)
2140
+ hits SideQuestProgressHit[]
2141
+ serviceBooking ServiceBooking? @relation(fields: [serviceBookingId], references: [id], onDelete: SetNull)
2142
+ service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull)
2143
+ vendorBookingRequest VendorBookingRequest? @relation(fields: [vendorBookingRequestId], references: [id], onDelete: SetNull)
2144
+ exhibitorBookingRequest ExhibitorBookingRequest? @relation(fields: [exhibitorBookingRequestId], references: [id], onDelete: SetNull)
2145
+
2146
+ @@index([sideQuestId, sortOrder])
2147
+ @@index([sideQuestId, codeHash])
2148
+ @@index([serviceBookingId])
2149
+ @@index([vendorBookingRequestId])
2150
+ @@index([exhibitorBookingRequestId])
2151
+ }
2152
+
2153
+ /// Per-guest progress toward requiredTicks (checkpoint scan or Connect meet).
2154
+ model SideQuestProgressHit {
2155
+ id String @id @default(cuid())
2156
+ sideQuestId String
2157
+ userId String
2158
+ /// Stable key: "cp:{checkpointId}" | "user:{meetUserId}" | "name:{normalized}"
2159
+ dedupeKey String
2160
+ checkpointId String?
2161
+ meetDisplayName String?
2162
+ meetUserId String?
2163
+ createdAt DateTime @default(now())
2164
+
2165
+ sideQuest SideQuest @relation(fields: [sideQuestId], references: [id], onDelete: Cascade)
2166
+ user User @relation("SideQuestProgressHits", fields: [userId], references: [id], onDelete: Cascade)
2167
+ checkpoint SideQuestCheckpoint? @relation(fields: [checkpointId], references: [id], onDelete: Cascade)
2168
+ meetUser User? @relation("SideQuestMeetTargets", fields: [meetUserId], references: [id], onDelete: SetNull)
2169
+
2170
+ @@unique([sideQuestId, userId, dedupeKey])
2171
+ @@index([sideQuestId, userId])
2172
+ @@index([userId])
2173
+ @@index([checkpointId])
2105
2174
  }
2106
2175
 
2107
2176
  model SideQuestCompletion {
@@ -2217,6 +2286,8 @@ model CommunityQuestGoal {
2217
2286
  eventBadgeId String?
2218
2287
  bashPointsReward Int?
2219
2288
  unlockedAt DateTime?
2289
+ /// Set when ~80% near-goal push has been sent (once per event)
2290
+ nearGoalNotifiedAt DateTime?
2220
2291
  createdAt DateTime @default(now())
2221
2292
  updatedAt DateTime @updatedAt
2222
2293
 
@@ -3815,6 +3886,8 @@ model User {
3815
3886
  eventBadgeAwards EventBadgeAward[] @relation("EventBadgeAwards")
3816
3887
  sideQuestMilestoneClaims SideQuestMilestoneClaim[] @relation("SideQuestMilestoneClaims")
3817
3888
  sideQuestPrizeEntries SideQuestPrizeEntry[] @relation("SideQuestPrizeEntries")
3889
+ sideQuestProgressHits SideQuestProgressHit[] @relation("SideQuestProgressHits")
3890
+ sideQuestMeetTargets SideQuestProgressHit[] @relation("SideQuestMeetTargets")
3818
3891
  contacts Contact[]
3819
3892
  contactLists ContactList[] @relation("UserContactLists")
3820
3893
  contactlist ContactList[]
@@ -4638,6 +4711,7 @@ model Service {
4638
4711
  googleReviews GoogleReview[]
4639
4712
  serviceReviews ServiceReview[]
4640
4713
  providerHostReviews ProviderHostReview[]
4714
+ sideQuestCheckpoints SideQuestCheckpoint[]
4641
4715
 
4642
4716
  @@index([serviceListingStripeSubscriptionId])
4643
4717
  @@index([paymentAccountId])
@@ -6383,6 +6457,7 @@ model ServiceBooking {
6383
6457
  representedByOrganization Organization? @relation(fields: [representedByOrganizationId], references: [id], onDelete: SetNull)
6384
6458
  representation OrganizationArtistRepresentation? @relation("BookingRepresentation", fields: [representationId], references: [id], onDelete: SetNull)
6385
6459
  agencySettlement AgencyBookingSettlement?
6460
+ sideQuestCheckpoints SideQuestCheckpoint[]
6386
6461
 
6387
6462
  @@index([status])
6388
6463
  @@index([creatorId])
@@ -6452,6 +6527,7 @@ model VendorBookingRequest {
6452
6527
  vendorService Service @relation("VendorBookingService", fields: [vendorServiceId], references: [id], onDelete: Cascade)
6453
6528
  offers ServiceBookingOffer[]
6454
6529
  categoryExclusivities VendorCategoryExclusivity[]
6530
+ sideQuestCheckpoints SideQuestCheckpoint[]
6455
6531
 
6456
6532
  @@index([hostUserId])
6457
6533
  @@index([vendorServiceId])
@@ -6575,6 +6651,7 @@ model ExhibitorBookingRequest {
6575
6651
  exhibitorService Service @relation("ExhibitorBookingService", fields: [exhibitorServiceId], references: [id], onDelete: Cascade)
6576
6652
  hostUser User @relation("ExhibitorBookingHost", fields: [hostUserId], references: [id], onDelete: Cascade)
6577
6653
  serviceMessages ServiceBookingMessage[] @relation("ExhibitorServiceMessages")
6654
+ sideQuestCheckpoints SideQuestCheckpoint[]
6578
6655
 
6579
6656
  @@index([hostUserId])
6580
6657
  @@index([exhibitorServiceId])
@@ -7080,6 +7157,8 @@ enum NotificationType {
7080
7157
  PartnerPromotionBlast // Attendee: partner on-site promo deal at an event
7081
7158
  SideQuestRevealed // Attendee: host revealed a secret Side Quest
7082
7159
  CommunityQuestUnlocked // Attendee: community quest goal unlocked
7160
+ CommunityQuestNearGoal // Attendee: community goal ~80% of the way
7161
+ SideQuestWindowOpened // Attendee: a timed Side Quest just went live
7083
7162
  }
7084
7163
 
7085
7164
  /// Opt-in reminder for a scheduled tier release (v1.5).
@@ -0,0 +1,148 @@
1
+ import {
2
+ resolveBashEventCancellationPresetId,
3
+ bashEventCancellationPresetDisplayText,
4
+ bashEventCancellationPolicyDetailLabel,
5
+ defaultBashEventCancellationPolicyDisplayText,
6
+ resolveBashEventGuestRefundEligibility,
7
+ BASH_EVENT_CANCELLATION_PRESETS,
8
+ DEFAULT_BASH_EVENT_CANCELLATION_PRESET_ID,
9
+ } from "../bashEventCancellationPolicy";
10
+
11
+ describe("bashEventCancellationPolicy", () => {
12
+ it("defaults to Standard — 7 days", () => {
13
+ expect(DEFAULT_BASH_EVENT_CANCELLATION_PRESET_ID).toBe("flexible_7_day");
14
+ expect(defaultBashEventCancellationPolicyDisplayText()).toBe(
15
+ bashEventCancellationPresetDisplayText("flexible_7_day")
16
+ );
17
+ });
18
+
19
+ it("lists Anytime first as the most guest-friendly preset", () => {
20
+ expect(BASH_EVENT_CANCELLATION_PRESETS[0].id).toBe("anytime_until_start");
21
+ expect(BASH_EVENT_CANCELLATION_PRESETS[0].name).toBe("Anytime");
22
+ });
23
+
24
+ it("maps stored policy text to a short detail-page label", () => {
25
+ expect(bashEventCancellationPolicyDetailLabel(null)).toBeNull();
26
+ expect(
27
+ bashEventCancellationPolicyDetailLabel(
28
+ bashEventCancellationPresetDisplayText("anytime_until_start")
29
+ )
30
+ ).toBe("Anytime");
31
+ expect(
32
+ bashEventCancellationPolicyDetailLabel(
33
+ bashEventCancellationPresetDisplayText("flexible_7_day")
34
+ )
35
+ ).toBe("Standard — 7 days");
36
+ expect(
37
+ bashEventCancellationPolicyDetailLabel("Call the host for a refund.")
38
+ ).toBe("Call the host for a refund.");
39
+ });
40
+
41
+ it("resolves empty to null", () => {
42
+ expect(resolveBashEventCancellationPresetId(null)).toBeNull();
43
+ expect(resolveBashEventCancellationPresetId("")).toBeNull();
44
+ expect(resolveBashEventCancellationPresetId(" ")).toBeNull();
45
+ });
46
+
47
+ it("resolves each preset displayText to its id", () => {
48
+ for (const preset of BASH_EVENT_CANCELLATION_PRESETS) {
49
+ expect(resolveBashEventCancellationPresetId(preset.displayText)).toBe(
50
+ preset.id
51
+ );
52
+ expect(bashEventCancellationPresetDisplayText(preset.id)).toBe(
53
+ preset.displayText
54
+ );
55
+ }
56
+ });
57
+
58
+ it("treats unknown free text as custom", () => {
59
+ expect(
60
+ resolveBashEventCancellationPresetId("Contact the host for refund.")
61
+ ).toBe("custom");
62
+ });
63
+
64
+ describe("resolveBashEventGuestRefundEligibility", () => {
65
+ const start = new Date("2026-08-01T20:00:00.000Z");
66
+
67
+ it("auto-refunds Standard 7-day when more than 7 days out", () => {
68
+ const now = new Date("2026-07-20T20:00:00.000Z"); // 12 days before
69
+ const result = resolveBashEventGuestRefundEligibility(
70
+ bashEventCancellationPresetDisplayText("flexible_7_day"),
71
+ { eventStart: start, now }
72
+ );
73
+ expect(result).toEqual({
74
+ mode: "auto",
75
+ eligibleNow: true,
76
+ presetId: "flexible_7_day",
77
+ fullRefundHoursBeforeStart: 168,
78
+ });
79
+ });
80
+
81
+ it("blocks Standard 7-day when inside the 7-day window", () => {
82
+ const now = new Date("2026-07-28T20:00:00.000Z"); // 4 days before
83
+ const result = resolveBashEventGuestRefundEligibility(
84
+ bashEventCancellationPresetDisplayText("flexible_7_day"),
85
+ { eventStart: start, now }
86
+ );
87
+ expect(result.mode).toBe("auto");
88
+ expect(result.eligibleNow).toBe(false);
89
+ });
90
+
91
+ it("allows Anytime until the event starts", () => {
92
+ const now = new Date("2026-08-01T19:00:00.000Z");
93
+ const result = resolveBashEventGuestRefundEligibility(
94
+ bashEventCancellationPresetDisplayText("anytime_until_start"),
95
+ { eventStart: start, now }
96
+ );
97
+ expect(result.mode).toBe("auto");
98
+ expect(result.eligibleNow).toBe(true);
99
+ expect(result.fullRefundHoursBeforeStart).toBe(0);
100
+ });
101
+
102
+ it("blocks Anytime after the event starts", () => {
103
+ const now = new Date("2026-08-01T21:00:00.000Z");
104
+ const result = resolveBashEventGuestRefundEligibility(
105
+ bashEventCancellationPresetDisplayText("anytime_until_start"),
106
+ { eventStart: start, now }
107
+ );
108
+ expect(result.eligibleNow).toBe(false);
109
+ });
110
+
111
+ it("returns none for no_refunds", () => {
112
+ const result = resolveBashEventGuestRefundEligibility(
113
+ bashEventCancellationPresetDisplayText("no_refunds"),
114
+ { eventStart: start, now: new Date("2026-06-01T00:00:00.000Z") }
115
+ );
116
+ expect(result).toEqual({
117
+ mode: "none",
118
+ eligibleNow: false,
119
+ presetId: "no_refunds",
120
+ fullRefundHoursBeforeStart: null,
121
+ });
122
+ });
123
+
124
+ it("returns host_review for custom free text", () => {
125
+ const result = resolveBashEventGuestRefundEligibility(
126
+ "Email the host for a partial refund.",
127
+ { eventStart: start, now: new Date("2026-06-01T00:00:00.000Z") }
128
+ );
129
+ expect(result).toEqual({
130
+ mode: "host_review",
131
+ eligibleNow: true,
132
+ presetId: "custom",
133
+ fullRefundHoursBeforeStart: null,
134
+ });
135
+ });
136
+
137
+ it("uses Standard 7-day when policy is empty", () => {
138
+ const now = new Date("2026-07-20T20:00:00.000Z");
139
+ const result = resolveBashEventGuestRefundEligibility(null, {
140
+ eventStart: start,
141
+ now,
142
+ });
143
+ expect(result.presetId).toBe("flexible_7_day");
144
+ expect(result.mode).toBe("auto");
145
+ expect(result.eligibleNow).toBe(true);
146
+ });
147
+ });
148
+ });
@@ -19,8 +19,12 @@ describe("sideQuestIdeas", () => {
19
19
  expect(hits.every((h) => JSON.stringify(h).toLowerCase().includes("transit") || h.category === "Transit")).toBe(true);
20
20
  });
21
21
 
22
- it("includes Vote in phase 1 types", () => {
22
+ it("includes Vote, Circuit, and Connect in phase 1 types", () => {
23
23
  expect(SIDE_QUEST_PHASE1_TYPES).toContain("Vote");
24
+ expect(SIDE_QUEST_PHASE1_TYPES).toContain("Circuit");
25
+ expect(SIDE_QUEST_PHASE1_TYPES).toContain("Connect");
24
26
  expect(SIDE_QUEST_TYPE_META.Vote.phase1).toBe(true);
27
+ expect(SIDE_QUEST_TYPE_META.Circuit.phase1).toBe(true);
28
+ expect(SIDE_QUEST_TYPE_META.Connect.phase1).toBe(true);
25
29
  });
26
30
  });
@@ -2,6 +2,7 @@ import {
2
2
  buildSideQuestDeepLink,
3
3
  buildSideQuestLegacyPayload,
4
4
  parseSideQuestScanPayload,
5
+ parseTicketDoorQrPayload,
5
6
  } from "../sideQuestTypes.js";
6
7
 
7
8
  describe("sideQuestTypes deep links", () => {
@@ -38,7 +39,47 @@ describe("sideQuestTypes deep links", () => {
38
39
  });
39
40
  });
40
41
 
42
+ it("builds and parses checkpoint deep links", () => {
43
+ const url = buildSideQuestDeepLink({
44
+ bashEventId: "evt1",
45
+ questId: "q1",
46
+ code: "ab12",
47
+ checkpointId: "cp9",
48
+ frontendOrigin: "https://bash.community",
49
+ });
50
+ expect(url).toContain("checkpoint=cp9");
51
+ expect(parseSideQuestScanPayload(url)).toEqual({
52
+ bashEventId: "evt1",
53
+ questId: "q1",
54
+ checkpointId: "cp9",
55
+ code: "AB12",
56
+ });
57
+ });
58
+
41
59
  it("parses plain codes", () => {
42
60
  expect(parseSideQuestScanPayload("ab12")).toEqual({ code: "AB12" });
43
61
  });
62
+
63
+ it("parses ticket door QR JSON for Connect meets", () => {
64
+ expect(
65
+ parseTicketDoorQrPayload(
66
+ JSON.stringify({
67
+ eventId: "evt-1",
68
+ userId: "user-2",
69
+ ticketId: "tix-9",
70
+ qrToken: "tok",
71
+ timestamp: "2026-01-01T00:00:00.000Z",
72
+ })
73
+ )
74
+ ).toEqual({
75
+ eventId: "evt-1",
76
+ userId: "user-2",
77
+ ticketId: "tix-9",
78
+ qrToken: "tok",
79
+ });
80
+ expect(parseTicketDoorQrPayload("not-json")).toBeNull();
81
+ expect(
82
+ parseTicketDoorQrPayload(JSON.stringify({ eventId: "e", userId: "u" }))
83
+ ).toBeNull();
84
+ });
44
85
  });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Guest-facing cancellation policy presets for ticketed bash events
3
+ * (Eventbrite-style windows). Stored as the human-readable `displayText` in
4
+ * BashEvent.cancellationPolicy; refund eligibility is resolved from the matching
5
+ * named preset (or host-review for custom free text).
6
+ */
7
+
8
+ import { hoursUntilEventStart } from "./utils/service/cancellationPolicyRefundResolver.js";
9
+
10
+ export const BASH_EVENT_CANCELLATION_PRESET_IDS = [
11
+ "anytime_until_start",
12
+ "flexible_1_day",
13
+ "flexible_7_day",
14
+ "flexible_30_day",
15
+ "no_refunds",
16
+ "custom",
17
+ ] as const;
18
+
19
+ export type BashEventCancellationPresetId =
20
+ (typeof BASH_EVENT_CANCELLATION_PRESET_IDS)[number];
21
+
22
+ export type BashEventCancellationNamedPresetId = Exclude<
23
+ BashEventCancellationPresetId,
24
+ "custom"
25
+ >;
26
+
27
+ /** Default for new bashes and empty policy fields. */
28
+ export const DEFAULT_BASH_EVENT_CANCELLATION_PRESET_ID: BashEventCancellationNamedPresetId =
29
+ "flexible_7_day";
30
+
31
+ export type BashEventCancellationPreset = {
32
+ id: BashEventCancellationNamedPresetId;
33
+ name: string;
34
+ /** Short line under the name in the host picker. */
35
+ summary: string;
36
+ /** Exact string persisted to BashEvent.cancellationPolicy and shown to guests. */
37
+ displayText: string;
38
+ /**
39
+ * Hours before start required for a full auto guest refund.
40
+ * - `0` = anytime until the event starts (`hoursUntil > 0`)
41
+ * - `null` = no guest auto-refunds (`no_refunds`)
42
+ */
43
+ fullRefundHoursBeforeStart: number | null;
44
+ };
45
+
46
+ /** Ordered most guest-friendly → strictest (default is still Standard). */
47
+ export const BASH_EVENT_CANCELLATION_PRESETS: readonly BashEventCancellationPreset[] =
48
+ [
49
+ {
50
+ id: "anytime_until_start",
51
+ name: "Anytime",
52
+ summary: "Full refund until the event starts.",
53
+ displayText:
54
+ "Full refund if you cancel anytime before the event starts. No refunds after it begins.",
55
+ fullRefundHoursBeforeStart: 0,
56
+ },
57
+ {
58
+ id: "flexible_1_day",
59
+ name: "Flexible — 24 hours",
60
+ summary: "Full refund until 24 hours before start.",
61
+ displayText:
62
+ "Full refund if you cancel at least 24 hours before the event starts. No refunds after that.",
63
+ fullRefundHoursBeforeStart: 24,
64
+ },
65
+ {
66
+ id: "flexible_7_day",
67
+ name: "Standard — 7 days",
68
+ summary: "Full refund until 7 days before start.",
69
+ displayText:
70
+ "Full refund if you cancel at least 7 days before the event starts. No refunds after that.",
71
+ fullRefundHoursBeforeStart: 168,
72
+ },
73
+ {
74
+ id: "flexible_30_day",
75
+ name: "Strict — 30 days",
76
+ summary: "Full refund until 30 days before start.",
77
+ displayText:
78
+ "Full refund if you cancel at least 30 days before the event starts. No refunds after that.",
79
+ fullRefundHoursBeforeStart: 720,
80
+ },
81
+ {
82
+ id: "no_refunds",
83
+ name: "No guest refunds",
84
+ summary: "Tickets are final sale for guest cancellations.",
85
+ displayText:
86
+ "No refunds for guest cancellations. If the host cancels the event, refunds follow the host’s guarantee settings.",
87
+ fullRefundHoursBeforeStart: null,
88
+ },
89
+ ] as const;
90
+
91
+ const PRESET_BY_DISPLAY = new Map(
92
+ BASH_EVENT_CANCELLATION_PRESETS.map((p) => [p.displayText, p.id] as const)
93
+ );
94
+
95
+ const PRESET_BY_ID = new Map(
96
+ BASH_EVENT_CANCELLATION_PRESETS.map((p) => [p.id, p] as const)
97
+ );
98
+
99
+ /** Map a stored policy string to a preset id (or custom / null if empty). */
100
+ export function resolveBashEventCancellationPresetId(
101
+ policy: string | null | undefined
102
+ ): BashEventCancellationPresetId | null {
103
+ const trimmed = policy?.trim() ?? "";
104
+ if (!trimmed) return null;
105
+ return PRESET_BY_DISPLAY.get(trimmed) ?? "custom";
106
+ }
107
+
108
+ export function getBashEventCancellationPreset(
109
+ id: BashEventCancellationNamedPresetId
110
+ ): BashEventCancellationPreset {
111
+ const preset = PRESET_BY_ID.get(id);
112
+ if (!preset) {
113
+ throw new Error(`Unknown bash cancellation preset: ${id}`);
114
+ }
115
+ return preset;
116
+ }
117
+
118
+ export function bashEventCancellationPresetDisplayText(
119
+ id: BashEventCancellationNamedPresetId
120
+ ): string {
121
+ return getBashEventCancellationPreset(id).displayText;
122
+ }
123
+
124
+ export function defaultBashEventCancellationPolicyDisplayText(): string {
125
+ return bashEventCancellationPresetDisplayText(
126
+ DEFAULT_BASH_EVENT_CANCELLATION_PRESET_ID
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Short label for bash detail / meta UI. Named presets use the host-picker name
132
+ * (e.g. "Anytime"); custom/legacy free text returns the stored string.
133
+ */
134
+ export function bashEventCancellationPolicyDetailLabel(
135
+ policy: string | null | undefined
136
+ ): string | null {
137
+ const trimmed = policy?.trim() ?? "";
138
+ if (!trimmed) return null;
139
+ const id = resolveBashEventCancellationPresetId(trimmed);
140
+ if (id && id !== "custom") {
141
+ return getBashEventCancellationPreset(id).name;
142
+ }
143
+ return trimmed;
144
+ }
145
+
146
+ export type BashEventGuestRefundMode = "auto" | "host_review" | "none";
147
+
148
+ export type BashEventGuestRefundEligibility = {
149
+ mode: BashEventGuestRefundMode;
150
+ /** True when the guest may take the mode's money path right now. */
151
+ eligibleNow: boolean;
152
+ presetId: BashEventCancellationPresetId | null;
153
+ fullRefundHoursBeforeStart: number | null;
154
+ };
155
+
156
+ export type ResolveBashEventGuestRefundOptions = {
157
+ eventStart: Date | string | null | undefined;
158
+ now?: Date;
159
+ };
160
+
161
+ function parseEventStart(
162
+ eventStart: Date | string | null | undefined
163
+ ): Date | null {
164
+ if (eventStart == null) return null;
165
+ const d = eventStart instanceof Date ? eventStart : new Date(eventStart);
166
+ if (Number.isNaN(d.getTime())) return null;
167
+ return d;
168
+ }
169
+
170
+ /**
171
+ * Resolves guest self-serve refund path from stored cancellation policy text.
172
+ * - Named presets (except no_refunds): `auto` when still inside the window
173
+ * - `no_refunds`: `none`
174
+ * - Custom / unknown free text: `host_review` (host must approve)
175
+ * - Empty / missing policy: treated as default Standard 7-day preset
176
+ */
177
+ export function resolveBashEventGuestRefundEligibility(
178
+ policy: string | null | undefined,
179
+ options: ResolveBashEventGuestRefundOptions
180
+ ): BashEventGuestRefundEligibility {
181
+ const now = options.now ?? new Date();
182
+ const eventStart = parseEventStart(options.eventStart);
183
+ const hoursUntil =
184
+ eventStart != null ? hoursUntilEventStart(eventStart, now) : 0;
185
+
186
+ const trimmed = policy?.trim() ?? "";
187
+ const resolvedId = trimmed
188
+ ? resolveBashEventCancellationPresetId(trimmed)
189
+ : DEFAULT_BASH_EVENT_CANCELLATION_PRESET_ID;
190
+
191
+ if (resolvedId === "custom") {
192
+ return {
193
+ mode: "host_review",
194
+ eligibleNow: true,
195
+ presetId: "custom",
196
+ fullRefundHoursBeforeStart: null,
197
+ };
198
+ }
199
+
200
+ if (resolvedId == null) {
201
+ return {
202
+ mode: "none",
203
+ eligibleNow: false,
204
+ presetId: null,
205
+ fullRefundHoursBeforeStart: null,
206
+ };
207
+ }
208
+
209
+ const preset = getBashEventCancellationPreset(resolvedId);
210
+ const threshold = preset.fullRefundHoursBeforeStart;
211
+
212
+ if (threshold == null) {
213
+ return {
214
+ mode: "none",
215
+ eligibleNow: false,
216
+ presetId: resolvedId,
217
+ fullRefundHoursBeforeStart: null,
218
+ };
219
+ }
220
+
221
+ // threshold 0 = anytime until start; otherwise need hoursUntil >= threshold
222
+ const eligibleNow =
223
+ hoursUntil > 0 && (threshold === 0 || hoursUntil >= threshold);
224
+
225
+ return {
226
+ mode: "auto",
227
+ eligibleNow,
228
+ presetId: resolvedId,
229
+ fullRefundHoursBeforeStart: threshold,
230
+ };
231
+ }
@@ -836,6 +836,11 @@ export interface ResetPassword {
836
836
 
837
837
  export interface ApiResult<T, P extends ErrorDataType = ErrorDataType> {
838
838
  data?: T;
839
+ /**
840
+ * Endpoint-specific extras that are not the primary `data` payload
841
+ * (e.g. guest roster `depth`, ticket `refundMode`).
842
+ */
843
+ meta?: Record<string, unknown>;
839
844
  /** PATCH group member / some RSVP endpoints — true when BashPoints RSVP bonus was newly awarded */
840
845
  rsvpBonusAwarded?: boolean;
841
846
  /** POST event task assign — true when volunteer guestlist invite was created */
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from "./hostCrmInsights.js";
35
35
  export * from "./hostCrmAutomation.js";
36
36
  export * from "./relationshipHealth.js";
37
37
  export * from "./hostCrmConstants.js";
38
+ export * from "./bashEventCancellationPolicy.js";
38
39
  export * from "./userReportTypes.js";
39
40
  export * from "./mirroredPrismaEnums.js";
40
41
  export * from "./utils/featuredDiscoveryGeo.js";
@@ -23,7 +23,9 @@ export type SideQuestTypeId =
23
23
  | "Vote"
24
24
  | "Sponsor"
25
25
  | "Social"
26
- | "Trivia";
26
+ | "Trivia"
27
+ | "Circuit"
28
+ | "Connect";
27
29
 
28
30
  export const SIDE_QUEST_CATEGORY_META: Record<
29
31
  SideQuestCategoryId,
@@ -39,8 +41,14 @@ export const SIDE_QUEST_CATEGORY_META: Record<
39
41
  GiveBack: { label: "Give Back", emoji: "❤️" },
40
42
  };
41
43
 
42
- /** Phase 1 host-selectable quest types */
43
- export const SIDE_QUEST_PHASE1_TYPES: SideQuestTypeId[] = ["Qr", "Code", "Vote"];
44
+ /** Host-selectable quest types (single-shot + Circuit/Connect) */
45
+ export const SIDE_QUEST_PHASE1_TYPES: SideQuestTypeId[] = [
46
+ "Qr",
47
+ "Code",
48
+ "Vote",
49
+ "Circuit",
50
+ "Connect",
51
+ ];
44
52
 
45
53
  export const SIDE_QUEST_TYPE_META: Record<
46
54
  SideQuestTypeId,
@@ -96,6 +104,17 @@ export const SIDE_QUEST_TYPE_META: Record<
96
104
  description: "Answer a question correctly.",
97
105
  phase1: false,
98
106
  },
107
+ Circuit: {
108
+ label: "Vendor circuit",
109
+ description: "Guest scans QRs at N booths — progress ticks toward done.",
110
+ phase1: true,
111
+ },
112
+ Connect: {
113
+ label: "Icebreaker Connect",
114
+ description:
115
+ "Meet N people by scanning their ticket QR after the icebreaker.",
116
+ phase1: true,
117
+ },
99
118
  };
100
119
 
101
120
  export interface SideQuestIdea {