@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
@@ -930,6 +930,7 @@ model BashEvent {
930
930
  bookingOffers ServiceBookingOffer[]
931
931
  promotionBlastEntitlements PromotionBlastEntitlement[]
932
932
  promotionBlasts PromotionBlast[]
933
+ orgPromotionalPackages OrgPromotionalPackage[]
933
934
 
934
935
  @@index([templateId])
935
936
  @@index([parentEventId])
@@ -1423,6 +1424,73 @@ model PromotionBlastPushDelivery {
1423
1424
  @@index([bashEventId])
1424
1425
  }
1425
1426
 
1427
+ // --- Org Promotional Packages (host pays org to endorse event to members) ---
1428
+ enum OrgPromotionalPackageStatus {
1429
+ PENDING_APPROVAL
1430
+ ACTIVE
1431
+ COMPLETED
1432
+ REJECTED
1433
+ EXPIRED
1434
+ REFUNDED
1435
+ }
1436
+
1437
+ model OrgPromotionalPackage {
1438
+ id String @id @default(cuid())
1439
+ createdAt DateTime @default(now())
1440
+ updatedAt DateTime @updatedAt
1441
+
1442
+ organizationId String
1443
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
1444
+ bashEventId String
1445
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
1446
+ hostUserId String
1447
+ hostUser User @relation("OrgPromoHost", fields: [hostUserId], references: [id], onDelete: Cascade)
1448
+
1449
+ tierId String
1450
+ maxReach Int
1451
+ orgFeeCents Int
1452
+ bashPlatformFeeCents Int
1453
+ totalCents Int
1454
+
1455
+ stripePaymentIntentId String @unique
1456
+ authorizedAt DateTime
1457
+ capturedAt DateTime?
1458
+ status OrgPromotionalPackageStatus @default(PENDING_APPROVAL)
1459
+
1460
+ hostNote String?
1461
+ orgResponseNote String?
1462
+ approvedByUserId String?
1463
+ approvedBy User? @relation("OrgPromoApprover", fields: [approvedByUserId], references: [id], onDelete: SetNull)
1464
+ activatedAt DateTime?
1465
+
1466
+ membersNotifiedAt DateTime?
1467
+ notifyError String?
1468
+ notifyAttemptCount Int @default(0)
1469
+
1470
+ deliveries OrgPromotionalDelivery[]
1471
+
1472
+ @@index([organizationId, status])
1473
+ @@index([bashEventId, status])
1474
+ @@index([hostUserId])
1475
+ @@index([status])
1476
+ }
1477
+
1478
+ model OrgPromotionalDelivery {
1479
+ id String @id @default(cuid())
1480
+ createdAt DateTime @default(now())
1481
+
1482
+ packageId String
1483
+ package OrgPromotionalPackage @relation(fields: [packageId], references: [id], onDelete: Cascade)
1484
+ userId String
1485
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
1486
+
1487
+ channel String
1488
+
1489
+ @@unique([packageId, userId, channel])
1490
+ @@index([packageId])
1491
+ @@index([userId])
1492
+ }
1493
+
1426
1494
  /// USPS carrier-route catalog (Utah MVP; expand for national rollout).
1427
1495
  model PostalCarrierRoute {
1428
1496
  id String @id @default(cuid())
@@ -2794,6 +2862,9 @@ model User {
2794
2862
  sentBookingOffers ServiceBookingOffer[] @relation("OfferSender")
2795
2863
  receivedBookingOffers ServiceBookingOffer[] @relation("OfferRecipient")
2796
2864
  promotionBlastEntitlements PromotionBlastEntitlement[] @relation("PromotionBlastPartner")
2865
+ orgPromotionalPackagesHosted OrgPromotionalPackage[] @relation("OrgPromoHost")
2866
+ orgPromotionalPackagesApproved OrgPromotionalPackage[] @relation("OrgPromoApprover")
2867
+ orgPromotionalDeliveries OrgPromotionalDelivery[]
2797
2868
  promotionBlastsOwned PromotionBlast[] @relation("PromotionBlastOwner")
2798
2869
  promotionBlastsPaused PromotionBlast[] @relation("PromotionBlastPausedBy")
2799
2870
  promotionBlastRedemptions PromotionBlastRedemption[]
@@ -3232,6 +3303,13 @@ model Contact {
3232
3303
  contactEmail String?
3233
3304
  fullName String?
3234
3305
  phone String?
3306
+ /// Mailing address line 1 (street number and name). Maps to Lob `address1`.
3307
+ street String?
3308
+ /// Mailing address line 2 (apt, suite, unit). Maps to Lob `address2`.
3309
+ addressLine2 String?
3310
+ city String?
3311
+ state String?
3312
+ zipCode String?
3235
3313
  requestToConnectSent DateTime?
3236
3314
  requestToConnectAccepted DateTime?
3237
3315
  contactOwner User @relation(fields: [contactOwnerId], references: [id], onDelete: Cascade)
@@ -4175,6 +4253,10 @@ model Organization {
4175
4253
 
4176
4254
  smsSendLogs SmsSendLog[]
4177
4255
 
4256
+ /// Per-tier promotional package rates for external hosts (community / spotlight / featured).
4257
+ promotionalPackageRates Json?
4258
+ orgPromotionalPackages OrgPromotionalPackage[]
4259
+
4178
4260
  @@index([ownerId])
4179
4261
  @@index([slug])
4180
4262
  @@index([parentOrganizationId])
@@ -7738,6 +7820,7 @@ enum CreditSourceType {
7738
7820
  PostAfterEvent // First BashFeed post linked to event after it ends
7739
7821
  ReferralCodeUsed // Flat bonus for referrer when code drives a ticket/signup
7740
7822
  VenueLoyaltyRedemption // BashPoints moved through a VenueLoyaltyRedemption claim/redeem/refund
7823
+ PartnerPromoRedemption // BashPoints spent to unlock a local partner promo code
7741
7824
  }
7742
7825
 
7743
7826
  enum ReferralTier {
@@ -9143,21 +9226,61 @@ enum PartnerTier {
9143
9226
  Featured
9144
9227
  }
9145
9228
 
9229
+ enum PartnerListingType {
9230
+ Gear
9231
+ LocalService
9232
+ }
9233
+
9234
+ enum LocalServiceCategory {
9235
+ GetReady
9236
+ WearAndStyle
9237
+ EatAndDrink
9238
+ Move
9239
+ Recover
9240
+ Wellness
9241
+ HealthAndEssentials
9242
+ HomeAndLife
9243
+ ContentAndKeepsakes
9244
+ HostShortcuts
9245
+ PerksAndRetail
9246
+ Other
9247
+ }
9248
+
9146
9249
  model PartnerBrand {
9147
- id String @id @default(cuid())
9250
+ id String @id @default(cuid())
9148
9251
  name String
9149
9252
  logoUrl String?
9150
9253
  websiteUrl String?
9151
- description String? @db.Text
9152
- tier PartnerTier @default(Standard)
9153
- isActive Boolean @default(true)
9254
+ description String? @db.Text
9255
+ tier PartnerTier @default(Standard)
9256
+ isActive Boolean @default(true)
9154
9257
  affiliateBaseUrl String?
9155
9258
  listingFeeMonthly Int? // cents; null = affiliate-only
9156
- createdAt DateTime @default(now())
9259
+ listingType PartnerListingType @default(Gear)
9260
+ address String? @db.Text
9261
+ phone String?
9262
+ citySlug String?
9263
+ stateSlug String?
9264
+ serviceTimings String[] @default([])
9265
+ serviceCategory LocalServiceCategory?
9266
+ promoHeadline String?
9267
+ promoCode String?
9268
+ bookingUrl String?
9269
+ /// Weekly hours: { hours: { Monday: [{ open, close }] }, openDays: { Monday: true } }
9270
+ businessHours Json?
9271
+ timezone String?
9272
+ latitude Float?
9273
+ longitude Float?
9274
+ bashPointsCost Int?
9275
+ clicks Int @default(0)
9276
+ createdAt DateTime @default(now())
9157
9277
  products PartnerProduct[]
9158
9278
  adSlots PartnerAdSlot[]
9279
+ sourceInquiry PartnerInquiry?
9159
9280
 
9160
9281
  @@index([isActive, tier])
9282
+ @@index([listingType, citySlug])
9283
+ @@index([serviceCategory])
9161
9284
  }
9162
9285
 
9163
9286
  model PartnerProduct {
@@ -9215,12 +9338,17 @@ model PartnerInquiry {
9215
9338
  website String?
9216
9339
  description String @db.Text
9217
9340
  categories String[]
9218
- status PartnerInquiryStatus @default(Pending)
9219
- adminNote String? @db.Text
9220
- createdAt DateTime @default(now())
9221
- updatedAt DateTime @updatedAt
9341
+ listingType PartnerListingType @default(Gear)
9342
+ city String?
9343
+ status PartnerInquiryStatus @default(Pending)
9344
+ adminNote String? @db.Text
9345
+ createdBrandId String? @unique
9346
+ createdBrand PartnerBrand? @relation(fields: [createdBrandId], references: [id], onDelete: SetNull)
9347
+ createdAt DateTime @default(now())
9348
+ updatedAt DateTime @updatedAt
9222
9349
 
9223
9350
  @@index([status, createdAt])
9351
+ @@index([listingType])
9224
9352
  }
9225
9353
 
9226
9354
  // ============================================
@@ -138,6 +138,7 @@ describe("status enum value sets", () => {
138
138
  "PostAfterEvent",
139
139
  "ReferralCodeUsed",
140
140
  "VenueLoyaltyRedemption",
141
+ "PartnerPromoRedemption",
141
142
  ].sort(),
142
143
  );
143
144
  });
package/src/index.ts CHANGED
@@ -154,6 +154,8 @@ export {
154
154
  PrizePaymentMethod,
155
155
  PrizePaymentStatus,
156
156
  PartnerTier,
157
+ PartnerListingType,
158
+ LocalServiceCategory,
157
159
  PaymentProvider,
158
160
  PrizeType,
159
161
  ProjectorSetupFormat,
@@ -277,6 +279,8 @@ export type {
277
279
  SuggestionUpvote,
278
280
  } from "@prisma/client";
279
281
 
282
+ export * from "./utils/localPartnerHours.js";
283
+ export * from "./utils/eventCitySlug.js";
280
284
  export * from "./utils/addressUtils.js";
281
285
  export * from "./utils/apiUtils.js";
282
286
  export * from "./utils/arrayUtils.js";
@@ -336,6 +340,7 @@ export * from "./utils/blogUtils.js";
336
340
  export * from "./utils/entityUtils.js";
337
341
  export * from "./utils/flyerUtils.js";
338
342
  export * from "./utils/promotionBlastUtils.js";
343
+ export * from "./utils/orgPromotionalPackageUtils.js";
339
344
  export * from "./utils/guestSurface.js";
340
345
  export * from "./utils/ticketTierSchedule.js";
341
346
  export * from "./utils/lobFlyerUtils.js";
@@ -176,6 +176,7 @@ export const CreditSourceType = {
176
176
  PostAfterEvent: "PostAfterEvent",
177
177
  ReferralCodeUsed: "ReferralCodeUsed",
178
178
  VenueLoyaltyRedemption: "VenueLoyaltyRedemption",
179
+ PartnerPromoRedemption: "PartnerPromoRedemption",
179
180
  } as const satisfies Record<CreditSourceTypeEnum, CreditSourceTypeEnum>;
180
181
 
181
182
  export type CreditSourceType = CreditSourceTypeEnum;
@@ -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,115 @@
1
+ import {
2
+ buildOrgPromoPackageTerms,
3
+ chunkArray,
4
+ isBashEventEligibleForOrgPromo,
5
+ orgPromoPackageTermsFromPaymentIntent,
6
+ orgPromoPackageTotalCents,
7
+ ORG_PROMO_PI_METADATA_TYPE,
8
+ parseOrgPromotionalPackageRates,
9
+ selectMembersForOrgPromoReach,
10
+ } from "../orgPromotionalPackageUtils.js";
11
+
12
+ describe("orgPromotionalPackageUtils", () => {
13
+ it("parses valid rates", () => {
14
+ const rates = parseOrgPromotionalPackageRates({
15
+ community: { enabled: true, orgFeeCents: 5000 },
16
+ });
17
+ expect(rates?.community).toEqual({ enabled: true, orgFeeCents: 5000 });
18
+ });
19
+
20
+ it("rejects negative org fee", () => {
21
+ expect(
22
+ parseOrgPromotionalPackageRates({
23
+ community: { enabled: true, orgFeeCents: -1 },
24
+ })
25
+ ).toBeNull();
26
+ });
27
+
28
+ it("builds package terms and total", () => {
29
+ const rates = { community: { enabled: true, orgFeeCents: 1000 } };
30
+ const terms = buildOrgPromoPackageTerms("community", rates);
31
+ expect(terms).not.toBeNull();
32
+ expect(orgPromoPackageTotalCents(terms!)).toBe(1000 + 300);
33
+ });
34
+
35
+ it("selectMembersForOrgPromoReach caps by joinedAt order", () => {
36
+ const members = [
37
+ { id: "b", joinedAt: new Date("2024-02-01") },
38
+ { id: "a", joinedAt: new Date("2024-01-01") },
39
+ { id: "c", joinedAt: new Date("2024-03-01") },
40
+ ];
41
+ const selected = selectMembersForOrgPromoReach(members, 2);
42
+ expect(selected.map((m) => m.id)).toEqual(["a", "b"]);
43
+ });
44
+
45
+ it("chunks arrays for FCM", () => {
46
+ expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
47
+ });
48
+
49
+ it("rejects unpublished and org-only events", () => {
50
+ expect(isBashEventEligibleForOrgPromo({ status: "Draft" }).eligible).toBe(false);
51
+ const orgOnly = isBashEventEligibleForOrgPromo({
52
+ status: "Published",
53
+ isApproved: true,
54
+ startDateTime: new Date(),
55
+ requiresOrgMembership: true,
56
+ ticketTierCount: 1,
57
+ });
58
+ expect(orgOnly.eligible).toBe(false);
59
+ if (!orgOnly.eligible) {
60
+ expect(orgOnly.reason).toBe("event_org_only");
61
+ }
62
+ });
63
+
64
+ it("builds terms from payment intent metadata", () => {
65
+ const result = orgPromoPackageTermsFromPaymentIntent(
66
+ {
67
+ amount: 5300,
68
+ metadata: {
69
+ type: ORG_PROMO_PI_METADATA_TYPE,
70
+ tierId: "community",
71
+ orgFeeCents: "5000",
72
+ bashPlatformFeeCents: "300",
73
+ },
74
+ },
75
+ "community"
76
+ );
77
+ expect(result.ok).toBe(true);
78
+ if (result.ok) {
79
+ expect(result.totalCents).toBe(5300);
80
+ expect(result.terms.orgFeeCents).toBe(5000);
81
+ expect(result.terms.bashPlatformFeeCents).toBe(300);
82
+ }
83
+ });
84
+
85
+ it("rejects payment intent amount mismatch", () => {
86
+ const result = orgPromoPackageTermsFromPaymentIntent(
87
+ {
88
+ amount: 9999,
89
+ metadata: {
90
+ type: ORG_PROMO_PI_METADATA_TYPE,
91
+ tierId: "community",
92
+ orgFeeCents: "5000",
93
+ bashPlatformFeeCents: "300",
94
+ },
95
+ },
96
+ "community"
97
+ );
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) {
100
+ expect(result.error).toBe("payment_amount_mismatch");
101
+ }
102
+ });
103
+
104
+ it("accepts published ticketed events", () => {
105
+ expect(
106
+ isBashEventEligibleForOrgPromo({
107
+ status: "Published",
108
+ isApproved: true,
109
+ startDateTime: new Date(Date.now() + 86400000),
110
+ endDateTime: new Date(Date.now() + 172800000),
111
+ ticketTierCount: 2,
112
+ }).eligible
113
+ ).toBe(true);
114
+ });
115
+ });
@@ -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
+ }