@bash-app/bash-common 30.265.0 → 30.273.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 (54) hide show
  1. package/dist/__tests__/statusEnums.test.js +1 -0
  2. package/dist/__tests__/statusEnums.test.js.map +1 -1
  3. package/dist/index.d.ts +4 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +4 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/mirroredPrismaEnums.d.ts +1 -0
  8. package/dist/mirroredPrismaEnums.d.ts.map +1 -1
  9. package/dist/mirroredPrismaEnums.js +1 -0
  10. package/dist/mirroredPrismaEnums.js.map +1 -1
  11. package/dist/partnerStoreTypes.d.ts +93 -3
  12. package/dist/partnerStoreTypes.d.ts.map +1 -1
  13. package/dist/partnerStoreTypes.js +39 -1
  14. package/dist/partnerStoreTypes.js.map +1 -1
  15. package/dist/requestAuthUser.d.ts.map +1 -1
  16. package/dist/requestAuthUser.js +5 -3
  17. package/dist/requestAuthUser.js.map +1 -1
  18. package/dist/utils/__tests__/eventCitySlug.test.d.ts +2 -0
  19. package/dist/utils/__tests__/eventCitySlug.test.d.ts.map +1 -0
  20. package/dist/utils/__tests__/eventCitySlug.test.js +12 -0
  21. package/dist/utils/__tests__/eventCitySlug.test.js.map +1 -0
  22. package/dist/utils/__tests__/localPartnerHours.test.d.ts +2 -0
  23. package/dist/utils/__tests__/localPartnerHours.test.d.ts.map +1 -0
  24. package/dist/utils/__tests__/localPartnerHours.test.js +35 -0
  25. package/dist/utils/__tests__/localPartnerHours.test.js.map +1 -0
  26. package/dist/utils/__tests__/orgPromotionalPackageUtils.test.d.ts +2 -0
  27. package/dist/utils/__tests__/orgPromotionalPackageUtils.test.d.ts.map +1 -0
  28. package/dist/utils/__tests__/orgPromotionalPackageUtils.test.js +88 -0
  29. package/dist/utils/__tests__/orgPromotionalPackageUtils.test.js.map +1 -0
  30. package/dist/utils/eventCitySlug.d.ts +11 -0
  31. package/dist/utils/eventCitySlug.d.ts.map +1 -0
  32. package/dist/utils/eventCitySlug.js +20 -0
  33. package/dist/utils/eventCitySlug.js.map +1 -0
  34. package/dist/utils/localPartnerHours.d.ts +12 -0
  35. package/dist/utils/localPartnerHours.d.ts.map +1 -0
  36. package/dist/utils/localPartnerHours.js +92 -0
  37. package/dist/utils/localPartnerHours.js.map +1 -0
  38. package/dist/utils/orgPromotionalPackageUtils.d.ts +100 -0
  39. package/dist/utils/orgPromotionalPackageUtils.d.ts.map +1 -0
  40. package/dist/utils/orgPromotionalPackageUtils.js +201 -0
  41. package/dist/utils/orgPromotionalPackageUtils.js.map +1 -0
  42. package/package.json +1 -1
  43. package/prisma/schema.prisma +137 -9
  44. package/src/__tests__/statusEnums.test.ts +1 -0
  45. package/src/index.ts +5 -0
  46. package/src/mirroredPrismaEnums.ts +1 -0
  47. package/src/partnerStoreTypes.ts +134 -3
  48. package/src/requestAuthUser.ts +5 -3
  49. package/src/utils/__tests__/eventCitySlug.test.ts +17 -0
  50. package/src/utils/__tests__/localPartnerHours.test.ts +45 -0
  51. package/src/utils/__tests__/orgPromotionalPackageUtils.test.ts +115 -0
  52. package/src/utils/eventCitySlug.ts +23 -0
  53. package/src/utils/localPartnerHours.ts +98 -0
  54. package/src/utils/orgPromotionalPackageUtils.ts +278 -0
