@bash-app/bash-common 30.399.0 → 30.403.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 (39) hide show
  1. package/dist/__tests__/audienceImport.test.d.ts +2 -0
  2. package/dist/__tests__/audienceImport.test.d.ts.map +1 -0
  3. package/dist/__tests__/audienceImport.test.js +39 -0
  4. package/dist/__tests__/audienceImport.test.js.map +1 -0
  5. package/dist/__tests__/eventStaffRoles.test.d.ts +2 -0
  6. package/dist/__tests__/eventStaffRoles.test.d.ts.map +1 -0
  7. package/dist/__tests__/eventStaffRoles.test.js +37 -0
  8. package/dist/__tests__/eventStaffRoles.test.js.map +1 -0
  9. package/dist/audienceImport.d.ts +71 -0
  10. package/dist/audienceImport.d.ts.map +1 -0
  11. package/dist/audienceImport.js +115 -0
  12. package/dist/audienceImport.js.map +1 -0
  13. package/dist/eventStaffRoles.d.ts +39 -0
  14. package/dist/eventStaffRoles.d.ts.map +1 -0
  15. package/dist/eventStaffRoles.js +125 -0
  16. package/dist/eventStaffRoles.js.map +1 -0
  17. package/dist/extendedSchemas.d.ts +2 -0
  18. package/dist/extendedSchemas.d.ts.map +1 -1
  19. package/dist/extendedSchemas.js.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/utils/__tests__/locationVisibility.test.js +27 -1
  25. package/dist/utils/__tests__/locationVisibility.test.js.map +1 -1
  26. package/dist/utils/locationVisibility.d.ts +9 -0
  27. package/dist/utils/locationVisibility.d.ts.map +1 -1
  28. package/dist/utils/locationVisibility.js +13 -0
  29. package/dist/utils/locationVisibility.js.map +1 -1
  30. package/package.json +1 -1
  31. package/prisma/schema.prisma +102 -0
  32. package/src/__tests__/audienceImport.test.ts +58 -0
  33. package/src/__tests__/eventStaffRoles.test.ts +50 -0
  34. package/src/audienceImport.ts +181 -0
  35. package/src/eventStaffRoles.ts +168 -0
  36. package/src/extendedSchemas.ts +2 -0
  37. package/src/index.ts +2 -0
  38. package/src/utils/__tests__/locationVisibility.test.ts +38 -0
  39. package/src/utils/locationVisibility.ts +16 -0
