@bash-app/bash-common 30.386.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 (126) 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__/scenePulse.test.d.ts +2 -0
  16. package/dist/__tests__/scenePulse.test.d.ts.map +1 -0
  17. package/dist/__tests__/scenePulse.test.js +52 -0
  18. package/dist/__tests__/scenePulse.test.js.map +1 -0
  19. package/dist/__tests__/suggestedNight.test.d.ts +2 -0
  20. package/dist/__tests__/suggestedNight.test.d.ts.map +1 -0
  21. package/dist/__tests__/suggestedNight.test.js +10 -0
  22. package/dist/__tests__/suggestedNight.test.js.map +1 -0
  23. package/dist/__tests__/suggestionScore.test.d.ts +2 -0
  24. package/dist/__tests__/suggestionScore.test.d.ts.map +1 -0
  25. package/dist/__tests__/suggestionScore.test.js +68 -0
  26. package/dist/__tests__/suggestionScore.test.js.map +1 -0
  27. package/dist/__tests__/userTaste.test.d.ts +2 -0
  28. package/dist/__tests__/userTaste.test.d.ts.map +1 -0
  29. package/dist/__tests__/userTaste.test.js +21 -0
  30. package/dist/__tests__/userTaste.test.js.map +1 -0
  31. package/dist/budgetApproval.d.ts +126 -0
  32. package/dist/budgetApproval.d.ts.map +1 -0
  33. package/dist/budgetApproval.js +161 -0
  34. package/dist/budgetApproval.js.map +1 -0
  35. package/dist/crewSocialCopy.d.ts +16 -0
  36. package/dist/crewSocialCopy.d.ts.map +1 -0
  37. package/dist/crewSocialCopy.js +23 -0
  38. package/dist/crewSocialCopy.js.map +1 -0
  39. package/dist/definitions.d.ts +6 -0
  40. package/dist/definitions.d.ts.map +1 -1
  41. package/dist/definitions.js +6 -0
  42. package/dist/definitions.js.map +1 -1
  43. package/dist/discoveryScope.d.ts +48 -0
  44. package/dist/discoveryScope.d.ts.map +1 -0
  45. package/dist/discoveryScope.js +114 -0
  46. package/dist/discoveryScope.js.map +1 -0
  47. package/dist/extendedSchemas.d.ts +7 -0
  48. package/dist/extendedSchemas.d.ts.map +1 -1
  49. package/dist/extendedSchemas.js +8 -0
  50. package/dist/extendedSchemas.js.map +1 -1
  51. package/dist/home.d.ts +6 -1
  52. package/dist/home.d.ts.map +1 -1
  53. package/dist/home.js +5 -1
  54. package/dist/home.js.map +1 -1
  55. package/dist/hostCrmCrowdPulse.d.ts +2 -0
  56. package/dist/hostCrmCrowdPulse.d.ts.map +1 -1
  57. package/dist/hostCrmCrowdPulse.js.map +1 -1
  58. package/dist/hostCrmSegments.d.ts +6 -0
  59. package/dist/hostCrmSegments.d.ts.map +1 -1
  60. package/dist/hostCrmSegments.js.map +1 -1
  61. package/dist/index.d.ts +9 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +15 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/scenePulse.d.ts +66 -0
  66. package/dist/scenePulse.d.ts.map +1 -0
  67. package/dist/scenePulse.js +54 -0
  68. package/dist/scenePulse.js.map +1 -0
  69. package/dist/sms/smsTemplates.d.ts +6 -1
  70. package/dist/sms/smsTemplates.d.ts.map +1 -1
  71. package/dist/sms/smsTemplates.js +1 -0
  72. package/dist/sms/smsTemplates.js.map +1 -1
  73. package/dist/suggestedNight.d.ts +12 -0
  74. package/dist/suggestedNight.d.ts.map +1 -0
  75. package/dist/suggestedNight.js +12 -0
  76. package/dist/suggestedNight.js.map +1 -0
  77. package/dist/suggestionScore.d.ts +42 -0
  78. package/dist/suggestionScore.d.ts.map +1 -0
  79. package/dist/suggestionScore.js +100 -0
  80. package/dist/suggestionScore.js.map +1 -0
  81. package/dist/userTaste.d.ts +40 -0
  82. package/dist/userTaste.d.ts.map +1 -0
  83. package/dist/userTaste.js +103 -0
  84. package/dist/userTaste.js.map +1 -0
  85. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +9 -1
  86. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -1
  87. package/dist/utils/featuredDiscoveryGeo.d.ts +7 -0
  88. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -1
  89. package/dist/utils/featuredDiscoveryGeo.js +11 -0
  90. package/dist/utils/featuredDiscoveryGeo.js.map +1 -1
  91. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts +2 -0
  92. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts.map +1 -0
  93. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js +353 -0
  94. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js.map +1 -0
  95. package/dist/utils/service/foodAndBeverageQuote.d.ts +171 -0
  96. package/dist/utils/service/foodAndBeverageQuote.d.ts.map +1 -0
  97. package/dist/utils/service/foodAndBeverageQuote.js +467 -0
  98. package/dist/utils/service/foodAndBeverageQuote.js.map +1 -0
  99. package/package.json +1 -1
  100. package/prisma/schema.prisma +185 -53
  101. package/src/__tests__/bashSectionTitleForBashType.test.ts +10 -0
  102. package/src/__tests__/budgetApproval.test.ts +153 -0
  103. package/src/__tests__/crewSocialCopy.test.ts +25 -0
  104. package/src/__tests__/discoveryScope.test.ts +65 -0
  105. package/src/__tests__/scenePulse.test.ts +69 -0
  106. package/src/__tests__/suggestedNight.test.ts +11 -0
  107. package/src/__tests__/suggestionScore.test.ts +81 -0
  108. package/src/__tests__/userTaste.test.ts +33 -0
  109. package/src/budgetApproval.ts +303 -0
  110. package/src/crewSocialCopy.ts +37 -0
  111. package/src/definitions.ts +11 -0
  112. package/src/discoveryScope.ts +170 -0
  113. package/src/extendedSchemas.ts +8 -0
  114. package/src/home.ts +15 -0
  115. package/src/hostCrmCrowdPulse.ts +2 -0
  116. package/src/hostCrmSegments.ts +6 -0
  117. package/src/index.ts +18 -3
  118. package/src/scenePulse.ts +113 -0
  119. package/src/sms/smsTemplates.ts +6 -0
  120. package/src/suggestedNight.ts +20 -0
  121. package/src/suggestionScore.ts +140 -0
  122. package/src/userTaste.ts +152 -0
  123. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +13 -0
  124. package/src/utils/featuredDiscoveryGeo.ts +15 -0
  125. package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
  126. package/src/utils/service/foodAndBeverageQuote.ts +613 -0
