@bash-app/bash-common 30.346.0 → 30.354.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 (36) hide show
  1. package/dist/extendedSchemas.d.ts +195 -0
  2. package/dist/extendedSchemas.d.ts.map +1 -1
  3. package/dist/extendedSchemas.js +12 -0
  4. package/dist/extendedSchemas.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/locationShare.d.ts +16 -0
  10. package/dist/locationShare.d.ts.map +1 -1
  11. package/dist/sms/smsTemplates.d.ts +10 -1
  12. package/dist/sms/smsTemplates.d.ts.map +1 -1
  13. package/dist/sms/smsTemplates.js +1 -0
  14. package/dist/sms/smsTemplates.js.map +1 -1
  15. package/dist/utils/__tests__/moodTrackUtils.test.d.ts +2 -0
  16. package/dist/utils/__tests__/moodTrackUtils.test.d.ts.map +1 -0
  17. package/dist/utils/__tests__/moodTrackUtils.test.js +25 -0
  18. package/dist/utils/__tests__/moodTrackUtils.test.js.map +1 -0
  19. package/dist/utils/blog/blogCommentDbUtils.d.ts +4 -0
  20. package/dist/utils/blog/blogCommentDbUtils.d.ts.map +1 -1
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.d.ts.map +1 -1
  23. package/dist/utils/index.js +1 -0
  24. package/dist/utils/index.js.map +1 -1
  25. package/dist/utils/moodTrackUtils.d.ts +23 -0
  26. package/dist/utils/moodTrackUtils.d.ts.map +1 -0
  27. package/dist/utils/moodTrackUtils.js +171 -0
  28. package/dist/utils/moodTrackUtils.js.map +1 -0
  29. package/package.json +1 -1
  30. package/prisma/schema.prisma +84 -0
  31. package/src/extendedSchemas.ts +12 -0
  32. package/src/index.ts +1 -0
  33. package/src/sms/smsTemplates.ts +18 -2
  34. package/src/utils/__tests__/moodTrackUtils.test.ts +46 -0
  35. package/src/utils/index.ts +1 -0
  36. package/src/utils/moodTrackUtils.ts +190 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Bash mood track — optional soundtrack URL on a bash.
