@bash-app/bash-common 30.391.0 → 30.396.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 (101) hide show
  1. package/dist/__tests__/bashSectionTitleForBashType.test.js +5 -0
  2. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  3. package/dist/__tests__/budgetApproval.test.d.ts +2 -0
  4. package/dist/__tests__/budgetApproval.test.d.ts.map +1 -0
  5. package/dist/__tests__/budgetApproval.test.js +109 -0
  6. package/dist/__tests__/budgetApproval.test.js.map +1 -0
  7. package/dist/__tests__/crewSocialCopy.test.d.ts +2 -0
  8. package/dist/__tests__/crewSocialCopy.test.d.ts.map +1 -0
  9. package/dist/__tests__/crewSocialCopy.test.js +21 -0
  10. package/dist/__tests__/crewSocialCopy.test.js.map +1 -0
  11. package/dist/__tests__/discoveryScope.test.d.ts +2 -0
  12. package/dist/__tests__/discoveryScope.test.d.ts.map +1 -0
  13. package/dist/__tests__/discoveryScope.test.js +40 -0
  14. package/dist/__tests__/discoveryScope.test.js.map +1 -0
  15. package/dist/__tests__/suggestedNight.test.d.ts +2 -0
  16. package/dist/__tests__/suggestedNight.test.d.ts.map +1 -0
  17. package/dist/__tests__/suggestedNight.test.js +10 -0
  18. package/dist/__tests__/suggestedNight.test.js.map +1 -0
  19. package/dist/__tests__/suggestionScore.test.d.ts +2 -0
  20. package/dist/__tests__/suggestionScore.test.d.ts.map +1 -0
  21. package/dist/__tests__/suggestionScore.test.js +68 -0
  22. package/dist/__tests__/suggestionScore.test.js.map +1 -0
  23. package/dist/__tests__/userTaste.test.d.ts +2 -0
  24. package/dist/__tests__/userTaste.test.d.ts.map +1 -0
  25. package/dist/__tests__/userTaste.test.js +21 -0
  26. package/dist/__tests__/userTaste.test.js.map +1 -0
  27. package/dist/budgetApproval.d.ts +126 -0
  28. package/dist/budgetApproval.d.ts.map +1 -0
  29. package/dist/budgetApproval.js +161 -0
  30. package/dist/budgetApproval.js.map +1 -0
  31. package/dist/crewSocialCopy.d.ts +16 -0
  32. package/dist/crewSocialCopy.d.ts.map +1 -0
  33. package/dist/crewSocialCopy.js +23 -0
  34. package/dist/crewSocialCopy.js.map +1 -0
  35. package/dist/definitions.d.ts +1 -0
  36. package/dist/definitions.d.ts.map +1 -1
  37. package/dist/definitions.js +6 -0
  38. package/dist/definitions.js.map +1 -1
  39. package/dist/discoveryScope.d.ts +48 -0
  40. package/dist/discoveryScope.d.ts.map +1 -0
  41. package/dist/discoveryScope.js +114 -0
  42. package/dist/discoveryScope.js.map +1 -0
  43. package/dist/home.d.ts +6 -1
  44. package/dist/home.d.ts.map +1 -1
  45. package/dist/home.js +5 -1
  46. package/dist/home.js.map +1 -1
  47. package/dist/hostCrmCrowdPulse.d.ts +2 -0
  48. package/dist/hostCrmCrowdPulse.d.ts.map +1 -1
  49. package/dist/hostCrmCrowdPulse.js.map +1 -1
  50. package/dist/hostCrmSegments.d.ts +6 -0
  51. package/dist/hostCrmSegments.d.ts.map +1 -1
  52. package/dist/hostCrmSegments.js.map +1 -1
  53. package/dist/index.d.ts +7 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +13 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/sms/smsTemplates.d.ts +6 -1
  58. package/dist/sms/smsTemplates.d.ts.map +1 -1
  59. package/dist/sms/smsTemplates.js +1 -0
  60. package/dist/sms/smsTemplates.js.map +1 -1
  61. package/dist/suggestedNight.d.ts +12 -0
  62. package/dist/suggestedNight.d.ts.map +1 -0
  63. package/dist/suggestedNight.js +12 -0
  64. package/dist/suggestedNight.js.map +1 -0
  65. package/dist/suggestionScore.d.ts +42 -0
  66. package/dist/suggestionScore.d.ts.map +1 -0
  67. package/dist/suggestionScore.js +100 -0
  68. package/dist/suggestionScore.js.map +1 -0
  69. package/dist/userTaste.d.ts +40 -0
  70. package/dist/userTaste.d.ts.map +1 -0
  71. package/dist/userTaste.js +103 -0
  72. package/dist/userTaste.js.map +1 -0
  73. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +9 -1
  74. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -1
  75. package/dist/utils/featuredDiscoveryGeo.d.ts +7 -0
  76. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -1
  77. package/dist/utils/featuredDiscoveryGeo.js +11 -0
  78. package/dist/utils/featuredDiscoveryGeo.js.map +1 -1
  79. package/package.json +1 -1
  80. package/prisma/schema.prisma +152 -50
  81. package/src/__tests__/bashSectionTitleForBashType.test.ts +10 -0
  82. package/src/__tests__/budgetApproval.test.ts +153 -0
  83. package/src/__tests__/crewSocialCopy.test.ts +25 -0
  84. package/src/__tests__/discoveryScope.test.ts +65 -0
  85. package/src/__tests__/suggestedNight.test.ts +11 -0
  86. package/src/__tests__/suggestionScore.test.ts +81 -0
  87. package/src/__tests__/userTaste.test.ts +33 -0
  88. package/src/budgetApproval.ts +303 -0
  89. package/src/crewSocialCopy.ts +37 -0
  90. package/src/definitions.ts +6 -0
  91. package/src/discoveryScope.ts +170 -0
  92. package/src/home.ts +15 -0
  93. package/src/hostCrmCrowdPulse.ts +2 -0
  94. package/src/hostCrmSegments.ts +6 -0
  95. package/src/index.ts +12 -3
  96. package/src/sms/smsTemplates.ts +6 -0
  97. package/src/suggestedNight.ts +20 -0
  98. package/src/suggestionScore.ts +140 -0
  99. package/src/userTaste.ts +152 -0
  100. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +13 -0
  101. package/src/utils/featuredDiscoveryGeo.ts +15 -0
