@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.
- package/dist/__tests__/statusEnums.test.js +1 -0
- package/dist/__tests__/statusEnums.test.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/mirroredPrismaEnums.d.ts +1 -0
- package/dist/mirroredPrismaEnums.d.ts.map +1 -1
- package/dist/mirroredPrismaEnums.js +1 -0
- package/dist/mirroredPrismaEnums.js.map +1 -1
- package/dist/partnerStoreTypes.d.ts +93 -3
- package/dist/partnerStoreTypes.d.ts.map +1 -1
- package/dist/partnerStoreTypes.js +39 -1
- package/dist/partnerStoreTypes.js.map +1 -1
- package/dist/requestAuthUser.d.ts.map +1 -1
- package/dist/requestAuthUser.js +5 -3
- package/dist/requestAuthUser.js.map +1 -1
- package/dist/utils/__tests__/eventCitySlug.test.d.ts +2 -0
- package/dist/utils/__tests__/eventCitySlug.test.d.ts.map +1 -0
- package/dist/utils/__tests__/eventCitySlug.test.js +12 -0
- package/dist/utils/__tests__/eventCitySlug.test.js.map +1 -0
- package/dist/utils/__tests__/localPartnerHours.test.d.ts +2 -0
- package/dist/utils/__tests__/localPartnerHours.test.d.ts.map +1 -0
- package/dist/utils/__tests__/localPartnerHours.test.js +35 -0
- package/dist/utils/__tests__/localPartnerHours.test.js.map +1 -0
- package/dist/utils/__tests__/orgPromotionalPackageUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/orgPromotionalPackageUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/orgPromotionalPackageUtils.test.js +56 -0
- package/dist/utils/__tests__/orgPromotionalPackageUtils.test.js.map +1 -0
- package/dist/utils/eventCitySlug.d.ts +11 -0
- package/dist/utils/eventCitySlug.d.ts.map +1 -0
- package/dist/utils/eventCitySlug.js +20 -0
- package/dist/utils/eventCitySlug.js.map +1 -0
- package/dist/utils/localPartnerHours.d.ts +12 -0
- package/dist/utils/localPartnerHours.d.ts.map +1 -0
- package/dist/utils/localPartnerHours.js +92 -0
- package/dist/utils/localPartnerHours.js.map +1 -0
- package/dist/utils/orgPromotionalPackageUtils.d.ts +75 -0
- package/dist/utils/orgPromotionalPackageUtils.d.ts.map +1 -0
- package/dist/utils/orgPromotionalPackageUtils.js +151 -0
- package/dist/utils/orgPromotionalPackageUtils.js.map +1 -0
- package/package.json +1 -1
- package/prisma/schema.prisma +137 -9
- package/src/__tests__/statusEnums.test.ts +1 -0
- package/src/index.ts +5 -0
- package/src/mirroredPrismaEnums.ts +1 -0
- package/src/partnerStoreTypes.ts +134 -3
- package/src/requestAuthUser.ts +5 -3
- package/src/utils/__tests__/eventCitySlug.test.ts +17 -0
- package/src/utils/__tests__/localPartnerHours.test.ts +45 -0
- package/src/utils/__tests__/orgPromotionalPackageUtils.test.ts +73 -0
- package/src/utils/eventCitySlug.ts +23 -0
- package/src/utils/localPartnerHours.ts +98 -0
- package/src/utils/orgPromotionalPackageUtils.ts +212 -0
package/src/partnerStoreTypes.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
32
|
-
|
|
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
|
+
};
|
package/src/requestAuthUser.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
+
}
|