@bash-app/bash-common 30.265.0 → 30.272.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 +56 -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 +75 -0
  39. package/dist/utils/orgPromotionalPackageUtils.d.ts.map +1 -0
  40. package/dist/utils/orgPromotionalPackageUtils.js +151 -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 +73 -0
  52. package/src/utils/eventCitySlug.ts +23 -0
  53. package/src/utils/localPartnerHours.ts +98 -0
  54. package/src/utils/orgPromotionalPackageUtils.ts +212 -0
@@ -1,4 +1,8 @@
1
- import type { PartnerTier } from "@prisma/client";
1
+ import type {
2
+ LocalServiceCategory,
3
+ PartnerListingType,
4
+ PartnerTier,
5
+ } from "@prisma/client";
2
6
 
3
7
  /** Product row returned by BashStore / wizard suggestion APIs (no internal counters). */
4
8
  export type PartnerProductPublic = {
@@ -28,9 +32,136 @@ export type PartnerStoreResponse = {
28
32
  brands: (PartnerBrandSummary & { products: PartnerProductPublic[] })[];
29
33
  };
30
34
 
31
- /** GET /api/partner/suggestions at most one suggestion */
32
- export type PartnerSuggestionResponse = {
35
+ export type LocalPartnerOfferPublic = {
36
+ id: string;
37
+ name: string;
38
+ description: string | null;
39
+ imageUrl: string | null;
40
+ affiliateUrl: string;
41
+ priceSummary: string | null;
42
+ };
43
+
44
+ export type LocalPartnerPublic = {
45
+ id: string;
46
+ name: string;
47
+ logoUrl: string | null;
48
+ websiteUrl: string | null;
49
+ description: string | null;
50
+ tier: PartnerTier;
51
+ address: string | null;
52
+ phone: string | null;
53
+ citySlug: string | null;
54
+ stateSlug: string | null;
55
+ serviceTimings: string[];
56
+ serviceCategory: LocalServiceCategory | null;
57
+ promoHeadline: string | null;
58
+ promoCode: string | null;
59
+ bookingUrl: string | null;
60
+ businessHours: unknown;
61
+ timezone: string | null;
62
+ latitude: number | null;
63
+ longitude: number | null;
64
+ bashPointsCost: number | null;
65
+ distanceMiles: number | null;
66
+ offers: LocalPartnerOfferPublic[];
67
+ };
68
+
69
+ /** Gear wizard ad slot suggestion */
70
+ export type PartnerGearSuggestion = {
71
+ kind: "gear";
33
72
  slotId: string;
34
73
  product: PartnerProductPublic;
35
74
  brand: PartnerBrandSummary;
36
75
  };
76
+
77
+ /** Local service partner surfaced in wizard or feed contexts */
78
+ export type PartnerLocalSuggestion = {
79
+ kind: "local";
80
+ partner: LocalPartnerPublic;
81
+ };
82
+
83
+ /** GET /api/partner/suggestions — at most one suggestion */
84
+ export type PartnerSuggestionResponse = PartnerGearSuggestion | PartnerLocalSuggestion;
85
+
86
+ export type ClaimPartnerPromoResponse = {
87
+ promoCode: string;
88
+ bashPointsCost: number;
89
+ balanceAfter: number;
90
+ };
91
+
92
+ /** GET /api/partner/local-services */
93
+ export type LocalServiceListingResponse = {
94
+ featured: LocalPartnerPublic | null;
95
+ partners: LocalPartnerPublic[];
96
+ };
97
+
98
+ /** Display labels for LocalServiceCategory enum values. */
99
+ export const LOCAL_SERVICE_CATEGORY_LABELS: Record<LocalServiceCategory, string> = {
100
+ GetReady: "Get Ready",
101
+ WearAndStyle: "Wear & Style",
102
+ EatAndDrink: "Eat & Drink",
103
+ Move: "Move",
104
+ Recover: "Recover",
105
+ Wellness: "Wellness",
106
+ HealthAndEssentials: "Health & Essentials",
107
+ HomeAndLife: "Home & Life",
108
+ ContentAndKeepsakes: "Content & Keepsakes",
109
+ HostShortcuts: "Host Shortcuts",
110
+ PerksAndRetail: "Perks & Retail",
111
+ Other: "Other",
112
+ };
113
+
114
+ /** Ordered list for filter chips in BashStore Local Partners tab. */
115
+ export const LOCAL_SERVICE_CATEGORY_ORDER: LocalServiceCategory[] = [
116
+ "GetReady",
117
+ "WearAndStyle",
118
+ "EatAndDrink",
119
+ "Move",
120
+ "Recover",
121
+ "Wellness",
122
+ "HealthAndEssentials",
123
+ "HomeAndLife",
124
+ "ContentAndKeepsakes",
125
+ "HostShortcuts",
126
+ "PerksAndRetail",
127
+ "Other",
128
+ ];
129
+
130
+ export const LOCAL_SERVICE_TIMING_OPTIONS = [
131
+ { value: "pre_event", label: "Pre-event" },
132
+ { value: "day_of", label: "Day-of" },
133
+ { value: "post_event", label: "Post-event" },
134
+ ] as const;
135
+
136
+ export type LocalServiceTiming = (typeof LOCAL_SERVICE_TIMING_OPTIONS)[number]["value"];
137
+
138
+ export const PARTNER_LISTING_TYPE_LABELS: Record<PartnerListingType, string> = {
139
+ Gear: "Gear",
140
+ LocalService: "Local Service",
141
+ };
142
+
143
+ /** Admin / inquiry payloads for BashStore partner listing requests. */
144
+ export type PartnerInquiryRecord = {
145
+ id: string;
146
+ contactName: string;
147
+ email: string;
148
+ brandName: string;
149
+ website: string | null;
150
+ description: string;
151
+ categories: string[];
152
+ listingType: PartnerListingType;
153
+ city: string | null;
154
+ status: "Pending" | "Reviewed" | "Approved" | "Rejected";
155
+ adminNote: string | null;
156
+ createdBrandId: string | null;
157
+ createdAt: string;
158
+ };
159
+
160
+ /** GET /api/partner/inquiry-status */
161
+ export type PartnerInquiryStatusResponse = {
162
+ brandName: string;
163
+ status: string;
164
+ listingLive: boolean;
165
+ storeUrl: string | null;
166
+ createdBrandId: string | null;
167
+ };
@@ -90,16 +90,18 @@ export function parseRequestAuthFromJwtPayload(decoded: unknown): RequestAuthUse
90
90
  export function userHasSuperUserAccess(
91
91
  auth: Pick<RequestAuthUser, "roles" | "isSuperUser">,
92
92
  ): boolean {
93
- return auth.isSuperUser === true || auth.roles.includes(UserRole.SuperUser);
93
+ const roles = auth.roles ?? [];
94
+ return auth.isSuperUser === true || roles.includes(UserRole.SuperUser);
94
95
  }
95
96
 
96
97
  /** Admin or SuperUser role, or legacy `isSuperUser` flag. */
97
98
  export function userHasAdminAccess(
98
99
  auth: Pick<RequestAuthUser, "roles" | "isSuperUser">,
99
100
  ): boolean {
101
+ const roles = auth.roles ?? [];
100
102
  return (
101
103
  auth.isSuperUser === true ||
102
- auth.roles.includes(UserRole.SuperUser) ||
103
- auth.roles.includes(UserRole.Admin)
104
+ roles.includes(UserRole.SuperUser) ||
105
+ roles.includes(UserRole.Admin)
104
106
  );
105
107
  }
@@ -0,0 +1,17 @@
1
+ import { resolveEventCitySlug, resolveEventStateSlug } from "../eventCitySlug.js";
2
+
3
+ describe("eventCitySlug", () => {
4
+ it("resolveEventCitySlug slugifies city names", () => {
5
+ expect(resolveEventCitySlug({ city: "New York City", state: "NY" })).toBe(
6
+ "new-york-city"
7
+ );
8
+ expect(resolveEventCitySlug({ city: " ", state: "NY" })).toBeNull();
9
+ });
10
+
11
+ it("resolveEventStateSlug normalizes two-letter states", () => {
12
+ expect(resolveEventStateSlug({ city: "Brooklyn", state: "NY" })).toBe("ny");
13
+ expect(resolveEventStateSlug({ city: "LA", state: "California" })).toBe(
14
+ "california"
15
+ );
16
+ });
17
+ });
@@ -0,0 +1,45 @@
1
+ import {
2
+ isLocalPartnerOpenNow,
3
+ type LocalPartnerHoursConfig,
4
+ } from "../localPartnerHours.js";
5
+
6
+ describe("isLocalPartnerOpenNow", () => {
7
+ const sampleHours: LocalPartnerHoursConfig = {
8
+ openDays: {
9
+ Monday: true,
10
+ Tuesday: true,
11
+ Wednesday: true,
12
+ Thursday: true,
13
+ Friday: true,
14
+ Saturday: false,
15
+ Sunday: false,
16
+ },
17
+ hours: {
18
+ Monday: [{ open: "09:00", close: "17:00" }],
19
+ Tuesday: [{ open: "09:00", close: "17:00" }],
20
+ Wednesday: [{ open: "09:00", close: "17:00" }],
21
+ Thursday: [{ open: "09:00", close: "17:00" }],
22
+ Friday: [{ open: "09:00", close: "17:00" }],
23
+ Saturday: [{ open: "", close: "" }],
24
+ Sunday: [{ open: "", close: "" }],
25
+ },
26
+ };
27
+
28
+ it("returns null when hours are missing", () => {
29
+ expect(isLocalPartnerOpenNow(null, "America/New_York")).toBeNull();
30
+ });
31
+
32
+ it("returns true during configured weekday hours", () => {
33
+ const mondayMorning = new Date("2026-05-25T15:00:00.000Z");
34
+ expect(
35
+ isLocalPartnerOpenNow(sampleHours, "America/New_York", mondayMorning)
36
+ ).toBe(true);
37
+ });
38
+
39
+ it("returns false outside configured hours", () => {
40
+ const mondayNight = new Date("2026-05-26T03:00:00.000Z");
41
+ expect(
42
+ isLocalPartnerOpenNow(sampleHours, "America/New_York", mondayNight)
43
+ ).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,73 @@
1
+ import {
2
+ buildOrgPromoPackageTerms,
3
+ chunkArray,
4
+ isBashEventEligibleForOrgPromo,
5
+ orgPromoPackageTotalCents,
6
+ parseOrgPromotionalPackageRates,
7
+ selectMembersForOrgPromoReach,
8
+ } from "../orgPromotionalPackageUtils.js";
9
+
10
+ describe("orgPromotionalPackageUtils", () => {
11
+ it("parses valid rates", () => {
12
+ const rates = parseOrgPromotionalPackageRates({
13
+ community: { enabled: true, orgFeeCents: 5000 },
14
+ });
15
+ expect(rates?.community).toEqual({ enabled: true, orgFeeCents: 5000 });
16
+ });
17
+
18
+ it("rejects negative org fee", () => {
19
+ expect(
20
+ parseOrgPromotionalPackageRates({
21
+ community: { enabled: true, orgFeeCents: -1 },
22
+ })
23
+ ).toBeNull();
24
+ });
25
+
26
+ it("builds package terms and total", () => {
27
+ const rates = { community: { enabled: true, orgFeeCents: 1000 } };
28
+ const terms = buildOrgPromoPackageTerms("community", rates);
29
+ expect(terms).not.toBeNull();
30
+ expect(orgPromoPackageTotalCents(terms!)).toBe(1000 + 300);
31
+ });
32
+
33
+ it("selectMembersForOrgPromoReach caps by joinedAt order", () => {
34
+ const members = [
35
+ { id: "b", joinedAt: new Date("2024-02-01") },
36
+ { id: "a", joinedAt: new Date("2024-01-01") },
37
+ { id: "c", joinedAt: new Date("2024-03-01") },
38
+ ];
39
+ const selected = selectMembersForOrgPromoReach(members, 2);
40
+ expect(selected.map((m) => m.id)).toEqual(["a", "b"]);
41
+ });
42
+
43
+ it("chunks arrays for FCM", () => {
44
+ expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
45
+ });
46
+
47
+ it("rejects unpublished and org-only events", () => {
48
+ expect(isBashEventEligibleForOrgPromo({ status: "Draft" }).eligible).toBe(false);
49
+ const orgOnly = isBashEventEligibleForOrgPromo({
50
+ status: "Published",
51
+ isApproved: true,
52
+ startDateTime: new Date(),
53
+ requiresOrgMembership: true,
54
+ ticketTierCount: 1,
55
+ });
56
+ expect(orgOnly.eligible).toBe(false);
57
+ if (!orgOnly.eligible) {
58
+ expect(orgOnly.reason).toBe("event_org_only");
59
+ }
60
+ });
61
+
62
+ it("accepts published ticketed events", () => {
63
+ expect(
64
+ isBashEventEligibleForOrgPromo({
65
+ status: "Published",
66
+ isApproved: true,
67
+ startDateTime: new Date(Date.now() + 86400000),
68
+ endDateTime: new Date(Date.now() + 172800000),
69
+ ticketTierCount: 2,
70
+ }).eligible
71
+ ).toBe(true);
72
+ });
73
+ });
@@ -0,0 +1,23 @@
1
+ import { generateSlug } from "./slugUtils.js";
2
+
3
+ export type EventCityContext = {
4
+ city?: string | null;
5
+ state?: string | null;
6
+ };
7
+
8
+ /**
9
+ * Derive a partner-store city slug from bash event location text.
10
+ * Matches admin `citySlug` conventions (kebab-case).
11
+ */
12
+ export function resolveEventCitySlug(context: EventCityContext): string | null {
13
+ const city = context.city?.trim();
14
+ if (!city) return null;
15
+ return generateSlug(city);
16
+ }
17
+
18
+ export function resolveEventStateSlug(context: EventCityContext): string | null {
19
+ const state = context.state?.trim();
20
+ if (!state) return null;
21
+ if (state.length === 2) return state.toLowerCase();
22
+ return generateSlug(state);
23
+ }
@@ -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,212 @@
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
+ export function selectMembersForOrgPromoReach<T extends { joinedAt?: Date | string | null }>(
119
+ members: T[],
120
+ maxReach: number
121
+ ): T[] {
122
+ const sorted = [...members].sort((a, b) => {
123
+ const ta = a.joinedAt ? new Date(a.joinedAt).getTime() : 0;
124
+ const tb = b.joinedAt ? new Date(b.joinedAt).getTime() : 0;
125
+ return ta - tb;
126
+ });
127
+ if (maxReach === Number.POSITIVE_INFINITY || maxReach >= ORG_PROMO_MAX_REACH_UNLIMITED_SNAPSHOT) {
128
+ return sorted;
129
+ }
130
+ return sorted.slice(0, maxReach);
131
+ }
132
+
133
+ export function chunkArray<T>(items: T[], size: number): T[][] {
134
+ if (size <= 0) return [items];
135
+ const chunks: T[][] = [];
136
+ for (let i = 0; i < items.length; i += size) {
137
+ chunks.push(items.slice(i, i + size));
138
+ }
139
+ return chunks;
140
+ }
141
+
142
+ export type BashEventOrgPromoEligibilityInput = {
143
+ status?: string | null;
144
+ isApproved?: boolean | null;
145
+ startDateTime?: Date | string | null;
146
+ endDateTime?: Date | string | null;
147
+ requiresOrgMembership?: boolean | null;
148
+ ticketTierCount?: number;
149
+ allowDonations?: boolean | null;
150
+ };
151
+
152
+ const TICKETABLE_STATUSES = new Set(["Published", "PreSale"]);
153
+
154
+ export function isBashEventEligibleForOrgPromo(
155
+ event: BashEventOrgPromoEligibilityInput
156
+ ): { eligible: true } | { eligible: false; reason: OrgPromoIneligibleReason } {
157
+ const status = event.status ?? "Draft";
158
+ if (status === "Cancelled" || status === "Rejected" || status === "Archived") {
159
+ return { eligible: false, reason: "event_cancelled" };
160
+ }
161
+ if (!TICKETABLE_STATUSES.has(status)) {
162
+ return { eligible: false, reason: "event_not_published" };
163
+ }
164
+ if (event.isApproved === false) {
165
+ return { eligible: false, reason: "event_not_published" };
166
+ }
167
+ if (!event.startDateTime) {
168
+ return { eligible: false, reason: "event_missing_dates" };
169
+ }
170
+ if (event.endDateTime) {
171
+ const end = new Date(event.endDateTime);
172
+ if (end.getTime() < Date.now()) {
173
+ return { eligible: false, reason: "event_ended" };
174
+ }
175
+ }
176
+ if (event.requiresOrgMembership) {
177
+ return { eligible: false, reason: "event_org_only" };
178
+ }
179
+ const tierCount = event.ticketTierCount ?? 0;
180
+ if (tierCount === 0 && !event.allowDonations) {
181
+ return { eligible: false, reason: "event_not_ticketable" };
182
+ }
183
+ return { eligible: true };
184
+ }
185
+
186
+ export function formatOrgPromoRatesForHost(
187
+ rates: OrgPromotionalPackageRates | null,
188
+ connected: boolean
189
+ ): Array<{
190
+ tierId: OrgPromotionalPackageTierId;
191
+ enabled: boolean;
192
+ orgFeeCents: number;
193
+ platformFeeCents: number;
194
+ totalCents: number;
195
+ maxReach: number;
196
+ }> {
197
+ const parsed = rates ?? {};
198
+ return ORG_PROMO_TIER_IDS.map((tierId) => {
199
+ const tier = parsed[tierId];
200
+ const enabled = Boolean(tier?.enabled && connected);
201
+ const orgFeeCents = tier?.orgFeeCents ?? 0;
202
+ const platformFeeCents = ORG_PROMO_PLATFORM_FEES_CENTS[tierId];
203
+ return {
204
+ tierId,
205
+ enabled,
206
+ orgFeeCents,
207
+ platformFeeCents,
208
+ totalCents: orgFeeCents + platformFeeCents,
209
+ maxReach: orgPromoMaxReachForSnapshot(tierId),
210
+ };
211
+ });
212
+ }