@@ -9,38 +9,6 @@ datasource db {
9
9
  directUrl = env("POSTGRES_URL_NON_POOLING")
10
10
  }
11
11
 
12
- model Club {
13
- id String @id @default(cuid())
14
- name String
15
- street String
16
- userId String
17
- price Int?
18
- events BashEvent[]
19
- admin ClubAdmin[]
20
- members ClubMember[]
21
- budgets Budget[] @relation("ClubBudgets")
22
- }
23
-
24
- model ClubAdmin {
25
- id String @id @default(cuid())
26
- userId String
27
- clubId String
28
- role ClubAdminRole
29
- club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
30
- admin User @relation(fields: [userId], references: [id], onDelete: Cascade)
31
- }
32
-
33
- model ClubMember {
34
- id String @id @default(cuid())
35
- userId String
36
- clubId String
37
- status ClubMemberStatus
38
- statusHistory Json
39
- price Int?
40
- club Club @relation(fields: [clubId], references: [id], onDelete: Cascade)
41
- member User @relation(fields: [userId], references: [id], onDelete: Cascade)
42
- }
43
-
44
12
  model BashComment {
45
13
  id String @id @default(cuid())
46
14
  creatorId String
@@ -937,6 +905,9 @@ model BashEvent {
937
905
  /// Curated cause IDs from `CAUSE_TAGS` (bash-common) for discovery/search later.
938
906
  causeTags String[] @default([])
939
907
  eventType String @default("Other")
908
+ /// How far the draw goes — orthogonal to eventType. Hosts may set Local/State only.
909
+ discoveryScope DiscoveryScope @default(Unspecified)
910
+ discoveryScopeLabel String?
940
911
  timezone String?
941
912
  startDateTime DateTime?
942
913
  endDateTime DateTime?
@@ -994,7 +965,6 @@ model BashEvent {
994
965
  status BashStatus @default(Draft)
995
966
  tags String[]
996
967
  coverPhoto String?
997
- clubId String?
998
968
  dateTimePublished DateTime?
999
969
  includedItems String[]
1000
970
  videoLink String?
@@ -1276,13 +1246,13 @@ model BashEvent {
1276
1246
  associatedBashesReferencingMe AssociatedBash[]
1277
1247
  comments BashComment[]
1278
1248
  amountOfGuests AmountOfGuests? @relation(fields: [amountOfGuestsId], references: [id], onDelete: Cascade)
1279
- club Club? @relation(fields: [clubId], references: [id])
1280
1249
  creator User @relation("CreatedEvent", fields: [creatorId], references: [id], onDelete: Cascade)
1281
1250
  targetAudience TargetAudience? @relation(fields: [targetAudienceId], references: [id], onDelete: Cascade)
1282
1251
  transferredFrom User? @relation("TransferredFrom", fields: [transferredFromId], references: [id])
1283
1252
  payoutUser User? @relation("BashEventsPayoutRecipient", fields: [payoutUserId], references: [id], onDelete: SetNull)
1284
1253
  venue Venue? @relation(fields: [venueId], references: [id])
1285
1254
  messages BashEventMessage[]
1255
+ conversationMutes BashEventConversationMute[]
1286
1256
  transferRequests BashEventTransferRequest[]
1287
1257
  updateNotificationQueue EventUpdateNotificationQueue[]
1288
1258
  slugHistory BashSlugHistory[]
@@ -1390,6 +1360,7 @@ model BashEvent {
1390
1360
 
1391
1361
  // Organization Relations
1392
1362
  budgets Budget[] @relation("EventBudgets")
1363
+ budgetApprovalRequests BudgetApprovalRequest[]
1393
1364
  rsvps EventRSVP[] @relation("EventRSVPs")
1394
1365
 
1395
1366
  // Groups & page views (Phases 0–4)
@@ -1435,6 +1406,7 @@ model BashEvent {
1435
1406
  locationShareSessions LocationShareSession[]
1436
1407
  /// Private Memories vault captures for this bash
1437
1408
  memoryCaptures BashMemoryCapture[]
1409
+ suggestedNights SuggestedNight[]
1438
1410
 
1439
1411
  @@index([templateId])
1440
1412
  @@index([parentEventId])
@@ -1442,6 +1414,7 @@ model BashEvent {
1442
1414
  @@index([hostedByServiceId])
1443
1415
  @@index([eventSeriesId])
1444
1416
  @@index([status, aiApprovalVerdict])
1417
+ @@index([discoveryScope, startDateTime])
1445
1418
  }
1446
1419
 
1447
1420
  /// Lineup / attribution roster for an event (optional act list for checkout tagging).
@@ -4624,6 +4597,7 @@ model User {
4624
4597
  bashEventsPayoutRecipient BashEvent[] @relation("BashEventsPayoutRecipient")
4625
4598
  bashEventMessagesSent BashEventMessage[] @relation("BashMessageSender")
4626
4599
  bashEventMessagesRead BashEventMessageRead[] @relation("BashMessageReader")
4600
+ bashEventConversationMutes BashEventConversationMute[]
4627
4601
  transferRequestsFrom BashEventTransferRequest[] @relation("TransferRequestsFrom")
4628
4602
  transferRequestsTo BashEventTransferRequest[] @relation("TransferRequestsTo")
4629
4603
  venueFBCarryoverCredits VenueFBCarryoverCredit[] @relation("VenueFBCarryoverHost")
@@ -4634,8 +4608,6 @@ model User {
4634
4608
  blogPosts BlogPost[] @relation("BlogAuthor")
4635
4609
  checkouts Checkout[]
4636
4610
  waiverAcceptances WaiverAcceptance[]
4637
- clubAdmins ClubAdmin[]
4638
- clubMembers ClubMember[]
4639
4611
  competitions Competition[]
4640
4612
  competitionSponsorships CompetitionSponsor[]
4641
4613
  competitionPrizeOffersSubmitted CompetitionPrizeOffer[] @relation("CompetitionPrizeOffersSubmitted")
@@ -4855,6 +4827,8 @@ model User {
4855
4827
  approvedBudgets Budget[] @relation("BudgetApprover")
4856
4828
  submittedBudgets Budget[] @relation("BudgetSubmitter")
4857
4829
  ownedBudgets Budget[] @relation("UserBudgets")
4830
+ budgetApprovalsRequested BudgetApprovalRequest[] @relation("BudgetApprovalRequester")
4831
+ budgetApprovalsAsApprover BudgetApprovalRequest[] @relation("BudgetApprovalApprover")
4858
4832
  departmentsHeaded Department[] @relation("DepartmentHead")
4859
4833
  /// Enterprise platform (Phase 0-4): internal team members assigned as a named
4860
4834
  /// account manager, and org members who submit/approve procurement requests
@@ -4895,6 +4869,8 @@ model User {
4895
4869
  // Activity Intent / Experience Discovery
4896
4870
  userDiscoveryPreferences UserDiscoveryPreferences?
4897
4871
  activityIntents ActivityIntent[]
4872
+ tastePreferences UserTastePreference[]
4873
+ suggestedNights SuggestedNight[]
4898
4874
  experienceInvitations ExperienceInvitation[]
4899
4875
  experienceOpportunitiesCreated ExperienceOpportunity[] @relation("ExperienceOpportunityCreatedBy")
4900
4876
 
@@ -4944,6 +4920,12 @@ model UserPreferences {
4944
4920
  organizerUpdatesNotify Boolean @default(true)
4945
4921
  eventSuggestions Boolean @default(true)
4946
4922
  eventReminderNotify Boolean @default(true)
4923
+ /// Opt-in: engine may propose nights (Build my nights strip).
4924
+ suggestedNightsEnabled Boolean @default(false)
4925
+ /// Favorite-nudge email/push. Migrated to match eventReminderNotify.
4926
+ suggestedNightReminderNotify Boolean @default(true)
4927
+ /// Hosts in the guest's CRM may filter blasts by taste chips.
4928
+ hostsMayTargetTaste Boolean @default(true)
4947
4929
  newMessageNotify Boolean @default(true)
4948
4930
  invitationNotify Boolean @default(true)
4949
4931
  commentNotify Boolean @default(true)
@@ -5053,6 +5035,38 @@ model UserPreferences {
5053
5035
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5054
5036
  }
5055
5037
 
5038
+ model UserTastePreference {
5039
+ id String @id @default(cuid())
5040
+ userId String
5041
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5042
+ kind UserTasteKind
5043
+ value String
5044
+ weight Int @default(1)
5045
+ source UserTasteSource @default(Explicit)
5046
+ updatedAt DateTime @updatedAt
5047
+
5048
+ @@unique([userId, kind, value])
5049
+ @@index([userId, kind])
5050
+ }
5051
+
5052
+ model SuggestedNight {
5053
+ id String @id @default(cuid())
5054
+ userId String
5055
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5056
+ bashEventId String
5057
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
5058
+ status SuggestedNightStatus @default(Proposed)
5059
+ reason String[] @default([])
5060
+ proposedAt DateTime @default(now())
5061
+ acceptedAt DateTime?
5062
+ dismissedAt DateTime?
5063
+ remindedAt DateTime?
5064
+
5065
+ @@unique([userId, bashEventId])
5066
+ @@index([userId, status])
5067
+ @@index([bashEventId, status])
5068
+ }
5069
+
5056
5070
  model UserFollowing {
5057
5071
  id String @id @default(cuid())
5058
5072
  userId String
@@ -7263,7 +7277,6 @@ model Budget {
7263
7277
  // Flexible ownership - at least ONE should be set
7264
7278
  organizationId String?
7265
7279
  bashEventId String?
7266
- clubId String?
7267
7280
  departmentId String?
7268
7281
  userId String? // For personal event budgets
7269
7282
  /// Optional: budgets tied to a service profile (vendor/exhibitor/sponsor owner costs).
@@ -7301,7 +7314,6 @@ model Budget {
7301
7314
  // Relations
7302
7315
  organization Organization? @relation("OrganizationBudgets", fields: [organizationId], references: [id], onDelete: Cascade)
7303
7316
  bashEvent BashEvent? @relation("EventBudgets", fields: [bashEventId], references: [id], onDelete: Cascade)
7304
- club Club? @relation("ClubBudgets", fields: [clubId], references: [id], onDelete: Cascade)
7305
7317
  department Department? @relation("DepartmentBudgets", fields: [departmentId], references: [id], onDelete: Cascade)
7306
7318
  user User? @relation("UserBudgets", fields: [userId], references: [id], onDelete: Cascade)
7307
7319
  service Service? @relation("ServiceBudgets", fields: [serviceId], references: [id], onDelete: Cascade)
@@ -7312,7 +7324,6 @@ model Budget {
7312
7324
 
7313
7325
  @@index([organizationId])
7314
7326
  @@index([bashEventId])
7315
- @@index([clubId])
7316
7327
  @@index([departmentId])
7317
7328
  @@index([userId])
7318
7329
  @@index([serviceId])
@@ -7320,6 +7331,66 @@ model Budget {
7320
7331
  @@index([fiscalYear])
7321
7332
  }
7322
7333
 
7334
+ enum BudgetApprovalKind {
7335
+ CapAsk
7336
+ PlanSignOff
7337
+ }
7338
+
7339
+ enum BudgetApprovalStatus {
7340
+ Pending
7341
+ Approved
7342
+ ChangesRequested
7343
+ Declined
7344
+ Cancelled
7345
+ Superseded
7346
+ }
7347
+
7348
+ /// Event-level budget sign-off or pre-plan cap ask. Not per-line Budget.status.
7349
+ model BudgetApprovalRequest {
7350
+ id String @id @default(cuid())
7351
+ bashEventId String
7352
+ kind BudgetApprovalKind
7353
+ status BudgetApprovalStatus @default(Pending)
7354
+ requesterUserId String
7355
+ hostNote String?
7356
+ approverEmail String
7357
+ approverName String?
7358
+ approverUserId String?
7359
+ token String @unique @default(cuid())
7360
+ expiresAt DateTime
7361
+ snapshotJson Json
7362
+ snapshotTotalCents Int
7363
+ authorizedCents Int?
7364
+ decisionNote String?
7365
+ decidedAt DateTime?
7366
+ staleAt DateTime?
7367
+ createdAt DateTime @default(now())
7368
+ updatedAt DateTime @updatedAt
7369
+
7370
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
7371
+ requester User @relation("BudgetApprovalRequester", fields: [requesterUserId], references: [id], onDelete: Cascade)
7372
+ approverUser User? @relation("BudgetApprovalApprover", fields: [approverUserId], references: [id], onDelete: SetNull)
7373
+ lineNotes BudgetApprovalLineNote[]
7374
+
7375
+ @@index([bashEventId, status])
7376
+ @@index([approverEmail])
7377
+ }
7378
+
7379
+ model BudgetApprovalLineNote {
7380
+ id String @id @default(cuid())
7381
+ requestId String
7382
+ budgetId String?
7383
+ nameSnapshot String
7384
+ categorySnapshot String?
7385
+ allocatedCentsSnapshot Int
7386
+ note String
7387
+ createdAt DateTime @default(now())
7388
+
7389
+ request BudgetApprovalRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
7390
+
7391
+ @@index([requestId])
7392
+ }
7393
+
7323
7394
  enum BudgetContributionKind {
7324
7395
  Cash
7325
7396
  InKind
@@ -8510,18 +8581,17 @@ model BashEventMessageRead {
8510
8581
  @@index([userId])
8511
8582
  }
8512
8583
 
8513
- enum ClubAdminRole {
8514
- Owner
8515
- Admin
8516
- Staff
8517
- }
8584
+ /// Per-user mute for the night-of bash inbox thread (not discussion, not blasts).
8585
+ model BashEventConversationMute {
8586
+ id String @id @default(cuid())
8587
+ userId String
8588
+ bashEventId String
8589
+ mutedAt DateTime @default(now())
8590
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
8591
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
8518
8592
 
8519
- enum ClubMemberStatus {
8520
- Active
8521
- Inactive
8522
- Canceled
8523
- Paused
8524
- Removed
8593
+ @@unique([userId, bashEventId])
8594
+ @@index([bashEventId])
8525
8595
  }
8526
8596
 
8527
8597
  enum TaskStatus {
@@ -8624,6 +8694,10 @@ enum NotificationType {
8624
8694
  SideQuestWindowOpened // Attendee: a timed Side Quest just went live
8625
8695
  /// Attendee/ticket holder: host changed significant bash details (time, place, title, …)
8626
8696
  EventDetailsUpdated
8697
+ /// Approver already on Bash: host sent a budget sign-off or cap ask
8698
+ BudgetApprovalRequested
8699
+ /// Host: approver approved, requested changes, or declined a budget request
8700
+ BudgetApprovalDecided
8627
8701
  }
8628
8702
 
8629
8703
  /// Opt-in reminder for a scheduled tier release (v1.5).
@@ -8674,6 +8748,34 @@ enum BashEventSource {
8674
8748
  Partner // Generic: use sourcePartnerId for partner key (e.g. 'mixlog', custom integrations)
8675
8749
  }
8676
8750
 
8751
+ /// How far an event's draw goes. Orthogonal to BashEventType.
8752
+ enum DiscoveryScope {
8753
+ Local
8754
+ State
8755
+ National
8756
+ International
8757
+ Unspecified
8758
+ }
8759
+
8760
+ enum UserTasteKind {
8761
+ EventType
8762
+ CauseTag
8763
+ MusicGenre
8764
+ DiscoveryScope
8765
+ }
8766
+
8767
+ enum UserTasteSource {
8768
+ Explicit
8769
+ Inferred
8770
+ }
8771
+
8772
+ enum SuggestedNightStatus {
8773
+ Proposed
8774
+ Accepted
8775
+ Dismissed
8776
+ Expired
8777
+ }
8778
+
8677
8779
  /// Channel used to create a BashEvent. See BashEvent.creationChannel doc comment.
8678
8780
  enum CreationChannel {
8679
8781
  Web
@@ -52,6 +52,16 @@ describe("bashSectionTitleForBashType", () => {
52
52
  it("'Ideas' → 'Ideas'", () => {
53
53
  expect(bashSectionTitleForBashType("Ideas")).toBe("Ideas");
54
54
  });
55
+
56
+ it("scope rows use destination copy", () => {
57
+ expect(bashSectionTitleForBashType("National")).toBe(
58
+ "Across the country"
59
+ );
60
+ expect(bashSectionTitleForBashType("International")).toBe(
61
+ "Worth the flight"
62
+ );
63
+ expect(bashSectionTitleForBashType("CrewGoing")).toBe("The crew's going");
64
+ });
55
65
  });
56
66
 
57
67
  describe("BashEventType enum values get pluralized", () => {
@@ -0,0 +1,153 @@
1
+ import {
2
+ buildBudgetApprovalSnapshot,
3
+ budgetApprovalSnapshotsMatch,
4
+ eventBudgetApprovalWizardPath,
5
+ formatBudgetCents,
6
+ isBudgetApprovalExpired,
7
+ isValidApproverEmail,
8
+ normalizeApproverEmail,
9
+ parseBudgetApprovalSnapshot,
10
+ resolveApproverDisplayName,
11
+ validateBudgetApprovalDecide,
12
+ } from "../budgetApproval.js";
13
+
14
+ describe("budgetApproval helpers", () => {
15
+ it("normalizes and validates approver emails", () => {
16
+ expect(normalizeApproverEmail(" Pat@Acme.com ")).toBe("pat@acme.com");
17
+ expect(isValidApproverEmail("pat@acme.com")).toBe(true);
18
+ expect(isValidApproverEmail("not-an-email")).toBe(false);
19
+ });
20
+
21
+ it("hashes snapshots in a stable, order-independent way", () => {
22
+ const a = buildBudgetApprovalSnapshot([
23
+ { id: "b", name: "DJ", category: null, totalAllocatedCents: 20000 },
24
+ { id: "a", name: "Venue", category: "Rentals", totalAllocatedCents: 10000 },
25
+ ]);
26
+ const b = buildBudgetApprovalSnapshot([
27
+ { id: "a", name: "Venue", category: "Rentals", totalAllocatedCents: 10000 },
28
+ { id: "b", name: "DJ", category: null, totalAllocatedCents: 20000 },
29
+ ]);
30
+ expect(a.totalCents).toBe(30000);
31
+ expect(a.hash).toBe(b.hash);
32
+ expect(
33
+ budgetApprovalSnapshotsMatch(a.lines, parseBudgetApprovalSnapshot(b.lines))
34
+ ).toBe(true);
35
+ });
36
+
37
+ it("detects drift when an amount changes", () => {
38
+ const original = buildBudgetApprovalSnapshot([
39
+ { id: "a", name: "Venue", category: null, totalAllocatedCents: 10000 },
40
+ ]);
41
+ const changed = buildBudgetApprovalSnapshot([
42
+ { id: "a", name: "Venue", category: null, totalAllocatedCents: 12000 },
43
+ ]);
44
+ expect(budgetApprovalSnapshotsMatch(original.lines, changed.lines)).toBe(
45
+ false
46
+ );
47
+ });
48
+
49
+ it("requires cents on cap-ask approve and a note on request-changes", () => {
50
+ expect(
51
+ validateBudgetApprovalDecide({
52
+ action: "approve",
53
+ kind: "CapAsk",
54
+ authorizedCents: 0,
55
+ })
56
+ ).toEqual({
57
+ ok: false,
58
+ error: "Enter an authorized amount greater than zero.",
59
+ });
60
+ expect(
61
+ validateBudgetApprovalDecide({
62
+ action: "approve",
63
+ kind: "CapAsk",
64
+ authorizedCents: 50000,
65
+ }).ok
66
+ ).toBe(true);
67
+ expect(
68
+ validateBudgetApprovalDecide({
69
+ action: "changes",
70
+ kind: "PlanSignOff",
71
+ })
72
+ ).toEqual({
73
+ ok: false,
74
+ error: "Add an overall note or a note on at least one line.",
75
+ });
76
+ expect(
77
+ validateBudgetApprovalDecide({
78
+ action: "changes",
79
+ kind: "PlanSignOff",
80
+ lineNotes: [
81
+ {
82
+ nameSnapshot: "Venue",
83
+ allocatedCentsSnapshot: 10000,
84
+ note: "Too high",
85
+ },
86
+ ],
87
+ }).ok
88
+ ).toBe(true);
89
+ expect(
90
+ validateBudgetApprovalDecide({
91
+ action: "changes",
92
+ kind: "CapAsk",
93
+ lineNotes: [
94
+ {
95
+ nameSnapshot: "Venue",
96
+ allocatedCentsSnapshot: 10000,
97
+ note: "n/a",
98
+ },
99
+ ],
100
+ })
101
+ ).toEqual({
102
+ ok: false,
103
+ error: "A cap ask has no budget lines to annotate.",
104
+ });
105
+ });
106
+
107
+ it("resolves display name decided > hint > email", () => {
108
+ expect(
109
+ resolveApproverDisplayName({
110
+ decidedName: "Pat Chen",
111
+ hostHintName: "Boss",
112
+ email: "pat@acme.com",
113
+ })
114
+ ).toBe("Pat Chen");
115
+ expect(
116
+ resolveApproverDisplayName({
117
+ decidedName: " ",
118
+ hostHintName: "Boss",
119
+ email: "pat@acme.com",
120
+ })
121
+ ).toBe("Boss");
122
+ expect(
123
+ resolveApproverDisplayName({
124
+ decidedName: null,
125
+ hostHintName: null,
126
+ email: "pat@acme.com",
127
+ })
128
+ ).toBe("pat@acme.com");
129
+ });
130
+
131
+ it("computes expiry from expiresAt", () => {
132
+ expect(
133
+ isBudgetApprovalExpired("2020-01-01T00:00:00.000Z", new Date("2026-01-01"))
134
+ ).toBe(true);
135
+ expect(
136
+ isBudgetApprovalExpired("2030-01-01T00:00:00.000Z", new Date("2026-01-01"))
137
+ ).toBe(false);
138
+ });
139
+
140
+ it("formats money and the wizard deep link", () => {
141
+ expect(formatBudgetCents(420000)).toBe("$4,200");
142
+ expect(eventBudgetApprovalWizardPath("evt-1")).toContain(
143
+ "/bash-event-wizard/evt-1/"
144
+ );
145
+ expect(eventBudgetApprovalWizardPath("evt-1")).toContain("budgetTab=lines");
146
+ });
147
+
148
+ it("parses invalid snapshot json as empty", () => {
149
+ expect(parseBudgetApprovalSnapshot(null)).toEqual([]);
150
+ expect(parseBudgetApprovalSnapshot("nope")).toEqual([]);
151
+ expect(parseBudgetApprovalSnapshot([{ id: 1 }])).toEqual([]);
152
+ });
153
+ });
@@ -0,0 +1,25 @@
1
+ import { crewSocialProofCopy } from "../crewSocialCopy.js";
2
+
3
+ describe("crewSocialProofCopy", () => {
4
+ it("names visible crew", () => {
5
+ expect(
6
+ crewSocialProofCopy([
7
+ { displayName: "Nico", attendanceVisible: true },
8
+ { displayName: "Petra", attendanceVisible: true },
9
+ ]).line
10
+ ).toBe("Nico and Petra are going");
11
+ });
12
+
13
+ it("does not use a count when names are hidden", () => {
14
+ const copy = crewSocialProofCopy([
15
+ { displayName: "Sam", attendanceVisible: false },
16
+ { displayName: "Alex", attendanceVisible: false },
17
+ ]);
18
+ expect(copy.line).toBe("Your crew's on this one");
19
+ expect(copy.named).toBe(false);
20
+ });
21
+
22
+ it("omits the line when nobody is going", () => {
23
+ expect(crewSocialProofCopy([]).line).toBeNull();
24
+ });
25
+ });
@@ -0,0 +1,65 @@
1
+ import {
2
+ DiscoveryScope,
3
+ inferDiscoveryScopeFromText,
4
+ isHostSettableDiscoveryScope,
5
+ resolveDiscoveryScopeForWrite,
6
+ } from "../discoveryScope.js";
7
+
8
+ describe("discoveryScope", () => {
9
+ it("lets hosts set Local and State only", () => {
10
+ expect(isHostSettableDiscoveryScope(DiscoveryScope.Local)).toBe(true);
11
+ expect(isHostSettableDiscoveryScope(DiscoveryScope.State)).toBe(true);
12
+ expect(isHostSettableDiscoveryScope(DiscoveryScope.National)).toBe(false);
13
+ expect(isHostSettableDiscoveryScope(DiscoveryScope.International)).toBe(
14
+ false
15
+ );
16
+ });
17
+
18
+ it("infers State from state fair copy", () => {
19
+ expect(inferDiscoveryScopeFromText("Utah State Fair")).toBe(
20
+ DiscoveryScope.State
21
+ );
22
+ });
23
+
24
+ it("infers National from destination brands", () => {
25
+ expect(inferDiscoveryScopeFromText("EDC Las Vegas")).toBe(
26
+ DiscoveryScope.National
27
+ );
28
+ });
29
+
30
+ it("infers International from world-tour language", () => {
31
+ expect(inferDiscoveryScopeFromText("Tomorrowland world tour")).toBe(
32
+ DiscoveryScope.International
33
+ );
34
+ });
35
+
36
+ it("clamps host National without a hard signal", () => {
37
+ expect(
38
+ resolveDiscoveryScopeForWrite({
39
+ requested: DiscoveryScope.National,
40
+ role: "host",
41
+ title: "House party in Sugar House",
42
+ })
43
+ ).toBe(DiscoveryScope.Unspecified);
44
+ });
45
+
46
+ it("allows host National when inference already matches", () => {
47
+ expect(
48
+ resolveDiscoveryScopeForWrite({
49
+ requested: DiscoveryScope.National,
50
+ role: "host",
51
+ title: "Coachella Weekend 1",
52
+ })
53
+ ).toBe(DiscoveryScope.National);
54
+ });
55
+
56
+ it("lets scrapers set National", () => {
57
+ expect(
58
+ resolveDiscoveryScopeForWrite({
59
+ requested: DiscoveryScope.National,
60
+ role: "scraper",
61
+ title: "Random fest",
62
+ })
63
+ ).toBe(DiscoveryScope.National);
64
+ });
65
+ });
@@ -0,0 +1,11 @@
1
+ import { unfavoriteSuggestedNightAction } from "../suggestedNight.js";
2
+
3
+ describe("unfavoriteSuggestedNightAction", () => {
4
+ it("deletes when never reminded", () => {
5
+ expect(unfavoriteSuggestedNightAction(null)).toBe("delete");
6
+ });
7
+
8
+ it("dismisses after a nudge", () => {
9
+ expect(unfavoriteSuggestedNightAction(new Date())).toBe("dismiss");
10
+ });
11
+ });
@@ -0,0 +1,81 @@
1
+ import { DiscoveryScope } from "../discoveryScope.js";
2
+ import { scoreSuggestion, SUGGESTION_WEIGHTS } from "../suggestionScore.js";
3
+ import { UserTasteKind, UserTasteSource } from "../userTaste.js";
4
+
5
+ function base() {
6
+ return {
7
+ connectionsGoingVisible: 0,
8
+ connectionsGoingHidden: 0,
9
+ followedHost: false,
10
+ attendedHost: false,
11
+ eventType: "Festival",
12
+ causeTags: [] as string[],
13
+ musicGenre: null,
14
+ discoveryScope: DiscoveryScope.Local,
15
+ artistsToSee: [] as string[],
16
+ eventTitle: "Block party",
17
+ distanceMiles: 5,
18
+ radiusMiles: 50,
19
+ isFeatured: false,
20
+ trendingScore: 0,
21
+ paidFeatureTasteOverlap: false,
22
+ tastes: [] as Array<{
23
+ kind: string;
24
+ value: string;
25
+ weight: number;
26
+ source: string;
27
+ }>,
28
+ };
29
+ }
30
+
31
+ describe("scoreSuggestion", () => {
32
+ it("ranks crew going above paid taste overlap", () => {
33
+ const crew = scoreSuggestion({
34
+ ...base(),
35
+ connectionsGoingVisible: 1,
36
+ });
37
+ const paid = scoreSuggestion({
38
+ ...base(),
39
+ paidFeatureTasteOverlap: true,
40
+ tastes: [
41
+ {
42
+ kind: UserTasteKind.EventType,
43
+ value: "Festival",
44
+ weight: 1,
45
+ source: UserTasteSource.Explicit,
46
+ },
47
+ ],
48
+ });
49
+ expect(crew).toBeGreaterThan(SUGGESTION_WEIGHTS.paidFeatureTaste);
50
+ expect(crew).toBeGreaterThan(
51
+ paid - SUGGESTION_WEIGHTS.explicitTaste
52
+ );
53
+ });
54
+
55
+ it("lets travel taste break radius for National events only", () => {
56
+ const tastes = [
57
+ {
58
+ kind: UserTasteKind.DiscoveryScope,
59
+ value: DiscoveryScope.National,
60
+ weight: 1,
61
+ source: UserTasteSource.Explicit,
62
+ },
63
+ ];
64
+ expect(
65
+ scoreSuggestion({
66
+ ...base(),
67
+ discoveryScope: DiscoveryScope.National,
68
+ distanceMiles: 900,
69
+ tastes,
70
+ })
71
+ ).toBeGreaterThan(0);
72
+ expect(
73
+ scoreSuggestion({
74
+ ...base(),
75
+ discoveryScope: DiscoveryScope.Local,
76
+ distanceMiles: 900,
77
+ tastes,
78
+ })
79
+ ).toBe(Number.NEGATIVE_INFINITY);
80
+ });
81
+ });