@@ -0,0 +1,98 @@
1
+ /** Weekly hours shape aligned with service profile hours config. */
2
+ export type LocalPartnerHoursConfig = {
3
+ hours: Record<string, { open: string; close: string }[]>;
4
+ openDays: Record<string, boolean>;
5
+ };
6
+
7
+ const WEEKDAYS = [
8
+ "Sunday",
9
+ "Monday",
10
+ "Tuesday",
11
+ "Wednesday",
12
+ "Thursday",
13
+ "Friday",
14
+ "Saturday",
15
+ ] as const;
16
+
17
+ function parseMinutes(value: string): number | null {
18
+ const trimmed = value.trim();
19
+ if (!trimmed) return null;
20
+ const match = /^(\d{1,2}):(\d{2})$/.exec(trimmed);
21
+ if (!match) return null;
22
+ const hours = Number(match[1]);
23
+ const minutes = Number(match[2]);
24
+ if (hours > 23 || minutes > 59) return null;
25
+ return hours * 60 + minutes;
26
+ }
27
+
28
+ function parseHoursConfig(raw: unknown): LocalPartnerHoursConfig | null {
29
+ if (!raw || typeof raw !== "object") return null;
30
+ const config = raw as Partial<LocalPartnerHoursConfig>;
31
+ if (!config.hours || typeof config.hours !== "object") return null;
32
+ if (!config.openDays || typeof config.openDays !== "object") return null;
33
+ return {
34
+ hours: config.hours,
35
+ openDays: config.openDays,
36
+ };
37
+ }
38
+
39
+ function getZonedWeekdayAndMinutes(now: Date, timeZone: string): {
40
+ weekday: (typeof WEEKDAYS)[number];
41
+ minutes: number;
42
+ } | null {
43
+ try {
44
+ const formatter = new Intl.DateTimeFormat("en-US", {
45
+ timeZone,
46
+ weekday: "long",
47
+ hour: "2-digit",
48
+ minute: "2-digit",
49
+ hour12: false,
50
+ });
51
+ const parts = formatter.formatToParts(now);
52
+ const weekday = parts.find((p) => p.type === "weekday")?.value;
53
+ const hour = parts.find((p) => p.type === "hour")?.value;
54
+ const minute = parts.find((p) => p.type === "minute")?.value;
55
+ if (!weekday || hour == null || minute == null) return null;
56
+ if (!WEEKDAYS.includes(weekday as (typeof WEEKDAYS)[number])) return null;
57
+ return {
58
+ weekday: weekday as (typeof WEEKDAYS)[number],
59
+ minutes: Number(hour) * 60 + Number(minute),
60
+ };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Returns null when hours are not configured; true/false when they are. */
67
+ export function isLocalPartnerOpenNow(
68
+ businessHours: unknown,
69
+ timezone: string | null | undefined,
70
+ now: Date = new Date()
71
+ ): boolean | null {
72
+ const config = parseHoursConfig(businessHours);
73
+ if (!config) return null;
74
+
75
+ const tz = timezone?.trim() || "America/New_York";
76
+ const zoned = getZonedWeekdayAndMinutes(now, tz);
77
+ if (!zoned) return null;
78
+
79
+ if (config.openDays[zoned.weekday] === false) return false;
80
+
81
+ const slots = config.hours[zoned.weekday] ?? [];
82
+ for (const slot of slots) {
83
+ const open = parseMinutes(slot.open);
84
+ const close = parseMinutes(slot.close);
85
+ if (open == null || close == null) continue;
86
+ if (close > open) {
87
+ if (zoned.minutes >= open && zoned.minutes < close) return true;
88
+ } else if (zoned.minutes >= open || zoned.minutes < close) {
89
+ return true;
90
+ }
91
+ }
92
+
93
+ return false;
94
+ }
95
+
96
+ export function localPartnerHasBusinessHours(businessHours: unknown): boolean {
97
+ return parseHoursConfig(businessHours) != null;
98
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Org Promotional Packages — hosts pay orgs to endorse events to org members.
3
+ */
4
+
5
+ export type OrgPromotionalPackageTierId = "community" | "spotlight" | "featured";
6
+
7
+ export type OrgPromoIneligibleReason =
8
+ | "event_not_published"
9
+ | "event_ended"
10
+ | "event_cancelled"
11
+ | "event_org_only"
12
+ | "event_not_ticketable"
13
+ | "event_missing_dates";
14
+
15
+ export interface OrgPromotionalPackageTerms {
16
+ tierId: OrgPromotionalPackageTierId;
17
+ maxReach: number;
18
+ orgFeeCents: number;
19
+ bashPlatformFeeCents: number;
20
+ }
21
+
22
+ export interface OrgPromotionalPackageRates {
23
+ community?: { enabled: boolean; orgFeeCents: number };
24
+ spotlight?: { enabled: boolean; orgFeeCents: number };
25
+ featured?: { enabled: boolean; orgFeeCents: number };
26
+ }
27
+
28
+ export const ORG_PROMO_PLATFORM_FEES_CENTS: Record<OrgPromotionalPackageTierId, number> = {
29
+ community: 300,
30
+ spotlight: 700,
31
+ featured: 1500,
32
+ };
33
+
34
+ /** Runtime cap; featured uses MAX_REACH_UNLIMITED_SNAPSHOT in DB. */
35
+ export const ORG_PROMO_MAX_REACH: Record<OrgPromotionalPackageTierId, number> = {
36
+ community: 500,
37
+ spotlight: 2000,
38
+ featured: Number.POSITIVE_INFINITY,
39
+ };
40
+
41
+ /** Stored on OrgPromotionalPackage.maxReach when tier is featured. */
42
+ export const ORG_PROMO_MAX_REACH_UNLIMITED_SNAPSHOT = 2_147_483_647;
43
+
44
+ export const ORG_PROMO_MAX_ORG_FEE_CENTS = 500_000;
45
+ export const ORG_PROMO_HOST_NOTE_MAX_LENGTH = 500;
46
+ export const ORG_PROMO_OPEN_STATUSES = ["PENDING_APPROVAL", "ACTIVE"] as const;
47
+
48
+ export const ORG_PROMO_TIER_IDS: OrgPromotionalPackageTierId[] = [
49
+ "community",
50
+ "spotlight",
51
+ "featured",
52
+ ];
53
+
54
+ export const ORG_PROMO_PI_METADATA_TYPE = "org_promotional_package";
55
+
56
+ export const FCM_MULTICAST_MAX_TOKENS = 500;
57
+
58
+ export function orgPromoMaxReachForSnapshot(tierId: OrgPromotionalPackageTierId): number {
59
+ const reach = ORG_PROMO_MAX_REACH[tierId];
60
+ if (reach === Number.POSITIVE_INFINITY) {
61
+ return ORG_PROMO_MAX_REACH_UNLIMITED_SNAPSHOT;
62
+ }
63
+ return reach;
64
+ }
65
+
66
+ export function isOrgPromoTierId(value: string): value is OrgPromotionalPackageTierId {
67
+ return ORG_PROMO_TIER_IDS.includes(value as OrgPromotionalPackageTierId);
68
+ }
69
+
70
+ export function parseOrgPromotionalPackageRates(raw: unknown): OrgPromotionalPackageRates | null {
71
+ if (!raw || typeof raw !== "object") return null;
72
+ const out: OrgPromotionalPackageRates = {};
73
+ for (const tierId of ORG_PROMO_TIER_IDS) {
74
+ const entry = (raw as Record<string, unknown>)[tierId];
75
+ if (entry === undefined) continue;
76
+ if (!entry || typeof entry !== "object") return null;
77
+ const e = entry as Record<string, unknown>;
78
+ if (typeof e.enabled !== "boolean") return null;
79
+ if (typeof e.orgFeeCents !== "number" || !Number.isInteger(e.orgFeeCents)) return null;
80
+ if (e.orgFeeCents < 0 || e.orgFeeCents > ORG_PROMO_MAX_ORG_FEE_CENTS) return null;
81
+ out[tierId] = { enabled: e.enabled, orgFeeCents: e.orgFeeCents };
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export function orgHasEnabledPromoTier(rates: OrgPromotionalPackageRates | null): boolean {
87
+ if (!rates) return false;
88
+ return ORG_PROMO_TIER_IDS.some((t) => rates[t]?.enabled === true);
89
+ }
90
+
91
+ export function assertOrgPromoRatesAllowEnable(
92
+ rates: OrgPromotionalPackageRates,
93
+ stripeConnectedAccountId: string | null
94
+ ): { ok: true } | { error: "org_connect_required" } {
95
+ if (!orgHasEnabledPromoTier(rates)) return { ok: true };
96
+ if (!stripeConnectedAccountId) return { error: "org_connect_required" };
97
+ return { ok: true };
98
+ }
99
+
100
+ export function buildOrgPromoPackageTerms(
101
+ tierId: OrgPromotionalPackageTierId,
102
+ rates: OrgPromotionalPackageRates
103
+ ): OrgPromotionalPackageTerms | null {
104
+ const tierRate = rates[tierId];
105
+ if (!tierRate?.enabled) return null;
106
+ return {
107
+ tierId,
108
+ maxReach: ORG_PROMO_MAX_REACH[tierId],
109
+ orgFeeCents: tierRate.orgFeeCents,
110
+ bashPlatformFeeCents: ORG_PROMO_PLATFORM_FEES_CENTS[tierId],
111
+ };
112
+ }
113
+
114
+ export function orgPromoPackageTotalCents(terms: OrgPromotionalPackageTerms): number {
115
+ return terms.orgFeeCents + terms.bashPlatformFeeCents;
116
+ }
117
+
118
+ /** PaymentIntent.metadata shape written by createOrgPromoPaymentIntent. */
119
+ export type OrgPromoPaymentIntentMetadata = {
120
+ type?: string;
121
+ organizationId?: string;
122
+ bashEventId?: string;
123
+ tierId?: string;
124
+ hostUserId?: string;
125
+ orgFeeCents?: string;
126
+ bashPlatformFeeCents?: string;
127
+ };
128
+
129
+ function parseMetadataInt(value: string | undefined): number | null {
130
+ if (value === undefined || value === "") return null;
131
+ const n = Number.parseInt(value, 10);
132
+ if (!Number.isInteger(n) || n < 0) return null;
133
+ return n;
134
+ }
135
+
136
+ /**
137
+ * Build package fee snapshot from an authorized PI (confirm-paid).
138
+ * Amount and metadata must match what was set at intent time.
139
+ */
140
+ export function orgPromoPackageTermsFromPaymentIntent(
141
+ pi: { amount: number | null; metadata?: OrgPromoPaymentIntentMetadata | null },
142
+ expectedTierId: OrgPromotionalPackageTierId
143
+ ):
144
+ | { ok: true; terms: OrgPromotionalPackageTerms; totalCents: number }
145
+ | { ok: false; error: string } {
146
+ const m = pi.metadata ?? {};
147
+ if (m.type !== ORG_PROMO_PI_METADATA_TYPE) {
148
+ return { ok: false, error: "invalid_payment_type" };
149
+ }
150
+ if (!isOrgPromoTierId(m.tierId ?? "") || m.tierId !== expectedTierId) {
151
+ return { ok: false, error: "payment_tier_mismatch" };
152
+ }
153
+ const orgFeeCents = parseMetadataInt(m.orgFeeCents);
154
+ const bashPlatformFeeCents = parseMetadataInt(m.bashPlatformFeeCents);
155
+ if (orgFeeCents === null || bashPlatformFeeCents === null) {
156
+ return { ok: false, error: "invalid_payment_amounts" };
157
+ }
158
+ if (orgFeeCents > ORG_PROMO_MAX_ORG_FEE_CENTS) {
159
+ return { ok: false, error: "invalid_payment_amounts" };
160
+ }
161
+ const expectedPlatform = ORG_PROMO_PLATFORM_FEES_CENTS[expectedTierId];
162
+ if (bashPlatformFeeCents !== expectedPlatform) {
163
+ return { ok: false, error: "invalid_platform_fee" };
164
+ }
165
+ const totalCents = orgFeeCents + bashPlatformFeeCents;
166
+ if (totalCents < 50) {
167
+ return { ok: false, error: "invalid_payment_amounts" };
168
+ }
169
+ if (pi.amount !== null && pi.amount !== totalCents) {
170
+ return { ok: false, error: "payment_amount_mismatch" };
171
+ }
172
+ return {
173
+ ok: true,
174
+ totalCents,
175
+ terms: {
176
+ tierId: expectedTierId,
177
+ maxReach: ORG_PROMO_MAX_REACH[expectedTierId],
178
+ orgFeeCents,
179
+ bashPlatformFeeCents,
180
+ },
181
+ };
182
+ }
183
+
184
+ export function selectMembersForOrgPromoReach<T extends { joinedAt?: Date | string | null }>(
185
+ members: T[],
186
+ maxReach: number
187
+ ): T[] {
188
+ const sorted = [...members].sort((a, b) => {
189
+ const ta = a.joinedAt ? new Date(a.joinedAt).getTime() : 0;
190
+ const tb = b.joinedAt ? new Date(b.joinedAt).getTime() : 0;
191
+ return ta - tb;
192
+ });
193
+ if (maxReach === Number.POSITIVE_INFINITY || maxReach >= ORG_PROMO_MAX_REACH_UNLIMITED_SNAPSHOT) {
194
+ return sorted;
195
+ }
196
+ return sorted.slice(0, maxReach);
197
+ }
198
+
199
+ export function chunkArray<T>(items: T[], size: number): T[][] {
200
+ if (size <= 0) return [items];
201
+ const chunks: T[][] = [];
202
+ for (let i = 0; i < items.length; i += size) {
203
+ chunks.push(items.slice(i, i + size));
204
+ }
205
+ return chunks;
206
+ }
207
+
208
+ export type BashEventOrgPromoEligibilityInput = {
209
+ status?: string | null;
210
+ isApproved?: boolean | null;
211
+ startDateTime?: Date | string | null;
212
+ endDateTime?: Date | string | null;
213
+ requiresOrgMembership?: boolean | null;
214
+ ticketTierCount?: number;
215
+ allowDonations?: boolean | null;
216
+ };
217
+
218
+ const TICKETABLE_STATUSES = new Set(["Published", "PreSale"]);
219
+
220
+ export function isBashEventEligibleForOrgPromo(
221
+ event: BashEventOrgPromoEligibilityInput
222
+ ): { eligible: true } | { eligible: false; reason: OrgPromoIneligibleReason } {
223
+ const status = event.status ?? "Draft";
224
+ if (status === "Cancelled" || status === "Rejected" || status === "Archived") {
225
+ return { eligible: false, reason: "event_cancelled" };
226
+ }
227
+ if (!TICKETABLE_STATUSES.has(status)) {
228
+ return { eligible: false, reason: "event_not_published" };
229
+ }
230
+ if (event.isApproved === false) {
231
+ return { eligible: false, reason: "event_not_published" };
232
+ }
233
+ if (!event.startDateTime) {
234
+ return { eligible: false, reason: "event_missing_dates" };
235
+ }
236
+ if (event.endDateTime) {
237
+ const end = new Date(event.endDateTime);
238
+ if (end.getTime() < Date.now()) {
239
+ return { eligible: false, reason: "event_ended" };
240
+ }
241
+ }
242
+ if (event.requiresOrgMembership) {
243
+ return { eligible: false, reason: "event_org_only" };
244
+ }
245
+ const tierCount = event.ticketTierCount ?? 0;
246
+ if (tierCount === 0 && !event.allowDonations) {
247
+ return { eligible: false, reason: "event_not_ticketable" };
248
+ }
249
+ return { eligible: true };
250
+ }
251
+
252
+ export function formatOrgPromoRatesForHost(
253
+ rates: OrgPromotionalPackageRates | null,
254
+ connected: boolean
255
+ ): Array<{
256
+ tierId: OrgPromotionalPackageTierId;
257
+ enabled: boolean;
258
+ orgFeeCents: number;
259
+ platformFeeCents: number;
260
+ totalCents: number;
261
+ maxReach: number;
262
+ }> {
263
+ const parsed = rates ?? {};
264
+ return ORG_PROMO_TIER_IDS.map((tierId) => {
265
+ const tier = parsed[tierId];
266
+ const enabled = Boolean(tier?.enabled && connected);
267
+ const orgFeeCents = tier?.orgFeeCents ?? 0;
268
+ const platformFeeCents = ORG_PROMO_PLATFORM_FEES_CENTS[tierId];
269
+ return {
270
+ tierId,
271
+ enabled,
272
+ orgFeeCents,
273
+ platformFeeCents,
274
+ totalCents: orgFeeCents + platformFeeCents,
275
+ maxReach: orgPromoMaxReachForSnapshot(tierId),
276
+ };
277
+ });
278
+ }