@@ -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;
@@ -1054,6 +1054,14 @@ export const SERVICE_PUBLIC_LIST_DATA_TO_INCLUDE = {
1054
1054
  eventServiceSubType: true,
1055
1055
  formatOptions: true,
1056
1056
  crowdSize: true,
1057
+ // F&B per-guest pricing & logistics fields
1058
+ pricingModel: true,
1059
+ baseRateCents: true,
1060
+ minimumQuantity: true,
1061
+ minimumChargeCents: true,
1062
+ goodsOrServices: true,
1063
+ fulfillmentModes: true,
1064
+ leadTimeDays: true,
1057
1065
  },
1058
1066
  },
1059
1067
  entertainmentService: {
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";
@@ -36,6 +42,7 @@ export * from "./accessScreening.js";
36
42
  export * from "./applicationFunnel.js";
37
43
  export * from "./hostCrmSegments.js";
38
44
  export * from "./hostCrmCrowdPulse.js";
45
+ export * from "./scenePulse.js";
39
46
  export * from "./hostCrmLastBashProof.js";
40
47
  export * from "./hostCrmInsights.js";
41
48
  export * from "./hostCrmAutomation.js";
@@ -55,10 +62,15 @@ export * from "./targetAudienceLabels.js";
55
62
  export * from "./liveActivity/liveActivityTypes.js";
56
63
 
57
64
  // Re-export ALL Prisma enums as values (usable as runtime constants, e.g. ServiceTypes.Entertainment)
58
- // 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):
59
67
  // mirroredPrismaEnums.ts: ServiceCancellationPolicy
60
68
  // definitions.ts: YearsOfExperience, GovIdRetentionPolicy, EntertainmentServiceType, MusicGenreType, ServiceTypes,
61
69
  // membershipDefinitions.ts: MembershipTier, ReferralTier, CreditTransactionType
70
+ // budgetApproval.ts: BudgetApprovalKind, BudgetApprovalStatus
71
+ // discoveryScope.ts: DiscoveryScope
72
+ // userTaste.ts: UserTasteKind, UserTasteSource
73
+ // suggestedNight.ts: SuggestedNightStatus
62
74
  export {
63
75
  $Enums,
64
76
  AISpecialistFormat,
@@ -107,13 +119,13 @@ export {
107
119
  BrandStrategistFormat,
108
120
  CPAsFormat,
109
121
  CateringFormat,
122
+ CelebrationCakeFormat,
123
+ GrazingTableFormat,
110
124
  CelebrityAppearanceFormat,
111
125
  CenterpiecesFormat,
112
126
  ChairsFormat,
113
127
  ClaimRequestStatus,
114
128
  CleaningSubType,
115
- ClubAdminRole,
116
- ClubMemberStatus,
117
129
  ConnectionStatus,
118
130
  ConsentType,
119
131
  ConsultantFormat,
@@ -189,6 +201,7 @@ export {
189
201
  NotificationPriority,
190
202
  NotificationType,
191
203
  Occupation,
204
+ OfficiantFormat,
192
205
  OfferClaimStatus,
193
206
  OfferDiscountType,
194
207
  OrganizationArtistInvitationStatus,
@@ -225,6 +238,7 @@ export {
225
238
  RecurringFrequency,
226
239
  RedemptionMethod,
227
240
  RentalEquipmentSubType,
241
+ RestroomTrailerFormat,
228
242
  ReportStatus,
229
243
  ReportingFormat,
230
244
  RewardReportStatus,
@@ -429,6 +443,7 @@ export * from "./utils/mathUtils.js";
429
443
  export * from "./utils/birthdayExperienceQuality.js";
430
444
  export * from "./utils/service/apiServiceBookingApiUtils.js";
431
445
  export * from "./utils/service/frontendServiceBookingUtils.js";
446
+ export * from "./utils/service/foodAndBeverageQuote.js";
432
447
  export * from "./utils/service/serviceBookingStatusUtils.js";
433
448
  export * from "./utils/service/serviceDBUtils.js";
434
449
  export * from "./utils/service/serviceRateUtils.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Community Builder scene pulse — city / region / world aggregates.
3
+ * You (Crowd Pulse) stays host ops; these scopes are public scene energy + platform proof.
4
+ */
5
+
6
+ export const SCENE_PULSE_SCOPES = ["city", "region", "world"] as const;
7
+
8
+ export type ScenePulseScope = (typeof SCENE_PULSE_SCOPES)[number];
9
+
10
+ export type ScenePulseGeo = {
11
+ city: string | null;
12
+ state: string | null;
13
+ country: string | null;
14
+ /** Short label for copy ("Salt Lake City", "Utah", "World"). */
15
+ label: string;
16
+ timeZone: string;
17
+ };
18
+
19
+ export type ScenePulseEnergy = {
20
+ /** Public rooms currently live. */
21
+ liveNow: number;
22
+ /** Unique Going RSVPs + ticketed people on live + tonight + this weekend. */
23
+ going: number;
24
+ tonightBashes: number;
25
+ tonightGoing: number;
26
+ weekendBashes: number;
27
+ weekendGoing: number;
28
+ /** Unique people who RSVP'd Going or paid for a ticket in the last 7 days. */
29
+ newThisWeek: number;
30
+ /**
31
+ * Distinct live/tonight/weekend public bashes with a lineup slot or a
32
+ * host-spotlighted featured guest (names in the room — not CRM).
33
+ */
34
+ namesInTheRoom: number;
35
+ };
36
+
37
+ /** World-only cumulative credibility. Null on city/region. */
38
+ export type ScenePulseWorldCred = {
39
+ totalPeople: number;
40
+ bashesHosted: number;
41
+ ticketsSold: number;
42
+ gmvCents: number;
43
+ citiesRepresented: number;
44
+ regionsRepresented: number;
45
+ };
46
+
47
+ export type ScenePulse = {
48
+ scope: ScenePulseScope;
49
+ geo: ScenePulseGeo | null;
50
+ /** True when city (or region) can't be resolved from profile or hosted bashes. */
51
+ needsLocation: boolean;
52
+ energy: ScenePulseEnergy;
53
+ world: ScenePulseWorldCred | null;
54
+ };
55
+
56
+ /** Optional city/state used to pulse a scene other than the host's profile. */
57
+ export type ScenePulseGeoOverride = {
58
+ city: string | null;
59
+ state: string | null;
60
+ };
61
+
62
+ const MAX_GEO_CITY_CHARS = 80;
63
+ const MAX_GEO_STATE_CHARS = 40;
64
+
65
+ function normalizeGeoPart(raw: unknown, maxChars: number): string | null {
66
+ if (typeof raw !== "string") return null;
67
+ const trimmed = raw.replace(/\s+/g, " ").trim();
68
+ if (!trimmed) return null;
69
+ return trimmed.slice(0, maxChars);
70
+ }
71
+
72
+ /**
73
+ * Blank city+state → null (use profile). Otherwise a trimmed override.
74
+ */
75
+ export function normalizeScenePulseGeoOverride(input: {
76
+ city?: unknown;
77
+ state?: unknown;
78
+ }): ScenePulseGeoOverride | null {
79
+ const city = normalizeGeoPart(input.city, MAX_GEO_CITY_CHARS);
80
+ const state = normalizeGeoPart(input.state, MAX_GEO_STATE_CHARS);
81
+ if (!city && !state) return null;
82
+ return { city, state };
83
+ }
84
+
85
+ export function emptyScenePulseEnergy(): ScenePulseEnergy {
86
+ return {
87
+ liveNow: 0,
88
+ going: 0,
89
+ tonightBashes: 0,
90
+ tonightGoing: 0,
91
+ weekendBashes: 0,
92
+ weekendGoing: 0,
93
+ newThisWeek: 0,
94
+ namesInTheRoom: 0,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Missing / blank → city. Invalid string → null (caller should 400).
100
+ */
101
+ export function parseScenePulseScope(raw: unknown): ScenePulseScope | null {
102
+ if (raw == null || raw === "") return "city";
103
+ if (typeof raw !== "string") return null;
104
+ const normalized = raw.trim().toLowerCase();
105
+ if (
106
+ normalized === "city" ||
107
+ normalized === "region" ||
108
+ normalized === "world"
109
+ ) {
110
+ return normalized;
111
+ }
112
+ return null;
113
+ }
@@ -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
+ }
@@ -0,0 +1,140 @@
1
+ import { DiscoveryScope, isNationalOrInternational } from "./discoveryScope.js";
2
+ import { UserTasteKind, UserTasteSource } from "./userTaste.js";
3
+
4
+ export type SuggestionTasteRow = {
5
+ kind: string;
6
+ value: string;
7
+ weight: number;
8
+ source: string;
9
+ };
10
+
11
+ export type SuggestionSignals = {
12
+ connectionsGoingVisible: number;
13
+ connectionsGoingHidden: number;
14
+ followedHost: boolean;
15
+ attendedHost: boolean;
16
+ eventType: string;
17
+ causeTags: readonly string[];
18
+ musicGenre?: string | null;
19
+ discoveryScope: string;
20
+ artistsToSee: readonly string[];
21
+ eventTitle: string;
22
+ distanceMiles: number | null;
23
+ radiusMiles: number;
24
+ isFeatured: boolean;
25
+ trendingScore: number;
26
+ paidFeatureTasteOverlap: boolean;
27
+ tastes: readonly SuggestionTasteRow[];
28
+ };
29
+
30
+ export const SUGGESTION_WEIGHTS = {
31
+ connectionsGoing: 40,
32
+ followedHost: 24,
33
+ attendedHost: 20,
34
+ explicitTaste: 16,
35
+ explicitTasteLove: 32,
36
+ inferredTaste: 8,
37
+ travelScope: 10,
38
+ featured: 3,
39
+ trending: 2,
40
+ paidFeatureTaste: 1,
41
+ artistsToSee: 4,
42
+ } as const;
43
+
44
+ export const PAID_FEATURE_TASTE_CAP = SUGGESTION_WEIGHTS.paidFeatureTaste;
45
+
46
+ export function scoreSuggestion(signals: SuggestionSignals): number {
47
+ let score = 0;
48
+ if (signals.connectionsGoingVisible + signals.connectionsGoingHidden > 0) {
49
+ score += SUGGESTION_WEIGHTS.connectionsGoing;
50
+ }
51
+ if (signals.followedHost) score += SUGGESTION_WEIGHTS.followedHost;
52
+ if (signals.attendedHost) score += SUGGESTION_WEIGHTS.attendedHost;
53
+
54
+ for (const taste of signals.tastes) {
55
+ if (taste.weight < 0) {
56
+ if (tasteMatchesEvent(taste, signals)) return Number.NEGATIVE_INFINITY;
57
+ continue;
58
+ }
59
+ if (!tasteMatchesEvent(taste, signals)) continue;
60
+ if (taste.kind === UserTasteKind.DiscoveryScope) {
61
+ score += SUGGESTION_WEIGHTS.travelScope;
62
+ continue;
63
+ }
64
+ if (taste.source === UserTasteSource.Explicit) {
65
+ score +=
66
+ taste.weight === 2
67
+ ? SUGGESTION_WEIGHTS.explicitTasteLove
68
+ : SUGGESTION_WEIGHTS.explicitTaste;
69
+ } else {
70
+ score += SUGGESTION_WEIGHTS.inferredTaste;
71
+ }
72
+ }
73
+
74
+ if (signals.isFeatured) score += SUGGESTION_WEIGHTS.featured;
75
+ if (signals.trendingScore > 0) score += SUGGESTION_WEIGHTS.trending;
76
+ if (signals.paidFeatureTasteOverlap) {
77
+ score += PAID_FEATURE_TASTE_CAP;
78
+ }
79
+ if (
80
+ signals.artistsToSee.some((a) =>
81
+ signals.eventTitle.toLowerCase().includes(a.toLowerCase())
82
+ )
83
+ ) {
84
+ score += SUGGESTION_WEIGHTS.artistsToSee;
85
+ }
86
+
87
+ const withinRadius = isWithinDiscoveryRadius(signals);
88
+ if (!withinRadius) return Number.NEGATIVE_INFINITY;
89
+
90
+ if (signals.distanceMiles != null && signals.radiusMiles > 0) {
91
+ const decay = Math.min(1, signals.distanceMiles / signals.radiusMiles);
92
+ score -= decay * 4;
93
+ }
94
+
95
+ return score;
96
+ }
97
+
98
+ export function isWithinDiscoveryRadius(signals: SuggestionSignals): boolean {
99
+ const travel = signals.tastes.some(
100
+ (t) =>
101
+ t.kind === UserTasteKind.DiscoveryScope &&
102
+ isNationalOrInternational(t.value as DiscoveryScope) &&
103
+ t.weight > 0
104
+ );
105
+ if (
106
+ travel &&
107
+ isNationalOrInternational(signals.discoveryScope as DiscoveryScope)
108
+ ) {
109
+ return true;
110
+ }
111
+ if (signals.distanceMiles == null) return true;
112
+ return signals.distanceMiles <= signals.radiusMiles;
113
+ }
114
+
115
+ function tasteMatchesEvent(
116
+ taste: SuggestionTasteRow,
117
+ signals: SuggestionSignals
118
+ ): boolean {
119
+ switch (taste.kind) {
120
+ case UserTasteKind.EventType:
121
+ return signals.eventType === taste.value;
122
+ case UserTasteKind.CauseTag:
123
+ return signals.causeTags.includes(taste.value);
124
+ case UserTasteKind.MusicGenre:
125
+ return (signals.musicGenre ?? "") === taste.value;
126
+ case UserTasteKind.DiscoveryScope:
127
+ return signals.discoveryScope === taste.value;
128
+ default:
129
+ return false;
130
+ }
131
+ }
132
+
133
+ export function crewOutranksPaid(signals: SuggestionSignals): boolean {
134
+ const crew =
135
+ signals.connectionsGoingVisible +
136
+ signals.connectionsGoingHidden +
137
+ (signals.followedHost ? 1 : 0) +
138
+ (signals.attendedHost ? 1 : 0);
139
+ return crew > 0;
140
+ }