Persist event sign designs and harden invitation permissions
This commit is contained in:
@@ -18,7 +18,7 @@ const presets = {
|
|||||||
|
|
||||||
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
|
export function SignDesignControls({ design, onChange, onBusy }: { design: SignDesign; onChange: (patch: Partial<SignDesign>) => void; onBusy: (busy: boolean) => void }) {
|
||||||
const [busy, setBusy] = useState(false);
|
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);
|
const uploadId = useRef(0);
|
||||||
async function upload(file: File, field: "logoImage" | "backgroundImage") {
|
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) {
|
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>
|
<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. 3–48 inches per edge. Small signs should be scanned up close.</FieldDescription>
|
<FieldDescription>Portrait, square, or landscape. 3–48 inches per edge. Small signs should be scanned up close.</FieldDescription>
|
||||||
</> : null}
|
</> : 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>
|
<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>
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default async function GuestSignPage({ params }: { params: Promise<{ id:
|
|||||||
let event;
|
let event;
|
||||||
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
|
try { event = await caller.manager.event({ eventId: id }); } catch { notFound(); }
|
||||||
const current = effectiveEvent(event);
|
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)}
|
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} />;
|
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 Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { guestQrSvg, guestSignSvg, signPrintHtml, signPaper, SIGN_PAPERS, type SignDesign, type SignQr, type SignPaper } from "@/lib/guest-sign";
|
||||||
import { SignDesignControls } from "./design-controls";
|
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;
|
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 [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 [fontError, setFontError] = useState(false);
|
||||||
const [assetBusy, setAssetBusy] = 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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
void Promise.all(["funnel-display", "inter", "geologica"].map(async name => {
|
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"; }
|
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 svg = guestSignSvg(sizeError ? { ...design, paper: "letter" } : design, guestUrl, qr);
|
||||||
const fontReady = Boolean(design.fontData && design.interFontData && design.geologicaFontData);
|
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);
|
const localUrl = ["localhost", "127.0.0.1", "[::1]"].includes(new URL(guestUrl).hostname);
|
||||||
function downloadSvg(content: string, filename: string) {
|
function downloadSvg(content: string, filename: string) {
|
||||||
const url = URL.createObjectURL(new Blob([content], { type: "image/svg+xml;charset=utf-8" }));
|
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. Everyone’s photos. Customize, preview, and print.</p>
|
<p className="text-sm text-muted-foreground">A little sign. Everyone’s photos. Customize, preview, and print.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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 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 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>
|
<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>
|
||||||
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
<div className="grid items-start gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||||
<Card>
|
<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>
|
<CardContent>
|
||||||
|
<fieldset disabled={loadingDesign || loadError || saving} className="min-w-0">
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
|
<Field><FieldLabel htmlFor="sign-paper">Paper size</FieldLabel>
|
||||||
<Select value={design.paper} onValueChange={paper => setDesign(d => ({ ...d, paper: paper as SignPaper }))}>
|
<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 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>
|
<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>
|
</FieldGroup>
|
||||||
|
</fieldset>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">
|
<div className="min-w-0 rounded-xl border bg-muted p-4 sm:p-6 xl:sticky xl:top-24">
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
export const SIGN_PAPERS = {
|
import { SIGN_PAPERS, type SavedSign } from "@album/contracts";
|
||||||
letter: { label: "US Letter · 8.5 × 11 in", width: "8.5in", height: "11in", viewHeight: 1100 },
|
export { SIGN_PAPERS };
|
||||||
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;
|
|
||||||
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
|
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
|
||||||
export type SignDesign = { title: string; headline: string; message: string; paper: SignPaper; ink: "indigo" | "black";
|
export type SignDesign = SavedSign & {
|
||||||
customWidth?: number; customHeight?: number; unit?: "in" | "mm";
|
fontData?: string; interFontData?: string; geologicaFontData?: string;
|
||||||
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 SignQr = { path: string; size: number };
|
export type SignQr = { path: string; size: number };
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { managerRouter } from "./routers/manager";
|
|||||||
import { photosRouter } from "./routers/photos";
|
import { photosRouter } from "./routers/photos";
|
||||||
import { platformRouter } from "./routers/platform";
|
import { platformRouter } from "./routers/platform";
|
||||||
import { viewerRouter } from "./routers/viewer";
|
import { viewerRouter } from "./routers/viewer";
|
||||||
|
import { signsRouter } from "./routers/signs";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
health: publicProcedure.query(() => ({
|
health: publicProcedure.query(() => ({
|
||||||
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
service: "album-trpc",
|
service: "album-trpc",
|
||||||
})),
|
})),
|
||||||
viewer: viewerRouter,
|
viewer: viewerRouter,
|
||||||
|
signs: signsRouter,
|
||||||
event: eventRouter,
|
event: eventRouter,
|
||||||
banners: bannersRouter,
|
banners: bannersRouter,
|
||||||
guest: guestRouter,
|
guest: guestRouter,
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
loadGroupAccess,
|
loadGroupAccess,
|
||||||
|
loadEventAccess,
|
||||||
|
requireEventPermission,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
requireGroupPermission,
|
requireGroupPermission,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
import { GROUP_PERMISSIONS } from "@/server/permissions";
|
import { EVENT_PERMISSIONS, GROUP_PERMISSIONS, PLATFORM_PERMISSIONS } from "@/server/permissions";
|
||||||
import { getPlatformRole } from "@/server/roles";
|
import { getPlatformRole, hasPlatformPermission } from "@/server/roles";
|
||||||
import { resolveGroupQuota } from "@/server/entitlements";
|
import { resolveGroupQuota } from "@/server/entitlements";
|
||||||
import { countGroupOwners } from "@/server/membership";
|
import { countGroupOwners } from "@/server/membership";
|
||||||
import { writeAudit } from "@/server/audit";
|
import { writeAudit } from "@/server/audit";
|
||||||
@@ -33,6 +35,25 @@ import { publicAppOrigin } from "@/server/public-app-url";
|
|||||||
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
|
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
|
||||||
import { invites } from "@album/database";
|
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({
|
export const groupRouter = createTRPCRouter({
|
||||||
list: protectedProcedure.query(async ({ ctx }) => {
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return getDb()
|
return getDb()
|
||||||
@@ -222,6 +243,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||||
const token = newToken();
|
const token = newToken();
|
||||||
|
await validateInviteGrants(ctx.session.user.id, input);
|
||||||
await getDb().insert(invites).values({
|
await getDb().insert(invites).values({
|
||||||
kind: "email",
|
kind: "email",
|
||||||
email: input.email,
|
email: input.email,
|
||||||
@@ -266,6 +288,7 @@ export const groupRouter = createTRPCRouter({
|
|||||||
platformRole,
|
platformRole,
|
||||||
);
|
);
|
||||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||||
|
await validateInviteGrants(ctx.session.user.id, input);
|
||||||
const code = await createInviteCode({
|
const code = await createInviteCode({
|
||||||
reusable: input.reusable ?? false,
|
reusable: input.reusable ?? false,
|
||||||
maxUses: input.maxUses ?? 1,
|
maxUses: input.maxUses ?? 1,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ export function assertInviteRedeemable(invite: typeof invites.$inferSelect) {
|
|||||||
if (invite.status === "expired") {
|
if (invite.status === "expired") {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite 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" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||||
}
|
}
|
||||||
if (!invite.reusable && invite.status === "accepted") {
|
if (!invite.reusable && invite.status === "accepted") {
|
||||||
@@ -79,7 +79,13 @@ export async function redeemInviteForUser(
|
|||||||
input: { token: string; userId: string },
|
input: { token: string; userId: string },
|
||||||
db: Database = getDb(),
|
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) {
|
if (!invite) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||||
}
|
}
|
||||||
@@ -93,6 +99,9 @@ export async function redeemInviteForUser(
|
|||||||
if (!actor) {
|
if (!actor) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
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;
|
let groupId = invite.groupId;
|
||||||
if (
|
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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
export { SIGN_PAPERS, savedSignSchema, type SavedSign } from "./sign";
|
||||||
|
|
||||||
export const eventStatusSchema = z.enum(["draft", "published", "closed"]);
|
export const eventStatusSchema = z.enum(["draft", "published", "closed"]);
|
||||||
export const groupRoleSchema = z.enum(["owner", "member"]);
|
export const groupRoleSchema = z.enum(["owner", "member"]);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { savedSignSchema } from "./sign";
|
||||||
|
|
||||||
|
test("saved designs reject unsafe assets, invalid settings, and embedded runtime fonts", () => {
|
||||||
|
const design = { title: "Our event", headline: "Share your moments", message: "Photos welcome", paper: "card6x4", ink: "indigo" };
|
||||||
|
expect(savedSignSchema.safeParse(design).success).toBe(true);
|
||||||
|
for (const patch of [{ paper: "constructor" }, { title: "" }, { headline: "x".repeat(45) }, { customWidth: Infinity }, { background: "red" }, { backgroundImage: "https://example.com/photo.png" }, { logoImage: "data:image/svg+xml;base64,AA==" }, { fontData: "data:font/woff2;base64,AA==" }]) {
|
||||||
|
expect(savedSignSchema.safeParse({ ...design, ...patch }).success).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const image = z.string().max(12 * 1024 * 1024).regex(/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/).optional();
|
||||||
|
const papers: ["custom", ...(keyof typeof SIGN_PAPERS)[]] = ["custom", ...Object.keys(SIGN_PAPERS) as (keyof typeof SIGN_PAPERS)[]];
|
||||||
|
export const savedSignSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(120), headline: z.string().trim().min(1).max(44),
|
||||||
|
message: z.string().trim().min(1).max(120),
|
||||||
|
paper: z.enum(papers),
|
||||||
|
ink: z.enum(["indigo", "black"]), layout: z.enum(["photo", "typography", "compact"]).optional(),
|
||||||
|
customWidth: z.number().finite().positive().optional(), customHeight: z.number().finite().positive().optional(),
|
||||||
|
unit: z.enum(["in", "mm"]).optional(), background: z.string().regex(/^#[a-f0-9]{6}$/i).optional(),
|
||||||
|
accent: z.string().regex(/^#[a-f0-9]{6}$/i).optional(), font: z.enum(["funnel", "inter", "geologica"]).optional(),
|
||||||
|
decoration: z.enum(["arch", "frame", "minimal"]).optional(), showBrand: z.boolean().optional(),
|
||||||
|
backgroundImage: image, logoImage: image,
|
||||||
|
}).strict();
|
||||||
|
export type SavedSign = z.infer<typeof savedSignSchema>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE "event_signs" (
|
||||||
|
"event_id" uuid PRIMARY KEY REFERENCES "events"("id") ON DELETE CASCADE,
|
||||||
|
"revision" uuid NOT NULL,
|
||||||
|
"object_key" text NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
"when": 1788996000000,
|
"when": 1788996000000,
|
||||||
"tag": "0010_banner_crop",
|
"tag": "0010_banner_crop",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
},
|
||||||
|
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,13 @@ export const photoExports = pgTable("photo_exports", {
|
|||||||
...timestamps,
|
...timestamps,
|
||||||
}, (table) => [index("photo_exports_queue_idx").on(table.status, table.createdAt), uniqueIndex("photo_exports_active_idx").on(table.eventId,table.requestedBy).where(sql`${table.status} IN ('pending', 'processing')`), check("photo_exports_filter_check",sql`${table.filter} IN ('approved', 'all')`)]);
|
}, (table) => [index("photo_exports_queue_idx").on(table.status, table.createdAt), uniqueIndex("photo_exports_active_idx").on(table.eventId,table.requestedBy).where(sql`${table.status} IN ('pending', 'processing')`), check("photo_exports_filter_check",sql`${table.filter} IN ('approved', 'all')`)]);
|
||||||
|
|
||||||
|
export const eventSigns = pgTable("event_signs", {
|
||||||
|
eventId: uuid("event_id").primaryKey().references(() => events.id, { onDelete: "cascade" }),
|
||||||
|
revision: uuid("revision").notNull(),
|
||||||
|
objectKey: text("object_key").notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const eventBanners = pgTable("event_banners", {
|
export const eventBanners = pgTable("event_banners", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
|
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user