Skip to content

Commit dc64f5e

Browse files
feat(portal,sanity): support multiple guests per booking
A booking can now list every guest appearing on an episode in `guests[]`. Each guest signs in with their own email, edits only their own speaker profile, and signs their own media release; the host can view and edit any guest's profile but cannot sign on a guest's behalf. Single-guest bookings without `guests[]` behave exactly as before. - booking schema: `guests[]` with name, email, profile link, and per-guest release signature/consents - portal-guests: resolve the guest list (legacy single-guest fallback) - access list includes every guest email - sign-release / guest-profile: act on the signed-in guest's entry; the first guest is mirrored to the top-level release fields for the ops app - guest-profile: only the host may target an arbitrary guestSlug - portal page: "Guests on This Episode" roster with profile/release status - fix biome lint/format errors already on main so CI passes Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 655b5b4 commit dc64f5e

12 files changed

Lines changed: 476 additions & 88 deletions

File tree

‎apps/sanity/schemas/documents/booking.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,74 @@ export default defineType({
1919
type: 'string',
2020
validation: (Rule) => Rule.required().email(),
2121
}),
22+
defineField({
23+
name: 'guests',
24+
title: 'Guests on This Episode',
25+
description:
26+
'Everyone appearing on the episode. Each guest signs in with their own email, edits their own speaker profile, and signs their own media release. Leave empty for a single-guest booking (Guest Full Legal Name / Guest Email are used).',
27+
type: 'array',
28+
of: [
29+
{
30+
type: 'object',
31+
name: 'bookingGuest',
32+
fields: [
33+
defineField({
34+
name: 'name',
35+
title: 'Full Legal Name',
36+
type: 'string',
37+
validation: (Rule) => Rule.required(),
38+
}),
39+
defineField({
40+
name: 'email',
41+
title: 'Email',
42+
type: 'string',
43+
validation: (Rule) => Rule.required().email(),
44+
}),
45+
defineField({name: 'guestSlug', title: 'Linked Guest Slug', type: 'string'}),
46+
defineField({name: 'guestDocId', title: 'Linked Guest Document ID', type: 'string'}),
47+
defineField({
48+
name: 'signatureMode',
49+
title: 'E-SIGN Signature Mode',
50+
type: 'string',
51+
options: {
52+
list: [
53+
{title: 'Drawn Canvas Signature', value: 'draw'},
54+
{title: 'Typed Legal Signature', value: 'type'},
55+
],
56+
},
57+
}),
58+
defineField({
59+
name: 'signatureData',
60+
title: 'E-SIGN Signature Data (Typed Name or Base64 PNG)',
61+
type: 'text',
62+
}),
63+
defineField({name: 'signedAt', title: 'E-SIGN Executed At', type: 'datetime'}),
64+
defineField({
65+
name: 'consentRecording',
66+
title: 'Consent: Recording & Distribution',
67+
type: 'boolean',
68+
}),
69+
defineField({
70+
name: 'consentCommercial',
71+
title: 'Consent: Commercial & Sponsorship Use',
72+
type: 'boolean',
73+
}),
74+
defineField({
75+
name: 'consentLikeness',
76+
title: 'Consent: Likeness & Social Clips',
77+
type: 'boolean',
78+
}),
79+
],
80+
preview: {
81+
select: {title: 'name', subtitle: 'email', signedAt: 'signedAt'},
82+
prepare: ({title, subtitle, signedAt}) => ({
83+
title,
84+
subtitle: `${subtitle || ''}${signedAt ? ' · release signed' : ' · release pending'}`,
85+
}),
86+
},
87+
},
88+
],
89+
}),
2290
defineField({
2391
name: 'company',
2492
title: 'Company / Flagship Project',

‎apps/site/src/lib/og-utils.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,7 @@ function mascotShowcaseElement({
571571
* - Massive readable typography, large cover artwork (440x248) or 210px mascot medallion.
572572
* - Top full-bleed gradient accent stripe.
573573
*/
574-
function cardShell(
575-
accentBarGradient: string,
576-
content: any,
577-
) {
574+
function cardShell(accentBarGradient: string, content: any) {
578575
return h(
579576
"div",
580577
{

‎apps/site/src/lib/portal-guests.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* A person appearing on an episode. Multi-guest bookings store these in
3+
* `booking.guests[]`; older single-guest bookings keep one guest in the
4+
* top-level `guestName` / `guestEmail` / `signature*` fields, which
5+
* `resolvePortalGuests` maps to a single entry with `_key: "primary"`.
6+
*/
7+
export interface PortalGuest {
8+
_key: string;
9+
name: string;
10+
email: string;
11+
guestSlug?: string;
12+
guestDocId?: string;
13+
signatureMode?: string;
14+
signatureData?: string;
15+
signedAt?: string;
16+
}
17+
18+
export const PRIMARY_GUEST_KEY = "primary";
19+
20+
export function hasGuestList(
21+
booking: Record<string, any> | null | undefined,
22+
): boolean {
23+
return Array.isArray(booking?.guests) && booking.guests.length > 0;
24+
}
25+
26+
export function resolvePortalGuests(
27+
booking: Record<string, any> | null | undefined,
28+
lead?: Record<string, any> | null,
29+
): PortalGuest[] {
30+
if (hasGuestList(booking)) {
31+
return (booking?.guests as Array<Record<string, any>>)
32+
.filter((g) => g?._key && g?.email)
33+
.map((g) => ({
34+
_key: g._key,
35+
name: g.name || g.email,
36+
email: String(g.email).trim().toLowerCase(),
37+
guestSlug: g.guestSlug,
38+
guestDocId: g.guestDocId,
39+
signatureMode: g.signatureMode,
40+
signatureData: g.signatureData,
41+
signedAt: g.signedAt,
42+
}));
43+
}
44+
45+
const email = (booking?.guestEmail || lead?.contactEmail || "")
46+
.trim()
47+
.toLowerCase();
48+
const name = booking?.guestName || lead?.contactName || "";
49+
if (!email && !name) {
50+
return [];
51+
}
52+
return [
53+
{
54+
_key: PRIMARY_GUEST_KEY,
55+
name: name || email,
56+
email,
57+
guestSlug: booking?.guestSlug || lead?.guestSlug,
58+
guestDocId: booking?.guestDocId || lead?.guestDocId,
59+
signatureMode: booking?.signatureMode,
60+
signatureData: booking?.signatureData,
61+
signedAt: booking?.signedAt,
62+
},
63+
];
64+
}
65+
66+
export function findGuestByEmail(
67+
guests: PortalGuest[],
68+
email?: string | null,
69+
): PortalGuest | undefined {
70+
const clean = (email || "").trim().toLowerCase();
71+
if (!clean) {
72+
return undefined;
73+
}
74+
return guests.find((g) => g.email === clean);
75+
}

‎apps/site/src/lib/portal-security.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ export function extractPortalAllowedEmails(
109109
doc.coordinatorEmail,
110110
...(Array.isArray(doc.coordinatorEmails) ? doc.coordinatorEmails : []),
111111
...(Array.isArray(doc.allowedEmails) ? doc.allowedEmails : []),
112+
...(Array.isArray(doc.guests)
113+
? doc.guests.map((g: { email?: string }) => g?.email)
114+
: []),
112115
];
113116
for (const item of rawStrings) {
114117
if (typeof item !== "string") {
@@ -125,6 +128,10 @@ export function extractPortalAllowedEmails(
125128
return Array.from(out);
126129
}
127130

131+
export function isHostEmail(email?: string | null): boolean {
132+
return HOST_ADMIN_EMAILS.has((email || "").trim().toLowerCase());
133+
}
134+
128135
/**
129136
* Checks whether an authenticated Better-Auth user's email is authorized to unlock
130137
* a specific Studio Portal (exact guest/sponsor email, coordinator email, same non-public

0 commit comments

Comments
 (0)