@bash-app/bash-common 30.316.0 → 30.319.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 (55) hide show
  1. package/dist/__tests__/appleIapProducts.test.d.ts +2 -0
  2. package/dist/__tests__/appleIapProducts.test.d.ts.map +1 -0
  3. package/dist/__tests__/appleIapProducts.test.js +32 -0
  4. package/dist/__tests__/appleIapProducts.test.js.map +1 -0
  5. package/dist/__tests__/hostCrmGrowthPack.test.d.ts +2 -0
  6. package/dist/__tests__/hostCrmGrowthPack.test.d.ts.map +1 -0
  7. package/dist/__tests__/hostCrmGrowthPack.test.js +25 -0
  8. package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -0
  9. package/dist/__tests__/hostCrmSegments.test.js +10 -0
  10. package/dist/__tests__/hostCrmSegments.test.js.map +1 -1
  11. package/dist/__tests__/ticketExtShapes.test.d.ts +2 -0
  12. package/dist/__tests__/ticketExtShapes.test.d.ts.map +1 -0
  13. package/dist/__tests__/ticketExtShapes.test.js +45 -0
  14. package/dist/__tests__/ticketExtShapes.test.js.map +1 -0
  15. package/dist/appleIapProducts.d.ts +64 -0
  16. package/dist/appleIapProducts.d.ts.map +1 -0
  17. package/dist/appleIapProducts.js +113 -0
  18. package/dist/appleIapProducts.js.map +1 -0
  19. package/dist/definitions.d.ts +3 -1
  20. package/dist/definitions.d.ts.map +1 -1
  21. package/dist/definitions.js +2 -0
  22. package/dist/definitions.js.map +1 -1
  23. package/dist/extendedSchemas.d.ts +106 -27
  24. package/dist/extendedSchemas.d.ts.map +1 -1
  25. package/dist/extendedSchemas.js +15 -0
  26. package/dist/extendedSchemas.js.map +1 -1
  27. package/dist/hostCrmAutomation.d.ts +25 -0
  28. package/dist/hostCrmAutomation.d.ts.map +1 -1
  29. package/dist/hostCrmAutomation.js +29 -0
  30. package/dist/hostCrmAutomation.js.map +1 -1
  31. package/dist/hostCrmSegments.d.ts +2 -0
  32. package/dist/hostCrmSegments.d.ts.map +1 -1
  33. package/dist/hostCrmSegments.js +9 -0
  34. package/dist/hostCrmSegments.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/sms/smsTemplates.d.ts +13 -1
  40. package/dist/sms/smsTemplates.d.ts.map +1 -1
  41. package/dist/sms/smsTemplates.js +3 -0
  42. package/dist/sms/smsTemplates.js.map +1 -1
  43. package/package.json +1 -1
  44. package/prisma/schema.prisma +69 -1
  45. package/src/__tests__/appleIapProducts.test.ts +52 -0
  46. package/src/__tests__/hostCrmGrowthPack.test.ts +33 -0
  47. package/src/__tests__/hostCrmSegments.test.ts +13 -0
  48. package/src/__tests__/ticketExtShapes.test.ts +55 -0
  49. package/src/appleIapProducts.ts +203 -0
  50. package/src/definitions.ts +2 -0
  51. package/src/extendedSchemas.ts +147 -35
  52. package/src/hostCrmAutomation.ts +59 -0
  53. package/src/hostCrmSegments.ts +12 -0
  54. package/src/index.ts +1 -0
  55. package/src/sms/smsTemplates.ts +6 -0
@@ -158,6 +158,15 @@ describe("applyHostCrmSegmentRules", () => {
158
158
  })
159
159
  ).toHaveLength(1);
160
160
  });
161
+
162
+ it("filters by maxEventCount (first-timers style segments)", () => {
163
+ const audience = [
164
+ member({ userId: "first-timer", eventCount: 1 }),
165
+ member({ userId: "repeat", eventCount: 4 }),
166
+ ];
167
+ const filtered = applyHostCrmSegmentRules(audience, { maxEventCount: 1 });
168
+ expect(filtered.map((r) => r.userId)).toEqual(["first-timer"]);
169
+ });
161
170
  });
162
171
 