3
+ * Preferred: YouTube Music. Also: Spotify, Apple Music, SoundCloud.
4
+ * Guests tap to play; never autoplay.
5
+ */
6
+ export type MoodTrackProvider = "youtubeMusic" | "spotify" | "appleMusic" | "soundcloud";
7
+ export type MoodTrackEmbed = {
8
+ provider: MoodTrackProvider;
9
+ /** Original pasted URL (normalized). */
10
+ url: string;
11
+ /** iframe src when the provider supports embed; null → link-out only. */
12
+ embedUrl: string | null;
13
+ label: string;
14
+ };
15
+ /**
16
+ * Normalize + validate a mood track URL for storage.
17
+ * Empty / whitespace → null. Unsupported host → throws with a host-facing message.
18
+ */
19
+ export declare function normalizeMoodTrackUrl(raw: string | null | undefined): string | null;
20
+ /** Parse a mood URL into an embed plan (or null if unsupported). */
21
+ export declare function parseMoodTrackUrl(raw: string | null | undefined): MoodTrackEmbed | null;
22
+ export declare function isMoodTrackUrlAllowed(raw: string | null | undefined): boolean;
23
+ //# sourceMappingURL=moodTrackUtils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"moodTrackUtils.d.ts","sourceRoot":"","sources":["../../src/utils/moodTrackUtils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,iBAAiB,GACzB,cAAc,GACd,SAAS,GACT,YAAY,GACZ,YAAY,CAAC;AAEjB,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAyEF;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC7B,MAAM,GAAG,IAAI,CAWf;AAED,oEAAoE;AACpE,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC7B,cAAc,GAAG,IAAI,CAsEvB;AAED,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAG7E"}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Bash mood track — optional soundtrack URL on a bash.
3
+ * Preferred: YouTube Music. Also: Spotify, Apple Music, SoundCloud.
4
+ * Guests tap to play; never autoplay.
5
+ */
6
+ const YOUTUBE_HOSTS = new Set([
7
+ "music.youtube.com",
8
+ "www.youtube.com",
9
+ "youtube.com",
10
+ "youtu.be",
11
+ "m.youtube.com",
12
+ ]);
13
+ const SPOTIFY_HOSTS = new Set(["open.spotify.com", "spotify.com"]);
14
+ const APPLE_HOSTS = new Set(["music.apple.com", "itunes.apple.com"]);
15
+ const SOUNDCLOUD_HOSTS = new Set(["soundcloud.com", "www.soundcloud.com", "m.soundcloud.com"]);
16
+ function hostnameOf(raw) {
17
+ try {
18
+ return new URL(raw).hostname.toLowerCase();
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function ensureHttps(raw) {
25
+ const trimmed = raw.trim();
26
+ if (!trimmed)
27
+ return null;
28
+ const withScheme = /^https?:\/\//i.test(trimmed)
29
+ ? trimmed
30
+ : `https://${trimmed}`;
31
+ try {
32
+ const u = new URL(withScheme);
33
+ if (u.protocol !== "http:" && u.protocol !== "https:")
34
+ return null;
35
+ return u.toString();
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function youtubeVideoId(url) {
42
+ if (url.hostname === "youtu.be") {
43
+ const id = url.pathname.replace(/^\//, "").split("/")[0];
44
+ return id || null;
45
+ }
46
+ const v = url.searchParams.get("v");
47
+ if (v)
48
+ return v;
49
+ const parts = url.pathname.split("/").filter(Boolean);
50
+ const watchIdx = parts.indexOf("watch");
51
+ if (watchIdx >= 0 && parts[watchIdx + 1])
52
+ return parts[watchIdx + 1];
53
+ const embedIdx = parts.indexOf("embed");
54
+ if (embedIdx >= 0 && parts[embedIdx + 1])
55
+ return parts[embedIdx + 1];
56
+ const shortsIdx = parts.indexOf("shorts");
57
+ if (shortsIdx >= 0 && parts[shortsIdx + 1])
58
+ return parts[shortsIdx + 1];
59
+ return null;
60
+ }
61
+ function youtubePlaylistId(url) {
62
+ return url.searchParams.get("list");
63
+ }
64
+ function spotifyEmbedPath(url) {
65
+ // /track/id | /album/id | /playlist/id | /episode/id
66
+ const m = url.pathname.match(/^\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/);
67
+ if (!m)
68
+ return null;
69
+ return `/${m[1]}/${m[2]}`;
70
+ }
71
+ function appleEmbedPath(url) {
72
+ // music.apple.com/{country}/album/... or /song/...
73
+ if (!url.pathname || url.pathname === "/")
74
+ return null;
75
+ return url.pathname + url.search;
76
+ }
77
+ /**
78
+ * Normalize + validate a mood track URL for storage.
79
+ * Empty / whitespace → null. Unsupported host → throws with a host-facing message.
80
+ */
81
+ export function normalizeMoodTrackUrl(raw) {
82
+ if (raw == null)
83
+ return null;
84
+ const trimmed = raw.trim();
85
+ if (!trimmed)
86
+ return null;
87
+ const parsed = parseMoodTrackUrl(trimmed);
88
+ if (!parsed) {
89
+ throw new Error("Use a YouTube Music, Spotify, Apple Music, or SoundCloud link.");
90
+ }
91
+ return parsed.url;
92
+ }
93
+ /** Parse a mood URL into an embed plan (or null if unsupported). */
94
+ export function parseMoodTrackUrl(raw) {
95
+ const https = ensureHttps(raw ?? "");
96
+ if (!https)
97
+ return null;
98
+ let url;
99
+ try {
100
+ url = new URL(https);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ const host = hostnameOf(https);
106
+ if (!host)
107
+ return null;
108
+ if (YOUTUBE_HOSTS.has(host)) {
109
+ const videoId = youtubeVideoId(url);
110
+ const listId = youtubePlaylistId(url);
111
+ if (videoId) {
112
+ const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
113
+ embed.searchParams.set("playsinline", "1");
114
+ embed.searchParams.set("rel", "0");
115
+ if (listId)
116
+ embed.searchParams.set("list", listId);
117
+ return {
118
+ provider: "youtubeMusic",
119
+ url: https,
120
+ embedUrl: embed.toString(),
121
+ label: "YouTube Music",
122
+ };
123
+ }
124
+ if (listId) {
125
+ return {
126
+ provider: "youtubeMusic",
127
+ url: https,
128
+ embedUrl: `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(listId)}`,
129
+ label: "YouTube Music",
130
+ };
131
+ }
132
+ return null;
133
+ }
134
+ if (SPOTIFY_HOSTS.has(host)) {
135
+ const path = spotifyEmbedPath(url);
136
+ if (!path)
137
+ return null;
138
+ return {
139
+ provider: "spotify",
140
+ url: https,
141
+ embedUrl: `https://open.spotify.com/embed${path}`,
142
+ label: "Spotify",
143
+ };
144
+ }
145
+ if (APPLE_HOSTS.has(host)) {
146
+ const path = appleEmbedPath(url);
147
+ if (!path)
148
+ return null;
149
+ return {
150
+ provider: "appleMusic",
151
+ url: https,
152
+ embedUrl: `https://embed.music.apple.com${path}`,
153
+ label: "Apple Music",
154
+ };
155
+ }
156
+ if (SOUNDCLOUD_HOSTS.has(host)) {
157
+ return {
158
+ provider: "soundcloud",
159
+ url: https,
160
+ embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(https)}&auto_play=false&hide_related=true&show_comments=false&visual=false`,
161
+ label: "SoundCloud",
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+ export function isMoodTrackUrlAllowed(raw) {
167
+ if (raw == null || !String(raw).trim())
168
+ return true;
169
+ return parseMoodTrackUrl(raw) != null;
170
+ }
171
+ //# sourceMappingURL=moodTrackUtils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"moodTrackUtils.js","sourceRoot":"","sources":["../../src/utils/moodTrackUtils.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bash-app/bash-common",
3
- "version": "30.346.0",
3
+ "version": "30.354.0",
4
4
  "description": "Common data and scripts to use on the frontend and backend",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -789,6 +789,8 @@ model BashEvent {
789
789
  dateTimePublished DateTime?
790
790
  includedItems String[]
791
791
  videoLink String?
792
+ /// Optional mood track URL (YouTube Music preferred; Spotify / Apple Music / SoundCloud also OK).
793
+ moodTrackUrl String?
792
794
  subtitle String?
793
795
  isFeatured Boolean?
794
796
  featuredUntil DateTime? // When paid feature expires (null = no expiry / super-user pinned)
@@ -977,6 +979,9 @@ model BashEvent {
977
979
  themeColor String? // Dominant hex color extracted from the event flyer (e.g. "#7c3aed")
978
980
  /// RSVP-gated discussion thread on bash detail
979
981
  discussionEnabled Boolean @default(true)
982
+ /// When true, ticket holders / approved RSVPs get notified on new top-level
983
+ /// discussion posts and icebreaker answers (off by default). Hosts always get those.
984
+ notifyAttendeesOnDiscussion Boolean @default(false)
980
985
  /// Let attendees share invite links with ?ref= attribution
981
986
  guestsCanInviteOthers Boolean @default(false)
982
987
  /// Private-event screening: route access requests to a host approval queue
@@ -4183,6 +4188,10 @@ model User {
4183
4188
  referredById String?
4184
4189
  referralPurchaseCredited Boolean @default(false)
4185
4190
 
4191
+ /// Best home-hero “Stuff the horn” clear time (ms). Lower is better; null = never cleared.
4192
+ hornClearBestMs Int?
4193
+ hornClearBestAt DateTime?
4194
+
4186
4195
  // Security Fields
4187
4196
  accountLockedUntil DateTime? // Account lockout timestamp for failed login protection
4188
4197
  revokedTokens RevokedToken[] @relation("RevokedTokens") // Revoked JWT tokens
@@ -4365,6 +4374,7 @@ model User {
4365
4374
  productLaunchRankSubmissions ProductLaunchRankSubmission[]
4366
4375
  enterpriseLeads EnterpriseLead[]
4367
4376
  feedbackTeamApplications FeedbackTeamApplication[]
4377
+ newsletterSubscriptions NewsletterSubscriber[]
4368
4378
  pushNotificationTokens PushNotificationToken[]
4369
4379
  redeemedVouchers Voucher[]
4370
4380
  bashEventPromoCodesIUsed BashEventPromoCode[] @relation("BashEventPromoCodeToUser")
@@ -4727,6 +4737,60 @@ model EventRankings {
4727
4737
  @@index([totalAttendees])
4728
4738
  }
4729
4739
 
4740
+ /// Marketplace service leaderboards (per serviceType; global + city ranks).
4741
+ /// Not UserStats.serviceRank (passport stub). Snapshot refreshed by calculator.
4742
+ model ServiceRankings {
4743
+ id String @id @default(cuid())
4744
+ serviceId String @unique
4745
+
4746
+ serviceType ServiceTypes?
4747
+
4748
+ // Score components
4749
+ bookingScore Float @default(0) // confirmed booking count (all-time)
4750
+ ratingScore Float @default(0) // Bayesian-adjusted serviceRating
4751
+ reviewScore Float @default(0) // log volume of ratings
4752
+ overallScore Float @default(0) // composite for default sort
4753
+
4754
+ // Stat snapshots (shown on leaderboard rows)
4755
+ confirmedBookings Int @default(0)
4756
+ serviceRating Float @default(0)
4757
+ totalRatings Int @default(0)
4758
+
4759
+ // Period booking volumes (time filters; ratings stay lifetime)
4760
+ confirmedBookingsMonth Int @default(0)
4761
+ confirmedBookingsWeek Int @default(0)
4762
+
4763
+ // Ranks within serviceType (null when type missing or below city threshold)
4764
+ globalRank Int?
4765
+ cityRank Int?
4766
+
4767
+ // Location (copied from Service for filter/rank locality)
4768
+ city String?
4769
+ state String?
4770
+ country String @default("US")
4771
+
4772
+ // Time-window composite scores (booking volume swapped; rating/reviews lifetime)
4773
+ lastMonthScore Float @default(0)
4774
+ lastWeekScore Float @default(0)
4775
+ allTimeScore Float @default(0)
4776
+
4777
+ lastCalculated DateTime @default(now())
4778
+ createdAt DateTime @default(now())
4779
+ updatedAt DateTime @updatedAt
4780
+
4781
+ service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
4782
+
4783
+ @@index([serviceId])
4784
+ @@index([serviceType, globalRank])
4785
+ @@index([serviceType, city, cityRank])
4786
+ @@index([serviceType, overallScore])
4787
+ @@index([serviceType, confirmedBookings])
4788
+ @@index([serviceType, ratingScore])
4789
+ @@index([serviceType, totalRatings])
4790
+ @@index([city])
4791
+ @@index([overallScore])
4792
+ }
4793
+
4730
4794
  model UserConnection {
4731
4795
  id String @id @default(cuid())
4732
4796
  requesterId String // User who sent the request
@@ -5093,6 +5157,7 @@ model Service {
5093
5157
  serviceReviews ServiceReview[]
5094
5158
  providerHostReviews ProviderHostReview[]
5095
5159
  sideQuestCheckpoints SideQuestCheckpoint[]
5160
+ serviceRankings ServiceRankings?
5096
5161
 
5097
5162
  @@index([serviceListingStripeSubscriptionId])
5098
5163
  @@index([paymentAccountId])
@@ -12490,3 +12555,22 @@ model FeedbackTeamApplication {
12490
12555
  @@index([createdAt])
12491
12556
  @@index([userId])
12492
12557
  }
12558
+
12559
+ /// Platform newsletter / weekly roundup capture (footer + home). Not org-scoped — see OrganizationSubscriber.
12560
+ model NewsletterSubscriber {
12561
+ id String @id @default(cuid())
12562
+ email String @unique
12563
+ userId String?
12564
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
12565
+ /// Capture surface: footer | weekly_roundup
12566
+ source String @default("footer")
12567
+ /// Optional city context when signing up from a city roundup rail
12568
+ city String?
12569
+ createdAt DateTime @default(now())
12570
+ updatedAt DateTime @updatedAt
12571
+ unsubscribedAt DateTime?
12572
+
12573
+ @@index([createdAt])
12574
+ @@index([userId])
12575
+ @@index([source])
12576
+ }
@@ -208,6 +208,9 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
208
208
  /** BashPoints loyalty (API may omit when viewing someone else’s profile). */
209
209
  bashPointsBalance: true,
210
210
  bashPointsEarnedLifetime: true,
211
+ /** Home hero “Stuff the horn” best clear (ms; lower is better). */
212
+ hornClearBestMs: true,
213
+ hornClearBestAt: true,
211
214
  /** Host reputation fields */
212
215
  hostRating: true,
213
216
  totalRatings: true,
@@ -233,6 +236,15 @@ export const PUBLIC_TEASER_USER_DATA_TO_SELECT = {
233
236
  imageUpdatedAt: true,
234
237
  uploadedImage: true,
235
238
  uploadedImageUpdatedAt: true,
239
+ /** Public social links for attendee lists / teasers (IG, LinkedIn, etc.). */
240
+ socialMediaProfiles: {
241
+ select: {
242
+ id: true,
243
+ platform: true,
244
+ url: true,
245
+ userId: true,
246
+ },
247
+ },
236
248
  } satisfies Prisma.UserSelect;
237
249
 
238
250
  export type PublicTeaserUser = Prisma.UserGetPayload<{
package/src/index.ts CHANGED
@@ -401,6 +401,7 @@ export * from "./utils/sortUtils.js";
401
401
  export * from "./utils/stringUtils.js";
402
402
  export * from "./utils/ticketListUtils.js";
403
403
  export * from "./utils/urlUtils.js";
404
+ export * from "./utils/moodTrackUtils.js";
404
405
  export * from "./utils/userPromoCodeUtils.js";
405
406
  export * from "./utils/userSubscriptionUtils.js";
406
407
  export * from "./utils/blog/blogDbUtils.js";
@@ -7,6 +7,7 @@ export const SMS_TEMPLATE_KEYS = [
7
7
  "EVENT_REMINDER_24H",
8
8
  "EVENT_REMINDER_3H",
9
9
  "EVENT_REMINDER_1H",
10
+ "EVENT_INVITE",
10
11
  "EVENT_VENUE_CHANGED",
11
12
  "EVENT_TIME_CHANGED",
12
13
  "EVENT_CANCELLED",
@@ -21,9 +22,24 @@ export type SmsTemplateKey = (typeof SMS_TEMPLATE_KEYS)[number];
21
22
  /** Typed context per template (API maps these to final message strings). */
22
23
  export type SmsTemplateContext = {
23
24
  EVENT_REMINDER_1WEEK: { eventTitle: string; shortUrl: string };
24
- EVENT_REMINDER_24H: { eventTitle: string; shortUrl: string };
25
+ /** Optional socialProof = L3 “friends going” line (24h / 1h cadences). */
26
+ EVENT_REMINDER_24H: {
27
+ eventTitle: string;
28
+ shortUrl: string;
29
+ socialProof?: string;
30
+ };
25
31
  EVENT_REMINDER_3H: { eventTitle: string; shortUrl: string };
26
- EVENT_REMINDER_1H: { eventTitle: string; shortUrl: string };
32
+ EVENT_REMINDER_1H: {
33
+ eventTitle: string;
34
+ shortUrl: string;
35
+ socialProof?: string;
36
+ };
37
+ /** First-touch guest invite SMS (verified phone + SMS prefs only). */
38
+ EVENT_INVITE: {
39
+ eventTitle: string;
40
+ shortUrl: string;
41
+ inviterFirstName?: string;
42
+ };
27
43
  EVENT_VENUE_CHANGED: { eventTitle: string; shortUrl: string };
28
44
  EVENT_TIME_CHANGED: { eventTitle: string; shortUrl: string };
29
45
  EVENT_CANCELLED: { eventTitle: string; shortUrl: string };
@@ -0,0 +1,46 @@
1
+ import {
2
+ isMoodTrackUrlAllowed,
3
+ normalizeMoodTrackUrl,
4
+ parseMoodTrackUrl,
5
+ } from "../moodTrackUtils";
6
+
7
+ describe("moodTrackUtils", () => {
8
+ it("parses YouTube Music watch URLs into youtube embeds", () => {
9
+ const parsed = parseMoodTrackUrl(
10
+ "https://music.youtube.com/watch?v=dQw4w9WgXcQ"
11
+ );
12
+ expect(parsed?.provider).toBe("youtubeMusic");
13
+ expect(parsed?.embedUrl).toContain("youtube.com/embed/dQw4w9WgXcQ");
14
+ });
15
+
16
+ it("parses Spotify track URLs", () => {
17
+ const parsed = parseMoodTrackUrl(
18
+ "https://open.spotify.com/track/11dFghVXANMlKmJXsNCbNl"
19
+ );
20
+ expect(parsed?.provider).toBe("spotify");
21
+ expect(parsed?.embedUrl).toBe(
22
+ "https://open.spotify.com/embed/track/11dFghVXANMlKmJXsNCbNl"
23
+ );
24
+ });
25
+
26
+ it("parses Apple Music and SoundCloud", () => {
27
+ expect(
28
+ parseMoodTrackUrl(
29
+ "https://music.apple.com/us/album/example/123?i=456"
30
+ )?.provider
31
+ ).toBe("appleMusic");
32
+ expect(
33
+ parseMoodTrackUrl("https://soundcloud.com/artist/track")?.provider
34
+ ).toBe("soundcloud");
35
+ });
36
+
37
+ it("normalizes empty to null and rejects unsupported hosts", () => {
38
+ expect(normalizeMoodTrackUrl(" ")).toBeNull();
39
+ expect(normalizeMoodTrackUrl(null)).toBeNull();
40
+ expect(() => normalizeMoodTrackUrl("https://example.com/song")).toThrow(
41
+ /YouTube Music/
42
+ );
43
+ expect(isMoodTrackUrlAllowed("")).toBe(true);
44
+ expect(isMoodTrackUrlAllowed("https://evil.example/x")).toBe(false);
45
+ });
46
+ });
@@ -12,6 +12,7 @@ export * from './entityUtils.js';
12
12
  export * from './generalDateTimeUtils.js';
13
13
  export * from './luxonUtils.js';
14
14
  export * from './mathUtils.js';
15
+ export * from './moodTrackUtils.js';
15
16
  export * from './objUtils.js';
16
17
  export * from './paymentUtils.js';
17
18
  export * from './promoCodesUtils.js';
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Bash mood track — optional soundtrack URL on a bash.
3
+ * Preferred: YouTube Music. Also: Spotify, Apple Music, SoundCloud.
4
+ * Guests tap to play; never autoplay.
5
+ */
6
+
7
+ export type MoodTrackProvider =
8
+ | "youtubeMusic"
9
+ | "spotify"
10
+ | "appleMusic"
11
+ | "soundcloud";
12
+
13
+ export type MoodTrackEmbed = {
14
+ provider: MoodTrackProvider;
15
+ /** Original pasted URL (normalized). */
16
+ url: string;
17
+ /** iframe src when the provider supports embed; null → link-out only. */
18
+ embedUrl: string | null;
19
+ label: string;
20
+ };
21
+
22
+ const YOUTUBE_HOSTS = new Set([
23
+ "music.youtube.com",
24
+ "www.youtube.com",
25
+ "youtube.com",
26
+ "youtu.be",
27
+ "m.youtube.com",
28
+ ]);
29
+
30
+ const SPOTIFY_HOSTS = new Set(["open.spotify.com", "spotify.com"]);
31
+ const APPLE_HOSTS = new Set(["music.apple.com", "itunes.apple.com"]);
32
+ const SOUNDCLOUD_HOSTS = new Set(["soundcloud.com", "www.soundcloud.com", "m.soundcloud.com"]);
33
+
34
+ function hostnameOf(raw: string): string | null {
35
+ try {
36
+ return new URL(raw).hostname.toLowerCase();
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function ensureHttps(raw: string): string | null {
43
+ const trimmed = raw.trim();
44
+ if (!trimmed) return null;
45
+ const withScheme = /^https?:\/\//i.test(trimmed)
46
+ ? trimmed
47
+ : `https://${trimmed}`;
48
+ try {
49
+ const u = new URL(withScheme);
50
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
51
+ return u.toString();
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function youtubeVideoId(url: URL): string | null {
58
+ if (url.hostname === "youtu.be") {
59
+ const id = url.pathname.replace(/^\//, "").split("/")[0];
60
+ return id || null;
61
+ }
62
+ const v = url.searchParams.get("v");
63
+ if (v) return v;
64
+ const parts = url.pathname.split("/").filter(Boolean);
65
+ const watchIdx = parts.indexOf("watch");
66
+ if (watchIdx >= 0 && parts[watchIdx + 1]) return parts[watchIdx + 1];
67
+ const embedIdx = parts.indexOf("embed");
68
+ if (embedIdx >= 0 && parts[embedIdx + 1]) return parts[embedIdx + 1];
69
+ const shortsIdx = parts.indexOf("shorts");
70
+ if (shortsIdx >= 0 && parts[shortsIdx + 1]) return parts[shortsIdx + 1];
71
+ return null;
72
+ }
73
+
74
+ function youtubePlaylistId(url: URL): string | null {
75
+ return url.searchParams.get("list");
76
+ }
77
+
78
+ function spotifyEmbedPath(url: URL): string | null {
79
+ // /track/id | /album/id | /playlist/id | /episode/id
80
+ const m = url.pathname.match(
81
+ /^\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/
82
+ );
83
+ if (!m) return null;
84
+ return `/${m[1]}/${m[2]}`;
85
+ }
86
+
87
+ function appleEmbedPath(url: URL): string | null {
88
+ // music.apple.com/{country}/album/... or /song/...
89
+ if (!url.pathname || url.pathname === "/") return null;
90
+ return url.pathname + url.search;
91
+ }
92
+
93
+ /**
94
+ * Normalize + validate a mood track URL for storage.
95
+ * Empty / whitespace → null. Unsupported host → throws with a host-facing message.
96
+ */
97
+ export function normalizeMoodTrackUrl(
98
+ raw: string | null | undefined
99
+ ): string | null {
100
+ if (raw == null) return null;
101
+ const trimmed = raw.trim();
102
+ if (!trimmed) return null;
103
+ const parsed = parseMoodTrackUrl(trimmed);
104
+ if (!parsed) {
105
+ throw new Error(
106
+ "Use a YouTube Music, Spotify, Apple Music, or SoundCloud link."
107
+ );
108
+ }
109
+ return parsed.url;
110
+ }
111
+
112
+ /** Parse a mood URL into an embed plan (or null if unsupported). */
113
+ export function parseMoodTrackUrl(
114
+ raw: string | null | undefined
115
+ ): MoodTrackEmbed | null {
116
+ const https = ensureHttps(raw ?? "");
117
+ if (!https) return null;
118
+ let url: URL;
119
+ try {
120
+ url = new URL(https);
121
+ } catch {
122
+ return null;
123
+ }
124
+ const host = hostnameOf(https);
125
+ if (!host) return null;
126
+
127
+ if (YOUTUBE_HOSTS.has(host)) {
128
+ const videoId = youtubeVideoId(url);
129
+ const listId = youtubePlaylistId(url);
130
+ if (videoId) {
131
+ const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
132
+ embed.searchParams.set("playsinline", "1");
133
+ embed.searchParams.set("rel", "0");
134
+ if (listId) embed.searchParams.set("list", listId);
135
+ return {
136
+ provider: "youtubeMusic",
137
+ url: https,
138
+ embedUrl: embed.toString(),
139
+ label: "YouTube Music",
140
+ };
141
+ }
142
+ if (listId) {
143
+ return {
144
+ provider: "youtubeMusic",
145
+ url: https,
146
+ embedUrl: `https://www.youtube.com/embed/videoseries?list=${encodeURIComponent(listId)}`,
147
+ label: "YouTube Music",
148
+ };
149
+ }
150
+ return null;
151
+ }
152
+
153
+ if (SPOTIFY_HOSTS.has(host)) {
154
+ const path = spotifyEmbedPath(url);
155
+ if (!path) return null;
156
+ return {
157
+ provider: "spotify",
158
+ url: https,
159
+ embedUrl: `https://open.spotify.com/embed${path}`,
160
+ label: "Spotify",
161
+ };
162
+ }
163
+
164
+ if (APPLE_HOSTS.has(host)) {
165
+ const path = appleEmbedPath(url);
166
+ if (!path) return null;
167
+ return {
168
+ provider: "appleMusic",
169
+ url: https,
170
+ embedUrl: `https://embed.music.apple.com${path}`,
171
+ label: "Apple Music",
172
+ };
173
+ }
174
+
175
+ if (SOUNDCLOUD_HOSTS.has(host)) {
176
+ return {
177
+ provider: "soundcloud",
178
+ url: https,
179
+ embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(https)}&auto_play=false&hide_related=true&show_comments=false&visual=false`,
180
+ label: "SoundCloud",
181
+ };
182
+ }
183
+
184
+ return null;
185
+ }
186
+
187
+ export function isMoodTrackUrlAllowed(raw: string | null | undefined): boolean {
188
+ if (raw == null || !String(raw).trim()) return true;
189
+ return parseMoodTrackUrl(raw) != null;
190
+ }