@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.
- package/dist/extendedSchemas.d.ts +195 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +12 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/locationShare.d.ts +16 -0
- package/dist/locationShare.d.ts.map +1 -1
- package/dist/sms/smsTemplates.d.ts +10 -1
- package/dist/sms/smsTemplates.d.ts.map +1 -1
- package/dist/sms/smsTemplates.js +1 -0
- package/dist/sms/smsTemplates.js.map +1 -1
- package/dist/utils/__tests__/moodTrackUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/moodTrackUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/moodTrackUtils.test.js +25 -0
- package/dist/utils/__tests__/moodTrackUtils.test.js.map +1 -0
- package/dist/utils/blog/blogCommentDbUtils.d.ts +4 -0
- package/dist/utils/blog/blogCommentDbUtils.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/moodTrackUtils.d.ts +23 -0
- package/dist/utils/moodTrackUtils.d.ts.map +1 -0
- package/dist/utils/moodTrackUtils.js +171 -0
- package/dist/utils/moodTrackUtils.js.map +1 -0
- package/package.json +1 -1
- package/prisma/schema.prisma +84 -0
- package/src/extendedSchemas.ts +12 -0
- package/src/index.ts +1 -0
- package/src/sms/smsTemplates.ts +18 -2
- package/src/utils/__tests__/moodTrackUtils.test.ts +46 -0
- package/src/utils/index.ts +1 -0
- 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
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
+
}
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -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";
|
package/src/sms/smsTemplates.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|