Skip to content

Commit 1b9bf89

Browse files
committed
feat(portal): unify self-serve guest profile editor, inline E-SIGN, draft preview & GCal 24h/1h reminders
1 parent 0c59141 commit 1b9bf89

11 files changed

Lines changed: 1535 additions & 31 deletions

File tree

‎apps/site/src/lib/google-calendar.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,15 @@ export async function createGoogleCalendarBookingEvent(opts: {
683683
start: { dateTime: start.toISOString() },
684684
end: { dateTime: end.toISOString() },
685685
attendees,
686+
reminders: {
687+
useDefault: false,
688+
overrides: [
689+
{ method: "email", minutes: 1440 },
690+
{ method: "popup", minutes: 1440 },
691+
{ method: "email", minutes: 60 },
692+
{ method: "popup", minutes: 60 },
693+
],
694+
},
686695
};
687696

688697
if (isPreMeeting) {
@@ -812,6 +821,15 @@ export async function approveGoogleCalendarRecordingEvent(opts: {
812821
{ email: "alex@codingcat.dev", displayName: "Alex Patterson", responseStatus: "accepted" },
813822
{ email: opts.guestEmail, displayName: opts.guestName },
814823
],
824+
reminders: {
825+
useDefault: false,
826+
overrides: [
827+
{ method: "email", minutes: 1440 },
828+
{ method: "popup", minutes: 1440 },
829+
{ method: "email", minutes: 60 },
830+
{ method: "popup", minutes: 60 },
831+
],
832+
},
815833
};
816834

817835
try {

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

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,45 @@ const PUBLIC_EMAIL_DOMAINS = new Set([
8282
"fastmail.com",
8383
]);
8484

85+
const CORPORATE_DOMAIN_ALIASES: Record<string, string[]> = {
86+
"aws.com": ["amazon.com", "aws.amazon.com"],
87+
"amazon.com": ["aws.com", "aws.amazon.com"],
88+
};
89+
90+
/**
91+
* Extracts all allowed email addresses (including `coordinatorEmails` and comma-separated lists)
92+
* from one or more Sanity booking / sponsorLead documents.
93+
*/
94+
export function extractPortalAllowedEmails(
95+
...docs: Array<Record<string, any> | null | undefined>
96+
): string[] {
97+
const out = new Set<string>();
98+
for (const doc of docs) {
99+
if (!doc || typeof doc !== "object") continue;
100+
const rawStrings = [
101+
doc.guestEmail,
102+
doc.contactEmail,
103+
doc.coordinatorEmail,
104+
...(Array.isArray(doc.coordinatorEmails) ? doc.coordinatorEmails : []),
105+
...(Array.isArray(doc.allowedEmails) ? doc.allowedEmails : []),
106+
];
107+
for (const item of rawStrings) {
108+
if (typeof item !== "string") continue;
109+
for (const part of item.split(/[,;\s]+/)) {
110+
const clean = part.trim().toLowerCase();
111+
if (clean && clean.includes("@")) {
112+
out.add(clean);
113+
}
114+
}
115+
}
116+
}
117+
return Array.from(out);
118+
}
119+
85120
/**
86121
* Checks whether an authenticated Better-Auth user's email is authorized to unlock
87-
* a specific Studio Portal (exact guest/sponsor email, same non-public corporate domain,
88-
* or CodingCat.dev host admin).
122+
* a specific Studio Portal (exact guest/sponsor email, coordinator email, same non-public
123+
* corporate domain, or CodingCat.dev host admin).
89124
*/
90125
export function isAllowedPortalEmail(
91126
candidateEmail: string,
@@ -99,18 +134,19 @@ export function isAllowedPortalEmail(
99134
const candidateDomain = cleanCandidate.split("@")[1] || "";
100135

101136
for (const rawAllowed of allowedEmails) {
102-
const cleanAllowed = (rawAllowed || "").trim().toLowerCase();
103-
if (!cleanAllowed || !cleanAllowed.includes("@")) continue;
104-
if (cleanCandidate === cleanAllowed) return true;
105-
106-
// Allow teammates from the same corporate email domain (e.g. @mastra.ai, @fusionauth.io)
107-
const allowedDomain = cleanAllowed.split("@")[1] || "";
108-
if (
109-
allowedDomain &&
110-
!PUBLIC_EMAIL_DOMAINS.has(allowedDomain) &&
111-
candidateDomain === allowedDomain
112-
) {
113-
return true;
137+
if (!rawAllowed) continue;
138+
for (const part of String(rawAllowed).split(/[,;\s]+/)) {
139+
const cleanAllowed = part.trim().toLowerCase();
140+
if (!cleanAllowed || !cleanAllowed.includes("@")) continue;
141+
if (cleanCandidate === cleanAllowed) return true;
142+
143+
// Allow teammates from the same corporate email domain (e.g. @mastra.ai, @fusionauth.io, @aws.com <-> @amazon.com)
144+
const allowedDomain = cleanAllowed.split("@")[1] || "";
145+
if (allowedDomain && !PUBLIC_EMAIL_DOMAINS.has(allowedDomain)) {
146+
if (candidateDomain === allowedDomain) return true;
147+
const aliases = CORPORATE_DOMAIN_ALIASES[allowedDomain] || [];
148+
if (aliases.includes(candidateDomain)) return true;
149+
}
114150
}
115151
}
116152

‎apps/site/src/pages/api/auth/[...slug].ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { env } from "cloudflare:workers";
22
import type { APIRoute } from "astro";
33
import { ensureDefaultUserAndSession, getPortalAuth } from "@/lib/auth";
4-
import { isAllowedPortalEmail } from "@/lib/portal-security";
4+
import {
5+
extractPortalAllowedEmails,
6+
isAllowedPortalEmail,
7+
} from "@/lib/portal-security";
58
import { getSanityServerClient } from "@/lib/sanity/write-client";
69

710
export const prerender = false;
@@ -36,24 +39,24 @@ export const ALL: APIRoute = async ({ request, locals }) => {
3639
const candidateEmail = (cloned.email || "").trim().toLowerCase();
3740
const { client } = getSanityServerClient();
3841
const [leadDoc, bookingDoc] = await Promise.all([
39-
client.fetch<{ contactEmail?: string } | null>(
40-
`*[_type == "sponsorLead" && (portalToken == $token || legacyPortalToken == $token || _id == $privateLeadId || _id == $leadId)][0]{ contactEmail }`,
42+
client.fetch<Record<string, any> | null>(
43+
`*[_type == "sponsorLead" && (portalToken == $token || legacyPortalToken == $token || _id == $privateLeadId || _id == $leadId)][0]{ contactEmail, coordinatorEmail, coordinatorEmails, allowedEmails }`,
4144
{
4245
token: portalTokenHeader,
4346
privateLeadId: `private.sponsorLead-${portalTokenHeader}`,
4447
leadId: `sponsorLead-${portalTokenHeader}`,
4548
},
4649
),
47-
client.fetch<{ guestEmail?: string } | null>(
48-
`*[_type == "booking" && (portalToken == $token || legacyPortalToken == $token || _id == $privateBookingId)] | order(_createdAt desc)[0]{ guestEmail }`,
50+
client.fetch<Record<string, any> | null>(
51+
`*[_type == "booking" && (portalToken == $token || legacyPortalToken == $token || _id == $privateBookingId)] | order(_createdAt desc)[0]{ guestEmail, coordinatorEmail, coordinatorEmails, allowedEmails }`,
4952
{
5053
token: portalTokenHeader,
5154
privateBookingId: `private.booking-${portalTokenHeader}`,
5255
},
5356
),
5457
]);
5558

56-
const allowedEmails = [leadDoc?.contactEmail, bookingDoc?.guestEmail];
59+
const allowedEmails = extractPortalAllowedEmails(leadDoc, bookingDoc);
5760
if (!isAllowedPortalEmail(candidateEmail, allowedEmails)) {
5861
return new Response(
5962
JSON.stringify({

‎apps/site/src/pages/api/book/manage.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,39 @@ export const POST: APIRoute = async ({ request, url }) => {
192192
_id: normalizedId,
193193
} as any);
194194
created++;
195+
} else if (doc._id && doc._type === "guest") {
196+
const { avatarImageUrl, ...guestFields } = doc;
197+
let coverImage = guestFields.coverImage;
198+
if (!coverImage && typeof avatarImageUrl === "string" && avatarImageUrl.startsWith("http")) {
199+
try {
200+
const imgRes = await fetch(avatarImageUrl);
201+
if (imgRes.ok) {
202+
const contentType = imgRes.headers.get("content-type") || "image/jpeg";
203+
const arrayBuf = await imgRes.arrayBuffer();
204+
const asset = await client.assets.upload(
205+
"image",
206+
new Blob([arrayBuf], { type: contentType }),
207+
{ filename: `${String(doc._id).replace(/[^a-z0-9-_]/gi, "-")}.jpg` },
208+
);
209+
if (asset?._id) {
210+
coverImage = {
211+
_type: "image",
212+
asset: {
213+
_type: "reference",
214+
_ref: asset._id,
215+
},
216+
};
217+
}
218+
}
219+
} catch {
220+
// Ignore avatar upload error
221+
}
222+
}
223+
await client.createOrReplace({
224+
...guestFields,
225+
...(coverImage ? { coverImage } : {}),
226+
} as any);
227+
created++;
195228
}
196229
}
197230
for (const p of body.patches || []) {

‎apps/site/src/pages/api/portal/assets.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { APIRoute } from "astro";
2-
import { verifyPortalRequestAuth } from "@/lib/portal-security";
2+
import {
3+
extractPortalAllowedEmails,
4+
verifyPortalRequestAuth,
5+
} from "@/lib/portal-security";
36
import { getSanityServerClient } from "@/lib/sanity/write-client";
47

58
const CORS_HEADERS = {
@@ -70,6 +73,9 @@ export const GET: APIRoute = async ({ request, url }) => {
7073
legacyPortalToken,
7174
guestEmail,
7275
contactEmail,
76+
coordinatorEmail,
77+
coordinatorEmails,
78+
allowedEmails,
7379
sharedAssets
7480
}`,
7581
{ token },
@@ -90,7 +96,7 @@ export const GET: APIRoute = async ({ request, url }) => {
9096
request,
9197
url,
9298
token: canonicalToken,
93-
allowedEmails: [ownerDoc.guestEmail, ownerDoc.contactEmail],
99+
allowedEmails: extractPortalAllowedEmails(ownerDoc),
94100
});
95101
if (!auth.authorized) {
96102
return new Response(
@@ -266,6 +272,9 @@ export const POST: APIRoute = async ({ request, url }) => {
266272
guestName,
267273
guestEmail,
268274
contactEmail,
275+
coordinatorEmail,
276+
coordinatorEmails,
277+
allowedEmails,
269278
source,
270279
bookingSource
271280
}`,
@@ -295,7 +304,7 @@ export const POST: APIRoute = async ({ request, url }) => {
295304
request,
296305
url: url ?? new URL(request.url),
297306
token: resolvedToken,
298-
allowedEmails: targetDocs.flatMap((d) => [d.guestEmail, d.contactEmail]),
307+
allowedEmails: extractPortalAllowedEmails(...targetDocs),
299308
});
300309
if (!auth.authorized) {
301310
return new Response(

0 commit comments

Comments
 (0)