@bash-app/bash-common 30.391.0 → 30.393.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 +140 -53
  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,7 +1246,6 @@ 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])
@@ -1390,6 +1359,7 @@ model BashEvent {
1390
1359
 
1391
1360
  // Organization Relations
1392
1361
  budgets Budget[] @relation("EventBudgets")
1362
+ budgetApprovalRequests BudgetApprovalRequest[]
1393
1363
  rsvps EventRSVP[] @relation("EventRSVPs")
1394
1364
 
1395
1365
  // Groups & page views (Phases 0–4)
@@ -1435,6 +1405,7 @@ model BashEvent {
1435
1405
  locationShareSessions LocationShareSession[]
1436
1406
  /// Private Memories vault captures for this bash
1437
1407
  memoryCaptures BashMemoryCapture[]
1408
+ suggestedNights SuggestedNight[]
1438
1409
 
1439
1410
  @@index([templateId])
1440
1411
  @@index([parentEventId])
@@ -1442,6 +1413,7 @@ model BashEvent {
1442
1413
  @@index([hostedByServiceId])
1443
1414
  @@index([eventSeriesId])
1444
1415
  @@index([status, aiApprovalVerdict])
1416
+ @@index([discoveryScope, startDateTime])
1445
1417
  }
1446
1418
 
1447
1419
  /// Lineup / attribution roster for an event (optional act list for checkout tagging).
@@ -4634,8 +4606,6 @@ model User {
4634
4606
  blogPosts BlogPost[] @relation("BlogAuthor")
4635
4607
  checkouts Checkout[]
4636
4608
  waiverAcceptances WaiverAcceptance[]
4637
- clubAdmins ClubAdmin[]
4638
- clubMembers ClubMember[]
4639
4609
  competitions Competition[]
4640
4610
  competitionSponsorships CompetitionSponsor[]
4641
4611
  competitionPrizeOffersSubmitted CompetitionPrizeOffer[] @relation("CompetitionPrizeOffersSubmitted")
@@ -4855,6 +4825,8 @@ model User {
4855
4825
  approvedBudgets Budget[] @relation("BudgetApprover")
4856
4826
  submittedBudgets Budget[] @relation("BudgetSubmitter")
4857
4827
  ownedBudgets Budget[] @relation("UserBudgets")
4828
+ budgetApprovalsRequested BudgetApprovalRequest[] @relation("BudgetApprovalRequester")
4829
+ budgetApprovalsAsApprover BudgetApprovalRequest[] @relation("BudgetApprovalApprover")
4858
4830
  departmentsHeaded Department[] @relation("DepartmentHead")
4859
4831
  /// Enterprise platform (Phase 0-4): internal team members assigned as a named
4860
4832
  /// account manager, and org members who submit/approve procurement requests
@@ -4895,6 +4867,8 @@ model User {
4895
4867
  // Activity Intent / Experience Discovery
4896
4868
  userDiscoveryPreferences UserDiscoveryPreferences?
4897
4869
  activityIntents ActivityIntent[]
4870
+ tastePreferences UserTastePreference[]
4871
+ suggestedNights SuggestedNight[]
4898
4872
  experienceInvitations ExperienceInvitation[]
4899
4873
  experienceOpportunitiesCreated ExperienceOpportunity[] @relation("ExperienceOpportunityCreatedBy")
4900
4874
 
@@ -4944,6 +4918,12 @@ model UserPreferences {
4944
4918
  organizerUpdatesNotify Boolean @default(true)
4945
4919
  eventSuggestions Boolean @default(true)
4946
4920
  eventReminderNotify Boolean @default(true)
4921
+ /// Opt-in: engine may propose nights (Build my nights strip).
4922
+ suggestedNightsEnabled Boolean @default(false)
4923
+ /// Favorite-nudge email/push. Migrated to match eventReminderNotify.
4924
+ suggestedNightReminderNotify Boolean @default(true)
4925
+ /// Hosts in the guest's CRM may filter blasts by taste chips.
4926
+ hostsMayTargetTaste Boolean @default(true)
4947
4927
  newMessageNotify Boolean @default(true)
4948
4928
  invitationNotify Boolean @default(true)
4949
4929
  commentNotify Boolean @default(true)
@@ -5053,6 +5033,38 @@ model UserPreferences {
5053
5033
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5054
5034
  }
5055
5035
 
5036
+ model UserTastePreference {
5037
+ id String @id @default(cuid())
5038
+ userId String
5039
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5040
+ kind UserTasteKind
5041
+ value String
5042
+ weight Int @default(1)
5043
+ source UserTasteSource @default(Explicit)
5044
+ updatedAt DateTime @updatedAt
5045
+
5046
+ @@unique([userId, kind, value])
5047
+ @@index([userId, kind])
5048
+ }
5049
+
5050
+ model SuggestedNight {
5051
+ id String @id @default(cuid())
5052
+ userId String
5053
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5054
+ bashEventId String
5055
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
5056
+ status SuggestedNightStatus @default(Proposed)
5057
+ reason String[] @default([])
5058
+ proposedAt DateTime @default(now())
5059
+ acceptedAt DateTime?
5060
+ dismissedAt DateTime?
5061
+ remindedAt DateTime?
5062
+
5063
+ @@unique([userId, bashEventId])
5064
+ @@index([userId, status])
5065
+ @@index([bashEventId, status])
5066
+ }
5067
+
5056
5068
  model UserFollowing {
5057
5069
  id String @id @default(cuid())
5058
5070
  userId String
@@ -7263,7 +7275,6 @@ model Budget {
7263
7275
  // Flexible ownership - at least ONE should be set
7264
7276
  organizationId String?
7265
7277
  bashEventId String?
7266
- clubId String?
7267
7278
  departmentId String?
7268
7279
  userId String? // For personal event budgets
7269
7280
  /// Optional: budgets tied to a service profile (vendor/exhibitor/sponsor owner costs).
@@ -7301,7 +7312,6 @@ model Budget {
7301
7312
  // Relations
7302
7313
  organization Organization? @relation("OrganizationBudgets", fields: [organizationId], references: [id], onDelete: Cascade)
7303
7314
  bashEvent BashEvent? @relation("EventBudgets", fields: [bashEventId], references: [id], onDelete: Cascade)
7304
- club Club? @relation("ClubBudgets", fields: [clubId], references: [id], onDelete: Cascade)
7305
7315
  department Department? @relation("DepartmentBudgets", fields: [departmentId], references: [id], onDelete: Cascade)
7306
7316
  user User? @relation("UserBudgets", fields: [userId], references: [id], onDelete: Cascade)
7307
7317
  service Service? @relation("ServiceBudgets", fields: [serviceId], references: [id], onDelete: Cascade)
@@ -7312,7 +7322,6 @@ model Budget {
7312
7322
 
7313
7323
  @@index([organizationId])
7314
7324
  @@index([bashEventId])
7315
- @@index([clubId])
7316
7325
  @@index([departmentId])
7317
7326
  @@index([userId])
7318
7327
  @@index([serviceId])
@@ -7320,6 +7329,66 @@ model Budget {
7320
7329
  @@index([fiscalYear])
7321
7330
  }
7322
7331
 
7332
+ enum BudgetApprovalKind {
7333
+ CapAsk
7334
+ PlanSignOff
7335
+ }
7336
+
7337
+ enum BudgetApprovalStatus {
7338
+ Pending
7339
+ Approved
7340
+ ChangesRequested
7341
+ Declined
7342
+ Cancelled
7343
+ Superseded
7344
+ }
7345
+
7346
+ /// Event-level budget sign-off or pre-plan cap ask. Not per-line Budget.status.
7347
+ model BudgetApprovalRequest {
7348
+ id String @id @default(cuid())
7349
+ bashEventId String
7350
+ kind BudgetApprovalKind
7351
+ status BudgetApprovalStatus @default(Pending)
7352
+ requesterUserId String
7353
+ hostNote String?
7354
+ approverEmail String
7355
+ approverName String?
7356
+ approverUserId String?
7357
+ token String @unique @default(cuid())
7358
+ expiresAt DateTime
7359
+ snapshotJson Json
7360
+ snapshotTotalCents Int
7361
+ authorizedCents Int?
7362
+ decisionNote String?
7363
+ decidedAt DateTime?
7364
+ staleAt DateTime?
7365
+ createdAt DateTime @default(now())
7366
+ updatedAt DateTime @updatedAt
7367
+
7368
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
7369
+ requester User @relation("BudgetApprovalRequester", fields: [requesterUserId], references: [id], onDelete: Cascade)
7370
+ approverUser User? @relation("BudgetApprovalApprover", fields: [approverUserId], references: [id], onDelete: SetNull)
7371
+ lineNotes BudgetApprovalLineNote[]
7372
+
7373
+ @@index([bashEventId, status])
7374
+ @@index([approverEmail])
7375
+ }
7376
+
7377
+ model BudgetApprovalLineNote {
7378
+ id String @id @default(cuid())
7379
+ requestId String
7380
+ budgetId String?
7381
+ nameSnapshot String
7382
+ categorySnapshot String?
7383
+ allocatedCentsSnapshot Int
7384
+ note String
7385
+ createdAt DateTime @default(now())
7386
+
7387
+ request BudgetApprovalRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
7388
+
7389
+ @@index([requestId])
7390
+ }
7391
+
7323
7392
  enum BudgetContributionKind {
7324
7393
  Cash
7325
7394
  InKind
@@ -8510,20 +8579,6 @@ model BashEventMessageRead {
8510
8579
  @@index([userId])
8511
8580
  }
8512
8581
 
8513
- enum ClubAdminRole {
8514
- Owner
8515
- Admin
8516
- Staff
8517
- }
8518
-
8519
- enum ClubMemberStatus {
8520
- Active
8521
- Inactive
8522
- Canceled
8523
- Paused
8524
- Removed
8525
- }
8526
-
8527
8582
  enum TaskStatus {
8528
8583
  Open
8529
8584
  Accepted
@@ -8624,6 +8679,10 @@ enum NotificationType {
8624
8679
  SideQuestWindowOpened // Attendee: a timed Side Quest just went live
8625
8680
  /// Attendee/ticket holder: host changed significant bash details (time, place, title, …)
8626
8681
  EventDetailsUpdated
8682
+ /// Approver already on Bash: host sent a budget sign-off or cap ask
8683
+ BudgetApprovalRequested
8684
+ /// Host: approver approved, requested changes, or declined a budget request
8685
+ BudgetApprovalDecided
8627
8686
  }
8628
8687
 
8629
8688
  /// Opt-in reminder for a scheduled tier release (v1.5).
@@ -8674,6 +8733,34 @@ enum BashEventSource {
8674
8733
  Partner // Generic: use sourcePartnerId for partner key (e.g. 'mixlog', custom integrations)
8675
8734
  }
8676
8735
 
8736
+ /// How far an event's draw goes. Orthogonal to BashEventType.
8737
+ enum DiscoveryScope {
8738
+ Local
8739
+ State
8740
+ National
8741
+ International
8742
+ Unspecified
8743
+ }
8744
+
8745
+ enum UserTasteKind {
8746
+ EventType
8747
+ CauseTag
8748
+ MusicGenre
8749
+ DiscoveryScope
8750
+ }
8751
+
8752
+ enum UserTasteSource {
8753
+ Explicit
8754
+ Inferred
8755
+ }
8756
+
8757
+ enum SuggestedNightStatus {
8758
+ Proposed
8759
+ Accepted
8760
+ Dismissed
8761
+ Expired
8762
+ }
8763
+
8677
8764
  /// Channel used to create a BashEvent. See BashEvent.creationChannel doc comment.
8678
8765
  enum CreationChannel {
8679
8766
  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
+ });