Persist event sign designs and harden invitation permissions

This commit is contained in:
2026-09-10 09:52:02 -04:00
parent 3a115f1161
commit 76ef573b3a
15 changed files with 289 additions and 32 deletions
@@ -18,7 +18,7 @@ const presets = {
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
const [busy, setBusy] = useState(false);
const [preset, setPreset] = useState("indigo");
const preset = Object.entries(presets).find(([, values]) => Object.entries(values).every(([key, value]) => key === "paper" || design[key as keyof SignDesign] === value))?.[0] ?? "";
const uploadId = useRef(0);
async function upload(file: File, field: "logoImage" | "backgroundImage") {
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) {
@@ -52,7 +52,7 @@ export function SignDesignControls({ design, onChange, onBusy }: { design: SignD
<div className="grid grid-cols-2 gap-3">{(["customWidth", "customHeight"] as const).map((key, i) => <Field key={key}><FieldLabel htmlFor={`sign-${key}`}>{i === 0 ? "Width" : "Height"}</FieldLabel><Input id={`sign-${key}`} type="number" step="0.01" min={design.unit === "mm" ? 76.2 : 3} max={design.unit === "mm" ? 1219.2 : 48} value={design[key] ?? ""} onChange={e => onChange({ [key]: e.target.value === "" ? 0 : Number(e.target.value) })} /></Field>)}</div>
<FieldDescription>Portrait, square, or landscape. 348 inches per edge. Small signs should be scanned up close.</FieldDescription>
</> : null}
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { setPreset(value); onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Indigo</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel htmlFor="sign-preset">Design preset</FieldLabel><Select value={preset} onValueChange={value => { onChange(presets[value as keyof typeof presets]); }}><SelectTrigger id="sign-preset" className="w-full"><SelectValue placeholder="Custom design" /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="linenCard">Card · Linen (no photo)</SelectItem><SelectItem value="indigo">Typography · Indigo</SelectItem><SelectItem value="linen">Photo · Linen</SelectItem><SelectItem value="forest">Photo · Forest</SelectItem><SelectItem value="mono">Typography · Paper</SelectItem></SelectGroup></SelectContent></Select></Field>
<Field><FieldLabel htmlFor="sign-font">Heading font</FieldLabel><Select value={design.font ?? "funnel"} onValueChange={font => onChange({ font: font as SignDesign["font"] })}><SelectTrigger id="sign-font" className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="funnel">Funnel Display</SelectItem><SelectItem value="geologica">Geologica</SelectItem><SelectItem value="inter">Inter</SelectItem></SelectGroup></SelectContent></Select></Field>
<FieldDescription>{design.layout === "compact" ? "The Linen text-and-QR design without a photo. Starts at 6 × 4 inches; choose any paper size above." : design.layout === "photo" ? "A large event photo with a compact QR section. Add a photo below to enable sign export." : "Bold typography, a full-color background, and a prominent QR. Your uploaded photo is kept for photo presets."}</FieldDescription>
<div className="grid grid-cols-2 gap-3">
@@ -10,6 +10,8 @@ export default async function GuestSignPage({ params }: { params: Promise<{ id:
let event;
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
const current = effectiveEvent(event);
const saved = await caller.signs.get({ eventId: id });
return <GuestSignStudio eventId={event.id} title={event.title} guestUrl={event.guestUrl} qr={signQr(event.guestUrl)}
savedUrl={saved?.url ?? null} savedRevision={saved?.revision ?? null} canSave={event.permissions.includes("settings.manage")}
warning={current.status === "draft" ? "This event is not public yet. Publish it before guests scan this sign." : !current.uploadEnabled ? "Submissions are currently closed. Enable uploads or check the event schedule before using this sign." : null} />;
}
@@ -2,7 +2,9 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon } from "lucide-react";
import { ArrowLeftIcon, DownloadIcon, PrinterIcon, QrCodeIcon, SaveIcon } from "lucide-react";
import { savedSignSchema } from "@album/contracts";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,12 +14,66 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
import { guestQrSvg, guestSignSvg, signPrintHtml, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign";
import { SignDesignControls } from "./design-controls";
export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
function serializedDesign(design: SignDesign) {
const { fontData, interFontData, geologicaFontData, ...saved } = design;
return JSON.stringify(saved);
}
export function GuestSignStudio({ eventId, title, guestUrl, qr, warning, savedUrl, savedRevision, canSave }: {
eventId: string; title: string; guestUrl: string; qr: SignQr; warning: string | null;
savedUrl: string | null; savedRevision: string | null; canSave: boolean;
}) {
const [design, setDesign] = useState<SignDesign>({ title, headline: "Share your favorite moments", message: "Help us see the day through your eyes. Add your photos to our shared album.", paper: "letter", ink: "indigo", font: "funnel", layout: "typography", background: "#4055b5", accent: "#ffffff", decoration: "minimal", showBrand: true, customWidth: 8.5, customHeight: 11, unit: "in" });
const [fontError, setFontError] = useState(false);
const [assetBusy, setAssetBusy] = useState(false);
const [loadingDesign, setLoadingDesign] = useState(Boolean(savedUrl));
const [loadError, setLoadError] = useState(false);
const [revision, setRevision] = useState(savedRevision);
const [snapshot, setSnapshot] = useState(() => serializedDesign(design));
const [saving, setSaving] = useState(false);
const prepare = api.signs.prepare.useMutation();
const saveMutation = api.signs.save.useMutation();
const dirty = serializedDesign(design) !== snapshot;
useEffect(() => {
if (!savedUrl) return;
let active = true;
void fetch(savedUrl).then(async response => {
if (!response.ok) throw new Error("Could not load saved design");
const loaded = savedSignSchema.parse(await response.json()) as SignDesign;
if (active) {
setDesign(d => ({ ...loaded, fontData: d.fontData, interFontData: d.interFontData, geologicaFontData: d.geologicaFontData }));
setSnapshot(serializedDesign(loaded));
setLoadingDesign(false);
}
}).catch(() => { if (active) { setLoadError(true); setLoadingDesign(false); } });
return () => { active = false; };
}, [savedUrl]);
useEffect(() => {
if (!dirty) return;
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; };
const navigation = (event: MouseEvent) => {
const link = event.target instanceof Element ? event.target.closest("a[href]") as HTMLAnchorElement | null : null;
if (!link || link.download || link.target === "_blank" || event.metaKey || event.ctrlKey || event.shiftKey || !/^https?:/.test(link.href)) return;
if (link.href !== window.location.href && !window.confirm("Leave without saving your sign changes?")) { event.preventDefault(); event.stopPropagation(); }
};
window.addEventListener("beforeunload", warn);
document.addEventListener("click", navigation, true);
return () => { window.removeEventListener("beforeunload", warn); document.removeEventListener("click", navigation, true); };
}, [dirty]);
async function saveDesign() {
const content = serializedDesign(design);
const body = new Blob([content], { type: "application/json" });
if (body.size > 20 * 1024 * 1024) { toast.error("Design is too large. Use a smaller photo or logo."); return; }
setSaving(true);
try {
const upload = await prepare.mutateAsync({ eventId });
const response = await fetch(upload.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body });
if (!response.ok) throw new Error("Design upload failed. Please try again.");
const result = await saveMutation.mutateAsync({ eventId, uploadId: upload.uploadId, revision });
setRevision(result.revision); setSnapshot(content); toast.success("Design saved to this event");
} catch (error) { toast.error(error instanceof Error ? error.message : "Could not save design"); }
finally { setSaving(false); }
}
useEffect(() => {
let active = true;
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
@@ -40,7 +96,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
try { paper = signPaper(design); } catch (error) { sizeError = error instanceof Error ? error.message : "Invalid paper size"; }
const svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr);
const fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData);
const valid = Boolean(design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
const valid = Boolean(!loadingDesign && !loadError && design.title.trim() && design.headline.trim() && design.message.trim() && !sizeError && fontReady && !assetBusy && (design.layout !== "photo" || design.backgroundImage));
const localUrl = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(guestUrl).hostname);
function downloadSvg(content: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" }));
@@ -81,6 +137,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
<p className="text-sm text-muted-foreground">A little sign. Everyones photos. Customize, preview, and print.</p>
</div>
<div className="flex flex-wrap gap-2">
{canSave ? <Button type="button" variant="outline" disabled={!valid || (!dirty && Boolean(revision)) || loadingDesign || loadError || saving} onClick={() => void saveDesign()}><SaveIcon data-icon="inline-start" />{saving ? "Saving…" : "Save design"}</Button> : null}
<Button type="button" variant="outline" onClick={() => downloadSvg(guestQrSvg(qr), `manyangles-${eventId}-qr.svg`)}><QrCodeIcon data-icon="inline-start" />Download QR SVG</Button>
<Button variant="outline" disabled={!valid} onClick={() => downloadSvg(svg, `manyangles-${eventId}-guest-sign-${design.paper}.svg`)}><DownloadIcon data-icon="inline-start" />Download sign SVG</Button>
<Button disabled={!valid || printing} onClick={print}><PrinterIcon data-icon="inline-start" />{printing ? "Preparing…" : "Print / Save PDF"}</Button>
@@ -88,8 +145,9 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
</div>
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<CardHeader><CardTitle>Make it yours</CardTitle><CardDescription>Edits update the preview only, not your event. Download your sign before leaving.</CardDescription></CardHeader>
<CardHeader><CardTitle>Make it yours</CardTitle><CardDescription role="status">{loadingDesign ? "Loading saved design…" : loadError ? "Could not load your saved design. Reload this page before editing." : saving ? "Saving design…" : dirty ? "Unsaved changes" : revision ? "Saved to this event" : "Choose a preset to get started."} {!canSave ? "You can preview and download; an event manager must save changes." : "Your photo, logo, and settings are saved together."}</CardDescription></CardHeader>
<CardContent>
<fieldset disabled={loadingDesign || loadError || saving} className="min-w-0">
<FieldGroup>
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
<Select value={design.paper} onValueChange={paper => setDesign(d => ({ ...d, paper: paper as SignPaper }))}>
@@ -109,6 +167,7 @@ export function GuestSignStudio({ eventId, title, guestUrl, qr, warning }: {
<p role="status" className="text-sm text-muted-foreground">{localUrl ? "Local preview: this QR points to localhost and will not work on guests phones. Generate the final sign on your deployed site." : "Before printing a batch, scan one copy with your phone."}{warning ? ` ${warning}` : ""}</p>
<p className="text-xs text-muted-foreground">Print at 100% on the selected paper size, with browser headers and footers off. The sign includes a safe page margin and a clear border around the QR code.</p>
</FieldGroup>
</fieldset>
</CardContent>
</Card>
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">
+4 -21
View File
@@ -1,25 +1,8 @@
export const SIGN_PAPERS = {
letter: { label: "US Letter · 8.5 × 11 in", width: "8.5in", height: "11in", viewHeight: 1100 },
a4: { label: "A4 · 210 × 297 mm", width: "210mm", height: "297mm", viewHeight: 1202 },
letterLandscape: { label: "US Letter landscape · 11 × 8.5 in", width: "11in", height: "8.5in", viewHeight: 850 * 8.5 / 11 },
a4Landscape: { label: "A4 landscape · 297 × 210 mm", width: "297mm", height: "210mm", viewHeight: 850 * 210 / 297 },
a5: { label: "A5 · 148 × 210 mm", width: "148mm", height: "210mm", viewHeight: 850 * 210 / 148 },
a5Landscape: { label: "A5 landscape · 210 × 148 mm", width: "210mm", height: "148mm", viewHeight: 850 * 148 / 210 },
card3x5: { label: "Small card · 3 × 5 in", width: "3in", height: "5in", viewHeight: 850 * 5 / 3 },
card5x3: { label: "Small landscape card · 5 × 3 in", width: "5in", height: "3in", viewHeight: 510 },
card4x6: { label: "Postcard · 4 × 6 in", width: "4in", height: "6in", viewHeight: 1275 },
card6x4: { label: "Landscape postcard · 6 × 4 in", width: "6in", height: "4in", viewHeight: 850 * 4 / 6 },
card5x7: { label: "Table card · 5 × 7 in", width: "5in", height: "7in", viewHeight: 1190 },
card7x5: { label: "Landscape table card · 7 × 5 in", width: "7in", height: "5in", viewHeight: 850 * 5 / 7 },
square: { label: "Square card · 5 × 5 in", width: "5in", height: "5in", viewHeight: 850 },
} as const;
import { SIGN_PAPERS, type SavedSign } from "@album/contracts";
export { SIGN_PAPERS };
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
export type SignDesign = { title: string; headline: string; message: string; paper: SignPaper; ink: "indigo" | "black";
customWidth?: number; customHeight?: number; unit?: "in" | "mm";
layout?: "photo" | "typography" | "compact";
background?: string; accent?: string; font?: "funnel" | "geologica" | "inter";
decoration?: "arch" | "frame" | "minimal"; backgroundImage?: string; logoImage?: string;
showBrand?: boolean; fontData?: string; interFontData?: string; geologicaFontData?: string;
export type SignDesign = SavedSign & {
fontData?: string; interFontData?: string; geologicaFontData?: string;
};
export type SignQr = { path: string; size: number };
+2
View File
@@ -8,6 +8,7 @@ import { managerRouter } from "./routers/manager";
import { photosRouter } from "./routers/photos";
import { platformRouter } from "./routers/platform";
import { viewerRouter } from "./routers/viewer";
import { signsRouter } from "./routers/signs";
export const appRouter = createTRPCRouter({
health: publicProcedure.query(() => ({
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
service: "album-trpc",
})),
viewer: viewerRouter,
signs: signsRouter,
event: eventRouter,
banners: bannersRouter,
guest: guestRouter,
+25 -2
View File
@@ -18,11 +18,13 @@ import { z } from "zod";
import {
createTRPCRouter,
loadGroupAccess,
loadEventAccess,
requireEventPermission,
protectedProcedure,
requireGroupPermission,
} from "../trpc";
import { GROUP_PERMISSIONS } from "@/server/permissions";
import { getPlatformRole } from "@/server/roles";
import { EVENT_PERMISSIONS, GROUP_PERMISSIONS, PLATFORM_PERMISSIONS } from "@/server/permissions";
import { getPlatformRole, hasPlatformPermission } from "@/server/roles";
import { resolveGroupQuota } from "@/server/entitlements";
import { countGroupOwners } from "@/server/membership";
import { writeAudit } from "@/server/audit";
@@ -33,6 +35,25 @@ import { publicAppOrigin } from "@/server/public-app-url";
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
import { invites } from "@album/database";
async function validateInviteGrants(userId: string, input: {
groupId?: string; eventId?: string; eventRole?: string;
grantUnlimitedEvents?: boolean; grantEventLimit?: number | null; grantComplimentary?: boolean;
}) {
const platformRole = await getPlatformRole(userId);
if ((input.grantUnlimitedEvents || input.grantEventLimit != null || input.grantComplimentary) &&
!hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Only platform administrators can grant event entitlements." });
}
if (input.eventId) {
const { event, access } = await loadEventAccess(userId, input.eventId, platformRole);
if (event.groupId !== input.groupId) throw new TRPCError({ code: "BAD_REQUEST", message: "Event must belong to this workspace." });
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
if (input.eventRole === "owner") requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
} else if (input.eventRole) {
throw new TRPCError({ code: "BAD_REQUEST", message: "An event is required for an event role." });
}
}
export const groupRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return getDb()
@@ -222,6 +243,7 @@ export const groupRouter = createTRPCRouter({
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
const token = newToken();
await validateInviteGrants(ctx.session.user.id, input);
await getDb().insert(invites).values({
kind: "email",
email: input.email,
@@ -266,6 +288,7 @@ export const groupRouter = createTRPCRouter({
platformRole,
);
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
await validateInviteGrants(ctx.session.user.id, input);
const code = await createInviteCode({
reusable: input.reusable ?? false,
maxUses: input.maxUses ?? 1,
+59
View File
@@ -0,0 +1,59 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { events, eventSigns, getDb } from "@album/database";
import { savedSignSchema } from "@album/contracts";
import { createPresignedGetUrl, createPresignedPutUrl, getObjectBuffer, headObject, putObject, deleteObject } from "@album/storage";
import { createTRPCRouter, protectedProcedure, loadEventAccess, requireEventPermission, EVENT_PERMISSIONS } from "../trpc";
import { getPlatformRole } from "@/server/roles";
import { signPaper, type SignDesign } from "@/lib/guest-sign";
import { writeAudit } from "@/server/audit";
const eventInput = z.object({ eventId: z.string().uuid() });
const pendingKey = (eventId: string, userId: string, uploadId: string) => `events/${eventId}/signs/pending/${userId}/${uploadId}.json`;
async function access(userId: string, eventId: string, write: boolean) {
const result = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
requireEventPermission(result.access.permissions, write ? EVENT_PERMISSIONS.SETTINGS_MANAGE : EVENT_PERMISSIONS.OVERVIEW_READ);
return result;
}
export const signsRouter = createTRPCRouter({
get: protectedProcedure.input(eventInput).query(async ({ ctx, input }) => {
await access(ctx.session.user.id, input.eventId, false);
const [saved] = await getDb().select().from(eventSigns).where(eq(eventSigns.eventId, input.eventId));
return saved ? { revision: saved.revision, url: await createPresignedGetUrl(saved.objectKey), updatedAt: saved.updatedAt } : null;
}),
prepare: protectedProcedure.input(eventInput).mutation(async ({ ctx, input }) => {
await access(ctx.session.user.id, input.eventId, true);
const uploadId = crypto.randomUUID();
return { uploadId, url: await createPresignedPutUrl({ key: pendingKey(input.eventId, ctx.session.user.id, uploadId), contentType: "application/json" }) };
}),
save: protectedProcedure.input(eventInput.extend({ uploadId: z.string().uuid(), revision: z.string().uuid().nullable() })).mutation(async ({ ctx, input }) => {
const { event } = await access(ctx.session.user.id, input.eventId, true);
const key = pendingKey(input.eventId, ctx.session.user.id, input.uploadId);
const metadata = await headObject(key);
if (!metadata?.ContentLength || metadata.ContentLength > 20 * 1024 * 1024) throw new TRPCError({ code: "BAD_REQUEST", message: "Design must be under 20 MB. Try a smaller photo." });
const bytes = await getObjectBuffer(key);
if (bytes.length > 20 * 1024 * 1024) throw new TRPCError({ code: "BAD_REQUEST", message: "Design is too large." });
let design;
try {
design = savedSignSchema.parse(JSON.parse(bytes.toString("utf8")));
signPaper(design as SignDesign);
} catch { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid design. Check the wording, images, and paper size." }); }
const revision = crypto.randomUUID();
const objectKey = `events/${input.eventId}/signs/saved/${revision}.json`;
// Publish a validated immutable copy, never the still-writable upload URL.
await putObject({ key: objectKey, body: Buffer.from(JSON.stringify(design)), contentType: "application/json" });
try {
await getDb().transaction(async tx => {
await tx.select({ id: events.id }).from(events).where(eq(events.id, input.eventId)).for("update");
const [current] = await tx.select().from(eventSigns).where(eq(eventSigns.eventId, input.eventId));
if ((current?.revision ?? null) !== input.revision) throw new TRPCError({ code: "CONFLICT", message: "Someone saved a newer design. Reload it before saving your changes." });
await tx.insert(eventSigns).values({ eventId: input.eventId, revision, objectKey }).onConflictDoUpdate({ target: eventSigns.eventId, set: { revision, objectKey, updatedAt: new Date() } });
await writeAudit({ eventId: input.eventId, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "sign.save", subjectType: "event", subjectId: input.eventId }, tx as unknown as ReturnType<typeof getDb>);
});
} catch (error) { await deleteObject(objectKey).catch(() => {}); throw error; }
await deleteObject(key).catch(() => {});
return { revision };
}),
});
+11 -2
View File
@@ -58,7 +58,7 @@ export function assertInviteRedeemable(invite: typeof invites.$inferSelect) {
if (invite.status === "expired") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
}
if (invite.expiresAt && invite.expiresAt < new Date()) {
if (invite.expiresAt && invite.expiresAt <= new Date()) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
}
if (!invite.reusable && invite.status === "accepted") {
@@ -79,7 +79,13 @@ export async function redeemInviteForUser(
input: { token: string; userId: string },
db: Database = getDb(),
) {
const invite = await findInviteByToken(input.token, db);
return db.transaction(tx => redeemLockedInvite(input, tx as unknown as Database));
}
async function redeemLockedInvite(input: { token: string; userId: string }, db: Database) {
// Serialize redemptions and keep membership grants and usage accounting atomic.
const [invite] = await db.select().from(invites)
.where(eq(invites.tokenHash, hashToken(input.token))).limit(1).for("update");
if (!invite) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
}
@@ -93,6 +99,9 @@ export async function redeemInviteForUser(
if (!actor) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (invite.kind === "email" && (!invite.email || invite.email.trim().toLowerCase() !== actor.email.trim().toLowerCase())) {
throw new TRPCError({ code: "FORBIDDEN", message: "Sign in with the email address this invitation was sent to." });
}
let groupId = invite.groupId;
if (
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test";
import sharp from "sharp";
import { and, eq, inArray } from "drizzle-orm";
import { getDb, user, groups, events, eventMemberships, groupMemberships, invites, eventSigns, auditEvents } from "@album/database";
import { deletePrefix } from "@album/storage";
import { hashToken } from "./tokens";
import { redeemInviteForUser } from "./invites";
import { signsRouter } from "./api/routers/signs";
import { groupRouter } from "./api/routers/group";
import type { TrpcContext } from "./api/trpc";
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => {
for (const key of ["DATABASE_URL", "S3_ENDPOINT"]) if (!["localhost", "127.0.0.1"].includes(new URL(process.env[key]!).hostname)) throw new Error("Local services required");
const db = getDb();
const id = crypto.randomUUID();
const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Polish test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning();
const [group] = await db.insert(groups).values({ name: "Polish test", slug: id, createdByUserId: people[0]!.id }).returning();
const [event] = await db.insert(events).values({ groupId: group!.id, title: "Polish test", slug: id }).returning();
const ctx = (n: number): TrpcContext => ({ session: { user: people[n]!, session: {} } as TrpcContext["session"], cookies: new Map(), activeGroupId: null, requestOrigin: "http://localhost:3000", clientIdentifier: id, guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {} });
try {
await db.insert(groupMemberships).values({ groupId: group!.id, userId: people[0]!.id, role: "owner" });
await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" });
const token = crypto.randomUUID();
await db.insert(invites).values({ kind: "email", email: people[0]!.email.toUpperCase(), tokenHash: hashToken(token), groupId: group!.id, maxUses: 1 });
await expect(redeemInviteForUser({ token, userId: people[1]!.id })).rejects.toThrow("email address");
const attempts = await Promise.allSettled([redeemInviteForUser({ token, userId: people[0]!.id }), redeemInviteForUser({ token, userId: people[0]!.id })]);
expect(attempts.filter(r => r.status === "fulfilled")).toHaveLength(1);
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
expect(used!.usedCount).toBe(1);
const workspace = groupRouter.createCaller(ctx(0));
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
const signs = signsRouter.createCaller(ctx(0));
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
expect(await signs.get({ eventId: event!.id })).toBeNull();
const image = `data:image/png;base64,${(await sharp({ create: { width: 8, height: 8, channels: 3, background: "#4055b5" } }).png().toBuffer()).toString("base64")}`;
const design = { title: "Saved test", headline: "Share your moments", message: "Photos welcome", paper: "card6x4", ink: "indigo", layout: "compact", background: "#f1eadf", accent: "#473a30", backgroundImage: image, logoImage: image };
const upload = async () => {
const prepared = await signs.prepare({ eventId: event!.id });
const response = await fetch(prepared.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(design) });
expect(response.ok).toBe(true);
return prepared.uploadId;
};
const first = await signs.save({ eventId: event!.id, uploadId: await upload(), revision: null });
const saved = await signs.get({ eventId: event!.id });
expect(saved!.revision).toBe(first.revision);
expect(await (await fetch(saved!.url)).json()).toEqual(design);
await expect(signs.save({ eventId: event!.id, uploadId: await upload(), revision: null })).rejects.toThrow("newer design");
expect((await signs.get({ eventId: event!.id }))!.revision).toBe(first.revision);
const second = await signs.save({ eventId: event!.id, uploadId: await upload(), revision: first.revision });
expect(second.revision).not.toBe(first.revision);
await db.update(eventMemberships).set({ role: "viewer" }).where(and(eq(eventMemberships.eventId, event!.id), eq(eventMemberships.userId, people[0]!.id)));
await db.delete(groupMemberships).where(eq(groupMemberships.groupId, group!.id));
await expect(signs.prepare({ eventId: event!.id })).rejects.toThrow();
} finally {
await deletePrefix(`events/${event!.id}/signs/`);
await db.delete(eventSigns).where(eq(eventSigns.eventId, event!.id));
await db.delete(invites).where(eq(invites.groupId, group!.id));
await db.delete(auditEvents).where(eq(auditEvents.groupId, group!.id));
await db.delete(events).where(eq(events.id, event!.id));
await db.delete(groups).where(eq(groups.id, group!.id));
await db.delete(user).where(inArray(user.id, people.map(p => p.id)));
}
}, 30000);