@bash-app/bash-common 30.391.0 → 30.393.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 (101) hide show
  1. package/dist/__tests__/bashSectionTitleForBashType.test.js +5 -0
  2. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  3. package/dist/__tests__/budgetApproval.test.d.ts +2 -0
  4. package/dist/__tests__/budgetApproval.test.d.ts.map +1 -0
  5. package/dist/__tests__/budgetApproval.test.js +109 -0
  6. package/dist/__tests__/budgetApproval.test.js.map +1 -0
  7. package/dist/__tests__/crewSocialCopy.test.d.ts +2 -0
  8. package/dist/__tests__/crewSocialCopy.test.d.ts.map +1 -0
  9. package/dist/__tests__/crewSocialCopy.test.js +21 -0
  10. package/dist/__tests__/crewSocialCopy.test.js.map +1 -0
  11. package/dist/__tests__/discoveryScope.test.d.ts +2 -0
  12. package/dist/__tests__/discoveryScope.test.d.ts.map +1 -0
  13. package/dist/__tests__/discoveryScope.test.js +40 -0
  14. package/dist/__tests__/discoveryScope.test.js.map +1 -0
  15. package/dist/__tests__/suggestedNight.test.d.ts +2 -0
  16. package/dist/__tests__/suggestedNight.test.d.ts.map +1 -0
  17. package/dist/__tests__/suggestedNight.test.js +10 -0
  18. package/dist/__tests__/suggestedNight.test.js.map +1 -0
  19. package/dist/__tests__/suggestionScore.test.d.ts +2 -0
  20. package/dist/__tests__/suggestionScore.test.d.ts.map +1 -0
  21. package/dist/__tests__/suggestionScore.test.js +68 -0
  22. package/dist/__tests__/suggestionScore.test.js.map +1 -0
  23. package/dist/__tests__/userTaste.test.d.ts +2 -0
  24. package/dist/__tests__/userTaste.test.d.ts.map +1 -0
  25. package/dist/__tests__/userTaste.test.js +21 -0
  26. package/dist/__tests__/userTaste.test.js.map +1 -0
  27. package/dist/budgetApproval.d.ts +126 -0
  28. package/dist/budgetApproval.d.ts.map +1 -0
  29. package/dist/budgetApproval.js +161 -0
  30. package/dist/budgetApproval.js.map +1 -0
  31. package/dist/crewSocialCopy.d.ts +16 -0
  32. package/dist/crewSocialCopy.d.ts.map +1 -0
  33. package/dist/crewSocialCopy.js +23 -0
  34. package/dist/crewSocialCopy.js.map +1 -0
  35. package/dist/definitions.d.ts +1 -0
  36. package/dist/definitions.d.ts.map +1 -1
  37. package/dist/definitions.js +6 -0
  38. package/dist/definitions.js.map +1 -1
  39. package/dist/discoveryScope.d.ts +48 -0
  40. package/dist/discoveryScope.d.ts.map +1 -0
  41. package/dist/discoveryScope.js +114 -0
  42. package/dist/discoveryScope.js.map +1 -0
  43. package/dist/home.d.ts +6 -1
  44. package/dist/home.d.ts.map +1 -1
  45. package/dist/home.js +5 -1
  46. package/dist/home.js.map +1 -1
  47. package/dist/hostCrmCrowdPulse.d.ts +2 -0
  48. package/dist/hostCrmCrowdPulse.d.ts.map +1 -1
  49. package/dist/hostCrmCrowdPulse.js.map +1 -1
  50. package/dist/hostCrmSegments.d.ts +6 -0
  51. package/dist/hostCrmSegments.d.ts.map +1 -1
  52. package/dist/hostCrmSegments.js.map +1 -1
  53. package/dist/index.d.ts +7 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +13 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/sms/smsTemplates.d.ts +6 -1
  58. package/dist/sms/smsTemplates.d.ts.map +1 -1
  59. package/dist/sms/smsTemplates.js +1 -0
  60. package/dist/sms/smsTemplates.js.map +1 -1
  61. package/dist/suggestedNight.d.ts +12 -0
  62. package/dist/suggestedNight.d.ts.map +1 -0
  63. package/dist/suggestedNight.js +12 -0
  64. package/dist/suggestedNight.js.map +1 -0
  65. package/dist/suggestionScore.d.ts +42 -0
  66. package/dist/suggestionScore.d.ts.map +1 -0
  67. package/dist/suggestionScore.js +100 -0
  68. package/dist/suggestionScore.js.map +1 -0
  69. package/dist/userTaste.d.ts +40 -0
  70. package/dist/userTaste.d.ts.map +1 -0
  71. package/dist/userTaste.js +103 -0
  72. package/dist/userTaste.js.map +1 -0
  73. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +9 -1
  74. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -1
  75. package/dist/utils/featuredDiscoveryGeo.d.ts +7 -0
  76. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -1
  77. package/dist/utils/featuredDiscoveryGeo.js +11 -0
  78. package/dist/utils/featuredDiscoveryGeo.js.map +1 -1
  79. package/package.json +1 -1
  80. package/prisma/schema.prisma +140 -53
  81. package/src/__tests__/bashSectionTitleForBashType.test.ts +10 -0
  82. package/src/__tests__/budgetApproval.test.ts +153 -0
  83. package/src/__tests__/crewSocialCopy.test.ts +25 -0
  84. package/src/__tests__/discoveryScope.test.ts +65 -0
  85. package/src/__tests__/suggestedNight.test.ts +11 -0
  86. package/src/__tests__/suggestionScore.test.ts +81 -0
  87. package/src/__tests__/userTaste.test.ts +33 -0
  88. package/src/budgetApproval.ts +303 -0
  89. package/src/crewSocialCopy.ts +37 -0
  90. package/src/definitions.ts +6 -0
  91. package/src/discoveryScope.ts +170 -0
  92. package/src/home.ts +15 -0
  93. package/src/hostCrmCrowdPulse.ts +2 -0
  94. package/src/hostCrmSegments.ts +6 -0
  95. package/src/index.ts +12 -3
  96. package/src/sms/smsTemplates.ts +6 -0
  97. package/src/suggestedNight.ts +20 -0
  98. package/src/suggestionScore.ts +140 -0
  99. package/src/userTaste.ts +152 -0
  100. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +13 -0
  101. package/src/utils/featuredDiscoveryGeo.ts +15 -0