@@ -1202,6 +1202,8 @@ model BashEvent {
1202
1202
  lastBroadcastAt DateTime?
1203
1203
  /// Optional host note appended to ticket purchase confirmation emails.
1204
1204
  confirmationEmailNote String? @db.Text
1205
+ /// Guest-facing "meet here" label; pin uses existing coordinates.
1206
+ meetingPointLabel String?
1205
1207
  /// When true, notify opted-in CRM audience on publish (optional tag in crmPublishNotifyTag).
1206
1208
  crmPublishNotifyEnabled Boolean @default(false)
1207
1209
  crmPublishNotifyTag String?
@@ -1244,6 +1246,7 @@ model BashEvent {
1244
1246
  requireExpenseApproval Boolean @default(false)
1245
1247
 
1246
1248
  associatedBashesReferencingMe AssociatedBash[]
1249
+ eventStaffAudits EventStaffAudit[]
1247
1250
  comments BashComment[]
1248
1251
  amountOfGuests AmountOfGuests? @relation(fields: [amountOfGuestsId], references: [id], onDelete: Cascade)
1249
1252
  creator User @relation("CreatedEvent", fields: [creatorId], references: [id], onDelete: Cascade)
@@ -2900,6 +2903,80 @@ model HostAttendeeProfile {
2900
2903
  @@index([hostUserId])
2901
2904
  }
2902
2905
 
2906
+ enum EventStaffRole {
2907
+ Admin
2908
+ Organizer
2909
+ Promoter
2910
+ DoorStaff
2911
+ ServiceProvider
2912
+ Volunteer
2913
+ }
2914
+
2915
+ enum AudienceImportSource {
2916
+ Eventbrite
2917
+ Luma
2918
+ Partiful
2919
+ Posh
2920
+ GenericCsv
2921
+ }
2922
+
2923
+ enum HostImportConnectionProvider {
2924
+ Eventbrite
2925
+ Luma
2926
+ }
2927
+
2928
+ /// Host-wide standing guestlist (free-entry contacts reused across bashes).
2929
+ model HostStandingGuestlistEntry {
2930
+ id String @id @default(cuid())
2931
+ hostUserId String
2932
+ email String?
2933
+ phone String?
2934
+ fullName String?
2935
+ createdAt DateTime @default(now())
2936
+ updatedAt DateTime @updatedAt
2937
+
2938
+ host User @relation("HostStandingGuestlistOwned", fields: [hostUserId], references: [id], onDelete: Cascade)
2939
+
2940
+ @@index([hostUserId])
2941
+ @@index([hostUserId, email])
2942
+ }
2943
+
2944
+ /// Encrypted Eventbrite OAuth / Luma API-key connection for audience import.
2945
+ model HostImportConnection {
2946
+ id String @id @default(cuid())
2947
+ hostUserId String
2948
+ provider HostImportConnectionProvider
2949
+ encryptedAccessToken String @db.Text
2950
+ encryptedRefreshToken String? @db.Text
2951
+ tokenExpiry DateTime?
2952
+ createdAt DateTime @default(now())
2953
+ updatedAt DateTime @updatedAt
2954
+
2955
+ host User @relation("HostImportConnectionsOwned", fields: [hostUserId], references: [id], onDelete: Cascade)
2956
+
2957
+ @@unique([hostUserId, provider])
2958
+ }
2959
+
2960
+ /// Append-only staff role grant/change/revoke log.
2961
+ model EventStaffAudit {
2962
+ id String @id @default(cuid())
2963
+ bashEventId String
2964
+ hostUserId String
2965
+ targetEmail String?
2966
+ targetUserId String?
2967
+ action String
2968
+ fromRole EventStaffRole?
2969
+ toRole EventStaffRole?
2970
+ changedByUserId String
2971
+ createdAt DateTime @default(now())
2972
+
2973
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
2974
+ host User @relation("EventStaffAuditHost", fields: [hostUserId], references: [id], onDelete: Cascade)
2975
+
2976
+ @@index([bashEventId, createdAt])
2977
+ @@index([hostUserId])
2978
+ }
2979
+
2903
2980
  enum HostAttendeeActivityType {
2904
2981
  NoteAdded
2905
2982
  NoteUpdated
@@ -3614,6 +3691,9 @@ model Ticket {
3614
3691
  status TicketStatus @default(Pending)
3615
3692
  isFreeGuest Boolean?
3616
3693
  isNamedGuest Boolean @default(false)
3694
+ /// Competitor import: Eventbrite / Luma / Partiful / Posh / GenericCsv
3695
+ importSource String?
3696
+ importedExternalPaid Boolean @default(false)
3617
3697
  geoFenceCheckInUnnecessary Boolean?
3618
3698
  checkedInAt DateTime?
3619
3699
  checkedOutAt DateTime?
@@ -4756,6 +4836,9 @@ model User {
4756
4836
  eventDiscussionComments EventDiscussionComment[] @relation("EventDiscussionComments")
4757
4837
  hostAttendeeProfilesOwned HostAttendeeProfile[] @relation("HostAttendeeProfilesOwned")
4758
4838
  hostAttendeeProfilesTarget HostAttendeeProfile[] @relation("HostAttendeeProfilesTarget")
4839
+ hostStandingGuestlistEntries HostStandingGuestlistEntry[] @relation("HostStandingGuestlistOwned")
4840
+ hostImportConnections HostImportConnection[] @relation("HostImportConnectionsOwned")
4841
+ eventStaffAuditsAsHost EventStaffAudit[] @relation("EventStaffAuditHost")
4759
4842
  hostCrmSavedSegments HostCrmSavedSegment[] @relation("HostCrmSavedSegments")
4760
4843
  hostCrmEmailSendLogsAsHost HostCrmEmailSendLog[] @relation("HostCrmEmailSendLogsHost")
4761
4844
  hostCrmEmailSendLogsAsUser HostCrmEmailSendLog[] @relation("HostCrmEmailSendLogsUser")
@@ -5375,8 +5458,22 @@ model AssociatedBash {
5375
5458
  ownerUserId String?
5376
5459
  isOrganizer Boolean?
5377
5460
  isCoHost Boolean? @default(false)
5461
+ /// @deprecated — write-through for legacy callers. New rows set eventRole = DoorStaff
5462
+ /// (+ canAccessDoor override when needed). Drop after all read sites use
5463
+ /// associatedBashCanAccessDoor() instead of this boolean directly.
5378
5464
  isDoorStaff Boolean? @default(false)
5379
5465
  organizerTitle String?
5466
+ /// Null = legacy row; honor isOrganizer / isDoorStaff. Non-null uses role + overrides.
5467
+ eventRole EventStaffRole?
5468
+ canEditEvent Boolean?
5469
+ canEditTickets Boolean?
5470
+ canManageAttendees Boolean?
5471
+ canIssueComps Boolean?
5472
+ canAccessDoor Boolean?
5473
+ canViewFinances Boolean?
5474
+ canRefund Boolean?
5475
+ canMessage Boolean?
5476
+ canManageTrackingLinks Boolean?
5380
5477
  bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
5381
5478
  invitation Invitation? @relation(fields: [invitationId], references: [id])
5382
5479
  owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
@@ -7551,6 +7648,11 @@ model Expense {
7551
7648
  /// Tracks which invoice due reminders were sent: { dueSoon, dueDay, pastDue1, pastDue15 }.
7552
7649
  invoiceDueRemindersSent Json?
7553
7650
 
7651
+ /// Host-entered email for an off-platform vendor (no Bash account required).
7652
+ externalVendorEmail String?
7653
+ /// True when the host intentionally added an external vendor (vs OCR-only vendorName).
7654
+ externalVendorIsExternal Boolean @default(false)
7655
+
7554
7656
  budget Budget @relation(fields: [budgetId], references: [id], onDelete: Cascade)
7555
7657
  submitter User @relation("ExpenseSubmitter", fields: [submittedBy], references: [id])
7556
7658
  approver User? @relation("ExpenseApprover", fields: [approvedBy], references: [id])
@@ -0,0 +1,58 @@
1
+ import {
2
+ compareBashVsPartifulTicketFees,
3
+ importGuestDedupeKey,
4
+ isAudienceImportDestination,
5
+ isAudienceImportSource,
6
+ mapCsvHeaderToImportField,
7
+ normalizeImportEmail,
8
+ normalizeImportPhone,
9
+ stripeProcessingFeeCents,
10
+ } from "../audienceImport.js";
11
+
12
+ describe("audienceImport", () => {
13
+ it("maps competitor CSV headers", () => {
14
+ expect(mapCsvHeaderToImportField("Attendee Email")).toBe("email");
15
+ expect(mapCsvHeaderToImportField("Ticket Type")).toBe("tierLabel");
16
+ expect(mapCsvHeaderToImportField("unknown col")).toBeUndefined();
17
+ expect(mapCsvHeaderToImportField("source")).toBeUndefined();
18
+ });
19
+
20
+ it("normalizes email and phone", () => {
21
+ expect(normalizeImportEmail(" A@B.com ")).toBe("a@b.com");
22
+ expect(normalizeImportEmail("not-an-email")).toBeUndefined();
23
+ expect(normalizeImportPhone("(555) 123-4567")).toBe("5551234567");
24
+ expect(normalizeImportPhone("12")).toBeUndefined();
25
+ });
26
+
27
+ it("dedupes by email then phone then external id", () => {
28
+ expect(importGuestDedupeKey({ source: "Eventbrite", email: "A@b.com" })).toBe(
29
+ "email:a@b.com"
30
+ );
31
+ expect(importGuestDedupeKey({ source: "Luma", phone: "5551234567" })).toBe(
32
+ "phone:5551234567"
33
+ );
34
+ expect(
35
+ importGuestDedupeKey({ source: "Posh", externalId: "ord-1" })
36
+ ).toBe("ext:Posh:ord-1");
37
+ expect(importGuestDedupeKey({ source: "GenericCsv" })).toBeUndefined();
38
+ });
39
+
40
+ it("compares Bash vs Partiful with guest-pays primary", () => {
41
+ const result = compareBashVsPartifulTicketFees({
42
+ ticketPriceCents: 10000,
43
+ quantity: 1,
44
+ });
45
+ expect(result.bash.guestPaysCents).toBe(10000);
46
+ expect(result.bash.hostNetsCents).toBe(10000 - stripeProcessingFeeCents(10000));
47
+ expect(result.partiful.guestPaysCents).toBe(11200);
48
+ expect(result.partiful.hostNetsCents).toBe(10000);
49
+ expect(result.citation).toMatch(/June 2026/);
50
+ });
51
+
52
+ it("guards source and destination unions", () => {
53
+ expect(isAudienceImportSource("Eventbrite")).toBe(true);
54
+ expect(isAudienceImportSource("Dice")).toBe(false);
55
+ expect(isAudienceImportDestination("crm")).toBe(true);
56
+ expect(isAudienceImportDestination("sms")).toBe(false);
57
+ });
58
+ });
@@ -0,0 +1,50 @@
1
+ import {
2
+ associatedBashCanAccessDoor,
3
+ associatedBashCanEditEvent,
4
+ isEventStaffRole,
5
+ roleFromLegacyFlags,
6
+ writeThroughAssociatedBashFlags,
7
+ } from "../eventStaffRoles.js";
8
+
9
+ describe("eventStaffRoles", () => {
10
+ it("write-through keeps organizer and door flags in sync", () => {
11
+ expect(writeThroughAssociatedBashFlags("Organizer", true)).toEqual({
12
+ isOrganizer: true,
13
+ isDoorStaff: false,
14
+ isCoHost: true,
15
+ });
16
+ expect(writeThroughAssociatedBashFlags("DoorStaff")).toEqual({
17
+ isOrganizer: false,
18
+ isDoorStaff: true,
19
+ isCoHost: false,
20
+ });
21
+ expect(writeThroughAssociatedBashFlags("Promoter").isOrganizer).toBe(false);
22
+ });
23
+
24
+ it("edit access honors role when set, else isOrganizer", () => {
25
+ expect(associatedBashCanEditEvent({ eventRole: "Organizer" })).toBe(true);
26
+ expect(associatedBashCanEditEvent({ eventRole: "DoorStaff" })).toBe(false);
27
+ expect(associatedBashCanEditEvent({ eventRole: "Admin", canEditEvent: false })).toBe(
28
+ false
29
+ );
30
+ expect(associatedBashCanEditEvent({ isOrganizer: true })).toBe(true);
31
+ expect(associatedBashCanEditEvent({ isOrganizer: false })).toBe(false);
32
+ });
33
+
34
+ it("door access honors role when set, else legacy flags", () => {
35
+ expect(associatedBashCanAccessDoor({ eventRole: "DoorStaff" })).toBe(true);
36
+ expect(associatedBashCanAccessDoor({ eventRole: "Volunteer" })).toBe(false);
37
+ expect(associatedBashCanAccessDoor({ isDoorStaff: true })).toBe(true);
38
+ expect(associatedBashCanAccessDoor({ isOrganizer: true })).toBe(true);
39
+ });
40
+
41
+ it("maps legacy flags and validates roles", () => {
42
+ expect(roleFromLegacyFlags({ isDoorStaff: true, isOrganizer: false })).toBe(
43
+ "DoorStaff"
44
+ );
45
+ expect(roleFromLegacyFlags({ isOrganizer: true })).toBe("Organizer");
46
+ expect(roleFromLegacyFlags({})).toBeNull();
47
+ expect(isEventStaffRole("Admin")).toBe(true);
48
+ expect(isEventStaffRole("Owner")).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Competitor audience import — shared types, CSV header maps, and fee comparison math.
3
+ * Adapters normalize Eventbrite / Luma / Partiful / Posh / generic CSVs into one row shape.
4
+ */
5
+
6
+ import { calculateStripeProcessingFeeCents } from "./utils/paymentUtils.js";
7
+
8
+ export const AUDIENCE_IMPORT_SOURCES = [
9
+ "Eventbrite",
10
+ "Luma",
11
+ "Partiful",
12
+ "Posh",
13
+ "GenericCsv",
14
+ ] as const;
15
+
16
+ export type AudienceImportSource = (typeof AUDIENCE_IMPORT_SOURCES)[number];
17
+
18
+ export const AUDIENCE_IMPORT_DESTINATIONS = [
19
+ "admission",
20
+ "crm",
21
+ "standingGuestlist",
22
+ ] as const;
23
+
24
+ export type AudienceImportDestination = (typeof AUDIENCE_IMPORT_DESTINATIONS)[number];
25
+
26
+ export type NormalizedImportGuest = {
27
+ source: AudienceImportSource;
28
+ externalId?: string;
29
+ fullName?: string;
30
+ email?: string;
31
+ phone?: string;
32
+ rsvpOrOrderStatus?: string;
33
+ tierLabel?: string;
34
+ checkedInAt?: string;
35
+ isComp?: boolean;
36
+ plusOnes?: number;
37
+ importedExternalPaid?: boolean;
38
+ };
39
+
40
+ export type AudienceImportPreviewRow = NormalizedImportGuest & {
41
+ willImport: boolean;
42
+ skipReason?: string;
43
+ };
44
+
45
+ export type AudienceImportCommitResult = {
46
+ added: number;
47
+ skipped: number;
48
+ failed: number;
49
+ failures: Array<{ email?: string; fullName?: string; reason: string }>;
50
+ };
51
+
52
+ export const MAX_IMPORT_ROWS = 5000;
53
+ export const IMPORT_BATCH_SIZE = 500;
54
+
55
+ /** Partiful's published June 2026 example: guest pays $112 for a $100 organizer price. */
56
+ export const PARTIFUL_PUBLISHED_GUEST_MULTIPLIER = 112 / 100;
57
+ export const PARTIFUL_FEE_EXAMPLE_CITATION =
58
+ "Based on Partiful's published June 2026 example — their fee varies by event.";
59
+
60
+ export type TicketFeeCompareInput = {
61
+ ticketPriceCents: number;
62
+ quantity: number;
63
+ };
64
+
65
+ export type TicketFeeCompareSide = {
66
+ guestPaysCents: number;
67
+ hostNetsCents: number;
68
+ };
69
+
70
+ export type TicketFeeCompareResult = {
71
+ bash: TicketFeeCompareSide;
72
+ partiful: TicketFeeCompareSide;
73
+ citation: string;
74
+ };
75
+
76
+ export function stripeProcessingFeeCents(amountCents: number): number {
77
+ return calculateStripeProcessingFeeCents(amountCents);
78
+ }
79
+
80
+ /**
81
+ * Guest-pays is the primary comparison. Host-nets is secondary.
82
+ * Bash HostAbsorbs: guest pays listed price; host nets listed minus Stripe.
83
+ * Partiful fee-inclusive: guest pays listed × published multiplier; host receives listed.
84
+ */
85
+ export function compareBashVsPartifulTicketFees(
86
+ input: TicketFeeCompareInput
87
+ ): TicketFeeCompareResult {
88
+ const qty = Math.max(1, Math.floor(input.quantity) || 1);
89
+ const listed = Math.max(0, Math.floor(input.ticketPriceCents)) * qty;
90
+ const bashStripe = stripeProcessingFeeCents(listed);
91
+ const partifulGuest = Math.round(listed * PARTIFUL_PUBLISHED_GUEST_MULTIPLIER);
92
+ return {
93
+ bash: {
94
+ guestPaysCents: listed,
95
+ hostNetsCents: Math.max(0, listed - bashStripe),
96
+ },
97
+ partiful: {
98
+ guestPaysCents: partifulGuest,
99
+ hostNetsCents: listed,
100
+ },
101
+ citation: PARTIFUL_FEE_EXAMPLE_CITATION,
102
+ };
103
+ }
104
+
105
+ export function normalizeImportEmail(value: string | undefined | null): string | undefined {
106
+ const trimmed = value?.trim().toLowerCase();
107
+ return trimmed && trimmed.includes("@") ? trimmed : undefined;
108
+ }
109
+
110
+ export function normalizeImportPhone(value: string | undefined | null): string | undefined {
111
+ const digits = (value ?? "").replace(/\D/g, "");
112
+ if (digits.length < 7) return undefined;
113
+ return digits;
114
+ }
115
+
116
+ export function importGuestDedupeKey(guest: NormalizedImportGuest): string | undefined {
117
+ const email = normalizeImportEmail(guest.email);
118
+ if (email) return `email:${email}`;
119
+ const phone = normalizeImportPhone(guest.phone);
120
+ if (phone) return `phone:${phone}`;
121
+ if (guest.externalId?.trim()) return `ext:${guest.source}:${guest.externalId.trim()}`;
122
+ return undefined;
123
+ }
124
+
125
+ /** CSV columns never overwrite `source` (set by the importer) or `importedExternalPaid`. */
126
+ export type AudienceImportCsvMappedField = Exclude<
127
+ keyof NormalizedImportGuest,
128
+ "source" | "importedExternalPaid"
129
+ >;
130
+
131
+ /** Competitor + generic CSV headers → NormalizedImportGuest fields. */
132
+ export const AUDIENCE_IMPORT_CSV_HEADERS: Record<string, AudienceImportCsvMappedField> = {
133
+ "full name": "fullName",
134
+ name: "fullName",
135
+ "attendee name": "fullName",
136
+ "ticket buyer name": "fullName",
137
+ "first name last name": "fullName",
138
+ guest: "fullName",
139
+ "guest name": "fullName",
140
+ "first name": "fullName",
141
+ email: "email",
142
+ "e-mail": "email",
143
+ "email address": "email",
144
+ "attendee email": "email",
145
+ "ticket holder email": "email",
146
+ "buyer email": "email",
147
+ phone: "phone",
148
+ "phone number": "phone",
149
+ mobile: "phone",
150
+ cell: "phone",
151
+ status: "rsvpOrOrderStatus",
152
+ "rsvp status": "rsvpOrOrderStatus",
153
+ "order status": "rsvpOrOrderStatus",
154
+ "approval status": "rsvpOrOrderStatus",
155
+ "ticket type": "tierLabel",
156
+ "ticket class": "tierLabel",
157
+ tier: "tierLabel",
158
+ "order id": "externalId",
159
+ "attendee id": "externalId",
160
+ "guest id": "externalId",
161
+ "checked in": "checkedInAt",
162
+ "checked_in_at": "checkedInAt",
163
+ complimentary: "isComp",
164
+ comp: "isComp",
165
+ "plus ones": "plusOnes",
166
+ "plus ones count": "plusOnes",
167
+ };
168
+
169
+ export function mapCsvHeaderToImportField(
170
+ header: string
171
+ ): AudienceImportCsvMappedField | undefined {
172
+ return AUDIENCE_IMPORT_CSV_HEADERS[header.trim().toLowerCase()];
173
+ }
174
+
175
+ export function isAudienceImportSource(value: string): value is AudienceImportSource {
176
+ return (AUDIENCE_IMPORT_SOURCES as readonly string[]).includes(value);
177
+ }
178
+
179
+ export function isAudienceImportDestination(value: string): value is AudienceImportDestination {
180
+ return (AUDIENCE_IMPORT_DESTINATIONS as readonly string[]).includes(value);
181
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Per-event staff role templates. Booleans on AssociatedBash stay the live
3
+ * write-through so existing canUserEditBashEvent / userCanAccessDoor callers
4
+ * keep working during the transition.
5
+ */
6
+
7
+ export const EVENT_STAFF_ROLES = [
8
+ "Admin",
9
+ "Organizer",
10
+ "Promoter",
11
+ "DoorStaff",
12
+ "ServiceProvider",
13
+ "Volunteer",
14
+ ] as const;
15
+
16
+ export type EventStaffRole = (typeof EVENT_STAFF_ROLES)[number];
17
+
18
+ export type EventStaffPermissionKey =
19
+ | "canEditEvent"
20
+ | "canEditTickets"
21
+ | "canManageAttendees"
22
+ | "canIssueComps"
23
+ | "canAccessDoor"
24
+ | "canViewFinances"
25
+ | "canRefund"
26
+ | "canMessage"
27
+ | "canManageTrackingLinks";
28
+
29
+ export type EventStaffPermissionFlags = Record<EventStaffPermissionKey, boolean>;
30
+
31
+ export type EventStaffBooleanWriteThrough = {
32
+ isOrganizer: boolean;
33
+ isDoorStaff: boolean;
34
+ isCoHost: boolean;
35
+ };
36
+
37
+ export const EVENT_STAFF_TEMPLATE_DEFAULTS: Record<EventStaffRole, EventStaffPermissionFlags> = {
38
+ Admin: {
39
+ canEditEvent: true,
40
+ canEditTickets: true,
41
+ canManageAttendees: true,
42
+ canIssueComps: true,
43
+ canAccessDoor: true,
44
+ canViewFinances: true,
45
+ canRefund: true,
46
+ canMessage: true,
47
+ canManageTrackingLinks: true,
48
+ },
49
+ Organizer: {
50
+ canEditEvent: true,
51
+ canEditTickets: true,
52
+ canManageAttendees: true,
53
+ canIssueComps: true,
54
+ canAccessDoor: true,
55
+ canViewFinances: false,
56
+ canRefund: false,
57
+ canMessage: true,
58
+ canManageTrackingLinks: true,
59
+ },
60
+ Promoter: {
61
+ canEditEvent: false,
62
+ canEditTickets: false,
63
+ canManageAttendees: false,
64
+ canIssueComps: true,
65
+ canAccessDoor: false,
66
+ canViewFinances: false,
67
+ canRefund: false,
68
+ canMessage: false,
69
+ canManageTrackingLinks: true,
70
+ },
71
+ DoorStaff: {
72
+ canEditEvent: false,
73
+ canEditTickets: false,
74
+ canManageAttendees: true,
75
+ canIssueComps: true,
76
+ canAccessDoor: true,
77
+ canViewFinances: false,
78
+ canRefund: false,
79
+ canMessage: false,
80
+ canManageTrackingLinks: false,
81
+ },
82
+ ServiceProvider: {
83
+ canEditEvent: false,
84
+ canEditTickets: false,
85
+ canManageAttendees: false,
86
+ canIssueComps: false,
87
+ canAccessDoor: false,
88
+ canViewFinances: false,
89
+ canRefund: false,
90
+ canMessage: false,
91
+ canManageTrackingLinks: false,
92
+ },
93
+ Volunteer: {
94
+ canEditEvent: false,
95
+ canEditTickets: false,
96
+ canManageAttendees: false,
97
+ canIssueComps: false,
98
+ canAccessDoor: false,
99
+ canViewFinances: false,
100
+ canRefund: false,
101
+ canMessage: false,
102
+ canManageTrackingLinks: false,
103
+ },
104
+ };
105
+
106
+ export function isEventStaffRole(value: string | null | undefined): value is EventStaffRole {
107
+ return !!value && (EVENT_STAFF_ROLES as readonly string[]).includes(value);
108
+ }
109
+
110
+ /** Setting a role also writes the legacy booleans so existing callers stay valid. */
111
+ export function writeThroughAssociatedBashFlags(
112
+ role: EventStaffRole,
113
+ isCoHost = false
114
+ ): EventStaffBooleanWriteThrough {
115
+ if (role === "Admin" || role === "Organizer") {
116
+ return { isOrganizer: true, isDoorStaff: false, isCoHost };
117
+ }
118
+ if (role === "DoorStaff") {
119
+ return { isOrganizer: false, isDoorStaff: true, isCoHost: false };
120
+ }
121
+ return { isOrganizer: false, isDoorStaff: false, isCoHost: false };
122
+ }
123
+
124
+ export function resolveEventStaffPermission(
125
+ role: EventStaffRole | null | undefined,
126
+ key: EventStaffPermissionKey,
127
+ override: boolean | null | undefined
128
+ ): boolean {
129
+ if (typeof override === "boolean") return override;
130
+ if (!isEventStaffRole(role)) return false;
131
+ return EVENT_STAFF_TEMPLATE_DEFAULTS[role][key];
132
+ }
133
+
134
+ /**
135
+ * Edit access: honor role+overrides when eventRole is set; otherwise the
136
+ * existing isOrganizer boolean. Never drop the boolean check for legacy rows.
137
+ */
138
+ export function associatedBashCanEditEvent(row: {
139
+ eventRole?: string | null;
140
+ isOrganizer?: boolean | null;
141
+ canEditEvent?: boolean | null;
142
+ }): boolean {
143
+ if (isEventStaffRole(row.eventRole)) {
144
+ return resolveEventStaffPermission(row.eventRole, "canEditEvent", row.canEditEvent);
145
+ }
146
+ return !!row.isOrganizer;
147
+ }
148
+
149
+ export function associatedBashCanAccessDoor(row: {
150
+ eventRole?: string | null;
151
+ isOrganizer?: boolean | null;
152
+ isDoorStaff?: boolean | null;
153
+ canAccessDoor?: boolean | null;
154
+ }): boolean {
155
+ if (isEventStaffRole(row.eventRole)) {
156
+ return resolveEventStaffPermission(row.eventRole, "canAccessDoor", row.canAccessDoor);
157
+ }
158
+ return Boolean(row.isOrganizer || row.isDoorStaff);
159
+ }
160
+
161
+ export function roleFromLegacyFlags(row: {
162
+ isOrganizer?: boolean | null;
163
+ isDoorStaff?: boolean | null;
164
+ }): EventStaffRole | null {
165
+ if (row.isDoorStaff && !row.isOrganizer) return "DoorStaff";
166
+ if (row.isOrganizer) return "Organizer";
167
+ return null;
168
+ }
@@ -1794,6 +1794,8 @@ export interface InvitationExtraData extends Invitation {
1794
1794
  isFreeGuest: boolean;
1795
1795
  isOrganizer?: boolean;
1796
1796
  isCoHost?: boolean;
1797
+ isDoorStaff?: boolean;
1798
+ eventRole?: string;
1797
1799
  organizerTitle?: string;
1798
1800
  isOwnershipTransfer?: boolean;
1799
1801
  includeQRCode?: boolean; // Controls whether QR code should be included in the invitation email
package/src/index.ts CHANGED
@@ -49,6 +49,8 @@ export * from "./hostCrmAutomation.js";
49
49
  export * from "./relationshipHealth.js";
50
50
  export * from "./hostCrmConstants.js";
51
51
  export * from "./hostPromoter.js";
52
+ export * from "./audienceImport.js";
53
+ export * from "./eventStaffRoles.js";
52
54
  export * from "./bashEventCancellationPolicy.js";
53
55
  export * from "./userReportTypes.js";
54
56
  export * from "./mirroredPrismaEnums.js";
@@ -5,6 +5,7 @@ import {
5
5
  defaultLocationRevealMode,
6
6
  geocodeQueryTextForPublicBashMap,
7
7
  isLocationGloballyPublic,
8
+ resolveMeetingPointLabel,
8
9
  redactBashEventLocation,
9
10
  shouldAutoRevealLocationToPublicNow,
10
11
  viewerCanSeeFullBashLocation,
@@ -310,3 +311,40 @@ describe("geocodeQueryTextForPublicBashMap", () => {
310
311
  ).toBe("Venue|123 St");
311
312
  });
312
313
  });
314
+
315
+ describe("resolveMeetingPointLabel", () => {
316
+ it("returns the trimmed label when an exact address is present", () => {
317
+ expect(
318
+ resolveMeetingPointLabel({
319
+ meetingPointLabel: " Side door on 9th ",
320
+ location: "123 Main St",
321
+ })
322
+ ).toBe("Side door on 9th");
323
+ });
324
+
325
+ it("treats street as an exact address when location is empty", () => {
326
+ expect(
327
+ resolveMeetingPointLabel({
328
+ meetingPointLabel: "Loading dock",
329
+ street: "88 Pier Ave",
330
+ })
331
+ ).toBe("Loading dock");
332
+ });
333
+
334
+ it("returns null when the label is blank or the address is still hidden", () => {
335
+ expect(
336
+ resolveMeetingPointLabel({
337
+ meetingPointLabel: " ",
338
+ location: "123 Main St",
339
+ })
340
+ ).toBeNull();
341
+ expect(
342
+ resolveMeetingPointLabel({
343
+ meetingPointLabel: "Side door",
344
+ location: null,
345
+ street: " ",
346
+ })
347
+ ).toBeNull();
348
+ expect(resolveMeetingPointLabel({})).toBeNull();
349
+ });
350
+ });