@bash-app/bash-common 30.330.0 → 30.333.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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * hostPromoter — kind/subtype mapping, hireable guards, deep links, influencer tiers.
3
+ */
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import {
6
+ HOST_PROMOTER_KIND,
7
+ buildHireStreetTeamServiceDeepLink,
8
+ eventServiceSubTypeFromHostPromoterKind,
9
+ hostPromoterKindFromEventServiceSubType,
10
+ isHostPromoterHireableKind,
11
+ isHostPromoterInfluencerTier,
12
+ isHostPromoterSyncServiceSubType,
13
+ } from "../hostPromoter.js";
14
+
15
+ describe("hostPromoterKindFromEventServiceSubType", () => {
16
+ test("maps known EventService subtypes to host promoter kinds", () => {
17
+ expect(hostPromoterKindFromEventServiceSubType("Promoter")).toBe(
18
+ HOST_PROMOTER_KIND.EventPromoter
19
+ );
20
+ expect(hostPromoterKindFromEventServiceSubType("Influencer")).toBe(
21
+ HOST_PROMOTER_KIND.Influencer
22
+ );
23
+ expect(hostPromoterKindFromEventServiceSubType("ContentCreator")).toBe(
24
+ HOST_PROMOTER_KIND.ContentCreator
25
+ );
26
+ expect(hostPromoterKindFromEventServiceSubType("Model")).toBe(
27
+ HOST_PROMOTER_KIND.Model
28
+ );
29
+ expect(hostPromoterKindFromEventServiceSubType("BrandAmbassador")).toBe(
30
+ HOST_PROMOTER_KIND.BrandAmbassador
31
+ );
32
+ });
33
+
34
+ test("returns null for unknown or empty subtypes", () => {
35
+ expect(hostPromoterKindFromEventServiceSubType("DJ")).toBeNull();
36
+ expect(hostPromoterKindFromEventServiceSubType(null)).toBeNull();
37
+ expect(hostPromoterKindFromEventServiceSubType(undefined)).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe("eventServiceSubTypeFromHostPromoterKind", () => {
42
+ test("maps hireable kinds back to EventService subtypes", () => {
43
+ expect(
44
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.EventPromoter)
45
+ ).toBe("Promoter");
46
+ expect(
47
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.Influencer)
48
+ ).toBe("Influencer");
49
+ expect(
50
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.ContentCreator)
51
+ ).toBe("ContentCreator");
52
+ expect(
53
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.Model)
54
+ ).toBe("Model");
55
+ expect(
56
+ eventServiceSubTypeFromHostPromoterKind(
57
+ HOST_PROMOTER_KIND.BrandAmbassador
58
+ )
59
+ ).toBe("BrandAmbassador");
60
+ });
61
+
62
+ test("PromoCode and unknown kinds have no hireable subtype", () => {
63
+ expect(
64
+ eventServiceSubTypeFromHostPromoterKind(HOST_PROMOTER_KIND.PromoCode)
65
+ ).toBeNull();
66
+ expect(eventServiceSubTypeFromHostPromoterKind("Nope")).toBeNull();
67
+ expect(eventServiceSubTypeFromHostPromoterKind(null)).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("isHostPromoterHireableKind / isHostPromoterSyncServiceSubType", () => {
72
+ test("hireable kinds exclude PromoCode", () => {
73
+ expect(isHostPromoterHireableKind(HOST_PROMOTER_KIND.Influencer)).toBe(
74
+ true
75
+ );
76
+ expect(isHostPromoterHireableKind(HOST_PROMOTER_KIND.PromoCode)).toBe(
77
+ false
78
+ );
79
+ expect(isHostPromoterHireableKind(null)).toBe(false);
80
+ expect(isHostPromoterHireableKind("")).toBe(false);
81
+ });
82
+
83
+ test("sync subtypes match marketplace EventService values", () => {
84
+ expect(isHostPromoterSyncServiceSubType("Promoter")).toBe(true);
85
+ expect(isHostPromoterSyncServiceSubType("PromoCode")).toBe(false);
86
+ expect(isHostPromoterSyncServiceSubType(undefined)).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe("buildHireStreetTeamServiceDeepLink", () => {
91
+ test("builds service detail path without bash query", () => {
92
+ expect(
93
+ buildHireStreetTeamServiceDeepLink({
94
+ serviceId: "svc-1",
95
+ eventServiceId: "es-1",
96
+ })
97
+ ).toBe("/services/svc-1/EventServices/es-1");
98
+ });
99
+
100
+ test("appends encoded bashEventId when provided", () => {
101
+ expect(
102
+ buildHireStreetTeamServiceDeepLink({
103
+ serviceId: "svc-1",
104
+ eventServiceId: "es-1",
105
+ bashEventId: "bash/with spaces",
106
+ })
107
+ ).toBe(
108
+ "/services/svc-1/EventServices/es-1?bashEventId=bash%2Fwith%20spaces"
109
+ );
110
+ });
111
+
112
+ test("omits query when bashEventId is null", () => {
113
+ expect(
114
+ buildHireStreetTeamServiceDeepLink({
115
+ serviceId: "svc-1",
116
+ eventServiceId: "es-1",
117
+ bashEventId: null,
118
+ })
119
+ ).toBe("/services/svc-1/EventServices/es-1");
120
+ });
121
+ });
122
+
123
+ describe("isHostPromoterInfluencerTier", () => {
124
+ test("accepts known audience tiers", () => {
125
+ expect(isHostPromoterInfluencerTier("Nano")).toBe(true);
126
+ expect(isHostPromoterInfluencerTier("Mega")).toBe(true);
127
+ });
128
+
129
+ test("rejects empty and unknown values", () => {
130
+ expect(isHostPromoterInfluencerTier(null)).toBe(false);
131
+ expect(isHostPromoterInfluencerTier("Huge")).toBe(false);
132
+ });
133
+ });
@@ -42,31 +42,48 @@ import type { UtmFields } from "./utmAttribution.js";
42
42
  /**
43
43
  * Prisma's `index-browser` bundle (used by Vite in bash-app) omits some enums as runtime
44
44
  * values — importing them from `@prisma/client` yields `undefined` and crashes on first
45
- * property access. These mirrors match the Prisma schema / full client enum strings.
46
- */
47
- /**
45
+ * property access. Incomplete Jest `@prisma/client` mocks hit the same landmine.
46
+ * These mirrors match the Prisma schema / full client enum strings.
47
+ *
48
48
  * "Also list as [complementary type]" — a one-time identity-field prefill copy
49
49
  * (see Service.complementaryOfServiceId), not an ongoing synced link. Scoped to
50
50
  * the Vendors <-> EventServices pairing only (e.g. a food truck that vends
51
51
  * publicly at festivals and is also paid a flat fee at private weddings).
52
52
  */
53
- export const COMPLEMENTARY_SERVICE_TYPE: Partial<
54
- Record<ServiceTypes, ServiceTypes>
55
- > = {
56
- [ServiceTypes.Vendors]: ServiceTypes.EventServices,
57
- [ServiceTypes.EventServices]: ServiceTypes.Vendors,
58
- };
53
+ export const COMPLEMENTARY_SERVICE_TYPE = {
54
+ Vendors: "EventServices",
55
+ EventServices: "Vendors",
56
+ } as const satisfies Partial<Record<ServiceTypes, ServiceTypes>>;
57
+
58
+ export type ComplementaryServiceSourceType = keyof typeof COMPLEMENTARY_SERVICE_TYPE;
59
+
60
+ export function isComplementaryServiceSourceType(
61
+ serviceType: ServiceTypes
62
+ ): serviceType is ComplementaryServiceSourceType {
63
+ // String compares — avoid ServiceTypes.* enum access (undefined in Vite's
64
+ // @prisma/client index-browser bundle).
65
+ return serviceType === "Vendors" || serviceType === "EventServices";
66
+ }
67
+
68
+ /** Vendors ↔ EventServices only; other types return undefined. */
69
+ export function complementaryServiceTypeFor(
70
+ serviceType: ServiceTypes
71
+ ): ServiceTypes | undefined {
72
+ if (!isComplementaryServiceSourceType(serviceType)) {
73
+ return undefined;
74
+ }
75
+ return COMPLEMENTARY_SERVICE_TYPE[serviceType];
76
+ }
59
77
 
60
78
  /**
61
79
  * Human-readable label for a ServiceTypes value — the raw enum ("EventServices")
62
80
  * is not fit for display copy. Only covers the values COMPLEMENTARY_SERVICE_TYPE
63
81
  * can produce; extend as more callers need display labels for other types.
64
82
  */
65
- export const SERVICE_TYPE_DISPLAY_LABEL: Partial<Record<ServiceTypes, string>> =
66
- {
67
- [ServiceTypes.Vendors]: "Vendor",
68
- [ServiceTypes.EventServices]: "Event Service",
69
- };
83
+ export const SERVICE_TYPE_DISPLAY_LABEL = {
84
+ Vendors: "Vendor",
85
+ EventServices: "Event Service",
86
+ } as const satisfies Partial<Record<ServiceTypes, string>>;
70
87
 
71
88
  /** Picks "a"/"an" for the given word, e.g. for "a Vendor" vs "an Event Service". */
72
89
  export function articleFor(word: string): "a" | "an" {
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Host street-team roster (Community Builder → Promoters).
3
+ * Distinct from per-bash `Promoter` promo-code rows and hireable marketing bookings.
4
+ */
5
+
6
+ import type { InfluencerAudienceTier } from "@prisma/client";
7
+
8
+ import {
9
+ INFLUENCER_AUDIENCE_TIERS,
10
+ type InfluencerAudienceTierId,
11
+ } from "./influencerAudience.js";
12
+
13
+ export const HOST_PROMOTER_KIND = {
14
+ PromoCode: "PromoCode",
15
+ EventPromoter: "EventPromoter",
16
+ Influencer: "Influencer",
17
+ ContentCreator: "ContentCreator",
18
+ Model: "Model",
19
+ BrandAmbassador: "BrandAmbassador",
20
+ } as const;
21
+
22
+ export type HostPromoterKind =
23
+ (typeof HOST_PROMOTER_KIND)[keyof typeof HOST_PROMOTER_KIND];
24
+
25
+ export const HOST_PROMOTER_KIND_LABELS: Record<HostPromoterKind, string> = {
26
+ PromoCode: "Promo code",
27
+ EventPromoter: "Event Promoter",
28
+ Influencer: "Influencer",
29
+ ContentCreator: "Content creator",
30
+ Model: "Model",
31
+ BrandAmbassador: "Brand ambassador",
32
+ };
33
+
34
+ /** Hireable Promotion & Marketing subtypes that sync onto the street-team roster. */
35
+ export const HOST_PROMOTER_SYNC_SERVICE_SUBTYPES = [
36
+ "Promoter",
37
+ "Influencer",
38
+ "ContentCreator",
39
+ "Model",
40
+ "BrandAmbassador",
41
+ ] as const;
42
+
43
+ export type HostPromoterSyncServiceSubType =
44
+ (typeof HOST_PROMOTER_SYNC_SERVICE_SUBTYPES)[number];
45
+
46
+ export function hostPromoterKindFromEventServiceSubType(
47
+ subType: string | null | undefined
48
+ ): HostPromoterKind | null {
49
+ switch (subType) {
50
+ case "Promoter":
51
+ return HOST_PROMOTER_KIND.EventPromoter;
52
+ case "Influencer":
53
+ return HOST_PROMOTER_KIND.Influencer;
54
+ case "ContentCreator":
55
+ return HOST_PROMOTER_KIND.ContentCreator;
56
+ case "Model":
57
+ return HOST_PROMOTER_KIND.Model;
58
+ case "BrandAmbassador":
59
+ return HOST_PROMOTER_KIND.BrandAmbassador;
60
+ default:
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Inverse of sync mapping — PromoCode has no hireable EventService subtype. */
66
+ export function eventServiceSubTypeFromHostPromoterKind(
67
+ kind: HostPromoterKind | string | null | undefined
68
+ ): HostPromoterSyncServiceSubType | null {
69
+ switch (kind) {
70
+ case HOST_PROMOTER_KIND.EventPromoter:
71
+ return "Promoter";
72
+ case HOST_PROMOTER_KIND.Influencer:
73
+ return "Influencer";
74
+ case HOST_PROMOTER_KIND.ContentCreator:
75
+ return "ContentCreator";
76
+ case HOST_PROMOTER_KIND.Model:
77
+ return "Model";
78
+ case HOST_PROMOTER_KIND.BrandAmbassador:
79
+ return "BrandAmbassador";
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /** Kinds that can be hired via EventService marketplace booking. */
86
+ export const HOST_PROMOTER_HIREABLE_KINDS = [
87
+ HOST_PROMOTER_KIND.EventPromoter,
88
+ HOST_PROMOTER_KIND.Influencer,
89
+ HOST_PROMOTER_KIND.ContentCreator,
90
+ HOST_PROMOTER_KIND.Model,
91
+ HOST_PROMOTER_KIND.BrandAmbassador,
92
+ ] as const;
93
+
94
+ export function isHostPromoterHireableKind(
95
+ kind: string | null | undefined
96
+ ): kind is (typeof HOST_PROMOTER_HIREABLE_KINDS)[number] {
97
+ return (
98
+ !!kind &&
99
+ (HOST_PROMOTER_HIREABLE_KINDS as readonly string[]).includes(kind)
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Deep link into a hireable EventService detail page, optionally preselecting a bash.
105
+ */
106
+ export function buildHireStreetTeamServiceDeepLink(args: {
107
+ serviceId: string;
108
+ eventServiceId: string;
109
+ bashEventId?: string | null;
110
+ }): string {
111
+ const base = `/services/${args.serviceId}/EventServices/${args.eventServiceId}`;
112
+ if (!args.bashEventId) return base;
113
+ return `${base}?bashEventId=${encodeURIComponent(args.bashEventId)}`;
114
+ }
115
+
116
+ export type HostPromoterHireableService = {
117
+ serviceId: string;
118
+ eventServiceId: string;
119
+ eventServiceSubType: string;
120
+ title: string | null;
121
+ };
122
+
123
+ export function isHostPromoterSyncServiceSubType(
124
+ subType: string | null | undefined
125
+ ): subType is HostPromoterSyncServiceSubType {
126
+ return (
127
+ !!subType &&
128
+ (HOST_PROMOTER_SYNC_SERVICE_SUBTYPES as readonly string[]).includes(subType)
129
+ );
130
+ }
131
+
132
+ export const HOST_PROMOTER_SOURCE = {
133
+ Manual: "Manual",
134
+ PastPromoCode: "PastPromoCode",
135
+ PastEventBooking: "PastEventBooking",
136
+ } as const;
137
+
138
+ export type HostPromoterSource =
139
+ (typeof HOST_PROMOTER_SOURCE)[keyof typeof HOST_PROMOTER_SOURCE];
140
+
141
+ /** Mirrors Prisma `HostPromoterStatus` for browser-safe imports. */
142
+ export const HostPromoterStatus = {
143
+ Active: "Active",
144
+ Archived: "Archived",
145
+ } as const;
146
+ export type HostPromoterStatus =
147
+ (typeof HostPromoterStatus)[keyof typeof HostPromoterStatus];
148
+
149
+ /** Mirrors Prisma `HostPromoterPayoutStatus`. */
150
+ export const HostPromoterPayoutStatus = {
151
+ Unpaid: "Unpaid",
152
+ Paid: "Paid",
153
+ Cancelled: "Cancelled",
154
+ } as const;
155
+ export type HostPromoterPayoutStatus =
156
+ (typeof HostPromoterPayoutStatus)[keyof typeof HostPromoterPayoutStatus];
157
+
158
+ /** Mirrors Prisma `HostPromoterPayoutMethod`. */
159
+ export const HostPromoterPayoutMethod = {
160
+ Venmo: "Venmo",
161
+ Zelle: "Zelle",
162
+ CashApp: "CashApp",
163
+ Cash: "Cash",
164
+ Other: "Other",
165
+ } as const;
166
+ export type HostPromoterPayoutMethod =
167
+ (typeof HostPromoterPayoutMethod)[keyof typeof HostPromoterPayoutMethod];
168
+
169
+ /** Mirrors Prisma `HostPromoterPayoutChannel`. */
170
+ export const HostPromoterPayoutChannel = {
171
+ OffPlatform: "OffPlatform",
172
+ Platform: "Platform",
173
+ } as const;
174
+ export type HostPromoterPayoutChannel =
175
+ (typeof HostPromoterPayoutChannel)[keyof typeof HostPromoterPayoutChannel];
176
+
177
+ /** Re-export influencer tier ids for roster forms (Nano / Micro / Macro / Mega). */
178
+ export const HOST_PROMOTER_INFLUENCER_TIERS = INFLUENCER_AUDIENCE_TIERS;
179
+ export type HostPromoterInfluencerTier = InfluencerAudienceTierId;
180
+
181
+ export function isHostPromoterInfluencerTier(
182
+ value: string | null | undefined
183
+ ): value is HostPromoterInfluencerTier {
184
+ return (
185
+ !!value &&
186
+ (INFLUENCER_AUDIENCE_TIERS as readonly string[]).includes(value)
187
+ );
188
+ }
189
+
190
+ export type HostPromoterPayoutSummary = {
191
+ id: string;
192
+ amountCents: number;
193
+ currency: string;
194
+ status: HostPromoterPayoutStatus;
195
+ method: HostPromoterPayoutMethod;
196
+ channel: HostPromoterPayoutChannel;
197
+ stripeCheckoutSessionId: string | null;
198
+ platformFeeCents: number;
199
+ paidAt: string | null;
200
+ note: string | null;
201
+ bashEventId: string | null;
202
+ bashEventTitle: string | null;
203
+ serviceBookingId: string | null;
204
+ createdAt: string;
205
+ };
206
+
207
+ export type HostPromoterPayoutReadiness = {
208
+ payable: boolean;
209
+ reason?: "no_bash_account" | "not_connected" | "onboarding_incomplete";
210
+ };
211
+
212
+ export type HostPromoterStripePayoutCreateInput = {
213
+ amountCents: number;
214
+ bashEventId?: string | null;
215
+ note?: string | null;
216
+ };
217
+
218
+ export type HostPromoterListItem = {
219
+ id: string;
220
+ email: string;
221
+ displayName: string | null;
222
+ promoterUserId: string | null;
223
+ kinds: HostPromoterKind[];
224
+ status: HostPromoterStatus;
225
+ notes: string | null;
226
+ defaultBashPointsRewardPerTicket: number | null;
227
+ /** Nano / Micro / Macro / Mega when this person is (also) an Influencer. */
228
+ influencerAudienceTier: InfluencerAudienceTier | null;
229
+ sources: HostPromoterSource[];
230
+ lastPromotedAt: string | null;
231
+ createdAt: string;
232
+ updatedAt: string;
233
+ /** Known Venmo/Zelle/CashApp handle from User.paymentHandle when linked. */
234
+ paymentHandle: string | null;
235
+ /** Distinct bashes this person had a Promoter / marketing booking on for this host. */
236
+ bashesPromotedCount: number;
237
+ /** Non-cancelled tickets attributed via this person's promo codes on host bashes. */
238
+ ticketsAttributed: number;
239
+ /** Sum of Promoter.creditsGenerated across host bashes (BashPoints). */
240
+ bashPointsEarned: number;
241
+ unpaidCashCents: number;
242
+ /** Open (non-Settled) Event Promoter booking count for this person. */
243
+ openEventPromoterBookings: number;
244
+ payouts: HostPromoterPayoutSummary[];
245
+ };
246
+
247
+ export type HostPromoterCreateInput = {
248
+ email: string;
249
+ displayName?: string | null;
250
+ kinds?: HostPromoterKind[];
251
+ notes?: string | null;
252
+ defaultBashPointsRewardPerTicket?: number | null;
253
+ influencerAudienceTier?: HostPromoterInfluencerTier | null;
254
+ };
255
+
256
+ export type HostPromoterUpdateInput = {
257
+ displayName?: string | null;
258
+ kinds?: HostPromoterKind[];
259
+ status?: HostPromoterStatus;
260
+ notes?: string | null;
261
+ defaultBashPointsRewardPerTicket?: number | null;
262
+ influencerAudienceTier?: HostPromoterInfluencerTier | null;
263
+ };
264
+
265
+ export type HostPromoterInviteToBashInput = {
266
+ hostPromoterIds: string[];
267
+ bashEventId: string;
268
+ ticketTierId: string;
269
+ codePrefix?: string | null;
270
+ bashPointsRewardPerTicket?: number | null;
271
+ discountAmountPercentage?: number | null;
272
+ };
273
+
274
+ export type HostPromoterInviteResult = {
275
+ hostPromoterId: string;
276
+ email: string;
277
+ promoCodeId: string;
278
+ code: string;
279
+ error?: string;
280
+ };
281
+
282
+ export type HostPromoterPayoutCreateInput = {
283
+ amountCents: number;
284
+ method?: HostPromoterPayoutMethod;
285
+ note?: string | null;
286
+ bashEventId?: string | null;
287
+ serviceBookingId?: string | null;
288
+ channel?: HostPromoterPayoutChannel;
289
+ };
290
+
291
+ export type HostPromoterPayoutUpdateInput = {
292
+ status?: HostPromoterPayoutStatus;
293
+ method?: HostPromoterPayoutMethod;
294
+ note?: string | null;
295
+ paidAt?: string | null;
296
+ };
297
+
298
+ export type HostPromoterEventBookingSummary = {
299
+ id: string;
300
+ bashEventId: string | null;
301
+ bashEventTitle: string | null;
302
+ promoterUserId: string | null;
303
+ promoterName: string;
304
+ promoterEmail: string | null;
305
+ hostPromoterId: string | null;
306
+ eventServiceSubType: string | null;
307
+ expectedAttendeesAtBooking: number | null;
308
+ checkedInAttendeesAtSettle: number | null;
309
+ maxAttendeesAttributable: number | null;
310
+ depositCents: number | null;
311
+ settlementDeltaCents: number | null;
312
+ settlementStatus: string;
313
+ settledAt: string | null;
314
+ };
315
+
316
+ export type HostPromoterSettlementSummary = {
317
+ unpaidCashCents: number;
318
+ unpaidCashCount: number;
319
+ openEventPromoterBookings: HostPromoterEventBookingSummary[];
320
+ totalBashPointsEarned: number;
321
+ };
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from "./hostCrmInsights.js";
35
35
  export * from "./hostCrmAutomation.js";
36
36
  export * from "./relationshipHealth.js";
37
37
  export * from "./hostCrmConstants.js";
38
+ export * from "./hostPromoter.js";
38
39
  export * from "./bashEventCancellationPolicy.js";
39
40
  export * from "./userReportTypes.js";
40
41
  export * from "./mirroredPrismaEnums.js";