@@ -0,0 +1,33 @@
1
+ import { UserTasteKind, isValidTasteValue, userHasTravelTaste } from "../userTaste.js";
2
+ import { DiscoveryScope } from "../discoveryScope.js";
3
+
4
+ describe("userTaste", () => {
5
+ it("accepts Festival event types and rejects junk", () => {
6
+ expect(isValidTasteValue(UserTasteKind.EventType, "Festival")).toBe(true);
7
+ expect(isValidTasteValue(UserTasteKind.EventType, "Networking")).toBe(
8
+ false
9
+ );
10
+ });
11
+
12
+ it("accepts cause tags and genres", () => {
13
+ expect(isValidTasteValue(UserTasteKind.CauseTag, "lgbtq")).toBe(true);
14
+ expect(isValidTasteValue(UserTasteKind.MusicGenre, "House")).toBe(true);
15
+ expect(isValidTasteValue(UserTasteKind.DiscoveryScope, DiscoveryScope.National)).toBe(
16
+ true
17
+ );
18
+ expect(
19
+ isValidTasteValue(UserTasteKind.DiscoveryScope, DiscoveryScope.Unspecified)
20
+ ).toBe(false);
21
+ });
22
+
23
+ it("detects travel taste", () => {
24
+ expect(
25
+ userHasTravelTaste([
26
+ { kind: UserTasteKind.DiscoveryScope, value: DiscoveryScope.National },
27
+ ])
28
+ ).toBe(true);
29
+ expect(
30
+ userHasTravelTaste([{ kind: UserTasteKind.EventType, value: "Festival" }])
31
+ ).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Event-level budget approval requests — plan sign-off and pre-plan cap asks.
3
+ * Shared types and validation for api + bash-app.
4
+ */
5
+
6
+ import {
7
+ BASH_EVENT_WIZARD_HOW_MAJOR_STEP,
8
+ BASH_EVENT_WIZARD_HOW_SUBSTEP,
9
+ } from "./definitions.js";
10
+
11
+ export const BUDGET_APPROVAL_TTL_DAYS = 14;
12
+
13
+ export const BudgetApprovalKind = {
14
+ CapAsk: "CapAsk",
15
+ PlanSignOff: "PlanSignOff",
16
+ } as const;
17
+
18
+ export type BudgetApprovalKind =
19
+ (typeof BudgetApprovalKind)[keyof typeof BudgetApprovalKind];
20
+
21
+ export const BUDGET_APPROVAL_KINDS = [
22
+ BudgetApprovalKind.CapAsk,
23
+ BudgetApprovalKind.PlanSignOff,
24
+ ] as const;
25
+ export type BudgetApprovalKindName = BudgetApprovalKind;
26
+
27
+ export const BudgetApprovalStatus = {
28
+ Pending: "Pending",
29
+ Approved: "Approved",
30
+ ChangesRequested: "ChangesRequested",
31
+ Declined: "Declined",
32
+ Cancelled: "Cancelled",
33
+ Superseded: "Superseded",
34
+ } as const;
35
+
36
+ export type BudgetApprovalStatus =
37
+ (typeof BudgetApprovalStatus)[keyof typeof BudgetApprovalStatus];
38
+
39
+ export const BUDGET_APPROVAL_STATUSES = [
40
+ BudgetApprovalStatus.Pending,
41
+ BudgetApprovalStatus.Approved,
42
+ BudgetApprovalStatus.ChangesRequested,
43
+ BudgetApprovalStatus.Declined,
44
+ BudgetApprovalStatus.Cancelled,
45
+ BudgetApprovalStatus.Superseded,
46
+ ] as const;
47
+ export type BudgetApprovalStatusName = BudgetApprovalStatus;
48
+
49
+ export const BUDGET_APPROVAL_ACTIONS = [
50
+ "approve",
51
+ "changes",
52
+ "decline",
53
+ ] as const;
54
+ export type BudgetApprovalAction = (typeof BUDGET_APPROVAL_ACTIONS)[number];
55
+
56
+ export type BudgetApprovalSnapshotLine = {
57
+ id: string;
58
+ name: string;
59
+ category: string | null;
60
+ totalAllocatedCents: number;
61
+ };
62
+
63
+ export type BudgetApprovalLineNoteInput = {
64
+ budgetId?: string | null;
65
+ nameSnapshot: string;
66
+ categorySnapshot?: string | null;
67
+ allocatedCentsSnapshot: number;
68
+ note: string;
69
+ };
70
+
71
+ export type BudgetApprovalDecideInput = {
72
+ action: BudgetApprovalAction;
73
+ kind: BudgetApprovalKindName;
74
+ approverName?: string | null;
75
+ decisionNote?: string | null;
76
+ authorizedCents?: number | null;
77
+ lineNotes?: BudgetApprovalLineNoteInput[] | null;
78
+ };
79
+
80
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
81
+
82
+ export function normalizeApproverEmail(email: string): string {
83
+ return email.trim().toLowerCase();
84
+ }
85
+
86
+ export function isValidApproverEmail(email: string): boolean {
87
+ return EMAIL_RE.test(email.trim());
88
+ }
89
+
90
+ export function isBudgetApprovalKind(
91
+ value: string
92
+ ): value is BudgetApprovalKindName {
93
+ return (BUDGET_APPROVAL_KINDS as readonly string[]).includes(value);
94
+ }
95
+
96
+ export function isBudgetApprovalAction(
97
+ value: string
98
+ ): value is BudgetApprovalAction {
99
+ return (BUDGET_APPROVAL_ACTIONS as readonly string[]).includes(value);
100
+ }
101
+
102
+ export function isBudgetApprovalExpired(
103
+ expiresAt: Date | string,
104
+ now: Date = new Date()
105
+ ): boolean {
106
+ return now.getTime() > new Date(expiresAt).getTime();
107
+ }
108
+
109
+ function canonicalizeSnapshotLines(
110
+ lines: BudgetApprovalSnapshotLine[]
111
+ ): BudgetApprovalSnapshotLine[] {
112
+ return [...lines]
113
+ .map((line) => ({
114
+ id: line.id,
115
+ name: line.name,
116
+ category: line.category ?? null,
117
+ totalAllocatedCents: line.totalAllocatedCents,
118
+ }))
119
+ .sort((a, b) => a.id.localeCompare(b.id));
120
+ }
121
+
122
+ export function buildBudgetApprovalSnapshot(
123
+ lines: Array<{
124
+ id: string;
125
+ name: string;
126
+ category?: string | null;
127
+ totalAllocatedCents: number;
128
+ }>
129
+ ): { lines: BudgetApprovalSnapshotLine[]; totalCents: number; hash: string } {
130
+ const snapshotLines: BudgetApprovalSnapshotLine[] = lines.map((line) => ({
131
+ id: line.id,
132
+ name: line.name,
133
+ category: line.category ?? null,
134
+ totalAllocatedCents: line.totalAllocatedCents,
135
+ }));
136
+ const totalCents = snapshotLines.reduce(
137
+ (sum, line) => sum + line.totalAllocatedCents,
138
+ 0
139
+ );
140
+ return {
141
+ lines: snapshotLines,
142
+ totalCents,
143
+ hash: budgetApprovalSnapshotHash(snapshotLines),
144
+ };
145
+ }
146
+
147
+ export function budgetApprovalSnapshotHash(
148
+ lines: BudgetApprovalSnapshotLine[]
149
+ ): string {
150
+ return JSON.stringify(canonicalizeSnapshotLines(lines));
151
+ }
152
+
153
+ export function parseBudgetApprovalSnapshot(
154
+ json: unknown
155
+ ): BudgetApprovalSnapshotLine[] {
156
+ if (!Array.isArray(json)) return [];
157
+ const lines: BudgetApprovalSnapshotLine[] = [];
158
+ for (const row of json) {
159
+ if (!row || typeof row !== "object") continue;
160
+ const rec = row as Record<string, unknown>;
161
+ if (typeof rec.id !== "string" || typeof rec.name !== "string") continue;
162
+ if (typeof rec.totalAllocatedCents !== "number") continue;
163
+ lines.push({
164
+ id: rec.id,
165
+ name: rec.name,
166
+ category: typeof rec.category === "string" ? rec.category : null,
167
+ totalAllocatedCents: rec.totalAllocatedCents,
168
+ });
169
+ }
170
+ return lines;
171
+ }
172
+
173
+ export function budgetApprovalSnapshotsMatch(
174
+ current: BudgetApprovalSnapshotLine[],
175
+ snapshot: BudgetApprovalSnapshotLine[]
176
+ ): boolean {
177
+ return (
178
+ budgetApprovalSnapshotHash(current) === budgetApprovalSnapshotHash(snapshot)
179
+ );
180
+ }
181
+
182
+ export function validateBudgetApprovalDecide(
183
+ input: BudgetApprovalDecideInput
184
+ ): { ok: true } | { ok: false; error: string } {
185
+ const decisionNote = input.decisionNote?.trim() ?? "";
186
+ const lineNotes = (input.lineNotes ?? []).filter(
187
+ (note) => note.note.trim().length > 0
188
+ );
189
+
190
+ if (input.kind === "CapAsk" && lineNotes.length > 0) {
191
+ return { ok: false, error: "A cap ask has no budget lines to annotate." };
192
+ }
193
+
194
+ if (input.action === "approve") {
195
+ if (input.kind === "CapAsk") {
196
+ const cents = input.authorizedCents;
197
+ if (cents == null || !Number.isFinite(cents) || cents <= 0) {
198
+ return {
199
+ ok: false,
200
+ error: "Enter an authorized amount greater than zero.",
201
+ };
202
+ }
203
+ }
204
+ return { ok: true };
205
+ }
206
+
207
+ if (input.action === "changes") {
208
+ if (input.kind === "CapAsk") {
209
+ if (!decisionNote) {
210
+ return {
211
+ ok: false,
212
+ error: "Add a note so the host knows what to change.",
213
+ };
214
+ }
215
+ return { ok: true };
216
+ }
217
+ if (!decisionNote && lineNotes.length === 0) {
218
+ return {
219
+ ok: false,
220
+ error: "Add an overall note or a note on at least one line.",
221
+ };
222
+ }
223
+ return { ok: true };
224
+ }
225
+
226
+ return { ok: true };
227
+ }
228
+
229
+ export function resolveApproverDisplayName(params: {
230
+ decidedName?: string | null;
231
+ hostHintName?: string | null;
232
+ email: string;
233
+ }): string {
234
+ const decided = params.decidedName?.trim();
235
+ if (decided) return decided;
236
+ const hint = params.hostHintName?.trim();
237
+ if (hint) return hint;
238
+ return params.email;
239
+ }
240
+
241
+ export function formatBudgetCents(cents: number): string {
242
+ return new Intl.NumberFormat("en-US", {
243
+ style: "currency",
244
+ currency: "USD",
245
+ minimumFractionDigits: 0,
246
+ maximumFractionDigits: 0,
247
+ }).format(cents / 100);
248
+ }
249
+
250
+ export function eventBudgetApprovalWizardPath(bashEventId: string): string {
251
+ return `/bash-event-wizard/${bashEventId}/${BASH_EVENT_WIZARD_HOW_MAJOR_STEP}/${BASH_EVENT_WIZARD_HOW_SUBSTEP.BUDGET}?budgetTab=lines`;
252
+ }
253
+
254
+ export type BudgetApprovalPublicDto = {
255
+ kind: BudgetApprovalKindName;
256
+ status: BudgetApprovalStatusName;
257
+ hostNote: string | null;
258
+ hostDisplayName: string;
259
+ event: {
260
+ id: string;
261
+ title: string;
262
+ startDateTime: string | null;
263
+ slug: string | null;
264
+ };
265
+ snapshotLines: BudgetApprovalSnapshotLine[];
266
+ snapshotTotalCents: number;
267
+ expiresAt: string;
268
+ expired: boolean;
269
+ alreadyDecided: boolean;
270
+ eventIsPubliclyViewable: boolean;
271
+ };
272
+
273
+ export type BudgetApprovalHostDto = {
274
+ id: string;
275
+ kind: BudgetApprovalKindName;
276
+ status: BudgetApprovalStatusName;
277
+ approverEmail: string;
278
+ approverName: string | null;
279
+ hostNote: string | null;
280
+ decisionNote: string | null;
281
+ authorizedCents: number | null;
282
+ snapshotTotalCents: number;
283
+ staleAt: string | null;
284
+ decidedAt: string | null;
285
+ expiresAt: string;
286
+ createdAt: string;
287
+ isExpired: boolean;
288
+ emailError?: boolean;
289
+ lineNotes: Array<{
290
+ budgetId: string | null;
291
+ nameSnapshot: string;
292
+ categorySnapshot: string | null;
293
+ allocatedCentsSnapshot: number;
294
+ note: string;
295
+ }>;
296
+ };
297
+
298
+ export type BudgetApprovalHostSummary = {
299
+ latest: BudgetApprovalHostDto | null;
300
+ history: BudgetApprovalHostDto[];
301
+ planMatchesSnapshot: boolean;
302
+ currentAuthorizedCents: number | null;
303
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Privacy-safe social-proof lines for suggestions and reminders.
3
+ * Never name hidden attendees. Never use a follow-count when names are hidden.
4
+ */
5
+
6
+ export type CrewGoingPerson = {
7
+ displayName: string;
8
+ attendanceVisible: boolean;
9
+ };
10
+
11
+ export type CrewSocialCopy = {
12
+ line: string | null;
13
+ named: boolean;
14
+ };
15
+
16
+ export function crewSocialProofCopy(
17
+ people: readonly CrewGoingPerson[]
18
+ ): CrewSocialCopy {
19
+ const visible = people.filter((p) => p.attendanceVisible && p.displayName.trim());
20
+ const hidden = people.filter((p) => !p.attendanceVisible);
21
+
22
+ if (visible.length >= 2) {
23
+ const a = visible[0].displayName.trim();
24
+ const b = visible[1].displayName.trim();
25
+ return { line: `${a} and ${b} are going`, named: true };
26
+ }
27
+ if (visible.length === 1) {
28
+ return { line: `${visible[0].displayName.trim()} is going`, named: true };
29
+ }
30
+ if (hidden.length > 0) {
31
+ return { line: "Your crew's on this one", named: false };
32
+ }
33
+ return { line: null, named: false };
34
+ }
35
+
36
+ export const CREW_GOING_RAIL_TITLE = "The crew's going";
37
+ export const SCENE_PROMPT_COPY = "What's your scene?";
@@ -675,6 +675,7 @@ export const HTTP_CODE_BAD_REQUEST = 400 as const;
675
675
  export const HTTP_CODE_UNAUTHORIZED = 401 as const;
676
676
  export const HTTP_CODE_FORBIDDEN = 403 as const;
677
677
  export const HTTP_CODE_NOT_FOUND = 404 as const;
678
+ export const HTTP_CODE_GONE = 410 as const;
678
679
  export const ERR_UNAUTHORIZED_REQUEST =
679
680
  "Unauthorized to perform requested action. Have you logged in?" as const;
680
681
  export const ERR_SESSION_EXPIRED =
@@ -1822,6 +1823,11 @@ const SYNTHETIC_BASH_SECTION_TITLE: Record<string, string> = {
1822
1823
  Featured: "Featured",
1823
1824
  Popular: "Popular",
1824
1825
  Trending: "Trending",
1826
+ CrewGoing: "The crew's going",
1827
+ Local: "Local",
1828
+ State: "Statewide",
1829
+ National: "Across the country",
1830
+ International: "Worth the flight",
1825
1831
  };
1826
1832
 
1827
1833
  /**
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Geographic / importance scope for public discovery.
3
+ * Orthogonal to `BashEventType` — a Fair can be Local or State.
4
+ */
5
+
6
+ export const DiscoveryScope = {
7
+ Local: "Local",
8
+ State: "State",
9
+ National: "National",
10
+ International: "International",
11
+ Unspecified: "Unspecified",
12
+ } as const;
13
+
14
+ export type DiscoveryScope =
15
+ (typeof DiscoveryScope)[keyof typeof DiscoveryScope];
16
+
17
+ export const DISCOVERY_SCOPE_VALUES: readonly DiscoveryScope[] = [
18
+ DiscoveryScope.Local,
19
+ DiscoveryScope.State,
20
+ DiscoveryScope.National,
21
+ DiscoveryScope.International,
22
+ DiscoveryScope.Unspecified,
23
+ ] as const;
24
+
25
+ /** Homepage / category row keys (not Unspecified). */
26
+ export const DISCOVERY_SCOPE_ROW_KEYS = [
27
+ DiscoveryScope.Local,
28
+ DiscoveryScope.State,
29
+ DiscoveryScope.National,
30
+ DiscoveryScope.International,
31
+ ] as const;
32
+
33
+ export const DISCOVERY_SCOPE_SECTION_TITLE: Record<
34
+ (typeof DISCOVERY_SCOPE_ROW_KEYS)[number],
35
+ string
36
+ > = {
37
+ Local: "Local",
38
+ State: "Statewide",
39
+ National: "Across the country",
40
+ International: "Worth the flight",
41
+ };
42
+
43
+ /** Hosts may set these without a hard signal. */
44
+ export const HOST_SETTABLE_DISCOVERY_SCOPES: readonly DiscoveryScope[] = [
45
+ DiscoveryScope.Unspecified,
46
+ DiscoveryScope.Local,
47
+ DiscoveryScope.State,
48
+ ];
49
+
50
+ export const HOST_SCOPE_CHIPS: ReadonlyArray<{
51
+ id: DiscoveryScope;
52
+ label: string;
53
+ }> = [
54
+ { id: DiscoveryScope.Unspecified, label: "Not sure yet" },
55
+ { id: DiscoveryScope.Local, label: "Just the neighborhood / city-wide" },
56
+ {
57
+ id: DiscoveryScope.State,
58
+ label: "People will drive from across the state",
59
+ },
60
+ ];
61
+
62
+ export function isDiscoveryScope(value: unknown): value is DiscoveryScope {
63
+ return (
64
+ typeof value === "string" &&
65
+ (DISCOVERY_SCOPE_VALUES as readonly string[]).includes(value)
66
+ );
67
+ }
68
+
69
+ export function isHostSettableDiscoveryScope(
70
+ value: unknown
71
+ ): value is DiscoveryScope {
72
+ return (
73
+ typeof value === "string" &&
74
+ (HOST_SETTABLE_DISCOVERY_SCOPES as readonly string[]).includes(value)
75
+ );
76
+ }
77
+
78
+ export function isNationalOrInternational(scope: DiscoveryScope): boolean {
79
+ return (
80
+ scope === DiscoveryScope.National || scope === DiscoveryScope.International
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Known touring / destination brands that are a hard signal for National.
86
+ * Small allowlist — not a taxonomy.
87
+ */
88
+ export const NATIONAL_SCOPE_BRAND_PATTERN =
89
+ /\b(insomniac|c3 presents|aeg presents|live nation|coachella|lollapalooza|bonnaroo|ultra miami|edc las vegas|edc vegas|burning man|sxsw|comic-?con|ces\b|super bowl|final four|wrestlemania)\b/i;
90
+
91
+ export const INTERNATIONAL_SCOPE_PATTERN =
92
+ /\b(world tour|world cup|olympics|tomorrowland|glastonbury|primavera sound|oktoberfest|cannes|eurovision|ibiza)\b/i;
93
+
94
+ export const STATE_SCOPE_PATTERN =
95
+ /\b(state fair|statewide|governor'?s? (?:ball|festival)|days of ['’]?47|pioneer day)\b/i;
96
+
97
+ export const LOCAL_SCOPE_PATTERN =
98
+ /\b(block party|neighborhood|community day|city-sponsored|city festival)\b/i;
99
+
100
+ export function inferDiscoveryScopeFromText(
101
+ title: string | undefined,
102
+ description?: string | undefined
103
+ ): DiscoveryScope {
104
+ const text = `${title ?? ""} ${description ?? ""}`.trim();
105
+ if (!text) return DiscoveryScope.Unspecified;
106
+ if (INTERNATIONAL_SCOPE_PATTERN.test(text)) {
107
+ return DiscoveryScope.International;
108
+ }
109
+ if (NATIONAL_SCOPE_BRAND_PATTERN.test(text)) {
110
+ return DiscoveryScope.National;
111
+ }
112
+ if (STATE_SCOPE_PATTERN.test(text)) return DiscoveryScope.State;
113
+ if (LOCAL_SCOPE_PATTERN.test(text)) return DiscoveryScope.Local;
114
+ return DiscoveryScope.Unspecified;
115
+ }
116
+
117
+ export type DiscoveryScopeWriteRole = "host" | "superUser" | "scraper";
118
+
119
+ /**
120
+ * Hosts cannot self-promote to National/International unless a hard text
121
+ * signal is already present (or the writer is a super-user / scraper).
122
+ */
123
+ export function resolveDiscoveryScopeForWrite(args: {
124
+ requested: string | null | undefined;
125
+ existing?: string | null | undefined;
126
+ role: DiscoveryScopeWriteRole;
127
+ title?: string | null;
128
+ description?: string | null;
129
+ }): DiscoveryScope {
130
+ const inferred = inferDiscoveryScopeFromText(
131
+ args.title ?? undefined,
132
+ args.description ?? undefined
133
+ );
134
+ const requested = isDiscoveryScope(args.requested)
135
+ ? args.requested
136
+ : undefined;
137
+ const existing = isDiscoveryScope(args.existing) ? args.existing : undefined;
138
+
139
+ if (args.role === "superUser" || args.role === "scraper") {
140
+ return requested ?? existing ?? inferred;
141
+ }
142
+
143
+ if (requested && isHostSettableDiscoveryScope(requested)) {
144
+ return requested;
145
+ }
146
+
147
+ if (
148
+ requested &&
149
+ isNationalOrInternational(requested) &&
150
+ inferred === requested
151
+ ) {
152
+ return requested;
153
+ }
154
+
155
+ if (existing && isHostSettableDiscoveryScope(existing)) {
156
+ return existing;
157
+ }
158
+
159
+ if (existing && isNationalOrInternational(existing)) {
160
+ return existing;
161
+ }
162
+
163
+ if (inferred !== DiscoveryScope.Unspecified && !requested) {
164
+ return inferred;
165
+ }
166
+
167
+ return DiscoveryScope.Unspecified;
168
+ }
169
+
170
+ export const MIN_EVENTS_FOR_SCOPE_ROW = 3;
package/src/home.ts CHANGED
@@ -23,7 +23,22 @@ export {
23
23
 
24
24
  export {
25
25
  DEFAULT_FEATURED_RADIUS_MILES,
26
+ haversineMiles,
26
27
  sortPublicEventsForFeaturedDiscovery,
27
28
  } from "./utils/featuredDiscoveryGeo.js";
28
29
 
29
30
  export { causeTagsMatchSearch } from "./causeTags.js";
31
+
32
+ export {
33
+ DiscoveryScope,
34
+ DISCOVERY_SCOPE_ROW_KEYS,
35
+ MIN_EVENTS_FOR_SCOPE_ROW,
36
+ isNationalOrInternational,
37
+ } from "./discoveryScope.js";
38
+
39
+ export { userHasTravelTaste, UserTasteKind } from "./userTaste.js";
40
+
41
+ export { CREW_GOING_RAIL_TITLE, SCENE_PROMPT_COPY } from "./crewSocialCopy.js";
42
+
43
+ export { scoreSuggestion } from "./suggestionScore.js";
44
+ export type { SuggestionTasteRow } from "./suggestionScore.js";
@@ -83,6 +83,8 @@ export type HostCrmCrowdPulseBash = {
83
83
  capacity: number | null;
84
84
  /** Distinct checked-in ticket holders. */
85
85
  checkedInCount: number;
86
+ /** Anonymized Accepted SuggestedNight / favorite saves. */
87
+ savedCount: number;
86
88
  };
87
89
 
88
90
  export type HostCrmCrowdPulse = {
@@ -41,6 +41,12 @@ export type HostCrmSegmentRules = {
41
41
  bringsGuests?: boolean;
42
42
  /** Ticket buyers for any of these bash event ids (e.g. the host's 3 most recently ended bashes — see the last3Events preset) */
43
43
  sourceEventIdIn?: string[];
44
+ /** Guest taste: EventType value (own audience + hostsMayTargetTaste only). */
45
+ tasteEventType?: string;
46
+ /** Guest taste: MusicGenre value. */
47
+ tasteGenre?: string;
48
+ /** Guest opted into National/International travel chips. */
49
+ tasteTravel?: boolean;
44
50
  };
45
51
 
46
52
  export type HostCrmDiscoverySource = "promoter" | "referral" | "direct";
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./definitions.js";
2
2
  export * from "./fundingMeter.js";
3
+ export * from "./budgetApproval.js";
3
4
  export * from "./giveaway.js";
4
5
  export * from "./requestAuthUser.js";
5
6
  export * from "./sms/smsTemplates.js";
@@ -13,6 +14,11 @@ export * from "./sideQuestIdeas.js";
13
14
  export * from "./sideQuestTypes.js";
14
15
  export * from "./icebreakerPrompts.js";
15
16
  export * from "./causeTags.js";
17
+ export * from "./discoveryScope.js";
18
+ export * from "./userTaste.js";
19
+ export * from "./crewSocialCopy.js";
20
+ export * from "./suggestionScore.js";
21
+ export * from "./suggestedNight.js";
16
22
  export * from "./competitionValidation.js";
17
23
  export * from "./groupTicketUtils.js";
18
24
  export * from "./guestPartySize.js";
@@ -56,10 +62,15 @@ export * from "./targetAudienceLabels.js";
56
62
  export * from "./liveActivity/liveActivityTypes.js";
57
63
 
58
64
  // Re-export ALL Prisma enums as values (usable as runtime constants, e.g. ServiceTypes.Entertainment)
59
- // Excludes enums already re-exported by definitions.ts, mirroredPrismaEnums.ts, and membershipDefinitions.ts:
65
+ // Excludes enums already re-exported by definitions.ts, mirroredPrismaEnums.ts, membershipDefinitions.ts,
66
+ // and local domain modules (so API boot does not wait on prisma generate):
60
67
  // mirroredPrismaEnums.ts: ServiceCancellationPolicy
61
68
  // definitions.ts: YearsOfExperience, GovIdRetentionPolicy, EntertainmentServiceType, MusicGenreType, ServiceTypes,
62
69
  // membershipDefinitions.ts: MembershipTier, ReferralTier, CreditTransactionType
70
+ // budgetApproval.ts: BudgetApprovalKind, BudgetApprovalStatus
71
+ // discoveryScope.ts: DiscoveryScope
72
+ // userTaste.ts: UserTasteKind, UserTasteSource
73
+ // suggestedNight.ts: SuggestedNightStatus
63
74
  export {
64
75
  $Enums,
65
76
  AISpecialistFormat,
@@ -115,8 +126,6 @@ export {
115
126
  ChairsFormat,
116
127
  ClaimRequestStatus,
117
128
  CleaningSubType,
118
- ClubAdminRole,
119
- ClubMemberStatus,
120
129
  ConnectionStatus,
121
130
  ConsentType,
122
131
  ConsultantFormat,
@@ -15,6 +15,7 @@ export const SMS_TEMPLATE_KEYS = [
15
15
  "EVENT_WAITLIST_APPROVED",
16
16
  "TICKET_CONFIRMED",
17
17
  "COAT_CHECK_CLAIM",
18
+ "SUGGESTED_NIGHT_REMINDER_24H",
18
19
  ] as const;
19
20
 
20
21
  export type SmsTemplateKey = (typeof SMS_TEMPLATE_KEYS)[number];
@@ -56,4 +57,9 @@ export type SmsTemplateContext = {
56
57
  claimLabel: string;
57
58
  ticketsUrl?: string;
58
59
  };
60
+ SUGGESTED_NIGHT_REMINDER_24H: {
61
+ eventTitle: string;
62
+ shortUrl: string;
63
+ socialProof?: string;
64
+ };
59
65
  };
@@ -0,0 +1,20 @@
1
+ export const SuggestedNightStatus = {
2
+ Proposed: "Proposed",
3
+ Accepted: "Accepted",
4
+ Dismissed: "Dismissed",
5
+ Expired: "Expired",
6
+ } as const;
7
+
8
+ export type SuggestedNightStatus =
9
+ (typeof SuggestedNightStatus)[keyof typeof SuggestedNightStatus];
10
+
11
+ export const FAVORITE_NUDGE_REMINDER_TYPE = "SUGGESTED_NIGHT_24H";
12
+
13
+ export type UnfavoriteSuggestedNightAction = "delete" | "dismiss";
14
+
15
+ /** Never nudged → delete. Already nudged → keep Dismissed so we do not re-propose. */
16
+ export function unfavoriteSuggestedNightAction(
17
+ remindedAt: Date | string | null | undefined
18
+ ): UnfavoriteSuggestedNightAction {
19
+ return remindedAt ? "dismiss" : "delete";
20
+ }