@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.
- package/dist/__tests__/appleIapProducts.test.d.ts +2 -0
- package/dist/__tests__/appleIapProducts.test.d.ts.map +1 -0
- package/dist/__tests__/appleIapProducts.test.js +32 -0
- package/dist/__tests__/appleIapProducts.test.js.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.d.ts +2 -0
- package/dist/__tests__/hostCrmGrowthPack.test.d.ts.map +1 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js +25 -0
- package/dist/__tests__/hostCrmGrowthPack.test.js.map +1 -0
- package/dist/__tests__/hostCrmSegments.test.js +10 -0
- package/dist/__tests__/hostCrmSegments.test.js.map +1 -1
- package/dist/__tests__/ticketExtShapes.test.d.ts +2 -0
- package/dist/__tests__/ticketExtShapes.test.d.ts.map +1 -0
- package/dist/__tests__/ticketExtShapes.test.js +45 -0
- package/dist/__tests__/ticketExtShapes.test.js.map +1 -0
- package/dist/appleIapProducts.d.ts +64 -0
- package/dist/appleIapProducts.d.ts.map +1 -0
- package/dist/appleIapProducts.js +113 -0
- package/dist/appleIapProducts.js.map +1 -0
- package/dist/definitions.d.ts +3 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +2 -0
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +106 -27
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +15 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/hostCrmAutomation.d.ts +25 -0
- package/dist/hostCrmAutomation.d.ts.map +1 -1
- package/dist/hostCrmAutomation.js +29 -0
- package/dist/hostCrmAutomation.js.map +1 -1
- package/dist/hostCrmSegments.d.ts +2 -0
- package/dist/hostCrmSegments.d.ts.map +1 -1
- package/dist/hostCrmSegments.js +9 -0
- package/dist/hostCrmSegments.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/sms/smsTemplates.d.ts +13 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +3 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +69 -1
- package/src/__tests__/appleIapProducts.test.ts +52 -0
- package/src/__tests__/hostCrmGrowthPack.test.ts +33 -0
- package/src/__tests__/hostCrmSegments.test.ts +13 -0
- package/src/__tests__/ticketExtShapes.test.ts +55 -0
- package/src/appleIapProducts.ts +203 -0
- package/src/definitions.ts +2 -0
- package/src/extendedSchemas.ts +147 -35
- package/src/hostCrmAutomation.ts +59 -0
- package/src/hostCrmSegments.ts +12 -0
- package/src/index.ts +1 -0
- 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";
|
package/src/definitions.ts
CHANGED
|
@@ -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>;
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
|
1575
|
-
*
|
|
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?:
|
|
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?:
|
|
1795
|
+
ticketsIOwn?: TicketFlexible[] | null;
|
|
1684
1796
|
ownedServices?: ServiceExt[];
|
|
1685
1797
|
createdServices?: ServiceExt[];
|
|
1686
1798
|
password?: string;
|
package/src/hostCrmAutomation.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hostCrmSegments.ts
CHANGED
|
@@ -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";
|
package/src/sms/smsTemplates.ts
CHANGED
|
@@ -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 };
|