163
172
  describe("HOST_CRM_PRESET_SEGMENTS", () => {
@@ -177,12 +186,16 @@ describe("HOST_CRM_PRESET_SEGMENTS", () => {
177
186
  "series_regular",
178
187
  "brings_friends",
179
188
  "last3Events",
189
+ "first_timers",
180
190
  ])
181
191
  );
182
192
  expect(ids).not.toContain("cadence_often");
183
193
  expect(
184
194
  HOST_CRM_PRESET_SEGMENTS.find((p) => p.id === "repeat_guests")?.rules
185
195
  ).toEqual({ minEventCount: 2 });
196
+ expect(
197
+ HOST_CRM_PRESET_SEGMENTS.find((p) => p.id === "first_timers")?.rules
198
+ ).toEqual({ maxEventCount: 1 });
186
199
  expect(
187
200
  HOST_CRM_PRESET_SEGMENTS.find((p) => p.id === "top_spenders")?.rules
188
201
  ).toEqual({});
@@ -0,0 +1,55 @@
1
+ import type { PublicUser, TicketFlexible } from "../extendedSchemas";
2
+ import {
3
+ isTicketExt,
4
+ ticketsWithOwnerAndForUser,
5
+ } from "../extendedSchemas";
6
+
7
+ function ticketStub(overrides: Partial<TicketFlexible> = {}): TicketFlexible {
8
+ return {
9
+ id: "ticket-1",
10
+ ownerId: "owner-1",
11
+ forUserId: "for-user-1",
12
+ ...overrides,
13
+ } as TicketFlexible;
14
+ }
15
+
16
+ describe("TicketFlexible / TicketExt shapes", () => {
17
+ const owner = { id: "owner-1" } as PublicUser;
18
+ const forUser = { id: "for-user-1" } as PublicUser;
19
+
20
+ it("isTicketExt requires both owner and forUser", () => {
21
+ expect(isTicketExt(ticketStub({ id: "t1" }))).toBe(false);
22
+ expect(isTicketExt(ticketStub({ id: "t2", owner }))).toBe(false);
23
+ expect(isTicketExt(ticketStub({ id: "t3", forUser }))).toBe(false);
24
+ expect(isTicketExt(ticketStub({ id: "t4", owner, forUser }))).toBe(true);
25
+ });
26
+
27
+ it("ticketsWithOwnerAndForUser keeps only TicketExt rows", () => {
28
+ const rows = [
29
+ ticketStub({ id: "a" }),
30
+ ticketStub({ id: "b", owner, forUser }),
31
+ ticketStub({ id: "c", owner }),
32
+ ];
33
+ const full = ticketsWithOwnerAndForUser(rows);
34
+ expect(full.map((t) => t.id)).toEqual(["b"]);
35
+ expect(full[0].owner.id).toBe("owner-1");
36
+ expect(full[0].forUser.id).toBe("for-user-1");
37
+ });
38
+
39
+ it("TicketExt is assignable from a narrowed TicketFlexible", () => {
40
+ const flexible = ticketStub({
41
+ id: "t5",
42
+ owner,
43
+ forUser,
44
+ postponeRespondedAt: new Date("2026-07-17T00:00:00.000Z"),
45
+ });
46
+ expect(isTicketExt(flexible)).toBe(true);
47
+ if (isTicketExt(flexible)) {
48
+ expect(flexible.owner.id).toBe("owner-1");
49
+ expect(flexible.forUser.id).toBe("for-user-1");
50
+ expect(flexible.postponeRespondedAt).toEqual(
51
+ new Date("2026-07-17T00:00:00.000Z")
52
+ );
53
+ }
54
+ });
55
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Apple App Store product catalog for iOS IAP.
3
+ * Product IDs must match App Store Connect exactly.
4
+ *
5
+ * Stripe remains the billing path on web/Android. On iOS native, these products
6
+ * unlock the same entitlements Stripe grants for digital goods (Guideline 3.1.1).
7
+ * Event tickets and real-world service bookings stay on Stripe (3.1.3(e)).
8
+ */
9
+ import { BashPassTier, MembershipTier } from "@prisma/client";
10
+
11
+ import { FEATURE_BOOST_DURATIONS, MEMBERSHIP_PRICING } from "./membershipDefinitions.js";
12
+ import { SERVICE_SUBSCRIPTION_TIERS } from "./definitions.js";
13
+
14
+ export type AppleIapProductFamily =
15
+ | "membership"
16
+ | "listing"
17
+ | "bashpass"
18
+ | "feature_boost";
19
+
20
+ export type AppleMembershipInterval = "monthly" | "yearly" | "lifetime";
21
+
22
+ export type AppleListingTier = "Ally" | "Partner" | "Patron";
23
+
24
+ export interface AppleMembershipProduct {
25
+ productId: string;
26
+ family: "membership";
27
+ tier: Exclude<MembershipTier, "Basic" | "Guest">;
28
+ interval: AppleMembershipInterval;
29
+ /** Reference price in cents (ASC must match; Apple may regionalize). */
30
+ priceCents: number;
31
+ }
32
+
33
+ export interface AppleListingProduct {
34
+ productId: string;
35
+ family: "listing";
36
+ tier: AppleListingTier;
37
+ interval: "monthly";
38
+ priceCents: number;
39
+ }
40
+
41
+ export interface AppleBashPassProduct {
42
+ productId: string;
43
+ family: "bashpass";
44
+ tier: BashPassTier;
45
+ interval: "monthly";
46
+ priceCents: number;
47
+ }
48
+
49
+ export interface AppleFeatureBoostProduct {
50
+ productId: string;
51
+ family: "feature_boost";
52
+ durationId: (typeof FEATURE_BOOST_DURATIONS)[number]["id"];
53
+ hours: number;
54
+ priceCents: number;
55
+ }
56
+
57
+ export type AppleIapProduct =
58
+ | AppleMembershipProduct
59
+ | AppleListingProduct
60
+ | AppleBashPassProduct
61
+ | AppleFeatureBoostProduct;
62
+
63
+ const MEMBERSHIP_TIERS = [
64
+ MembershipTier.Premium,
65
+ MembershipTier.Pro,
66
+ MembershipTier.Elite,
67
+ MembershipTier.Legend,
68
+ ] as const;
69
+
70
+ function membershipProductId(
71
+ tier: (typeof MEMBERSHIP_TIERS)[number],
72
+ interval: AppleMembershipInterval
73
+ ): string {
74
+ return `community.bash.us.membership.${tier.toLowerCase()}.${interval}`;
75
+ }
76
+
77
+ /** Lifetime price heuristic: 10× yearly (matches existing Stripe lifetime helpers). */
78
+ function lifetimePriceCents(tier: (typeof MEMBERSHIP_TIERS)[number]): number {
79
+ return MEMBERSHIP_PRICING[tier].yearly * 10;
80
+ }
81
+
82
+ export const APPLE_MEMBERSHIP_PRODUCTS: readonly AppleMembershipProduct[] =
83
+ MEMBERSHIP_TIERS.flatMap((tier) => {
84
+ const monthly: AppleMembershipProduct = {
85
+ productId: membershipProductId(tier, "monthly"),
86
+ family: "membership",
87
+ tier,
88
+ interval: "monthly",
89
+ priceCents: MEMBERSHIP_PRICING[tier].monthly,
90
+ };
91
+ const yearly: AppleMembershipProduct = {
92
+ productId: membershipProductId(tier, "yearly"),
93
+ family: "membership",
94
+ tier,
95
+ interval: "yearly",
96
+ priceCents: MEMBERSHIP_PRICING[tier].yearly,
97
+ };
98
+ const lifetime: AppleMembershipProduct = {
99
+ productId: membershipProductId(tier, "lifetime"),
100
+ family: "membership",
101
+ tier,
102
+ interval: "lifetime",
103
+ priceCents: lifetimePriceCents(tier),
104
+ };
105
+ return [monthly, yearly, lifetime];
106
+ });
107
+
108
+ /** Listing seat subscriptions — dollars in SERVICE_SUBSCRIPTION_TIERS → cents. */
109
+ export const APPLE_LISTING_PRODUCTS: readonly AppleListingProduct[] = (
110
+ ["Ally", "Partner", "Patron"] as const
111
+ ).map((tier) => ({
112
+ productId: `community.bash.us.listing.${tier.toLowerCase()}.monthly`,
113
+ family: "listing" as const,
114
+ tier,
115
+ interval: "monthly" as const,
116
+ priceCents: Math.round(SERVICE_SUBSCRIPTION_TIERS[tier].price * 100),
117
+ }));
118
+
119
+ /**
120
+ * Standalone BashPass monthly prices (cents). Mirror Stripe catalog when ASC is created.
121
+ * LITE $19.99 · STANDARD $49.99 · UNLIMITED $99.99
122
+ */
123
+ export const APPLE_BASHPASS_PRICE_CENTS: Record<BashPassTier, number> = {
124
+ [BashPassTier.LITE]: 1999,
125
+ [BashPassTier.STANDARD]: 4999,
126
+ [BashPassTier.UNLIMITED]: 9999,
127
+ };
128
+
129
+ export const APPLE_BASHPASS_PRODUCTS: readonly AppleBashPassProduct[] = (
130
+ Object.values(BashPassTier) as BashPassTier[]
131
+ ).map((tier) => ({
132
+ productId: `community.bash.us.bashpass.${tier.toLowerCase()}.monthly`,
133
+ family: "bashpass" as const,
134
+ tier,
135
+ interval: "monthly" as const,
136
+ priceCents: APPLE_BASHPASS_PRICE_CENTS[tier],
137
+ }));
138
+
139
+ export const APPLE_FEATURE_BOOST_PRODUCTS: readonly AppleFeatureBoostProduct[] =
140
+ FEATURE_BOOST_DURATIONS.map((d) => ({
141
+ productId: `community.bash.us.boost.${d.id}`,
142
+ family: "feature_boost" as const,
143
+ durationId: d.id,
144
+ hours: d.hours,
145
+ priceCents: d.priceCents,
146
+ }));
147
+
148
+ export const APPLE_IAP_PRODUCTS: readonly AppleIapProduct[] = [
149
+ ...APPLE_MEMBERSHIP_PRODUCTS,
150
+ ...APPLE_LISTING_PRODUCTS,
151
+ ...APPLE_BASHPASS_PRODUCTS,
152
+ ...APPLE_FEATURE_BOOST_PRODUCTS,
153
+ ];
154
+
155
+ const BY_PRODUCT_ID: ReadonlyMap<string, AppleIapProduct> = new Map(
156
+ APPLE_IAP_PRODUCTS.map((p) => [p.productId, p])
157
+ );
158
+
159
+ export function getAppleIapProduct(productId: string): AppleIapProduct | undefined {
160
+ return BY_PRODUCT_ID.get(productId);
161
+ }
162
+
163
+ export function isAppleIapProductId(productId: string): boolean {
164
+ return BY_PRODUCT_ID.has(productId);
165
+ }
166
+
167
+ export function listAppleIapProductIds(family?: AppleIapProductFamily): string[] {
168
+ if (!family) {
169
+ return APPLE_IAP_PRODUCTS.map((p) => p.productId);
170
+ }
171
+ return APPLE_IAP_PRODUCTS.filter((p) => p.family === family).map((p) => p.productId);
172
+ }
173
+
174
+ export function findAppleMembershipProduct(
175
+ tier: Exclude<MembershipTier, "Basic" | "Guest">,
176
+ interval: AppleMembershipInterval
177
+ ): AppleMembershipProduct | undefined {
178
+ return APPLE_MEMBERSHIP_PRODUCTS.find(
179
+ (p) => p.tier === tier && p.interval === interval
180
+ );
181
+ }
182
+
183
+ export function findAppleListingProduct(
184
+ tier: AppleListingTier
185
+ ): AppleListingProduct | undefined {
186
+ return APPLE_LISTING_PRODUCTS.find((p) => p.tier === tier);
187
+ }
188
+
189
+ export function findAppleBashPassProduct(
190
+ tier: BashPassTier
191
+ ): AppleBashPassProduct | undefined {
192
+ return APPLE_BASHPASS_PRODUCTS.find((p) => p.tier === tier);
193
+ }
194
+
195
+ export function findAppleFeatureBoostProduct(
196
+ durationId: (typeof FEATURE_BOOST_DURATIONS)[number]["id"]
197
+ ): AppleFeatureBoostProduct | undefined {
198
+ return APPLE_FEATURE_BOOST_PRODUCTS.find((p) => p.durationId === durationId);
199
+ }
200
+
201
+ /** Apple subscription management deep link (Settings → Subscriptions). */
202
+ export const APPLE_SUBSCRIPTIONS_MANAGEMENT_URL =
203
+ "https://apps.apple.com/account/subscriptions";
@@ -909,6 +909,8 @@ export enum ApiErrorType {
909
909
  payment_intent_creation_failed,
910
910
  /** Host requires waiver / custom terms; buyer did not attest acceptance */
911
911
  EventWaiverNotAccepted,
912
+ /** Bash is not Published/PreSale — ticket purchase and checkout creation are blocked */
913
+ EventNotAcceptingPurchases,
912
914
  }
913
915
 
914
916
  export type ErrorDataType = Record<RecordKey, unknown>;
@@ -371,6 +371,48 @@ export type BashEventMerchCatalog = {
371
371
  settings: BashEventHostMerchSettings;
372
372
  };
373
373
 
374
+ /** Mirrors Prisma `BashEventMerchOrderStatus` for app/API without requiring a Prisma client import. */
375
+ export const BashEventMerchOrderStatus = {
376
+ Paid: "Paid",
377
+ Fulfilled: "Fulfilled",
378
+ Cancelled: "Cancelled",
379
+ Refunded: "Refunded",
380
+ } as const;
381
+
382
+ export type BashEventMerchOrderStatus =
383
+ (typeof BashEventMerchOrderStatus)[keyof typeof BashEventMerchOrderStatus];
384
+
385
+ /** Fulfillment channel chosen at Stripe checkout for host-event merch. */
386
+ export type BashEventMerchFulfillment =
387
+ | "pickup"
388
+ | "shipping"
389
+ | "checkout_bundle";
390
+
391
+ /** One paid host-event merch order (Stripe Checkout → BashEventMerchOrder). */
392
+ export type BashEventMerchOrderExt = {
393
+ id: string;
394
+ bashEventId: string;
395
+ merchItemId: string;
396
+ buyerId: string;
397
+ itemNameSnapshot: string;
398
+ unitPriceCents: number;
399
+ fulfillment: string;
400
+ status: BashEventMerchOrderStatus;
401
+ stripeCheckoutSessionId: string;
402
+ shippingName: string | null;
403
+ shippingLine1: string | null;
404
+ shippingLine2: string | null;
405
+ shippingCity: string | null;
406
+ shippingState: string | null;
407
+ shippingPostalCode: string | null;
408
+ shippingCountry: string | null;
409
+ fulfilledAt: Date | string | null;
410
+ createdAt: Date | string;
411
+ updatedAt: Date | string;
412
+ buyer?: PublicUser | null;
413
+ bashEvent?: Pick<BashEvent, "id" | "title" | "slug"> | null;
414
+ };
415
+
374
416
  /**
375
417
  * Service profile merch purchase options (ServiceRatesAssociation.merchSettings JSON).
376
418
  *
@@ -409,18 +451,44 @@ export type BashEventHiredServicePublic = {
409
451
  }>;
410
452
  };
411
453
 
412
- export interface BashEventExt extends Override<
454
+ /**
455
+ * API/app bash shape. Declared as a `type` (not `interface extends Override`) so
456
+ * Prisma scalar redeclares stay `T` rather than `T | undefined` — a TypeScript
457
+ * quirk when interfaces extend intersection/mapped types.
458
+ *
459
+ * Policy fields are applied in a final `Omit & { ... }` intersection so they stay
460
+ * required even when Prisma's mapped `BashEvent` makes `Omit` leave residual
461
+ * optionality on those keys.
462
+ */
463
+ type BashEventExtBase = Override<
413
464
  Omit<
414
465
  BashEvent,
415
466
  | "ideaExpiresAt"
416
467
  | "ideaInterestThreshold"
417
468
  | "isAutoApprovable"
418
469
  | "merchandiseItems"
470
+ | "icebreakerPrompt"
471
+ | "cancellationPolicy"
472
+ | "refundsGuaranteedOnCancellation"
473
+ | "postponementAllowed"
474
+ | "isPostponed"
475
+ | "postponedAt"
476
+ | "postponeResponseDeadline"
419
477
  >,
420
- { icebreakerPrompt: string | null }
421
- > {
422
- /** Host-curated merch catalog (wizard); stored as JSON on BashEvent. Null = no merch configured. */
423
- merchandiseItems: BashEventMerchCatalog | null;
478
+ {
479
+ icebreakerPrompt: string | null;
480
+ cancellationPolicy: string | null;
481
+ refundsGuaranteedOnCancellation: boolean;
482
+ postponementAllowed: boolean;
483
+ isPostponed: boolean;
484
+ postponedAt: Date | null;
485
+ postponeResponseDeadline: Date | null;
486
+ merchandiseItems: BashEventMerchCatalog | null;
487
+ ideaExpiresAt: Date | null;
488
+ isAutoApprovable: boolean;
489
+ ideaInterestThreshold: number | null;
490
+ }
491
+ > & {
424
492
  // Prisma relation fields (not included in the flat BashEvent model type)
425
493
  coordinates?: Coordinates[];
426
494
  /** Linked venue (when bashEvent.venueId is set and `venue` is requested in include params) */
@@ -461,27 +529,39 @@ export interface BashEventExt extends Override<
461
529
  goingCount?: number;
462
530
  /** GET /event/:id when status=Idea — total IdeaInterest rows */
463
531
  ideaInterestCount?: number;
532
+ /** Feed/card alias for idea interest (some list payloads use this name). */
533
+ interestedCount?: number;
464
534
  /** GET /event/:id — PublicBashRsvp counts (published / presale) */
465
535
  publicRsvpCounts?: { going: number; interested: number; notGoing: number };
466
536
  /** Current viewer's soft RSVP (PublicBashRsvp), if any */
467
537
  myPublicRsvpStatus?: string | null;
468
538
  /** Host approval queue status for the viewer's PublicBashRsvp (Pending | Approved | Denied) */
469
539
  myPublicRsvpApprovalStatus?: string | null;
540
+ /** When the viewer last kept/declined after a postponement (PublicBashRsvp) */
541
+ myPublicRsvpPostponeRespondedAt?: Date | string | null;
542
+ /**
543
+ * Creator-view only when `isPostponed`: keep / decline / pending counts for
544
+ * active tickets and approved Going RSVPs.
545
+ */
546
+ postponeResponseSummary?: {
547
+ ticketsKept: number;
548
+ ticketsDeclined: number;
549
+ ticketsPending: number;
550
+ rsvpsKept: number;
551
+ rsvpsDeclined: number;
552
+ rsvpsPending: number;
553
+ };
470
554
  /** GET /event/:id and GET /public-event/:id (authenticated) when accessScreeningEnabled — viewer's latest access request status */
471
555
  myAccessRequestStatus?: "None" | "Pending" | "Approved" | "Denied" | null;
472
556
  /** GET /event/:id when status=Idea — viewer's IdeaInterest row (matched by email) */
473
557
  myIdeaInterest?: (IdeaInterest & { responses: IdeaInterestResponse[] }) | null;
474
- /** Rolling expiry timestamp for Idea events (aligned with Prisma `BashEvent.ideaExpiresAt`) */
475
- ideaExpiresAt: Date | null;
476
- /** Whether the Idea has met the auto-approval threshold */
477
- isAutoApprovable: boolean;
478
- /** Number of interests needed for auto-publish (see DEFAULT_IDEA_INTEREST_THRESHOLD) */
479
- ideaInterestThreshold: number | null;
480
558
  /** GET /event/:id when status=Idea — distinct service providers who sent a service-offer notification */
481
559
  ideaServiceProviderInterestCount?: number;
482
560
  // Event page visual customisation (stored as scalar columns on BashEvent)
483
561
  backgroundImage: string | null;
484
562
  themeColor: string | null;
563
+ /** Soft blur on theme photo backgrounds; hosts may turn off for a sharp image. */
564
+ backgroundBlurEnabled: boolean;
485
565
  /**
486
566
  * Computed at request time from the host Organization's nonprofit verification.
487
567
  * Drives the buyer-facing fee breakdown ("Nonprofit — no Bash platform fee").
@@ -506,7 +586,29 @@ export interface BashEventExt extends Override<
506
586
  * Scalar on `BashEvent`; included on GET /event/:id and GET /public-event/:id responses.
507
587
  */
508
588
  discussionEnabled: boolean;
509
- }
589
+ };
590
+
591
+ export type BashEventExt = Omit<
592
+ BashEventExtBase,
593
+ | "icebreakerPrompt"
594
+ | "cancellationPolicy"
595
+ | "refundsGuaranteedOnCancellation"
596
+ | "postponementAllowed"
597
+ | "isPostponed"
598
+ | "postponedAt"
599
+ | "postponeResponseDeadline"
600
+ > & {
601
+ icebreakerPrompt: string | null;
602
+ /** Free-text cancellation / refund policy shown at checkout and on tickets. */
603
+ cancellationPolicy: string | null;
604
+ /** When true (default), cancelling auto-refunds paid ticket holders. */
605
+ refundsGuaranteedOnCancellation: boolean;
606
+ /** Host may postpone after tickets exist if enabled before first sale. */
607
+ postponementAllowed: boolean;
608
+ isPostponed: boolean;
609
+ postponedAt: Date | null;
610
+ postponeResponseDeadline: Date | null;
611
+ };
510
612
 
511
613
  /**
512
614
  * Incoming create/update payload (e.g. Express JSON body): core date fields may still be ISO strings
@@ -1554,26 +1656,18 @@ export type TicketGiftGrantExt = Prisma.TicketGiftGrantGetPayload<{
1554
1656
  include: typeof TICKET_GIFT_GRANT_DATA_TO_INCLUDE;
1555
1657
  }>;
1556
1658
 
1557
- export interface TicketExt extends Ticket {
1558
- owner: PublicUser;
1559
- forUser: PublicUser;
1560
- checkout?: Checkout;
1561
- transfers?: TicketTransfer[];
1562
- metadata?: TicketMetadata[];
1563
- /**
1564
- * Included on authenticated GET /event/:id for the viewer's tickets when a
1565
- * peer transfer invite row exists (see transfer-withdraw / check-in guards).
1566
- */
1567
- transferInvite?: Pick<
1568
- TicketTransferInvite,
1569
- "status" | "expiresAt" | "token" | "toEmail" | "toUserId"
1570
- > | null;
1571
- }
1659
+ /**
1660
+ * Ticket transfer-invite teaser shared by list/detail payloads.
1661
+ * Authenticated GET `/event/:id` may include this without `owner` / `forUser`.
1662
+ */
1663
+ export type TicketTransferInviteTeaser = Pick<
1664
+ TicketTransferInvite,
1665
+ "status" | "expiresAt" | "token" | "toEmail" | "toUserId"
1666
+ >;
1572
1667
 
1573
1668
  /**
1574
- * Ticket rows in attendee/event payloads where nested relations may be omitted
1575
- * (e.g. authenticated GET `/event/:id` may include `transferInvite` without
1576
- * `owner` / `forUser` selects).
1669
+ * Ticket row when nested relations may be omitted (privacy / payload size).
1670
+ * Use on `BashEventExt.tickets` and other event-scoped lists.
1577
1671
  */
1578
1672
  export type TicketFlexible = Ticket & {
1579
1673
  owner?: PublicUser;
@@ -1581,12 +1675,30 @@ export type TicketFlexible = Ticket & {
1581
1675
  checkout?: Checkout;
1582
1676
  transfers?: TicketTransfer[];
1583
1677
  metadata?: TicketMetadata[];
1584
- transferInvite?: Pick<
1585
- TicketTransferInvite,
1586
- "status" | "expiresAt" | "token" | "toEmail" | "toUserId"
1587
- > | null;
1678
+ transferInvite?: TicketTransferInviteTeaser | null;
1588
1679
  };
1589
1680
 
1681
+ /**
1682
+ * Ticket row when `owner` and `forUser` were selected (host/admin / full ticket APIs).
1683
+ * Strict refinement of {@link TicketFlexible} — prefer narrowing with {@link isTicketExt}.
1684
+ */
1685
+ export type TicketExt = TicketFlexible & {
1686
+ owner: PublicUser;
1687
+ forUser: PublicUser;
1688
+ };
1689
+
1690
+ /** True when both buyer (`owner`) and assignee (`forUser`) are present. */
1691
+ export function isTicketExt(ticket: TicketFlexible): ticket is TicketExt {
1692
+ return ticket.owner != null && ticket.forUser != null;
1693
+ }
1694
+
1695
+ /** Keep only tickets that include both `owner` and `forUser`. */
1696
+ export function ticketsWithOwnerAndForUser(
1697
+ tickets: readonly TicketFlexible[]
1698
+ ): TicketExt[] {
1699
+ return tickets.filter(isTicketExt);
1700
+ }
1701
+
1590
1702
  export interface CheckoutExt extends Checkout {
1591
1703
  /** Full owner row for hosts/organizers; teaser row (no email) for other authenticated viewers. */
1592
1704
  owner: PublicUser | PublicTeaserUser;
@@ -1680,7 +1792,7 @@ export interface UserExt extends User {
1680
1792
  socialMediaPlatforms?: SocialMediaPlatform[] | null;
1681
1793
  reviews?: ReviewExt[] | null;
1682
1794
  contacts?: Contact[] | null;
1683
- ticketsIOwn?: TicketExt[] | null;
1795
+ ticketsIOwn?: TicketFlexible[] | null;
1684
1796
  ownedServices?: ServiceExt[];
1685
1797
  createdServices?: ServiceExt[];
1686
1798
  password?: string;
@@ -77,3 +77,62 @@ export type HostCrmAutomationInput = {
77
77
  cadenceDays?: number | null;
78
78
  enabled?: boolean;
79
79
  };
80
+
81
+ /**
82
+ * "Growth pack" — the one-click Host CRM automation pair (win-back + post-event
83
+ * thank-you). Specs live here so API enable, legacy backfill, and UI detection
84
+ * cannot drift apart.
85
+ */
86
+ export const HOST_CRM_GROWTH_PACK_WINBACK_PRESET_ID = "lapsed_180";
87
+ export const HOST_CRM_GROWTH_PACK_WINBACK_NAME = "Win-back email";
88
+ export const HOST_CRM_GROWTH_PACK_POST_EVENT_NAME = "Post-event thank-you";
89
+
90
+ /** Shared with the live win-back blast defaults — keep in lockstep. */
91
+ export const HOST_CRM_WINBACK_DEFAULT_SUBJECT =
92
+ "We miss you — come back out with us";
93
+ export const HOST_CRM_WINBACK_DEFAULT_BODY =
94
+ "It's been a while since your last bash with us. We'd love to see you again.";
95
+
96
+ /** Post-event cron substitutes {{eventTitle}}, {{firstName}}, {{bashFeedUrl}}. */
97
+ export const HOST_CRM_GROWTH_PACK_POST_EVENT_SUBJECT =
98
+ "Thank you for coming to {{eventTitle}}";
99
+ export const HOST_CRM_GROWTH_PACK_POST_EVENT_BODY =
100
+ "Hi {{firstName}},\n\nThanks so much for being part of {{eventTitle}} — it wouldn't have been the same without you. Hope to see you at the next one!\n\n{{bashFeedUrl}}";
101
+
102
+ type GrowthPackAutomationShape = {
103
+ trigger: string;
104
+ channel: string;
105
+ audiencePresetId?: string | null;
106
+ };
107
+
108
+ /** True when a Recurring Email + lapsed_180 win-back automation already exists. */
109
+ export function hostCrmHasWinBackPackAutomation(
110
+ automations: GrowthPackAutomationShape[]
111
+ ): boolean {
112
+ return automations.some(
113
+ (a) =>
114
+ a.trigger === HostCrmAutomationTrigger.Recurring &&
115
+ a.channel === HostCrmAutomationChannel.Email &&
116
+ a.audiencePresetId === HOST_CRM_GROWTH_PACK_WINBACK_PRESET_ID
117
+ );
118
+ }
119
+
120
+ /** True when a PostEvent Email thank-you automation already exists. */
121
+ export function hostCrmHasPostEventPackAutomation(
122
+ automations: GrowthPackAutomationShape[]
123
+ ): boolean {
124
+ return automations.some(
125
+ (a) =>
126
+ a.trigger === HostCrmAutomationTrigger.PostEvent &&
127
+ a.channel === HostCrmAutomationChannel.Email
128
+ );
129
+ }
130
+
131
+ export function hostCrmGrowthPackIsComplete(
132
+ automations: GrowthPackAutomationShape[]
133
+ ): boolean {
134
+ return (
135
+ hostCrmHasWinBackPackAutomation(automations) &&
136
+ hostCrmHasPostEventPackAutomation(automations)
137
+ );
138
+ }
@@ -10,6 +10,8 @@ export type HostCrmSegmentRules = {
10
10
  tag?: string;
11
11
  cadence?: SocialEventCadence;
12
12
  minEventCount?: number;
13
+ /** First-timers-style segment: attended at most this many of the host's bashes. */
14
+ maxEventCount?: number;
13
15
  lapsedDays?: number;
14
16
  /** Ticket buyers for this bash event id */
15
17
  sourceEventId?: string;
@@ -107,6 +109,10 @@ export function applyHostCrmSegmentRules<T extends HostCrmAudienceMember>(
107
109
  rows = rows.filter((r) => r.eventCount >= rules.minEventCount!);
108
110
  }
109
111
 
112
+ if (typeof rules.maxEventCount === "number" && rules.maxEventCount > 0) {
113
+ rows = rows.filter((r) => r.eventCount <= rules.maxEventCount!);
114
+ }
115
+
110
116
  if (typeof rules.lapsedDays === "number" && rules.lapsedDays > 0) {
111
117
  const cutoff = now.getTime() - rules.lapsedDays * 24 * 60 * 60 * 1000;
112
118
  rows = rows.filter((r) => {
@@ -200,6 +206,12 @@ export const HOST_CRM_PRESET_SEGMENTS: Array<{
200
206
  description: "People who have been to at least three of your bashes",
201
207
  rules: { minEventCount: 3 },
202
208
  },
209
+ {
210
+ id: "first_timers",
211
+ label: "First-timers",
212
+ description: "Came to exactly one of your bashes and haven't been back",
213
+ rules: { maxEventCount: 1 },
214
+ },
203
215
  {
204
216
  id: "lapsed_90",
205
217
  label: "Haven't been in 90 days",
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./storeTypes.js";
19
19
  export * from "./storePrintArtworkUtils.js";
20
20
  export * from "./bashFeedTypes.js";
21
21
  export * from "./membershipDefinitions.js";
22
+ export * from "./appleIapProducts.js";
22
23
  export * from "./onSaleCapabilityRecommendations.js";
23
24
  export * from "./ticketBnplPaymentMethods.js";
24
25
  export * from "./aiApproval.js";
@@ -3,6 +3,9 @@
3
3
  * Consumers use these keys for type-safe `sendTemplatedSms` calls.
4
4
  */
5
5
  export const SMS_TEMPLATE_KEYS = [
6
+ "EVENT_REMINDER_1WEEK",
7
+ "EVENT_REMINDER_24H",
8
+ "EVENT_REMINDER_3H",
6
9
  "EVENT_REMINDER_1H",
7
10
  "EVENT_VENUE_CHANGED",
8
11
  "EVENT_WAITLIST_APPROVED",
@@ -13,6 +16,9 @@ export type SmsTemplateKey = (typeof SMS_TEMPLATE_KEYS)[number];
13
16
 
14
17
  /** Typed context per template (API maps these to final message strings). */
15
18
  export type SmsTemplateContext = {
19
+ EVENT_REMINDER_1WEEK: { eventTitle: string; shortUrl: string };
20
+ EVENT_REMINDER_24H: { eventTitle: string; shortUrl: string };
21
+ EVENT_REMINDER_3H: { eventTitle: string; shortUrl: string };
16
22
  EVENT_REMINDER_1H: { eventTitle: string; shortUrl: string };
17
23
  EVENT_VENUE_CHANGED: { eventTitle: string; shortUrl: string };
18
24
  EVENT_WAITLIST_APPROVED: { eventTitle: string; shortUrl: string };