Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
+6 -2
View File
@@ -10,11 +10,15 @@ AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET= AUTHENTIK_CLIENT_SECRET=
EMAIL_PROVIDER=mailpit EMAIL_PROVIDER=mailpit
EMAIL_FROM=Album <photos@album.test> EMAIL_FROM=Manyangles <photos@manyangles.test>
SMTP_HOST=127.0.0.1 SMTP_HOST=127.0.0.1
SMTP_PORT=1027 SMTP_PORT=1027
RESEND_API_KEY= RESEND_API_KEY=
RESEND_FROM=Album <photos@album.test> # Signing secret for POST /api/webhooks/resend (not the API key).
RESEND_WEBHOOK_SECRET=
# Production: set EMAIL_PROVIDER=resend and EMAIL_FROM to a verified Resend domain.
# Run the worker with the same email configuration. Never use production for tests.
RESEND_FROM=Manyangles <photos@manyangles.test>
S3_ENDPOINT=http://127.0.0.1:3900 S3_ENDPOINT=http://127.0.0.1:3900
S3_PUBLIC_ENDPOINT=http://127.0.0.1:3900 S3_PUBLIC_ENDPOINT=http://127.0.0.1:3900
+9 -32
View File
@@ -1,39 +1,16 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers"; import { createServerCaller } from "@/trpc/server";
import Link from "next/link"; import { BackendShell } from "@/components/backend-shell";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { Button } from "@/components/ui/button";
import { DashboardTabBar } from "@/components/dashboard-tab-bar"; export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const caller = await createServerCaller();
export default async function AdminLayout({ const viewer = await caller.viewer.me();
children, if (!viewer.session) redirect("/sign-in?callbackURL=/admin");
}: { if (!viewer.platformRole) redirect("/dashboard");
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in?callbackURL=/admin");
const role = await getPlatformRole(session.user.id);
if (!role) redirect("/dashboard");
return ( return (
<> <BackendShell area="platform" groups={viewer.groups} activeGroupId={viewer.activeGroupId} showAdmin>
<main className="page-pad mx-auto w-full max-w-6xl py-6 pb-24 sm:py-10 sm:pb-10">
<div className="mb-6 hidden items-center gap-1 sm:flex">
<Button asChild variant="ghost" size="sm">
<Link href="/admin">Overview</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/admin/settings">Settings</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">Dashboard</Link>
</Button>
</div>
<div className="reveal">{children}</div> <div className="reveal">{children}</div>
</main> </BackendShell>
<DashboardTabBar showAdmin area="admin" />
</>
); );
} }
+5 -3
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { NativeSelect } from "@/components/ui/native-select";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
@@ -78,8 +80,8 @@ export function PlatformCodes({
) : null} ) : null}
{groups.length > 0 ? ( {groups.length > 0 ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <NativeSelect
className="rounded-md border bg-background px-2 py-1.5 text-sm"
value={groupId} value={groupId}
onChange={(event) => setGroupId(event.target.value)} onChange={(event) => setGroupId(event.target.value)}
> >
@@ -88,7 +90,7 @@ export function PlatformCodes({
{group.name} {group.name}
</option> </option>
))} ))}
</select> </NativeSelect>
<Button <Button
variant="secondary" variant="secondary"
disabled={!groupId} disabled={!groupId}
+13 -9
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PlatformRole } from "@album/contracts"; import type { PlatformRole } from "@album/contracts";
@@ -55,25 +57,27 @@ export function PlatformUsers() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{row.platformRole ? ( {row.platformRole ? (
<Badge variant="secondary">{row.platformRole}</Badge> <Badge variant="secondary" className="capitalize">{row.platformRole.replaceAll("_", " ")}</Badge>
) : null} ) : null}
<select <Select
className="rounded-md border bg-background px-2 py-1.5 text-sm"
value={row.platformRole ?? "none"} value={row.platformRole ?? "none"}
onChange={(event) => { onValueChange={(selected) => {
const value = event.target.value as PlatformRole | "none"; const value = selected as PlatformRole | "none";
setRole.mutate({ setRole.mutate({
userId: row.id, userId: row.id,
role: value === "none" ? null : value, role: value === "none" ? null : value,
}); });
}} }}
> >
<SelectTrigger aria-label={`Platform role for ${row.name}`}><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
{roles.map((role) => ( {roles.map((role) => (
<option key={role} value={role}> <SelectItem key={role} value={role} className="capitalize">
{role} {role.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase())}
</option> </SelectItem>
))} ))}
</select> </SelectGroup></SelectContent>
</Select>
</div> </div>
</li> </li>
))} ))}
@@ -1,5 +1,7 @@
"use client"; "use client";
import { NativeSelect } from "@/components/ui/native-select";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { EventCreatePolicy } from "@album/contracts"; import type { EventCreatePolicy } from "@album/contracts";
@@ -61,9 +63,9 @@ export function DeploymentSettingsForm({
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="policy">Event creation</FieldLabel> <FieldLabel htmlFor="policy">Event creation</FieldLabel>
<select <NativeSelect
id="policy" id="policy"
className="rounded-md border bg-background px-2 py-2"
value={policy} value={policy}
onChange={(event) => onChange={(event) =>
setPolicy(event.target.value as EventCreatePolicy) setPolicy(event.target.value as EventCreatePolicy)
@@ -72,7 +74,7 @@ export function DeploymentSettingsForm({
<option value="open">Open (quota still applies)</option> <option value="open">Open (quota still applies)</option>
<option value="invite">Invite code required</option> <option value="invite">Invite code required</option>
<option value="admin_only">Administrators only</option> <option value="admin_only">Administrators only</option>
</select> </NativeSelect>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="limit">Default event limit for new groups</FieldLabel> <FieldLabel htmlFor="limit">Default event limit for new groups</FieldLabel>
@@ -0,0 +1,36 @@
import { recordEmailWebhook, verifyEmailWebhook } from "@album/email/webhooks";
export const runtime = "nodejs";
export async function POST(request: Request) {
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (!secret) return new Response("Webhook not configured", { status: 503 });
// Read a bounded raw body: signature verification must precede parsing/storage.
const reader = request.body?.getReader();
if (!reader) return new Response("Missing body", { status: 400 });
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 256 * 1024) {
await reader.cancel();
return new Response("Payload too large", { status: 413 });
}
chunks.push(value);
}
let event;
try {
event = verifyEmailWebhook(Buffer.concat(chunks).toString("utf8"), request.headers, secret);
} catch {
return new Response("Invalid signature or event", { status: 400 });
}
try {
if (event) await recordEmailWebhook(event);
return new Response("OK");
} catch {
// Non-2xx requests are retried by Resend. Never log callback payloads.
return new Response("Temporarily unavailable", { status: 503 });
}
}
@@ -0,0 +1,23 @@
"use client";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
export function EmailHistory({ eventId }: { eventId: string }) {
const history = api.manager.emailHistory.useQuery({ eventId }, { refetchInterval: 5000 });
const retry = api.manager.retryEmail.useMutation({ onSuccess: () => { toast.success("Retry queued with the original idempotency key"); void history.refetch(); }, onError: (error) => toast.error(error.message) });
return <section className="flex flex-col gap-3" aria-label="Email delivery history">
<h3 className="text-lg font-semibold">Email delivery</h3>
<p className="text-xs text-muted-foreground">Sent means accepted by the provider. Delivered means accepted by the recipients mail server, not guaranteed inbox placement. Mailpit does not report delivery outcomes.</p>
{history.isError ? <p role="alert">Could not load deliveries.</p> : history.isLoading ? <p>Loading deliveries</p> : !history.data?.length ? <p className="text-muted-foreground">No emails queued yet.</p> : null}
{history.data?.map((row) => <div key={row.id} className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-2"><span>{row.recipient}</span><Badge variant="secondary">{row.status.charAt(0).toUpperCase() + row.status.slice(1)}</Badge></div>
<p className="text-xs text-muted-foreground">{row.provider} · {row.attempts} attempts · {new Date(row.updatedAt).toLocaleString()}</p>
{row.outcome ? <div><Badge variant={["bounced", "complained", "failed"].includes(row.outcome) ? "destructive" : "outline"}>{row.outcome === "delivery_delayed" ? "Delivery delayed" : row.outcome.charAt(0).toUpperCase() + row.outcome.slice(1)}</Badge></div> : null}
{row.providerId ? <p className="break-all font-mono text-xs">Provider reference: {row.providerId}</p> : null}
{row.lastError ? <p className="text-sm">{row.lastError}</p> : null}
{row.status === "review" && row.provider === "resend" && row.firstAttemptAt && Date.now() - row.firstAttemptAt.getTime() < 23 * 3600000 ? <Button variant="outline" className="self-start" disabled={retry.isPending} onClick={() => retry.mutate({ eventId, deliveryId: row.id })}>Retry safely</Button> : null}
</div>)}
</section>;
}
@@ -1,6 +1,11 @@
"use client"; "use client";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { useState } from "react";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { import {
Card, Card,
CardContent, CardContent,
@@ -10,7 +15,9 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
export function EventAudit({ eventId }: { eventId: string }) { export function EventAudit({ eventId }: { eventId: string }) {
const audit = api.manager.audit.useQuery({ eventId }); const [page, setPage] = useState(0);
const [category, setCategory] = useState<"all" | "event" | "photo" | "note" | "guest" | "gallery" | "submission">("all");
const audit = api.manager.audit.useQuery({ eventId, page, category });
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -18,15 +25,44 @@ export function EventAudit({ eventId }: { eventId: string }) {
<CardDescription>Recent changes for this event.</CardDescription> <CardDescription>Recent changes for this event.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-4 flex flex-wrap items-center gap-2">
<Select value={category} onValueChange={(value) => { setCategory(value as typeof category); setPage(0); }}><SelectTrigger aria-label="Activity category"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>
{["all", "event", "photo", "note", "guest", "gallery", "submission"].map((value) => <SelectItem key={value} value={value}>{value.charAt(0).toUpperCase() + value.slice(1)}</SelectItem>)}
</SelectGroup></SelectContent></Select>
<Button variant="outline" disabled={!page || audit.isFetching} onClick={() => setPage(page - 1)}>Newer</Button>
<Button variant="outline" disabled={audit.isFetching || audit.data?.length !== 50} onClick={() => setPage(page + 1)}>Older</Button>
</div>
{audit.isLoading ? <p role="status">Loading activity</p> : null}
{audit.isError ? <p role="alert">Could not load activity.</p> : null}
{audit.data?.length === 0 ? <p className="text-sm text-muted-foreground">No activity recorded yet.</p> : null}
<ul className="flex flex-col gap-2 text-sm"> <ul className="flex flex-col gap-2 text-sm">
{(audit.data ?? []).map((row) => ( {(audit.data ?? []).map((row) => (
<li key={row.id} className="flex justify-between gap-3"> <li key={row.id} className="flex flex-col gap-2 rounded-lg border p-4">
<span> <div className="flex flex-wrap justify-between gap-2">
{row.action} · {row.subjectType} <p className="font-medium">{row.actorName ?? String(row.metadata.actorName ?? (row.actorUserId ? "Deleted account" : "System"))} · {row.action.replaceAll(".", " ")}</p>
</span> <time dateTime={new Date(row.createdAt).toISOString()} className="text-muted-foreground">{new Date(row.createdAt).toLocaleString()}</time>
<span className="text-muted-foreground"> </div>
{new Date(row.createdAt).toLocaleString()} <p>{row.subjectLabel}</p>
</span> <p className="break-all font-mono text-xs text-muted-foreground">{row.subjectType}: {row.subjectId}</p>
{row.assetUrl ? (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="self-start">
<ImageIcon data-icon="inline-start" aria-hidden="true" />View referenced photo
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Referenced photo</DialogTitle>
<DialogDescription className="break-all">{row.subjectLabel}</DialogDescription>
</DialogHeader>
<img src={row.assetUrl} alt="Photo referenced by this audit entry" className="max-h-[70svh] w-full rounded-lg object-contain" />
</DialogContent>
</Dialog>
) : null}
{Object.keys(row.metadata).length ? <dl className="grid gap-1 text-xs">
{Object.entries(row.metadata).filter(([key]) => key !== "actorName" && key !== "subjectLabel").map(([key, value]) => <div key={key} className="flex flex-wrap gap-2"><dt className="font-medium">{key}</dt><dd className="break-all">{String(value ?? "—")}</dd></div>)}
</dl> : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -1,6 +1,9 @@
"use client"; "use client";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { CheckIcon, EyeOffIcon } from "lucide-react";
import { toast } from "sonner";
import { import {
Card, Card,
CardContent, CardContent,
@@ -15,7 +18,12 @@ import {
EmptyTitle, EmptyTitle,
} from "@/components/ui/empty"; } from "@/components/ui/empty";
export function EventNotes({ eventId }: { eventId: string }) { export function EventNotes({ eventId, canManage }: { eventId: string; canManage: boolean }) {
const utils = api.useUtils();
const moderate = api.manager.moderateNote.useMutation({
onSuccess: () => { void utils.manager.notes.invalidate({ eventId }); },
onError: (error) => toast.error(error.message),
});
const notes = api.manager.notes.useQuery({ eventId }); const notes = api.manager.notes.useQuery({ eventId });
const withNotes = (notes.data ?? []).filter((guest) => guest.note); const withNotes = (notes.data ?? []).filter((guest) => guest.note);
@@ -24,7 +32,7 @@ export function EventNotes({ eventId }: { eventId: string }) {
<CardHeader> <CardHeader>
<CardTitle>Notes</CardTitle> <CardTitle>Notes</CardTitle>
<CardDescription> <CardDescription>
Messages guests left for the event people. Messages guests left for the event people. Approved notes appear publicly only when note publishing is enabled in Settings.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -33,7 +41,7 @@ export function EventNotes({ eventId }: { eventId: string }) {
<EmptyHeader> <EmptyHeader>
<EmptyTitle>No notes yet</EmptyTitle> <EmptyTitle>No notes yet</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Guests can leave a note when they upload. Guests can send notes with or without photos.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
@@ -45,6 +53,11 @@ export function EventNotes({ eventId }: { eventId: string }) {
{guest.displayName ?? "Anonymous"} {guest.displayName ?? "Anonymous"}
</p> </p>
<p className="mt-2 whitespace-pre-wrap text-sm">{guest.note}</p> <p className="mt-2 whitespace-pre-wrap text-sm">{guest.note}</p>
{canManage ? <Button type="button" variant="outline" className="mt-3" disabled={moderate.isPending}
onClick={() => moderate.mutate({ eventId, guestId: guest.id, approved: !guest.noteApproved })}>
{guest.noteApproved ? <EyeOffIcon data-icon="inline-start" /> : <CheckIcon data-icon="inline-start" />}
{guest.noteApproved ? "Make private" : "Approve for publishing"}
</Button> : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -1,5 +1,7 @@
"use client"; "use client";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { EventRole } from "@album/contracts"; import type { EventRole } from "@album/contracts";
@@ -7,6 +9,10 @@ import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { MailIcon } from "lucide-react";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { galleryIsPublic } from "@/lib/publishing";
import { EmailHistory } from "./email-history";
import { import {
Card, Card,
CardContent, CardContent,
@@ -16,6 +22,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"]; const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"];
const roleLabels: Record<EventRole, string> = { owner: "Owner", manager: "Manager", moderator: "Moderator", viewer: "Viewer" };
export function EventPeople({ export function EventPeople({
eventId, eventId,
@@ -28,6 +35,16 @@ export function EventPeople({
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const members = api.manager.members.useQuery({ eventId }); const members = api.manager.members.useQuery({ eventId });
const guests = api.manager.guests.useQuery({ eventId }, { refetchInterval: 5000 });
const [guestSearch, setGuestSearch] = useState("");
const [preview, setPreview] = useState(false);
const emailPreview = api.manager.emailPreview.useQuery({ eventId }, { enabled: preview });
const notify = api.manager.notifyGuests.useMutation({
onSuccess: async (result) => { toast.success(`${result.queued} emails queued`); setPreview(false); await utils.manager.guests.invalidate({ eventId }); },
onError: (error) => toast.error(error.message),
});
const previouslyContacted = new Set((guests.data ?? []).filter((guest) => guest.email && (guest.notifiedAt || guest.notificationClaimedAt)).map((guest) => guest.email!.toLowerCase()));
const eligible = new Set((guests.data ?? []).filter((guest) => guest.email && guest.notifyWhenReady && !previouslyContacted.has(guest.email.toLowerCase())).map((guest) => guest.email!.toLowerCase())).size;
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [role, setRole] = useState<EventRole>("manager"); const [role, setRole] = useState<EventRole>("manager");
const setMember = api.manager.setMember.useMutation({ const setMember = api.manager.setMember.useMutation({
@@ -55,10 +72,33 @@ export function EventPeople({
<CardHeader> <CardHeader>
<CardTitle>People</CardTitle> <CardTitle>People</CardTitle>
<CardDescription> <CardDescription>
The couple can both be owners. Managers run the day. Moderators review photos. Guests attend or receive gallery notifications. Accounts have permission to manage this event; attending does not grant account access.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<h3 className="text-lg font-semibold">Guests</h3>
<Input aria-label="Search guests" placeholder="Search guests by name or email" value={guestSearch} onChange={(e) => setGuestSearch(e.target.value)} />
{guests.isLoading ? <p>Loading guests</p> : guests.isError ? <p role="alert">Could not load guests.</p> : !guests.data?.length ? <p className="text-muted-foreground">No guests yet. Guests appear when they register their details or contribute.</p> : null}
<ul className="flex flex-col gap-3">
{(guests.data ?? []).filter((guest) => `${guest.displayName ?? ""} ${guest.email ?? ""}`.toLowerCase().includes(guestSearch.toLowerCase())).map((guest) => <li key={guest.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3">
<div><p className="font-medium">{guest.displayName ?? "Anonymous guest"}</p><p className="text-xs text-muted-foreground">{guest.email ?? "No email provided"}</p></div>
<Badge variant="secondary">{guest.notifiedAt ? `Emailed ${new Date(guest.notifiedAt).toLocaleDateString()}` : guest.notificationClaimedAt ? "Delivery pending / needs review" : guest.email && guest.notifyWhenReady ? "Opted into gallery email" : "Not subscribed"}</Badge>
</li>)}
</ul>
{event.data?.permissions.includes("gallery.release") ? <div className="flex flex-col gap-3">
<Button type="button" variant="outline" onClick={() => setPreview(!preview)}>
<MailIcon data-icon="inline-start" aria-hidden="true" />Preview completion email ({eligible})
</Button>
<p className="text-xs text-muted-foreground">Complete the event and make the gallery public first. Only opted-in guests who have not already been emailed are eligible; repeated addresses are deduplicated.</p>
{preview ? <div className="flex flex-col gap-3 rounded-lg border p-4">
<p className="font-medium">The gallery for {event.data.title} is ready</p>
{emailPreview.data ? <iframe title="Gallery email preview" sandbox="" srcDoc={emailPreview.data.html} className="h-[36rem] w-full rounded-lg border" /> : <p>{emailPreview.isError ? "Could not load the preview." : "Loading preview…"}</p>}
<p className="text-sm">Send to {eligible} opted-in email addresses.</p>
<Button type="button" disabled={notify.isPending || !eligible || effectiveEvent(event.data).status !== "closed" || !galleryIsPublic(event.data.galleryPolicy, event.data.galleryReleasedAt, event.data.galleryVisibleAt)} onClick={() => notify.mutate({ eventId })}><MailIcon data-icon="inline-start" />{notify.isPending ? "Queueing…" : "Queue completion emails"}</Button>
</div> : null}
</div> : null}
{event.data?.permissions.includes("gallery.release") ? <EmailHistory eventId={eventId} /> : null}
<h3 className="text-lg font-semibold">Accounts with access</h3>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{(members.data ?? []).map((member) => ( {(members.data ?? []).map((member) => (
<li key={member.id} className="flex items-center justify-between gap-3"> <li key={member.id} className="flex items-center justify-between gap-3">
@@ -67,7 +107,7 @@ export function EventPeople({
<p className="text-xs text-muted-foreground">{member.email}</p> <p className="text-xs text-muted-foreground">{member.email}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary">{member.role}</Badge> <Badge variant="secondary">{roleLabels[member.role]}</Badge>
{canManage ? ( {canManage ? (
<Button <Button
size="sm" size="sm"
@@ -103,19 +143,22 @@ export function EventPeople({
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
className="max-w-xs" className="max-w-xs"
/> />
<select <Select value={role} onValueChange={(value) => setRole(value as EventRole)}>
className="rounded-md border bg-background px-2 py-1.5 text-sm" <SelectTrigger aria-label="Account role" className="min-w-36">
value={role} <SelectValue />
onChange={(event) => setRole(event.target.value as EventRole)} </SelectTrigger>
> <SelectContent position="popper" align="start">
<SelectGroup>
{roles {roles
.filter((value) => value !== "owner" || canGrantOwner) .filter((value) => value !== "owner" || canGrantOwner)
.map((value) => ( .map((value) => (
<option key={value} value={value}> <SelectItem key={value} value={value}>
{value} {roleLabels[value]}
</option> </SelectItem>
))} ))}
</select> </SelectGroup>
</SelectContent>
</Select>
<Button type="submit" disabled={setMember.isPending}> <Button type="submit" disabled={setMember.isPending}>
Add existing user Add existing user
</Button> </Button>
@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
const dateFields = [
["startsAt", "Event starts"], ["endsAt", "Event ends"],
["publishAt", "Event becomes public"], ["submissionsOpenAt", "Submissions open"],
["submissionsCloseAt", "Submissions close"], ["galleryVisibleAt", "Gallery becomes visible"], ["notesVisibleAt", "Notes become visible"],
] as const;
type DateKey = typeof dateFields[number][0];
function localDate(value: Date | null) {
return value ? new Date(value.getTime() - value.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : "";
}
export function EventSchedule({ eventId }: { eventId: string }) {
const event = api.manager.event.useQuery({ eventId });
const [dates, setDates] = useState<Partial<Record<DateKey, string>>>({});
const router = useRouter();
const utils = api.useUtils();
async function refresh() { await utils.manager.event.invalidate({ eventId }); router.refresh(); }
const update = api.manager.updateEvent.useMutation({ onSuccess: async () => { setDates({}); toast.success("Schedule saved"); await refresh(); }, onError: (error) => toast.error(error.message) });
const complete = api.manager.completeEvent.useMutation({ onSuccess: async () => { toast.success("Event completed. You can notify guests from People."); await refresh(); }, onError: (error) => toast.error(error.message) });
return <Card>
<CardHeader><CardTitle>Schedule & completion</CardTitle><CardDescription>Times use your devices timezone and are stored with timezone information. Empty dates mean manual control. Visibility schedules respect never publicly and approval settings.</CardDescription></CardHeader>
<CardContent>
{event.isError ? <p role="alert">Could not load the schedule.</p> : !event.data ? <p>Loading schedule</p> : <form className="flex flex-col gap-4" onSubmit={(e) => {
e.preventDefault();
update.mutate({ eventId, ...Object.fromEntries(Object.entries(dates).map(([key, value]) => [key, value ? new Date(value) : null])) });
}}>
<FieldGroup className="grid gap-4 sm:grid-cols-2">
{dateFields.map(([key, label]) => <Field key={key}><FieldLabel htmlFor={`schedule-${key}`}>{label}</FieldLabel>
<Input id={`schedule-${key}`} type="datetime-local" value={dates[key] ?? localDate(event.data![key])} onChange={(e) => setDates({ ...dates, [key]: e.target.value })} />
</Field>)}
</FieldGroup>
<p className="text-sm text-muted-foreground">Setting a future public date hides the guest page until then. Gallery and note schedules never approve existing private content. Submission closing ends new contributions; it does not hide the gallery. Dates alone do not send email.</p>
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={update.isPending || !Object.keys(dates).length}>Save schedule</Button>
<Button type="button" variant="outline" disabled={complete.isPending || Boolean(event.data.completedAt)} onClick={() => complete.mutate({ eventId })}>{event.data.completedAt ? "Event completed" : "Complete event & close submissions"}</Button></div>
</form>}
</CardContent>
</Card>;
}
@@ -1,6 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CheckIcon, ImageIcon, WandSparklesIcon } from "lucide-react";
import { eventSlugSchema } from "@album/contracts";
import { LocationInput } from "@/components/location-input";
import { BannerUpload } from "@/components/banner-upload";
import { toast } from "sonner"; import { toast } from "sonner";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -9,6 +14,8 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { NativeSelect } from "@/components/ui/native-select";
import type { PublishingPolicy } from "@album/contracts";
import { import {
Card, Card,
CardContent, CardContent,
@@ -22,38 +29,103 @@ export function EventSettingsForm({
title, title,
slug, slug,
description, description,
location,
latitude,
longitude,
bannerPhotoId,
customBannerId,
status, status,
listed, listed,
uploadEnabled, uploadEnabled,
galleryReleased, galleryReleased,
notesPolicy,
galleryPolicy,
showPhotoStats,
showSubmitterStats,
showNoteStats,
}: { }: {
eventId: string; eventId: string;
title: string; title: string;
slug: string; slug: string;
description: string | null; description: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
bannerPhotoId: string | null;
customBannerId: string | null;
status: "draft" | "published" | "closed"; status: "draft" | "published" | "closed";
listed: boolean; listed: boolean;
uploadEnabled: boolean; uploadEnabled: boolean;
galleryReleased: boolean; galleryReleased: boolean;
notesPolicy: PublishingPolicy;
galleryPolicy: PublishingPolicy;
showPhotoStats: boolean;
showSubmitterStats: boolean;
showNoteStats: boolean;
}) { }) {
const [publishing, setPublishing] = useState({ notesPolicy, galleryPolicy, showPhotoStats, showSubmitterStats, showNoteStats });
const [formTitle, setFormTitle] = useState(title); const [formTitle, setFormTitle] = useState(title);
const [formSlug, setFormSlug] = useState(slug); const [formSlug, setFormSlug] = useState(slug);
const [checkedSlug, setCheckedSlug] = useState(slug);
const [generatingSlug, setGeneratingSlug] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setCheckedSlug(formSlug), 350);
return () => clearTimeout(timer);
}, [formSlug]);
const slugValid = eventSlugSchema.safeParse(formSlug).success;
const availability = api.manager.checkSlug.useQuery({ eventId, slug: checkedSlug }, {
enabled: eventSlugSchema.safeParse(checkedSlug).success && checkedSlug !== slug,
staleTime: 0,
retry: false,
});
const slugChecking = formSlug !== slug && (formSlug !== checkedSlug || availability.isFetching || availability.isPending);
const slugAvailable = formSlug === slug || (formSlug === checkedSlug && !availability.isError && availability.data?.available === true);
const canSaveSlug = slugValid && slugAvailable && !slugChecking && !generatingSlug;
const slugMessage = !slugValid
? "Use 364 lowercase letters or numbers, with single hyphens between words."
: generatingSlug ? "Generating an available link…"
: formSlug === slug ? "This is your current guest link."
: availability.isError && formSlug === checkedSlug ? "Could not check availability. Try again."
: slugChecking ? "Checking availability…"
: slugAvailable ? "Available — save to use this guest link."
: "This guest link is already taken.";
const [formDescription, setFormDescription] = useState(description ?? ""); const [formDescription, setFormDescription] = useState(description ?? "");
const [formLocation, setFormLocation] = useState(location ?? "");
const [coordinates, setCoordinates] = useState(
latitude !== null && longitude !== null ? { latitude, longitude } : null,
);
const [formBanner, setFormBanner] = useState<string | null>(bannerPhotoId);
const [formCustomBanner, setFormCustomBanner] = useState<string | null>(customBannerId);
const router = useRouter();
const photos = api.manager.photos.useQuery({ eventId });
const bannerPhotos = (photos.data ?? []).filter((photo) =>
photo.visibility === "public" && photo.processingStatus === "ready" && photo.displayUrl,
);
const [formUploadEnabled, setFormUploadEnabled] = useState(uploadEnabled); const [formUploadEnabled, setFormUploadEnabled] = useState(uploadEnabled);
const [formListed, setFormListed] = useState(listed); const [formListed, setFormListed] = useState(listed);
const utils = api.useUtils(); const utils = api.useUtils();
async function generateSlug() {
setGeneratingSlug(true);
try {
const result = await utils.manager.generateSlug.fetch({ eventId, title: formTitle });
setFormSlug(result.slug);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not generate a link");
} finally { setGeneratingSlug(false); }
}
const updateEvent = api.manager.updateEvent.useMutation({ const updateEvent = api.manager.updateEvent.useMutation({
onSuccess: async () => { onSuccess: async () => {
toast.success("Event saved"); toast.success("Event saved");
await utils.manager.event.invalidate({ eventId }); await utils.manager.event.invalidate({ eventId });
router.refresh();
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
const release = api.manager.releaseGallery.useMutation({ const release = api.manager.releaseGallery.useMutation({
onSuccess: async (result) => { onSuccess: async (result) => {
toast.success( toast.success(
result.notified result.queued
? `Gallery released. ${result.notified} guests emailed.` ? `Gallery released. ${result.queued} emails queued.`
: "Gallery released", : "Gallery released",
); );
await utils.manager.event.invalidate({ eventId }); await utils.manager.event.invalidate({ eventId });
@@ -66,11 +138,17 @@ export function EventSettingsForm({
uploadEnabled?: boolean; uploadEnabled?: boolean;
listed?: boolean; listed?: boolean;
}) { }) {
if (!canSaveSlug) { toast.error("Choose a valid, available guest link before saving."); return; }
updateEvent.mutate({ updateEvent.mutate({
...publishing,
eventId, eventId,
title: formTitle, title: formTitle,
slug: formSlug, slug: formSlug,
description: formDescription.trim() || null, description: formDescription.trim() || null,
location: formLocation.trim() || null,
locationCoordinates: coordinates,
bannerPhotoId: formBanner,
customBannerId: formCustomBanner,
uploadEnabled: next?.uploadEnabled ?? formUploadEnabled, uploadEnabled: next?.uploadEnabled ?? formUploadEnabled,
listed: next?.listed ?? formListed, listed: next?.listed ?? formListed,
status: next?.status, status: next?.status,
@@ -83,7 +161,7 @@ export function EventSettingsForm({
<CardTitle className="text-lg font-semibold tracking-tight">Event settings</CardTitle> <CardTitle className="text-lg font-semibold tracking-tight">Event settings</CardTitle>
<CardDescription> <CardDescription>
The guest link works once published. Listing puts it on the homepage. The guest link works once published. Listing puts it on the homepage.
Release the gallery when you are ready for the public to see photos. Choose how photos and notes are published, and which counts guests can see.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -95,6 +173,35 @@ export function EventSettingsForm({
}} }}
> >
<FieldGroup> <FieldGroup>
<Field>
<FieldLabel htmlFor="notes-policy">Publish notes</FieldLabel>
<NativeSelect id="notes-policy" value={publishing.notesPolicy} onChange={(e) => setPublishing({ ...publishing, notesPolicy: e.target.value as PublishingPolicy })}>
<option value="never">Never publicly (organizers only)</option>
<option value="approved">After organizer approval</option>
<option value="automatic">Automatically</option>
</NativeSelect>
<FieldDescription>Automatic publishing applies to new or edited notes. Existing private notes stay private until approved.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="gallery-policy">Publish gallery</FieldLabel>
<NativeSelect id="gallery-policy" value={publishing.galleryPolicy} onChange={(e) => setPublishing({ ...publishing, galleryPolicy: e.target.value as PublishingPolicy })}>
<option value="never">Never publicly (collect photos only)</option>
<option value="approved">Approved photos, after gallery release</option>
<option value="automatic">Automatically publish new photos</option>
</NativeSelect>
<FieldDescription>Automatic mode opens the gallery and publishes new uploads after processing. Existing pending or hidden photos stay private until approved.</FieldDescription>
</Field>
{([
["showPhotoStats", "Show images uploaded"],
["showSubmitterStats", "Show submitters"],
["showNoteStats", "Show notes sent"],
] as const).map(([key, label]) => (
<Field key={key} orientation="horizontal">
<FieldLabel htmlFor={key}>{label}</FieldLabel>
<Switch id={key} checked={publishing[key]} onCheckedChange={(checked) => setPublishing({ ...publishing, [key]: checked })} />
</Field>
))}
<FieldDescription>Public counts include private submissions, but never disclose their contents or identities.</FieldDescription>
<Field> <Field>
<FieldLabel htmlFor="title">Title</FieldLabel> <FieldLabel htmlFor="title">Title</FieldLabel>
<Input <Input
@@ -104,24 +211,78 @@ export function EventSettingsForm({
required required
/> />
</Field> </Field>
<Field> <Field data-invalid={!slugValid || (formSlug !== slug && !slugChecking && !slugAvailable && !availability.isError)}>
<FieldLabel htmlFor="slug">Guest link slug</FieldLabel> <FieldLabel htmlFor="slug">Guest link slug</FieldLabel>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input <Input
id="slug" id="slug"
value={formSlug} value={formSlug}
onChange={(event) => setFormSlug(event.target.value)} onChange={(event) => setFormSlug(event.target.value)}
disabled={generatingSlug}
minLength={3}
maxLength={64}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={!slugValid || (formSlug !== slug && !slugChecking && !slugAvailable && !availability.isError)}
aria-describedby="slug-feedback slug-preview"
required required
/> />
<FieldDescription>Guests visit /e/{formSlug || "slug"}</FieldDescription> <Button type="button" variant="outline" disabled={generatingSlug || !formTitle.trim() || formTitle.trim().length > 120} onClick={() => void generateSlug()}>
{generatingSlug ? <Spinner data-icon="inline-start" /> : <WandSparklesIcon data-icon="inline-start" aria-hidden="true" />}Generate
</Button>
</div>
<FieldDescription id="slug-preview" className="break-all">Guests visit /e/{formSlug || "your-event"}</FieldDescription>
<p id="slug-feedback" role="status" aria-live="polite" className="text-sm text-muted-foreground">{slugMessage}</p>
{availability.isError && formSlug === checkedSlug && formSlug !== slug ? <Button type="button" variant="outline" onClick={() => void availability.refetch()}>Retry check</Button> : null}
{formSlug !== slug ? <FieldDescription>Changing this will stop the old guest link from working. The link is not reserved until you save.</FieldDescription> : null}
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="description">Description</FieldLabel> <FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea <Textarea
id="description" id="description"
maxLength={2000}
placeholder="Tell guests what the event is about and what to expect."
value={formDescription} value={formDescription}
onChange={(event) => setFormDescription(event.target.value)} onChange={(event) => setFormDescription(event.target.value)}
/> />
</Field> </Field>
<Field>
<FieldLabel htmlFor="location">Location</FieldLabel>
<LocationInput eventId={eventId} value={formLocation}
latitude={coordinates?.latitude ?? null} longitude={coordinates?.longitude ?? null}
onChange={(value, match) => {
setFormLocation(value);
setCoordinates(match ? { latitude: match.latitude, longitude: match.longitude } : null);
}} />
<FieldDescription>Shown on the event's guest page.</FieldDescription>
</Field>
<Field>
<span id="banner-label" className="text-sm font-medium">Event Banner</span>
<FieldDescription>
Upload a dedicated banner, or choose an approved gallery photo. Dedicated banners appear as soon as the event is published; gallery photos follow gallery publishing settings.
</FieldDescription>
<BannerUpload eventId={eventId} selectedId={formCustomBanner}
onSelect={(id) => { setFormCustomBanner(id); setFormBanner(null); }} />
<div role="group" aria-labelledby="banner-label" className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button type="button" aria-pressed={formBanner === null && formCustomBanner === null} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}
className="flex aspect-[3/2] flex-col items-center justify-center gap-2 rounded-lg border-2 border-border bg-muted text-sm text-muted-foreground aria-pressed:border-primary aria-pressed:text-primary focus-visible:outline-2 focus-visible:outline-ring">
<ImageIcon aria-hidden="true" className="size-5" />No Banner
</button>
{bannerPhotos.map((photo, index) => (
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
</button>
))}
</div>
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos</p> : null}
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
</Field>
<Field orientation="horizontal"> <Field orientation="horizontal">
<FieldLabel htmlFor="uploads">Accept uploads</FieldLabel> <FieldLabel htmlFor="uploads">Accept uploads</FieldLabel>
<Switch <Switch
@@ -146,7 +307,7 @@ export function EventSettingsForm({
</Field> </Field>
</FieldGroup> </FieldGroup>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button type="submit" disabled={updateEvent.isPending}> <Button type="submit" disabled={updateEvent.isPending || !canSaveSlug}>
{updateEvent.isPending ? <Spinner data-icon="inline-start" /> : null} {updateEvent.isPending ? <Spinner data-icon="inline-start" /> : null}
Save Save
</Button> </Button>
@@ -182,12 +343,12 @@ export function EventSettingsForm({
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
disabled={release.isPending} disabled={release.isPending || galleryPolicy === "never"}
onClick={() => onClick={() =>
release.mutate({ eventId, notifyGuests: true }) release.mutate({ eventId, notifyGuests: true })
} }
> >
{galleryReleased ? "Notify guests again" : "Release gallery"} {galleryReleased ? "Notify remaining guests" : "Release gallery"}
</Button> </Button>
</div> </div>
</form> </form>
@@ -8,6 +8,8 @@ import {
DownloadIcon, DownloadIcon,
EyeOffIcon, EyeOffIcon,
LockIcon, LockIcon,
MoreHorizontalIcon,
ExpandIcon,
Trash2Icon, Trash2Icon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
@@ -30,6 +32,16 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup,
DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
const visibilityLabels = {
pending: "Needs Review", public: "Public", hidden: "Hidden",
private: "Private", rejected: "Rejected",
} as const;
const processingOrder = { const processingOrder = {
pending: 0, pending: 0,
processing: 1, processing: 1,
@@ -72,13 +84,14 @@ export function ModerationGrid({
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}); });
const [previewId, setPreviewId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null); const [pendingDelete, setPendingDelete] = useState<string | null>(null);
if (photos.isLoading) { if (photos.isLoading) {
return ( return (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="aspect-square w-full rounded-xl" /> <Skeleton key={index} className="aspect-[3/2] w-full rounded-xl" />
))} ))}
</div> </div>
); );
@@ -92,6 +105,15 @@ export function ModerationGrid({
); );
}); });
const preview = rows.find((photo) => photo.id === previewId);
const busy = moderate.isPending || moderateSubmission.isPending || remove.isPending;
if (photos.isError) {
return <Empty className="border"><EmptyHeader><EmptyTitle>Photos couldn't load</EmptyTitle>
<EmptyDescription>{photos.error.message}</EmptyDescription></EmptyHeader>
<Button variant="outline" onClick={() => photos.refetch()}>Try Again</Button></Empty>;
}
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<Empty className="border"> <Empty className="border">
@@ -107,125 +129,125 @@ export function ModerationGrid({
return ( return (
<> <>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((photo) => ( {rows.map((photo) => (
<article <article
key={photo.id} key={photo.id}
className="flex flex-col overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10" className="flex flex-col overflow-hidden rounded-xl bg-card ring-1 ring-foreground/10"
> >
<div className="aspect-square bg-muted"> <button
type="button"
onClick={() => setPreviewId(photo.id)}
disabled={!photo.thumbUrl && !photo.displayUrl}
aria-label={`Preview photo from ${photo.contributorName ?? "Anonymous"}`}
className="group relative aspect-[3/2] w-full overflow-hidden bg-muted text-left focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
>
{photo.thumbUrl || photo.displayUrl ? ( {photo.thumbUrl || photo.displayUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt={`Photo from ${photo.contributorName ?? "Anonymous"}`}
src={photo.thumbUrl ?? photo.displayUrl ?? ""} width={600} height={400} loading="lazy" className="size-full object-cover" />
alt=""
className="size-full object-cover"
/>
) : ( ) : (
<div className="flex size-full items-center justify-center text-xs text-muted-foreground"> <span className="flex size-full items-center justify-center text-sm text-muted-foreground">
{photo.processingStatus} {photo.processingStatus === "failed" ? "Processing Failed" : "Preparing Photo…"}
</div>
)}
</div>
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-2">
<Badge variant="secondary">{photo.visibility}</Badge>
<span className="truncate text-xs text-muted-foreground">
{photo.contributorName ?? "Anonymous"}
</span> </span>
)}
<Badge variant="secondary" className="absolute left-3 top-3 bg-card/95 text-card-foreground shadow-sm">
{photo.visibility === "public" ? <CheckIcon aria-hidden="true" /> : null}
{visibilityLabels[photo.visibility]}
</Badge>
{photo.thumbUrl || photo.displayUrl ? (
<span className="absolute bottom-3 right-3 rounded-md bg-card/95 p-2 text-card-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<ExpandIcon className="size-4" aria-hidden="true" />
</span>
) : null}
</button>
<div className="flex min-h-16 items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{photo.contributorName ?? "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{photo.processingStatus !== "ready" ? photo.processingStatus : photo.visibility === "public" ? "Visible in the guest gallery" : "Not in the guest gallery"}
</p>
</div> </div>
<div className="flex flex-wrap gap-1"> {canModerate && photo.processingStatus === "ready" && photo.visibility === "pending" ? (
<Button className="min-h-11" disabled={busy}
onClick={() => moderate.mutate({ photoId: photo.id, visibility: "public" })}>
<CheckIcon data-icon="inline-start" aria-hidden="true" />Approve
</Button>
) : null}
{canModerate || canDelete || photo.originalUrl ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="size-11 shrink-0"
aria-label={`Photo actions for ${photo.contributorName ?? "Anonymous"}`} disabled={busy}>
<MoreHorizontalIcon aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
{canModerate && photo.processingStatus === "ready" ? ( {canModerate && photo.processingStatus === "ready" ? (
<> <>
<Button <DropdownMenuLabel>Photo Visibility</DropdownMenuLabel>
size="sm" <DropdownMenuGroup>
disabled={moderate.isPending} {([
onClick={() => { value: "public", label: "Make Public", icon: CheckIcon },
moderate.mutate({ photoId: photo.id, visibility: "public" }) { value: "hidden", label: "Hide from Gallery", icon: EyeOffIcon },
} ...(canPrivate ? [{ value: "private" as const, label: "Keep Private", icon: LockIcon }] : []),
> { value: "rejected", label: "Reject Photo", icon: XIcon },
<CheckIcon data-icon="inline-start" /> ] as const).map(({ value, label, icon: Icon }) => (
Public <DropdownMenuItem key={value} className="min-h-11 px-3"
</Button> disabled={photo.visibility === value}
<Button onSelect={() => moderate.mutate({ photoId: photo.id, visibility: value })}>
size="sm" <Icon aria-hidden="true" />{label}
variant="outline" </DropdownMenuItem>
disabled={moderate.isPending} ))}
onClick={() => </DropdownMenuGroup>
moderate.mutate({ photoId: photo.id, visibility: "hidden" }) <DropdownMenuSeparator />
}
>
<EyeOffIcon data-icon="inline-start" />
Hide
</Button>
{canPrivate ? (
<Button
size="sm"
variant="outline"
disabled={moderate.isPending}
onClick={() =>
moderate.mutate({
photoId: photo.id,
visibility: "private",
})
}
>
<LockIcon data-icon="inline-start" />
Keep
</Button>
) : null}
<Button
size="sm"
variant="outline"
disabled={moderate.isPending}
onClick={() =>
moderate.mutate({
photoId: photo.id,
visibility: "rejected",
})
}
>
<XIcon data-icon="inline-start" />
Reject
</Button>
<Button
size="sm"
variant="ghost"
disabled={moderateSubmission.isPending}
onClick={() =>
moderateSubmission.mutate({
submissionId: photo.submissionId,
visibility: "hidden",
})
}
>
Hide batch
</Button>
</> </>
) : null} ) : null}
<DropdownMenuGroup>
{photo.originalUrl ? ( {photo.originalUrl ? (
<Button size="sm" variant="ghost" asChild> <DropdownMenuItem asChild className="min-h-11 px-3">
<a href={photo.originalUrl} target="_blank" rel="noreferrer"> <a href={photo.originalUrl} target="_blank" rel="noreferrer">
<DownloadIcon data-icon="inline-start" /> <DownloadIcon aria-hidden="true" />Open Original
Original
</a> </a>
</Button> </DropdownMenuItem>
) : null} ) : null}
{canModerate && photo.processingStatus === "ready" ? (
<DropdownMenuItem className="min-h-11 px-3"
onSelect={() => moderateSubmission.mutate({ submissionId: photo.submissionId, visibility: "hidden" })}>
<EyeOffIcon aria-hidden="true" />Hide Entire Submission
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
{canDelete ? ( {canDelete ? (
<Button <>
size="sm" <DropdownMenuSeparator />
variant="destructive" <DropdownMenuGroup>
onClick={() => setPendingDelete(photo.id)} <DropdownMenuItem variant="destructive" className="min-h-11 px-3" onSelect={() => setPendingDelete(photo.id)}>
> <Trash2Icon aria-hidden="true" />Delete Photo
<Trash2Icon data-icon="inline-start" /> </DropdownMenuItem>
Delete </DropdownMenuGroup>
</Button> </>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null} ) : null}
</div>
</div> </div>
</article> </article>
))} ))}
</div> </div>
<Dialog open={Boolean(preview)} onOpenChange={(open) => { if (!open) setPreviewId(null); }}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader className="pr-8">
<DialogTitle>Photo Preview</DialogTitle>
<DialogDescription>{preview?.contributorName ?? "Anonymous"} · {preview ? visibilityLabels[preview.visibility] : ""}</DialogDescription>
</DialogHeader>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview.displayUrl ?? preview.thumbUrl ?? ""} alt={`Photo from ${preview.contributorName ?? "Anonymous"}`}
width={2048} height={1365} className="max-h-[70dvh] w-full rounded-lg object-contain" />
) : null}
</DialogContent>
</Dialog>
<Dialog <Dialog
open={Boolean(pendingDelete)} open={Boolean(pendingDelete)}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -6,7 +6,11 @@ import { EventSettingsForm } from "./event-settings-form";
import { CopyGuestLink, ModerationGrid } from "./moderation-grid"; import { CopyGuestLink, ModerationGrid } from "./moderation-grid";
import { EventPeople } from "./event-people"; import { EventPeople } from "./event-people";
import { EventNotes } from "./event-notes"; import { EventNotes } from "./event-notes";
import { galleryIsPublic } from "@/lib/publishing";
import { EventAudit } from "./event-audit"; import { EventAudit } from "./event-audit";
import { EventSchedule } from "./event-schedule";
import { eventStatusLabel } from "@/lib/event-status";
import { effectiveEvent } from "@/lib/event-lifecycle";
export default async function EventDashboardPage({ export default async function EventDashboardPage({
params, params,
@@ -47,16 +51,29 @@ export default async function EventDashboardPage({
id: "settings", id: "settings",
label: "Settings", label: "Settings",
content: ( content: (
<div className="flex flex-col gap-4">
<EventSchedule eventId={event.id} />
<EventSettingsForm <EventSettingsForm
eventId={event.id} eventId={event.id}
title={event.title} title={event.title}
slug={event.slug} slug={event.slug}
description={event.description} description={event.description}
status={event.status} location={event.location}
latitude={event.latitude}
longitude={event.longitude}
bannerPhotoId={event.bannerPhotoId}
customBannerId={event.customBannerId}
status={effectiveEvent(event).status}
listed={event.listed} listed={event.listed}
uploadEnabled={event.uploadEnabled} uploadEnabled={event.uploadEnabled}
galleryReleased={Boolean(event.galleryReleasedAt)} galleryReleased={Boolean(event.galleryReleasedAt)}
notesPolicy={event.notesPolicy}
galleryPolicy={event.galleryPolicy}
showPhotoStats={event.showPhotoStats}
showSubmitterStats={event.showSubmitterStats}
showNoteStats={event.showNoteStats}
/> />
</div>
), ),
}, },
] ]
@@ -81,7 +98,7 @@ export default async function EventDashboardPage({
{ {
id: "notes", id: "notes",
label: "Notes", label: "Notes",
content: <EventNotes eventId={event.id} />, content: <EventNotes eventId={event.id} canManage={canSettings} />,
}, },
] ]
: []), : []),
@@ -103,15 +120,16 @@ export default async function EventDashboardPage({
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="font-display text-3xl sm:text-4xl">{event.title}</h1> <h1 className="font-display text-3xl sm:text-4xl">{event.title}</h1>
<Badge variant="secondary">{event.status}</Badge> <Badge variant="secondary">{eventStatusLabel(event)}</Badge>
{event.listed ? <Badge variant="outline">Listed</Badge> : null} {event.listed ? <Badge variant="outline">Listed</Badge> : null}
{event.galleryReleasedAt ? ( {galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? (
<Badge variant="outline">Gallery live</Badge> <Badge variant="outline">Gallery live</Badge>
) : ( ) : (
<Badge variant="outline">Gallery held</Badge> <Badge variant="outline">Gallery held</Badge>
)} )}
</div> </div>
<p className="truncate text-sm text-muted-foreground">{event.guestUrl}</p> <p className="truncate text-sm text-muted-foreground">{event.guestUrl}</p>
{event.location ? <p className="text-sm text-muted-foreground">{event.location}</p> : null}
</div> </div>
<CopyGuestLink url={event.guestUrl} /> <CopyGuestLink url={event.guestUrl} />
</div> </div>
+8 -47
View File
@@ -1,59 +1,20 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { createServerCaller } from "@/trpc/server"; import { createServerCaller } from "@/trpc/server";
import { GroupSwitcher } from "@/components/group-switcher"; import { BackendShell } from "@/components/backend-shell";
import { DashboardTabBar } from "@/components/dashboard-tab-bar";
import { Button } from "@/components/ui/button";
export default async function DashboardLayout({ export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/sign-in?callbackURL=/dashboard");
}
const caller = await createServerCaller(); const caller = await createServerCaller();
const viewer = await caller.viewer.me(); const viewer = await caller.viewer.me();
const platformRole = await getPlatformRole(session.user.id); if (!viewer.session) redirect("/sign-in?callbackURL=/dashboard");
return ( return (
<> <BackendShell
<main className="page-pad mx-auto w-full max-w-6xl py-6 pb-24 sm:py-10 sm:pb-10"> area="workspace"
<div className="mb-6 hidden items-center justify-between gap-3 sm:flex">
<nav className="flex items-center gap-1">
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">Events</Link>
</Button>
{viewer.groups.length > 0 ? (
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard/people">People</Link>
</Button>
) : null}
{platformRole ? (
<Button asChild variant="ghost" size="sm">
<Link href="/admin">Admin</Link>
</Button>
) : null}
</nav>
<GroupSwitcher
groups={viewer.groups} groups={viewer.groups}
activeGroupId={viewer.activeGroupId} activeGroupId={viewer.activeGroupId}
/> showAdmin={Boolean(viewer.platformRole)}
</div> >
<div className="mb-5 sm:hidden">
<GroupSwitcher
groups={viewer.groups}
activeGroupId={viewer.activeGroupId}
/>
</div>
{children} {children}
</main> </BackendShell>
<DashboardTabBar showAdmin={Boolean(platformRole)} />
</>
); );
} }
+12 -2
View File
@@ -1,6 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { eventStatusLabel } from "@/lib/event-status";
import { ArrowRightIcon } from "lucide-react";
import { createServerCaller } from "@/trpc/server"; import { createServerCaller } from "@/trpc/server";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -67,6 +70,10 @@ export default async function DashboardPage() {
className="group min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none" className="group min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
> >
<Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0"> <Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt="" width={800} height={300} loading="lazy" decoding="async"
className="aspect-[8/3] w-full object-cover" />
) : null}
<CardHeader> <CardHeader>
<CardTitle className="truncate text-2xl font-semibold tracking-tight"> <CardTitle className="truncate text-2xl font-semibold tracking-tight">
{event.title} {event.title}
@@ -75,10 +82,13 @@ export default async function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent className="flex items-center justify-between gap-3"> <CardContent className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap gap-2"> <div className="flex min-w-0 flex-wrap gap-2">
<Badge variant="secondary">{event.status}</Badge> <Badge variant="secondary">{eventStatusLabel(event)}</Badge>
{event.listed ? <Badge variant="outline">Listed</Badge> : null} {event.listed ? <Badge variant="outline">Listed</Badge> : null}
</div> </div>
<span className="text-sm text-primary">Open</span> <span className={buttonVariants({ variant: "outline" })}>
Open
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</span>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@@ -1,5 +1,7 @@
"use client"; "use client";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { GroupRole } from "@album/contracts"; import type { GroupRole } from "@album/contracts";
@@ -103,14 +105,10 @@ export function GroupPeople({
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
className="max-w-xs" className="max-w-xs"
/> />
<select <Select value={role} onValueChange={(value) => setRole(value as GroupRole)}>
className="rounded-md border bg-background px-2 py-1.5 text-sm" <SelectTrigger aria-label="Group role"><SelectValue /></SelectTrigger>
value={role} <SelectContent><SelectGroup><SelectItem value="member">Member</SelectItem><SelectItem value="owner">Owner</SelectItem></SelectGroup></SelectContent>
onChange={(event) => setRole(event.target.value as GroupRole)} </Select>
>
<option value="member">member</option>
<option value="owner">owner</option>
</select>
<Button type="submit" disabled={setMember.isPending}> <Button type="submit" disabled={setMember.isPending}>
Add existing user Add existing user
</Button> </Button>
+2 -2
View File
@@ -18,7 +18,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
export function GuestGallery({ slug }: { slug: string }) { export function GuestGallery({ slug }: { slug: string }) {
const gallery = api.event.gallery.useQuery(slug); const gallery = api.event.gallery.useQuery(slug, { refetchInterval: 10_000 });
const [active, setActive] = useState<string | null>(null); const [active, setActive] = useState<string | null>(null);
if (gallery.isLoading) { if (gallery.isLoading) {
@@ -40,7 +40,7 @@ export function GuestGallery({ slug }: { slug: string }) {
<EmptyHeader> <EmptyHeader>
<EmptyTitle>No photos in the gallery yet</EmptyTitle> <EmptyTitle>No photos in the gallery yet</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Photos will appear here after the event people release the gallery. Photos will appear here when they are processed and approved for publishing.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
+42 -6
View File
@@ -2,7 +2,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ChevronDownIcon, UploadIcon } from "lucide-react"; import { ChevronDownIcon, SendIcon, UploadIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import type { PublishingPolicy } from "@album/contracts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MAX_PHOTO_BYTES } from "@album/contracts"; import { MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
@@ -31,14 +33,19 @@ function guestKey(slug: string, field: string) {
export function GuestUpload({ export function GuestUpload({
slug, slug,
uploadEnabled, uploadEnabled,
notesEnabled,
notesPolicy,
}: { }: {
slug: string; slug: string;
uploadEnabled: boolean; uploadEnabled: boolean;
notesEnabled: boolean;
notesPolicy: PublishingPolicy;
}) { }) {
const router = useRouter();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [notify, setNotify] = useState(true); const [notify, setNotify] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [queue, setQueue] = useState<QueueItem[]>([]); const [queue, setQueue] = useState<QueueItem[]>([]);
@@ -55,6 +62,7 @@ export function GuestUpload({
setName(storedName); setName(storedName);
setEmail(storedEmail); setEmail(storedEmail);
setNote(storedNote); setNote(storedNote);
setNotify(localStorage.getItem(guestKey(slug, "notify")) === "true");
if (storedName || storedEmail || storedNote) setDetailsOpen(true); if (storedName || storedEmail || storedNote) setDetailsOpen(true);
}, [slug]); }, [slug]);
@@ -63,11 +71,26 @@ export function GuestUpload({
[queue], [queue],
); );
async function sendNote(detailsOnly = false) {
try {
await ensureGuest.mutateAsync({ eventSlug: slug, displayName: name.trim() || undefined,
email: email.trim() || undefined, notifyWhenReady: Boolean(email.trim()) && notify, note: detailsOnly ? undefined : note.trim() });
localStorage.setItem(guestKey(slug, "name"), name.trim());
localStorage.setItem(guestKey(slug, "email"), email.trim());
localStorage.setItem(guestKey(slug, "notify"), String(notify));
if (!detailsOnly) { localStorage.removeItem(guestKey(slug, "note")); setNote(""); }
toast.success(detailsOnly ? "Guest details saved" : "Note sent");
router.refresh();
} catch (error) { toast.error(error instanceof Error ? error.message : "Could not send note"); }
}
async function uploadFiles(files: File[]) { async function uploadFiles(files: File[]) {
const accepted = files.filter(isAllowedPhoto); const accepted = files.filter(isAllowedPhoto);
if (!uploadEnabled || busy) return;
if (accepted.length !== files.length) { if (accepted.length !== files.length) {
toast.error("Some files were skipped. Use JPEG, PNG, WebP, or HEIC under 25 MB."); toast.error("Some files were skipped. Use JPEG, PNG, WebP, or HEIC under 25 MB.");
} }
if (!accepted.length) return;
const items: QueueItem[] = accepted.map((file) => ({ const items: QueueItem[] = accepted.map((file) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: file.name, name: file.name,
@@ -81,6 +104,7 @@ export function GuestUpload({
localStorage.setItem(guestKey(slug, "name"), trimmedName); localStorage.setItem(guestKey(slug, "name"), trimmedName);
localStorage.setItem(guestKey(slug, "email"), trimmedEmail); localStorage.setItem(guestKey(slug, "email"), trimmedEmail);
localStorage.setItem(guestKey(slug, "note"), trimmedNote); localStorage.setItem(guestKey(slug, "note"), trimmedNote);
localStorage.setItem(guestKey(slug, "notify"), String(notify));
try { try {
await ensureGuest.mutateAsync({ await ensureGuest.mutateAsync({
@@ -138,6 +162,7 @@ export function GuestUpload({
} }
} }
await utils.event.gallery.invalidate(slug); await utils.event.gallery.invalidate(slug);
router.refresh();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Could not start upload"); toast.error(error instanceof Error ? error.message : "Could not start upload");
setQueue((current) => setQueue((current) =>
@@ -150,17 +175,17 @@ export function GuestUpload({
} }
} }
if (!uploadEnabled) { if (!uploadEnabled && !notesEnabled) {
return ( return (
<Alert> <Alert>
<AlertDescription>Uploads are closed for this event.</AlertDescription> <AlertDescription>Submissions are closed for this event.</AlertDescription>
</Alert> </Alert>
); );
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<label {uploadEnabled ? <label
className={cn( className={cn(
"flex min-h-52 cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-primary/35 bg-card px-5 py-10 text-center shadow-sm transition-all duration-200 hover:border-primary/60 hover:bg-accent/50 motion-reduce:transition-none", "flex min-h-52 cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-primary/35 bg-card px-5 py-10 text-center shadow-sm transition-all duration-200 hover:border-primary/60 hover:bg-accent/50 motion-reduce:transition-none",
dragging && "scale-[1.01] border-primary bg-accent/60 shadow-[0_0_0_6px] shadow-primary/15 motion-reduce:scale-100", dragging && "scale-[1.01] border-primary bg-accent/60 shadow-[0_0_0_6px] shadow-primary/15 motion-reduce:scale-100",
@@ -212,7 +237,7 @@ export function GuestUpload({
{busy ? "Uploading…" : "Choose photos"} {busy ? "Uploading…" : "Choose photos"}
</span> </span>
</Button> </Button>
</label> </label> : null}
{queue.length > 0 ? ( {queue.length > 0 ? (
<ul className="flex flex-col gap-3" aria-live="polite"> <ul className="flex flex-col gap-3" aria-live="polite">
{queue.map((item) => ( {queue.map((item) => (
@@ -289,7 +314,18 @@ export function GuestUpload({
placeholder="Congratulations — enjoy the day." placeholder="Congratulations — enjoy the day."
maxLength={2000} maxLength={2000}
/> />
<FieldDescription>
{notesPolicy === "automatic" ? "Your note and name will be published automatically."
: notesPolicy === "approved" ? "Your note and name may be published after organizer approval."
: "Your note is private to the event organizers."}
{" "}You can send a note without photos. Sending another replaces your previous note.
</FieldDescription>
</Field> </Field>
<Button type="button" disabled={!notesEnabled || !note.trim() || ensureGuest.isPending || busy} onClick={() => void sendNote()}>
<SendIcon data-icon="inline-start" aria-hidden="true" />
{ensureGuest.isPending ? "Sending…" : "Send note"}
</Button>
<Button type="button" variant="outline" disabled={!email.trim() || ensureGuest.isPending || busy} onClick={() => void sendNote(true)}>Save details without a photo or note</Button>
</FieldGroup> </FieldGroup>
</div> </div>
</details> </details>
+41 -5
View File
@@ -1,7 +1,10 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { MapPinIcon } from "lucide-react";
import { EventMap } from "@/components/event-map";
import { createServerCaller } from "@/trpc/server"; import { createServerCaller } from "@/trpc/server";
import { formatEventDate } from "@/lib/utils"; import { formatEventDate } from "@/lib/utils";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { galleryIsPublic } from "@/lib/publishing";
import { GuestGallery } from "./guest-gallery"; import { GuestGallery } from "./guest-gallery";
import { GuestUpload } from "./guest-upload"; import { GuestUpload } from "./guest-upload";
@@ -20,11 +23,17 @@ export default async function EventPage({
} }
const when = formatEventDate(event.startsAt); const when = formatEventDate(event.startsAt);
const galleryLive = Boolean(event.galleryReleasedAt); const galleryLive = galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt);
const community = await caller.event.community(slug);
return ( return (
<main className="page-pad mx-auto flex w-full max-w-4xl flex-col gap-8 py-8 sm:gap-10 sm:py-12"> <main className="page-pad mx-auto flex w-full max-w-4xl flex-col gap-8 py-8 sm:gap-10 sm:py-12">
<header className="reveal flex flex-col gap-3"> <header className="reveal flex flex-col gap-3">
{event.bannerUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={event.bannerUrl} alt="" width={1600} height={600}
className="mb-3 aspect-[8/3] w-full rounded-2xl object-cover" />
) : null}
<p className="text-xs font-medium tracking-[0.22em] text-primary uppercase"> <p className="text-xs font-medium tracking-[0.22em] text-primary uppercase">
Guest gallery Guest gallery
</p> </p>
@@ -32,18 +41,45 @@ export default async function EventPage({
{event.title} {event.title}
</h1> </h1>
{when ? <p className="text-muted-foreground">{when}</p> : null} {when ? <p className="text-muted-foreground">{when}</p> : null}
{event.location ? (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<MapPinIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />{event.location}
</p>
) : null}
{event.description ? ( {event.description ? (
<p className="max-w-2xl text-muted-foreground">{event.description}</p> <p className="max-w-2xl whitespace-pre-line text-muted-foreground">{event.description}</p>
) : null} ) : null}
</header> </header>
{Object.values(community.stats).some((value) => value !== null) ? (
<dl className="flex flex-wrap gap-6 rounded-xl border bg-card p-4">
{([["photos", "Images uploaded"], ["submitters", "Submitters"], ["notes", "Notes sent"]] as const).map(([key, label]) =>
community.stats[key] !== null ? <div key={key}><dt className="text-sm text-muted-foreground">{label}</dt><dd className="text-2xl font-semibold">{community.stats[key]}</dd></div> : null)}
</dl>
) : null}
{event.latitude !== null && event.longitude !== null ? (
<section aria-label="Event location" className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Getting Here</h2>
<EventMap latitude={event.latitude} longitude={event.longitude} location={event.location ?? event.title} />
</section>
) : null}
<section id="upload" className="reveal-2 scroll-mt-24 flex flex-col gap-4"> <section id="upload" className="reveal-2 scroll-mt-24 flex flex-col gap-4">
<h2 className="sr-only">Add a photo</h2> <h2 className="sr-only">Add photos or send a note</h2>
<GuestUpload <GuestUpload
slug={event.slug} slug={event.slug}
uploadEnabled={event.uploadEnabled && event.status === "published"} uploadEnabled={event.uploadEnabled && event.status === "published"}
notesEnabled={event.status === "published" && (!event.submissionsOpenAt || event.submissionsOpenAt <= new Date())}
notesPolicy={event.notesPolicy}
/> />
</section> </section>
<Separator /> {event.notesPolicy !== "never" && community.notes.length > 0 ? (
<section className="flex flex-col gap-4" aria-label="Guest notes">
<h2 className="text-2xl font-semibold">Guest notes</h2>
{community.notes.map((note) => <blockquote key={note.id} className="rounded-xl border bg-card p-4">
<p className="whitespace-pre-wrap">{note.note}</p><footer className="mt-2 text-sm text-muted-foreground">{note.displayName ?? "Anonymous"}</footer>
</blockquote>)}
</section>
) : null}
{event.galleryPolicy !== "never" ? <><Separator />
<section className="reveal-3 flex flex-col gap-4"> <section className="reveal-3 flex flex-col gap-4">
<h2 className="text-2xl font-semibold tracking-tight">Gallery</h2> <h2 className="text-2xl font-semibold tracking-tight">Gallery</h2>
{galleryLive ? ( {galleryLive ? (
@@ -53,7 +89,7 @@ export default async function EventPage({
Photos will appear here when the event people release the gallery. Photos will appear here when the event people release the gallery.
</p> </p>
)} )}
</section> </section></> : null}
</main> </main>
); );
} }
+9 -4
View File
@@ -20,8 +20,9 @@
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-heading: var(--font-playfair), ui-serif, Georgia, serif; --font-heading: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-playfair), ui-serif, Georgia, serif; --font-display: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
--font-brand: var(--font-geologica), ui-sans-serif, system-ui, sans-serif;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -142,6 +143,10 @@
} }
@layer utilities { @layer utilities {
.brand-wordmark {
font-variation-settings: "SHRP" 70;
}
.page-pad { .page-pad {
padding-inline: max(1.25rem, env(safe-area-inset-left)) padding-inline: max(1.25rem, env(safe-area-inset-left))
max(1.25rem, env(safe-area-inset-right)); max(1.25rem, env(safe-area-inset-right));
@@ -262,14 +267,14 @@
} }
.font-display { .font-display {
font-family: var(--font-playfair), ui-serif, Georgia, serif; font-family: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
text-wrap: balance; text-wrap: balance;
} }
h1, h1,
h2, h2,
h3 { h3 {
font-family: var(--font-playfair), ui-serif, Georgia, serif; font-family: var(--font-funnel-display), ui-sans-serif, system-ui, sans-serif;
text-wrap: balance; text-wrap: balance;
} }
+14 -5
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Inter, Playfair_Display } from "next/font/google"; import { Funnel_Display, Geologica, Inter } from "next/font/google";
import { TRPCReactProvider } from "@/trpc/react"; import { TRPCReactProvider } from "@/trpc/react";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
@@ -13,14 +13,23 @@ const inter = Inter({
display: "swap", display: "swap",
}); });
const playfair = Playfair_Display({ const funnelDisplay = Funnel_Display({
variable: "--font-playfair", variable: "--font-funnel-display",
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
weight: ["600", "700", "800"], weight: "variable",
});
const geologica = Geologica({
variable: "--font-geologica",
subsets: ["latin"],
display: "swap",
weight: "variable",
axes: ["SHRP"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"),
applicationName: BRAND_NAME, applicationName: BRAND_NAME,
title: { title: {
default: BRAND_TITLE, default: BRAND_TITLE,
@@ -44,7 +53,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" lang="en"
className={`${inter.variable} ${playfair.variable} font-sans`} className={`${inter.variable} ${funnelDisplay.variable} ${geologica.variable} font-sans`}
suppressHydrationWarning suppressHydrationWarning
> >
<body> <body>
+7 -2
View File
@@ -101,7 +101,8 @@ export default async function HomePage() {
Shared albums for real life Shared albums for real life
</div> </div>
<h1 className="font-display text-[clamp(3.4rem,7vw,5.8rem)] leading-[0.92] font-semibold tracking-[-0.055em]"> <h1 className="font-display text-[clamp(3.4rem,7vw,5.8rem)] leading-[0.92] font-semibold tracking-[-0.055em]">
Every angle.<br /><span className="text-primary">One shared album.</span> <span className="block whitespace-nowrap">Every angle.</span>
<span className="block text-primary">One shared album.</span>
</h1> </h1>
<p className="max-w-lg text-lg leading-7 text-muted-foreground sm:text-xl sm:leading-8"> <p className="max-w-lg text-lg leading-7 text-muted-foreground sm:text-xl sm:leading-8">
Give guests one link to add their photos. Review everything in one place, share the best, and keep every original. Give guests one link to add their photos. Review everything in one place, share the best, and keep every original.
@@ -166,8 +167,12 @@ export default async function HomePage() {
{listed.map((event) => ( {listed.map((event) => (
<Link key={event.id} href={`/e/${event.slug}`} className="group block min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"> <Link key={event.id} href={`/e/${event.slug}`} className="group block min-w-0 rounded-xl focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none">
<Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0"> <Card className="h-full transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:shadow-md motion-reduce:transition-none motion-reduce:group-hover:translate-y-0">
{event.bannerUrl ? (
<img src={event.bannerUrl} alt="" width={800} height={300} loading="lazy" decoding="async"
className="aspect-[8/3] w-full object-cover" />
) : null}
<CardHeader> <CardHeader>
<div className="mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><BrandMark className="size-5" /></div> {!event.bannerUrl ? <div className="mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground"><BrandMark className="size-5" /></div> : null}
<CardTitle className="truncate text-xl font-semibold tracking-tight">{event.title}</CardTitle> <CardTitle className="truncate text-xl font-semibold tracking-tight">{event.title}</CardTitle>
<CardDescription>{formatEventDate(event.startsAt) ?? "Open gallery"}</CardDescription> <CardDescription>{formatEventDate(event.startsAt) ?? "Open gallery"}</CardDescription>
</CardHeader> </CardHeader>
@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { Check, ChevronDown, Globe, Images, ShieldCheck } from "lucide-react";
import { topBarControlClass } from "@/components/top-bar-control";
import { cn } from "@/lib/utils";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
const applications = {
"/": { label: "Public Site", description: "Public events and shared photo galleries", icon: Globe },
"/dashboard": { label: "Albums", description: "Events, photo review, and workspace access", icon: Images },
"/admin": { label: "Administration", description: "Users, invitations, and deployment policies", icon: ShieldCheck },
};
export function ApplicationSwitcher({ links, pathname }: {
links: { href: string; label: string }[];
pathname: string;
}) {
const currentHref = pathname.startsWith("/admin") ? "/admin" : pathname.startsWith("/dashboard") ? "/dashboard" : "/";
const current = applications[currentHref];
const CurrentIcon = current.icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" aria-label={`Application: ${current.label}`}
className={cn(topBarControlClass, "w-11 shrink-0 justify-center px-0 sm:w-auto sm:justify-start sm:px-3")}>
<CurrentIcon aria-hidden="true" className="size-4 text-primary" />
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown aria-hidden="true" className="hidden size-3.5 text-muted-foreground sm:block" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel>Applications</DropdownMenuLabel>
<DropdownMenuGroup>
{links.map((link) => {
const app = applications[link.href as keyof typeof applications];
if (!app) return null;
const active = link.href === currentHref;
const Icon = app.icon;
return (
<DropdownMenuItem key={link.href} asChild className="p-3">
<Link href={link.href} aria-current={active ? "location" : undefined}>
<span className="flex w-full min-w-0 flex-col gap-1">
<span className="flex items-center gap-3">
<Icon aria-hidden="true" className="text-primary" />
<strong className="min-w-0 flex-1">{app.label}</strong>
{active ? <Check aria-hidden="true" className="text-primary" /> : null}
</span>
<span className="pl-7 text-xs leading-5 text-muted-foreground">{app.description}</span>
</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import type { WorkspaceGroup } from "@/components/group-switcher";
import { DoubleSidebarNavigation } from "@/components/double-sidebar-navigation";
import { activeNavigationItem, adminWorkspaces, albumWorkspaces } from "@/lib/workspace-navigation";
import { cn } from "@/lib/utils";
export function BackendShell({ children, area, groups, activeGroupId }: {
children: ReactNode;
area: "workspace" | "platform";
groups: WorkspaceGroup[];
activeGroupId: string | null;
showAdmin?: boolean;
}) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
try { setCollapsed(localStorage.getItem("manyangles-sidebar-collapsed") === "true"); } catch {}
}, []);
function togglePanel() {
const next = !collapsed;
setCollapsed(next);
try { localStorage.setItem("manyangles-sidebar-collapsed", String(next)); } catch {}
}
const workspaces = area === "platform" ? adminWorkspaces : albumWorkspaces;
const applicationLabel = area === "platform" ? "Administration" : "Albums";
const activeItem = activeNavigationItem(workspaces, pathname);
const activeWorkspace = workspaces.find((workspace) => workspace.items.includes(activeItem!)) ?? workspaces[0]!;
const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0];
return (
<div className="min-h-[calc(100dvh-4rem)] bg-background">
<DoubleSidebarNavigation key={area} workspaces={workspaces} activeItem={activeItem}
applicationLabel={applicationLabel} panelCollapsed={collapsed} onTogglePanel={togglePanel}
groupName={area === "workspace" ? activeGroup?.name : undefined} />
<main className={cn("min-w-0", collapsed ? "lg:ml-[5.5rem]" : "lg:ml-[18.5rem]")}>
<div className="page-pad mx-auto w-full max-w-7xl py-5 lg:px-6 lg:py-6">
<nav aria-label="Breadcrumb" className="mb-5 flex items-center gap-2 text-xs text-muted-foreground">
<Link href={area === "platform" ? "/admin" : "/dashboard"} className="hover:text-foreground">{applicationLabel}</Link>
<ChevronRight aria-hidden="true" className="size-3" />
<span>{activeWorkspace.label}</span>
{activeItem && activeItem.label !== activeWorkspace.label ? <><ChevronRight aria-hidden="true" className="size-3" /><span>{activeItem.label}</span></> : null}
</nav>
{children}
</div>
</main>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { UploadIcon } from "lucide-react";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
export function BannerUpload({ eventId, selectedId, onSelect }: {
eventId: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [bannerId, setBannerId] = useState<string | null>(selectedId);
const create = api.banners.create.useMutation();
const complete = api.banners.complete.useMutation();
const status = api.banners.status.useQuery({ eventId, bannerId: bannerId ?? "" }, {
enabled: Boolean(bannerId),
refetchInterval: (query) => ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500,
retry: false,
});
async function upload(file: File) {
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
const parsed = allowedImageTypeSchema.safeParse(mime);
if (!parsed.success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
toast.error("Choose a JPEG, PNG, WebP, or HEIC image up to 25 MB.");
return;
}
setUploading(true);
try {
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
const response = await fetch(pending.uploadUrl, {
method: "PUT", body: file, headers: { "Content-Type": parsed.data }, signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error("Banner upload failed. Please try again.");
await complete.mutateAsync({ eventId, bannerId: pending.bannerId });
setBannerId(pending.bannerId);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Banner upload failed");
} finally { setUploading(false); }
}
const processing = Boolean(bannerId) && status.data?.status !== "ready" && status.data?.status !== "failed" && !status.isError;
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<input ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" aria-label="Upload a dedicated event banner"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void upload(file);
}} />
<Button type="button" variant="outline" disabled={uploading || processing} onClick={() => input.current?.click()}>
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : "Upload Banner"}
</Button>
{status.data?.status === "ready" && bannerId ? (
<Button type="button" variant="secondary" disabled={selectedId === bannerId} onClick={() => onSelect(bannerId)}>
{selectedId === bannerId ? "Banner Selected" : "Use Uploaded Banner"}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.</p>
{processing ? <p role="status" className="text-sm text-muted-foreground">Preparing your banner You can keep editing while it processes.</p> : null}
{status.isError || status.data?.status === "failed" ? <p role="alert" className="text-sm text-destructive">Couldn't prepare this banner. Try uploading another image.</p> : null}
{status.data?.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={status.data.url} alt="Uploaded banner preview" width={1200} height={450} className="aspect-[8/3] w-full rounded-lg object-cover" />
) : null}
</div>
);
}
+9 -6
View File
@@ -12,14 +12,14 @@ export function BrandMark({ className }: { className?: string }) {
<path <path
d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
/> />
<circle cx="21" cy="11" r="2" fill="currentColor" /> <circle cx="21" cy="11" r="2.25" fill="currentColor" />
<path <path
d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
@@ -37,10 +37,13 @@ export function BrandLockup({
wordmarkClassName?: string; wordmarkClassName?: string;
}) { }) {
return ( return (
<span className={cn("inline-flex items-center gap-2", className)}> <span className={cn("inline-flex h-7 items-center gap-2 text-foreground", className)}>
<BrandMark className={cn("text-primary", markClassName)} /> <BrandMark className={markClassName} />
<span <span
className={cn("font-heading font-semibold tracking-[-0.035em]", wordmarkClassName)} className={cn(
"brand-wordmark font-brand text-[1.45rem] leading-7 font-bold tracking-[-0.04em]",
wordmarkClassName,
)}
translate="no" translate="no"
> >
{BRAND_NAME} {BRAND_NAME}
@@ -0,0 +1,259 @@
"use client";
// Adapted from RaceTix ticketing's DoubleSidebarNavigation.
import Link from "next/link";
import { Check, ChevronsLeft, ChevronsRight, ExternalLink, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { NavigationItem, NavigationWorkspace } from "@/lib/workspace-navigation";
function groupBySection(items: NavigationItem[]) {
const groups = new Map<string | null, NavigationItem[]>();
for (const item of items) {
const key = item.section ?? null;
const list = groups.get(key) ?? [];
list.push(item);
groups.set(key, list);
}
return [...groups.entries()];
}
function groupIsActive(
workspace: NavigationWorkspace,
activeItem: NavigationItem | undefined,
) {
return !!activeItem && workspace.items.some((item) => item === activeItem);
}
function workspaceTarget(workspace: NavigationWorkspace) {
if (
workspace.href &&
workspace.items.some((item) => item.href === workspace.href)
) {
return workspace.href;
}
return workspace.items[0]?.href ?? "#";
}
const itemLinkClass = (active: boolean) =>
cn(
"flex min-h-11 min-w-0 items-center gap-2.5 rounded-md px-2.5 text-sm font-semibold transition-colors motion-reduce:transition-none",
active
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
);
function WorkspaceRail({
workspaces,
activeItem,
applicationLabel,
panelCollapsed,
onTogglePanel,
}: {
workspaces: NavigationWorkspace[];
activeItem: NavigationItem | undefined;
applicationLabel: string;
panelCollapsed: boolean;
onTogglePanel: () => void;
}) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<nav
aria-label={`${applicationLabel} workspaces`}
className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-2 py-3"
>
{workspaces.map((workspace) => {
const Icon = workspace.icon;
const active = groupIsActive(workspace, activeItem);
return (
<Link
key={workspace.label}
href={workspaceTarget(workspace)}
aria-current={active ? "location" : undefined}
title={workspace.label}
className={cn(
"group flex min-h-[4.25rem] flex-col items-center justify-center gap-1 rounded-lg px-1 text-center text-[0.65rem] font-bold leading-tight transition-colors motion-reduce:transition-none",
active
? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon aria-hidden="true" className="size-5 shrink-0" />
<span className="line-clamp-2 max-w-full text-balance">
{workspace.label}
</span>
</Link>
);
})}
</nav>
<Separator />
<div className="p-2">
<Button
type="button"
variant="ghost"
size="icon"
className="w-full"
onClick={onTogglePanel}
aria-label={
panelCollapsed
? "Show workspace navigation"
: "Hide workspace navigation"
}
title={
panelCollapsed
? "Show workspace navigation"
: "Hide workspace navigation"
}
>
{panelCollapsed ? (
<ChevronsRight aria-hidden="true" data-icon="inline-start" />
) : (
<ChevronsLeft aria-hidden="true" data-icon="inline-start" />
)}
</Button>
</div>
</div>
);
}
function WorkspacePanel({
workspace,
activeItem,
applicationLabel,
}: {
workspace: NavigationWorkspace;
activeItem: NavigationItem | undefined;
applicationLabel: string;
}) {
return (
<div className="flex h-full min-h-0 flex-col">
<header className="border-b px-4 py-4">
<p className="text-[0.65rem] font-black uppercase tracking-[0.18em] text-primary">
{applicationLabel} Workspace
</p>
<h2 className="mt-1 truncate text-base font-bold">{workspace.label}</h2>
<p className="mt-1 line-clamp-2 text-xs leading-4 text-muted-foreground">
{workspace.description}
</p>
</header>
<nav
aria-label={`${workspace.label} navigation`}
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3"
>
{groupBySection(workspace.items).map(([section, items]) => (
<section key={section ?? "items"} aria-label={section ?? undefined}>
{section ? (
<h3 className="px-2.5 pb-1.5 text-[0.65rem] font-black uppercase tracking-[0.14em] text-muted-foreground/80">
{section}
</h3>
) : null}
<div className="flex flex-col gap-0.5">
{items.map((item) => {
const Icon = item.icon;
const active = item === activeItem;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
title={item.description}
className={itemLinkClass(active)}
>
<Icon
aria-hidden="true"
className={cn(
"size-4 shrink-0",
active && "text-primary",
)}
/>
<span className="min-w-0 flex-1 truncate">
{item.label}
</span>
</Link>
);
})}
</div>
</section>
))}
</nav>
</div>
);
}
export function DoubleSidebarNavigation({
workspaces, activeItem, applicationLabel, panelCollapsed, onTogglePanel, groupName,
}: {
workspaces: NavigationWorkspace[];
activeItem: NavigationItem | undefined;
applicationLabel: string;
panelCollapsed: boolean;
onTogglePanel: () => void;
groupName?: string;
}) {
const activeWorkspace = workspaces.find((workspace) => groupIsActive(workspace, activeItem)) ?? workspaces[0]!;
const CurrentIcon = activeItem?.icon ?? activeWorkspace.icon;
return (
<>
<div className="sticky top-16 z-30 flex h-14 items-center justify-between gap-2 border-b bg-background/95 px-4 backdrop-blur lg:hidden">
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-md bg-primary/10 p-1.5 text-primary"><CurrentIcon aria-hidden="true" className="size-4" /></span>
<span className="truncate text-sm font-bold">{activeItem?.label ?? activeWorkspace.label}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open navigation"><Menu aria-hidden="true" data-icon="inline-start" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 max-w-[calc(100vw-2rem)]">
<DropdownMenuLabel>{groupName ?? applicationLabel} Workspaces</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuGroup key={workspace.label}>
{workspace.items.length > 1 ? <DropdownMenuLabel>{workspace.label}</DropdownMenuLabel> : null}
{workspace.items.map((item) => {
const Icon = workspace.items.length === 1 ? workspace.icon : item.icon;
const active = item === activeItem;
return (
<DropdownMenuItem key={item.href} asChild className="min-h-11 p-3">
<Link href={item.href} aria-current={active ? "page" : undefined}>
<Icon aria-hidden="true" />
<span className="min-w-0 flex-1">{workspace.items.length === 1 ? workspace.label : item.label}</span>
{active ? <Check aria-hidden="true" /> : null}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild className="min-h-11 p-3">
<Link href="/"><ExternalLink aria-hidden="true" />Public Site</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="fixed bottom-0 left-0 top-16 z-30 hidden w-[5.5rem] flex-col border-r bg-card text-card-foreground shadow-sm lg:flex">
<WorkspaceRail workspaces={workspaces} activeItem={activeItem} applicationLabel={applicationLabel}
panelCollapsed={panelCollapsed} onTogglePanel={onTogglePanel} />
</div>
{!panelCollapsed ? (
<aside aria-label="Workspace sidebar" className="fixed bottom-0 left-[5.5rem] top-16 z-20 hidden w-52 flex-col border-r bg-background/95 text-foreground shadow-sm backdrop-blur lg:flex">
<div className="min-h-0 flex-1"><WorkspacePanel workspace={activeWorkspace} activeItem={activeItem} applicationLabel={applicationLabel} /></div>
<Separator />
<div className="flex flex-col gap-2 p-3">
{groupName ? <p className="truncate px-2.5 text-xs font-semibold text-muted-foreground" title={groupName}>{groupName}</p> : null}
<Link href="/" className="flex min-h-11 items-center gap-2 rounded-md px-2.5 text-sm font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">
<ExternalLink aria-hidden="true" className="size-4" />Public Site
</Link>
</div>
</aside>
) : null}
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { openStreetMapEmbedUrl } from "@/lib/maps";
export function EventMap({ latitude, longitude, location }: {
latitude: number;
longitude: number;
location: string;
}) {
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
return (
<div className="flex flex-col gap-2">
<iframe title={`Map of ${location}`} src={openStreetMapEmbedUrl(latitude, longitude)}
loading="lazy" referrerPolicy="no-referrer" className="h-64 w-full rounded-xl border bg-muted" />
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="underline underline-offset-4">© OpenStreetMap contributors</a>
<a href={url} target="_blank" rel="noreferrer" className="font-semibold underline underline-offset-4">Open Larger Map</a>
</div>
</div>
);
}
+9 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ImageIcon, Settings2Icon, UsersIcon, StickyNoteIcon, ActivityIcon } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type EventWorkspaceTab = { export type EventWorkspaceTab = {
@@ -17,22 +18,26 @@ export function EventWorkspace({
tabs: EventWorkspaceTab[]; tabs: EventWorkspaceTab[];
}) { }) {
const initial = tabs[0]?.id ?? "photos"; const initial = tabs[0]?.id ?? "photos";
const icons = { photos: ImageIcon, settings: Settings2Icon, people: UsersIcon, notes: StickyNoteIcon, activity: ActivityIcon, audit: ActivityIcon };
return ( return (
<div className="reveal flex flex-col gap-6 sm:gap-8"> <div className="reveal flex flex-col gap-6 sm:gap-8">
{heading} {heading}
{tabs.length === 0 ? null : ( {tabs.length === 0 ? null : (
<Tabs defaultValue={initial} className="gap-5"> <Tabs defaultValue={initial} className="gap-5">
<TabsList className="sticky top-[calc(3.5rem+env(safe-area-inset-top))] z-30 h-11 w-full max-w-full justify-start overflow-x-auto bg-muted/90 backdrop-blur-md sm:top-[calc(4rem+env(safe-area-inset-top))] sm:h-10"> <TabsList variant="line" aria-label="Event sections" className="w-full max-w-full justify-start gap-2 overflow-x-auto p-0 shadow-[inset_0_-1px_0_var(--border)] group-data-horizontal/tabs:h-12">
{tabs.map((tab) => ( {tabs.map((tab) => {
const Icon = icons[tab.id as keyof typeof icons];
return (
<TabsTrigger <TabsTrigger
key={tab.id} key={tab.id}
value={tab.id} value={tab.id}
className="tap-target flex-none px-3" className="h-full flex-none gap-2 rounded-none border-0 px-4 py-3 transition-colors data-[state=active]:text-primary after:bg-primary group-data-horizontal/tabs:after:bottom-0"
> >
{Icon ? <Icon aria-hidden="true" /> : null}
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ); })}
</TabsList> </TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="flex flex-col gap-4"> <TabsContent key={tab.id} value={tab.id} className="flex flex-col gap-4">
+31 -26
View File
@@ -1,44 +1,49 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ImagesIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { import { cn } from "@/lib/utils";
Select, import { topBarControlClass } from "@/components/top-bar-control";
SelectContent, import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function GroupSwitcher({ export type WorkspaceGroup = {
groups, id: string;
activeGroupId, name: string;
}: { slug: string;
groups: { id: string; name: string }[]; role: "owner" | "member";
};
export function GroupSwitcher({ groups, activeGroupId, compact = false }: {
groups: WorkspaceGroup[];
activeGroupId: string | null; activeGroupId: string | null;
compact?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const utils = api.useUtils();
const select = api.group.select.useMutation({ const select = api.group.select.useMutation({
onSuccess: () => router.refresh(), onSuccess: async () => {
await utils.invalidate();
router.push("/dashboard");
router.refresh();
},
onError: (error) => toast.error(error.message),
}); });
if (groups.length === 0) return null; if (groups.length === 0) return null;
const value = activeGroupId ?? groups[0]?.id; const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]!;
return ( return (
<Select <Select value={activeGroup.id} disabled={select.isPending}
value={value} onValueChange={(groupId) => { if (groupId !== activeGroup.id) select.mutate({ groupId }); }}>
onValueChange={(groupId) => select.mutate({ groupId })} <SelectTrigger aria-label="Active workspace"
> className={cn(topBarControlClass, "w-11 shrink-0 px-3 sm:w-52 [&>svg:last-child]:hidden sm:[&>svg:last-child]:block", !compact && "sm:w-full")}>
<SelectTrigger <ImagesIcon aria-hidden="true" className="shrink-0 text-primary" />
aria-label="Active group" <SelectValue className="sr-only sm:not-sr-only">{activeGroup.name}</SelectValue>
className="tap-target w-full min-w-0 max-w-full sm:w-auto sm:max-w-56"
>
<SelectValue placeholder="Group" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" align="start">
<SelectGroup> <SelectGroup>
{groups.map((group) => ( {groups.map((group) => (
<SelectItem key={group.id} value={group.id}> <SelectItem key={group.id} value={group.id} className="min-h-11">
{group.name} {group.name}
</SelectItem> </SelectItem>
))} ))}
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useId, useState } from "react";
import type { LocationSuggestion } from "@album/contracts";
import { api } from "@/trpc/react";
import { Input } from "@/components/ui/input";
import { EventMap } from "@/components/event-map";
export function LocationInput({ eventId, value, latitude, longitude, onChange }: {
eventId: string;
value: string;
latitude: number | null;
longitude: number | null;
onChange: (value: string, match: LocationSuggestion | null) => void;
}) {
const listId = useId();
const [query, setQuery] = useState(value);
const [focused, setFocused] = useState(false);
const [active, setActive] = useState(-1);
useEffect(() => {
const timer = setTimeout(() => setQuery(value.trim()), 500);
return () => clearTimeout(timer);
}, [value]);
const enabled = focused && latitude === null && query.length >= 3 && query === value.trim();
const results = api.manager.searchLocations.useQuery({ eventId, query }, {
enabled, staleTime: 86_400_000, retry: false, refetchOnWindowFocus: false,
});
const suggestions = enabled ? results.data ?? [] : [];
const open = enabled && suggestions.length > 0;
function choose(match: LocationSuggestion) {
onChange(match.address, match);
setFocused(false);
setActive(-1);
}
return (
<div className="flex flex-col gap-3">
<div className="relative">
<Input id="location" value={value} maxLength={300} placeholder="Search for a venue or address"
autoComplete="off" role="combobox" aria-autocomplete="list"
aria-controls={open ? listId : undefined} aria-expanded={open}
aria-activedescendant={open && active >= 0 ? `${listId}-${active}` : undefined}
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
onChange={(event) => { onChange(event.target.value, null); setFocused(true); setActive(-1); }}
onKeyDown={(event) => {
if (event.key === "Escape") { setFocused(false); return; }
if (open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
setActive((index) => index < 0
? (event.key === "ArrowDown" ? 0 : suggestions.length - 1)
: (index + (event.key === "ArrowDown" ? 1 : suggestions.length - 1)) % suggestions.length);
}
if (open && event.key === "Enter") {
event.preventDefault();
if (active >= 0 && suggestions[active]) choose(suggestions[active]);
}
}} />
{open ? (
<div id={listId} role="listbox" aria-label="Matching locations"
className="absolute top-full z-10 mt-1 max-h-72 w-full overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md">
{suggestions.map((match, index) => (
<button key={`${match.latitude}:${match.longitude}:${match.address}`}
id={`${listId}-${index}`} type="button" role="option" tabIndex={-1}
aria-selected={index === active}
onMouseDown={(event) => event.preventDefault()} onClick={() => choose(match)}
className="flex min-h-11 w-full items-center rounded-md px-3 py-2 text-left text-sm hover:bg-accent aria-selected:bg-accent">
{match.address}
</button>
))}
</div>
) : null}
</div>
<p role="status" className="text-xs text-muted-foreground">
{enabled && results.isFetching ? "Finding locations…" :
enabled && results.isError ? results.error.message :
enabled && results.isSuccess && !suggestions.length ? "No matches found. Try a fuller address, or keep a text-only location." :
latitude !== null && longitude !== null ? "Location matched. Save the event to update its map." :
"Choose a matching address to add a map, or keep a text-only location."}
</p>
{latitude !== null && longitude !== null ? <EventMap latitude={latitude} longitude={longitude} location={value} /> : null}
</div>
);
}
+100 -55
View File
@@ -1,8 +1,27 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { MenuIcon } from "lucide-react"; import { usePathname, useRouter } from "next/navigation";
import {
ChevronDownIcon,
LogOutIcon,
MenuIcon,
UserRoundIcon,
} from "lucide-react";
import { ApplicationSwitcher } from "@/components/application-switcher";
import { topBarControlClass } from "@/components/top-bar-control";
import { cn } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -10,89 +29,115 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { SignOutButton } from "@/components/sign-out-button"; import { GroupSwitcher, type WorkspaceGroup } from "@/components/group-switcher";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { BrandLockup } from "@/components/brand-mark"; import { BrandLockup } from "@/components/brand-mark";
import { BRAND_NAME } from "@/lib/brand"; import { BRAND_NAME } from "@/lib/brand";
type HeaderLink = { type HeaderLink = { href: string; label: string };
href: string;
label: string;
};
export function SiteHeaderBar({ function initials(name: string) {
links, return name.split(/\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase();
primary, }
signedIn,
}: { export function SiteHeaderBar({ links, primary, signedIn, user, groups = [], activeGroupId = null }: {
links: HeaderLink[]; links: HeaderLink[];
primary?: HeaderLink | null; primary?: HeaderLink | null;
signedIn: boolean; signedIn: boolean;
user?: { name: string; email: string };
groups?: WorkspaceGroup[];
activeGroupId?: string | null;
}) { }) {
const pathname = usePathname();
const router = useRouter();
const backend = pathname.startsWith("/dashboard") || pathname.startsWith("/admin");
return ( return (
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/82 pt-[env(safe-area-inset-top)] backdrop-blur-xl"> <header className="sticky top-0 z-40 h-16 border-b border-border/80 bg-card/92 pt-[env(safe-area-inset-top)] shadow-[0_1px_0_color-mix(in_oklab,var(--foreground)_5%,transparent)] backdrop-blur-xl">
<div className="page-pad mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 sm:h-16"> <div className="flex h-full w-full items-center gap-2 px-4 sm:gap-3 sm:px-5">
<Link <Link
href="/" href={pathname.startsWith("/admin") ? "/admin" : signedIn ? "/dashboard" : "/"}
className="text-xl" className="flex h-11 shrink-0 items-center"
aria-label={`${BRAND_NAME} home`} aria-label={`${BRAND_NAME} home`}
> >
<BrandLockup markClassName="size-6" /> <BrandLockup markClassName="size-7" wordmarkClassName="hidden sm:inline" />
</Link> </Link>
{signedIn && backend && groups.length > 0 && !pathname.startsWith("/admin") ? (
<>
<span className="mx-1 h-7 w-px bg-border" />
<div className="min-w-0 max-w-56 flex-1">
<GroupSwitcher groups={groups} activeGroupId={activeGroupId} compact />
</div>
</>
) : null}
<div className="ml-auto flex items-center gap-1">
{signedIn ? (
<>
<ApplicationSwitcher links={links} pathname={pathname} />
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Account menu for ${user?.name ?? "user"}`}
className={cn(topBarControlClass, "w-11 shrink-0 justify-center px-1.5 sm:w-auto sm:justify-start sm:px-3")}
>
<span className="grid size-8 place-items-center rounded-full border bg-background text-xs font-bold tracking-wider">
{user?.name ? initials(user.name) : <UserRoundIcon />}
</span>
<span className="hidden max-w-32 truncate text-sm font-semibold lg:block">{user?.name}</span>
<ChevronDownIcon aria-hidden="true" className="hidden size-3.5 text-muted-foreground sm:block" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Signed in</DropdownMenuLabel>
<div className="px-1.5 pb-2">
<p className="truncate text-sm font-semibold">{user?.name}</p>
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={async () => {
await authClient.signOut();
router.push("/");
router.refresh();
}}>
<LogOutIcon />
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<nav className="hidden items-center gap-1 sm:flex"> <nav className="hidden items-center gap-1 sm:flex">
{links.map((link) => ( {links.map((link) => (
<Button key={link.href} asChild variant="ghost" size="sm"> <Button key={link.href} asChild variant="ghost"><Link href={link.href}>{link.label}</Link></Button>
<Link href={link.href}>{link.label}</Link>
</Button>
))} ))}
{primary ? ( {primary ? <Button asChild><Link href={primary.href}>{primary.label}</Link></Button> : null}
<Button asChild size="sm">
<Link href={primary.href}>{primary.label}</Link>
</Button>
) : null}
<ThemeToggle /> <ThemeToggle />
{signedIn ? <SignOutButton /> : null}
</nav> </nav>
<div className="flex items-center gap-1 sm:hidden"> <div className="flex items-center gap-1 sm:hidden">
<ThemeToggle /> <ThemeToggle />
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button type="button" variant="outline" size="icon" aria-label="Open menu"><MenuIcon /></Button>
type="button"
variant="outline"
size="icon-lg"
className="tap-target"
aria-label="Open menu"
>
<MenuIcon aria-hidden="true" />
</Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" className="w-[min(20rem,90vw)]"> <SheetContent side="right" className="w-[min(20rem,90vw)]">
<SheetHeader> <SheetHeader><SheetTitle><BrandLockup /></SheetTitle></SheetHeader>
<SheetTitle className="text-2xl"> <nav className="flex flex-col gap-2 px-4">
<BrandLockup /> {links.map((link) => <Button asChild key={link.href} variant="ghost" className="justify-start"><Link href={link.href}>{link.label}</Link></Button>)}
</SheetTitle> {primary ? <Button asChild className="justify-start"><Link href={primary.href}>{primary.label}</Link></Button> : null}
</SheetHeader>
<nav className="flex flex-col gap-2 px-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{links.map((link) => (
<Button asChild key={link.href} variant="ghost" className="tap-target justify-start">
<Link href={link.href}>{link.label}</Link>
</Button>
))}
{primary ? (
<Button asChild className="tap-target justify-start">
<Link href={primary.href}>{primary.label}</Link>
</Button>
) : null}
{signedIn ? (
<div className="pt-2">
<SignOutButton />
</div>
) : null}
</nav> </nav>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </div>
</>
)}
</div>
</div> </div>
</header> </header>
); );
+13 -15
View File
@@ -1,21 +1,20 @@
import { headers } from "next/headers"; import { createServerCaller } from "@/trpc/server";
import { auth } from "@/server/auth";
import { getPlatformRole } from "@/server/roles";
import { getDeploymentSettings } from "@/server/settings";
import { SiteHeaderBar } from "@/components/site-header-bar"; import { SiteHeaderBar } from "@/components/site-header-bar";
export async function SiteHeader() { export async function SiteHeader() {
const session = await auth.api.getSession({ headers: await headers() }); const viewer = await (await createServerCaller()).viewer.me();
const platformRole = session ? await getPlatformRole(session.user.id) : null;
const settings = await getDeploymentSettings();
if (session) { if (viewer.session) {
return ( return (
<SiteHeaderBar <SiteHeaderBar
signedIn signedIn
user={{ name: viewer.session.user.name, email: viewer.session.user.email }}
groups={viewer.groups}
activeGroupId={viewer.activeGroupId}
links={[ links={[
{ href: "/dashboard", label: "Dashboard" }, { href: "/", label: "Public site" },
...(platformRole ? [{ href: "/admin", label: "Admin" }] : []), { href: "/dashboard", label: "Workspace" },
...(viewer.platformRole ? [{ href: "/admin", label: "Administration" }] : []),
]} ]}
/> />
); );
@@ -25,11 +24,10 @@ export async function SiteHeader() {
<SiteHeaderBar <SiteHeaderBar
signedIn={false} signedIn={false}
links={[{ href: "/sign-in", label: "Sign in" }]} links={[{ href: "/sign-in", label: "Sign in" }]}
primary={ primary={{
settings.openSignup href: "/sign-up",
? { href: "/sign-up", label: "Host an event" } label: viewer.openSignup ? "Host an event" : "Have an invite?",
: { href: "/sign-up", label: "Have an invite?" } }}
}
/> />
); );
} }
+6 -1
View File
@@ -4,7 +4,12 @@ import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
return ( return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem> <NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children} {children}
</NextThemesProvider> </NextThemesProvider>
); );
+51 -9
View File
@@ -2,30 +2,72 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "lucide-react"; import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { topBarControlClass } from "@/components/top-bar-control";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const choices = [
{ value: "system", label: "System", description: "Match this device", icon: MonitorIcon },
{ value: "light", label: "Light", description: "Always use light mode", icon: SunIcon },
{ value: "dark", label: "Dark", description: "Always use dark mode", icon: MoonIcon },
] as const;
export function ThemeToggle() { export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
const dark = mounted && resolvedTheme === "dark"; const selected = mounted ? (theme ?? "system") : "system";
const SelectedIcon = choices.find((choice) => choice.value === selected)?.icon ?? MonitorIcon;
return ( return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-lg" size="icon"
className="tap-target" className={cn(topBarControlClass, "justify-center px-0")}
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"} aria-label={`Theme: ${selected}`}
disabled={!mounted}
onClick={() => setTheme(dark ? "light" : "dark")}
> >
{dark ? <SunIcon /> : <MoonIcon />} <SelectedIcon />
</Button> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuGroup>
{choices.map((choice) => {
const Icon = choice.icon;
const active = selected === choice.value;
return (
<DropdownMenuItem
key={choice.value}
className="min-h-12 gap-2.5"
onSelect={() => setTheme(choice.value)}
>
<Icon />
<span className="min-w-0 flex-1">
<span className="block font-medium">{choice.label}</span>
<span className="block text-xs text-muted-foreground">{choice.description}</span>
</span>
{active ? <CheckIcon className="text-primary" /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
); );
} }
@@ -0,0 +1,4 @@
// Shared by the application, group, appearance, and account menus.
// Adapted from RaceTix's ticketing top bar; Manyangles uses 44px controls.
export const topBarControlClass =
"flex h-11 min-w-0 items-center gap-2 rounded-md border border-transparent bg-transparent px-3 text-sm font-semibold outline-none transition-[border-color,background-color,box-shadow] hover:border-border hover:bg-muted focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/25 data-[state=open]:border-border data-[state=open]:bg-muted";
+3 -3
View File
@@ -21,16 +21,16 @@ const buttonVariants = cva(
}, },
size: { size: {
default: default:
"h-10 gap-2 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", "h-11 gap-2 px-3.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-9 gap-1.5 rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5", sm: "h-9 gap-1.5 rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-12 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4", lg: "h-12 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-4 has-data-[icon=inline-start]:pl-4",
icon: "size-10", icon: "size-11",
"icon-xs": "icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "icon-sm":
"size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-11", "icon-lg": "size-12",
}, },
}, },
defaultVariants: { defaultVariants: {
+1 -1
View File
@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-10 w-full min-w-0 rounded-lg border border-input bg-card/55 px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-11 w-full min-w-0 rounded-lg border border-input bg-card/55 px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className
)} )}
{...props} {...props}
@@ -0,0 +1,18 @@
import type { ComponentProps } from "react";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "cn";
export function NativeSelect({ className, ...props }: ComponentProps<"select">) {
return (
<div className="relative inline-grid min-w-0 items-center">
<select
{...props}
className={cn(
"h-11 w-full appearance-none rounded-lg border border-input bg-background py-2 pl-3 pr-9 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50",
className,
)}
/>
<ChevronDownIcon aria-hidden="true" className="pointer-events-none absolute right-3 size-4 text-muted-foreground" />
</div>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card/55 py-2 pr-3 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card/55 py-2 pr-3 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { effectiveEvent, notesAreVisible } from "./event-lifecycle";
import { galleryIsPublic } from "./publishing";
const now = new Date("2026-09-09T12:00:00Z");
const before = new Date(now.getTime() - 1000);
const after = new Date(now.getTime() + 1000);
const event = { status: "draft" as const, uploadEnabled: true, publishAt: null, submissionsOpenAt: null, submissionsCloseAt: null, completedAt: null };
test("scheduled publication opens exactly at its boundary and never exposes drafts through closing", () => {
expect(effectiveEvent({ ...event, publishAt: after }, now).status).toBe("draft");
expect(effectiveEvent({ ...event, publishAt: now }, now).status).toBe("published");
expect(effectiveEvent({ ...event, submissionsCloseAt: before }, now).status).toBe("draft");
});
test("submission windows gate uploads and completion closes them", () => {
expect(effectiveEvent({ ...event, status: "published", submissionsOpenAt: after }, now).uploadEnabled).toBe(false);
expect(effectiveEvent({ ...event, publishAt: before, submissionsCloseAt: now }, now).status).toBe("closed");
expect(effectiveEvent({ ...event, status: "published", completedAt: before }, now).uploadEnabled).toBe(false);
});
test("visibility schedules never bypass never-public or expose content early", () => {
expect(galleryIsPublic("approved", before, after, now)).toBe(false);
expect(galleryIsPublic("approved", null, now, now)).toBe(true);
expect(galleryIsPublic("never", before, before, now)).toBe(false);
expect(notesAreVisible("automatic", after, now)).toBe(false);
expect(notesAreVisible("approved", now, now)).toBe(true);
expect(notesAreVisible("never", before, now)).toBe(false);
});
+16
View File
@@ -0,0 +1,16 @@
export function effectiveEvent<T extends {
status: "draft" | "published" | "closed";
uploadEnabled: boolean;
publishAt: Date | null;
submissionsOpenAt: Date | null;
submissionsCloseAt: Date | null;
completedAt: Date | null;
}>(event: T, now = new Date()): Omit<T, "status" | "uploadEnabled"> & { status: "draft" | "published" | "closed"; uploadEnabled: boolean } {
const publicStatus = event.publishAt ? (event.publishAt <= now ? (event.status === "draft" ? "published" : event.status) : "draft") : event.status;
const status = publicStatus === "draft" ? "draft" : event.completedAt || (event.submissionsCloseAt && event.submissionsCloseAt <= now) ? "closed" : publicStatus;
return { ...event, status, uploadEnabled: event.uploadEnabled && status === "published" && (!event.submissionsOpenAt || event.submissionsOpenAt <= now) };
}
export function notesAreVisible(policy: string, visibleAt: Date | null, now = new Date()) {
return policy !== "never" && (!visibleAt || visibleAt <= now);
}
+7
View File
@@ -0,0 +1,7 @@
import { effectiveEvent } from "./event-lifecycle";
export function eventStatusLabel(event: Parameters<typeof effectiveEvent>[0], now = new Date()) {
if (event.completedAt) return "Completed";
if (event.publishAt && event.publishAt > now) return "Scheduled";
const current = effectiveEvent(event, now);
return current.status === "draft" ? "Draft" : current.status === "closed" ? "Closed" : current.uploadEnabled ? "Open" : "Published";
}
+11
View File
@@ -0,0 +1,11 @@
// Same OpenStreetMap embed used by RaceTix.
export function openStreetMapEmbedUrl(latitude: number, longitude: number) {
const offset = 0.008;
const query = new URLSearchParams({
bbox: [Math.max(-180, longitude - offset), Math.max(-90, latitude - offset),
Math.min(180, longitude + offset), Math.min(90, latitude + offset)].join(","),
layer: "mapnik",
marker: `${latitude},${longitude}`,
});
return `https://www.openstreetmap.org/export/embed.html?${query}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test";
import { galleryIsPublic } from "./publishing";
import { updateEventInputSchema } from "@album/contracts";
test("gallery publishing respects never, automatic, and manual release", () => {
expect(galleryIsPublic("never", new Date())).toBe(false);
expect(galleryIsPublic("never", null)).toBe(false);
expect(galleryIsPublic("automatic", null)).toBe(true);
expect(galleryIsPublic("approved", null)).toBe(false);
expect(galleryIsPublic("approved", new Date())).toBe(true);
});
test("publishing settings reject invalid modes and preserve omitted preferences", () => {
const eventId = "7fa618fc-3fcb-4fd4-a0af-03f14124cccc";
expect(updateEventInputSchema.safeParse({ eventId, notesPolicy: "everyone" }).success).toBe(false);
expect(updateEventInputSchema.parse({ eventId }).galleryPolicy).toBeUndefined();
expect(updateEventInputSchema.parse({ eventId, notesPolicy: "automatic", showPhotoStats: false }).showPhotoStats).toBe(false);
});
+5
View File
@@ -0,0 +1,5 @@
import type { PublishingPolicy } from "@album/contracts";
export function galleryIsPublic(policy: PublishingPolicy, releasedAt: Date | null, visibleAt: Date | null = null, now = new Date()) {
return policy !== "never" && (!visibleAt || visibleAt <= now) && (policy === "automatic" || releasedAt !== null || (visibleAt !== null && visibleAt <= now));
}
+6
View File
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { slugify } from "./slug"; import { slugify } from "./slug";
import { eventSlugSchema } from "@album/contracts";
describe("slugify", () => { describe("slugify", () => {
test("keeps long generated slugs valid when truncation lands on a hyphen", () => {
const slug = slugify(`${"a".repeat(63)} next word`);
expect(slug).toBe("a".repeat(63));
expect(eventSlugSchema.safeParse(slug).success).toBe(true);
});
test("lowercases and hyphenates titles", () => { test("lowercases and hyphenates titles", () => {
expect(slugify("Summer Block Party")).toBe("summer-block-party"); expect(slugify("Summer Block Party")).toBe("summer-block-party");
}); });
+2 -1
View File
@@ -5,6 +5,7 @@ export function slugify(input: string) {
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.slice(0, 64); .slice(0, 64)
.replace(/-+$/, "");
return slug.length >= 3 ? slug : `event-${crypto.randomUUID().slice(0, 8)}`; return slug.length >= 3 ? slug : `event-${crypto.randomUUID().slice(0, 8)}`;
} }
+51
View File
@@ -0,0 +1,51 @@
import { CalendarDays, Images, Users, ShieldCheck, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export type NavigationItem = {
label: string;
href: string;
icon: LucideIcon;
description?: string;
section?: string;
matchPaths?: string[];
};
export type NavigationWorkspace = {
label: string;
description: string;
icon: LucideIcon;
href?: string;
items: NavigationItem[];
};
export const albumWorkspaces: NavigationWorkspace[] = [
{
label: "Events", description: "Shared albums, guest uploads, and photo review.",
icon: Images, href: "/dashboard",
items: [{ label: "All Events", href: "/dashboard", icon: CalendarDays, matchPaths: ["/dashboard/events"] }],
},
{
label: "People", description: "Manage the people who help run your events.",
icon: Users, href: "/dashboard/people",
items: [{ label: "Workspace Access", href: "/dashboard/people", icon: Users }],
},
];
export const adminWorkspaces: NavigationWorkspace[] = [
{
label: "Platform", description: "Users, workspace access, and invitations.",
icon: ShieldCheck, href: "/admin",
items: [{ label: "Overview", href: "/admin", icon: ShieldCheck }],
},
{
label: "Settings", description: "Signup and event creation policies for Manyangles.",
icon: Settings, href: "/admin/settings",
items: [{ label: "Deployment Settings", href: "/admin/settings", icon: Settings }],
},
];
export function activeNavigationItem(workspaces: NavigationWorkspace[], pathname: string) {
return workspaces.flatMap((workspace) => workspace.items)
.filter((item) => item.href === pathname || item.matchPaths?.some((path) => pathname === path || pathname.startsWith(path + "/")))
.sort((a, b) => b.href.length - a.href.length)[0];
}
+2
View File
@@ -1,5 +1,6 @@
import { createTRPCRouter, publicProcedure } from "./trpc"; import { createTRPCRouter, publicProcedure } from "./trpc";
import { eventRouter } from "./routers/event"; import { eventRouter } from "./routers/event";
import { bannersRouter } from "./routers/banners";
import { guestRouter } from "./routers/guest"; import { guestRouter } from "./routers/guest";
import { groupRouter } from "./routers/group"; import { groupRouter } from "./routers/group";
import { invitesRouter } from "./routers/invites"; import { invitesRouter } from "./routers/invites";
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
})), })),
viewer: viewerRouter, viewer: viewerRouter,
event: eventRouter, event: eventRouter,
banners: bannersRouter,
guest: guestRouter, guest: guestRouter,
photos: photosRouter, photos: photosRouter,
group: groupRouter, group: groupRouter,
@@ -0,0 +1,48 @@
import { randomUUID } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { eventBanners, getDb } from "@album/database";
import { bannerInputSchema, createBannerInputSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { createPresignedPutUrl, createPresignedGetUrl, headObject } from "@album/storage";
import { createTRPCRouter, protectedProcedure, loadEventAccess, requireEventPermission, EVENT_PERMISSIONS } from "../trpc";
import { getPlatformRole } from "@/server/roles";
import { consumeRateLimit } from "@/server/rate-limit";
async function authorize(userId: string, eventId: string) {
const { access } = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
}
export const bannersRouter = createTRPCRouter({
create: protectedProcedure.input(createBannerInputSchema).mutation(async ({ ctx, input }) => {
await authorize(ctx.session.user.id, input.eventId);
const limit = await consumeRateLimit({ namespace: "banner-upload", identifier: ctx.session.user.id, limit: 20, windowMs: 600_000 });
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many banner uploads. Try again shortly." });
const id = randomUUID();
const originalKey = `events/${input.eventId}/banners/${id}/original`;
await getDb().insert(eventBanners).values({ id, eventId: input.eventId, originalKey, contentType: input.contentType, byteSize: input.byteSize });
return { bannerId: id, uploadUrl: await createPresignedPutUrl({ key: originalKey, contentType: input.contentType }) };
}),
complete: protectedProcedure.input(bannerInputSchema).mutation(async ({ ctx, input }) => {
await authorize(ctx.session.user.id, input.eventId);
const scope = and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId));
const [banner] = await getDb().select().from(eventBanners).where(scope).limit(1);
if (!banner) throw new TRPCError({ code: "NOT_FOUND" });
if (banner.status !== "uploading") return { status: banner.status };
const head = await headObject(banner.originalKey);
const size = Number(head?.ContentLength ?? 0);
if (!head || size <= 0 || size > MAX_PHOTO_BYTES || head.ContentType !== banner.contentType) {
throw new TRPCError({ code: "BAD_REQUEST", message: "The banner upload is missing, too large, or has the wrong file type." });
}
await getDb().update(eventBanners).set({ status: "pending", byteSize: size, updatedAt: new Date() })
.where(and(scope, eq(eventBanners.status, "uploading")));
return { status: "pending" };
}),
status: protectedProcedure.input(bannerInputSchema).query(async ({ ctx, input }) => {
await authorize(ctx.session.user.id, input.eventId);
const [banner] = await getDb().select().from(eventBanners)
.where(and(eq(eventBanners.id, input.bannerId), eq(eventBanners.eventId, input.eventId))).limit(1);
if (!banner) throw new TRPCError({ code: "NOT_FOUND" });
return { status: banner.status, url: banner.status === "ready" && banner.displayKey ? await createPresignedGetUrl(banner.displayKey) : null };
}),
});
+61 -12
View File
@@ -1,13 +1,33 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm"; import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
import { and, count, desc, eq, isNotNull, ne, sql } from "drizzle-orm";
import { galleryIsPublic } from "@/lib/publishing";
import { effectiveEvent, notesAreVisible } from "@/lib/event-lifecycle";
import { events, getDb, guests, photos, submissions } from "@album/database"; import { events, getDb, guests, photos, submissions } from "@album/database";
import { eventSlugSchema } from "@album/contracts"; import { eventSlugSchema } from "@album/contracts";
import { createPresignedGetUrl } from "@album/storage"; import { createPresignedGetUrl } from "@album/storage";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, publicProcedure } from "../trpc";
export const eventRouter = createTRPCRouter({ export const eventRouter = createTRPCRouter({
community: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [stored] = await getDb().select().from(events).where(eq(events.slug, input)).limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") throw new TRPCError({ code: "NOT_FOUND" });
const noteFilter = and(eq(guests.eventId, event.id), isNotNull(guests.note), ne(guests.note, ""));
const [notes, photoCount, submitterCount, noteCount] = await Promise.all([
!notesAreVisible(event.notesPolicy, event.notesVisibleAt) ? [] : getDb().select({ id: guests.id, displayName: guests.displayName, note: guests.note })
.from(guests).where(and(noteFilter, eq(guests.noteApproved, true))).orderBy(desc(guests.updatedAt)),
event.showPhotoStats ? getDb().select({ value: count() }).from(photos)
.where(and(eq(photos.eventId, event.id), ne(photos.processingStatus, "uploading"))) : [],
event.showSubmitterStats ? getDb().select({ value: count() }).from(guests)
.where(and(eq(guests.eventId, event.id), sql`((${guests.note} IS NOT NULL AND ${guests.note} <> '') OR EXISTS (SELECT 1 FROM submissions s JOIN photos p ON p.submission_id = s.id AND p.event_id = ${event.id} WHERE s.guest_id = ${guests.id} AND s.event_id = ${event.id} AND p.processing_status <> 'uploading'))`)) : [],
event.showNoteStats ? getDb().select({ value: count() }).from(guests).where(noteFilter) : [],
]);
return { notes, stats: { photos: photoCount[0]?.value ?? null, submitters: submitterCount[0]?.value ?? null, notes: noteCount[0]?.value ?? null } };
}),
listed: publicProcedure.query(async () => { listed: publicProcedure.query(async () => {
return getDb() const listed = await getDb()
.select({ .select({
id: events.id, id: events.id,
slug: events.slug, slug: events.slug,
@@ -16,49 +36,78 @@ export const eventRouter = createTRPCRouter({
startsAt: events.startsAt, startsAt: events.startsAt,
endsAt: events.endsAt, endsAt: events.endsAt,
galleryReleasedAt: events.galleryReleasedAt, galleryReleasedAt: events.galleryReleasedAt,
galleryPolicy: events.galleryPolicy,
bannerPhotoId: events.bannerPhotoId,
customBannerId: events.customBannerId,
galleryVisibleAt: events.galleryVisibleAt,
}) })
.from(events) .from(events)
.where(and(eq(events.listed, true), eq(events.status, "published"))) .where(and(eq(events.listed, true), sql`(${events.status} = 'published' OR (${events.status} = 'draft' AND ${events.publishAt} <= now()))`, sql`(${events.publishAt} IS NULL OR ${events.publishAt} <= now())`))
.orderBy(desc(events.startsAt), desc(events.createdAt)); .orderBy(desc(events.startsAt), desc(events.createdAt));
return Promise.all(listed.map(async ({ bannerPhotoId, customBannerId, ...event }) => ({
...event,
bannerUrl: customBannerId
? await customBannerUrl(event.id, customBannerId)
: galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, bannerPhotoId) : null,
})));
}), }),
bySlug: publicProcedure.input(eventSlugSchema).query(async ({ input }) => { bySlug: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb() const [stored] = await getDb()
.select({ .select({
id: events.id, id: events.id,
slug: events.slug, slug: events.slug,
title: events.title, title: events.title,
description: events.description, description: events.description,
location: events.location,
latitude: events.latitude,
longitude: events.longitude,
bannerPhotoId: events.bannerPhotoId,
customBannerId: events.customBannerId,
startsAt: events.startsAt, startsAt: events.startsAt,
endsAt: events.endsAt, endsAt: events.endsAt,
status: events.status, status: events.status,
listed: events.listed, listed: events.listed,
uploadEnabled: events.uploadEnabled, uploadEnabled: events.uploadEnabled,
publishAt: events.publishAt,
submissionsOpenAt: events.submissionsOpenAt,
submissionsCloseAt: events.submissionsCloseAt,
completedAt: events.completedAt,
galleryVisibleAt: events.galleryVisibleAt,
notesVisibleAt: events.notesVisibleAt,
notesPolicy: events.notesPolicy,
galleryPolicy: events.galleryPolicy,
showPhotoStats: events.showPhotoStats,
showSubmitterStats: events.showSubmitterStats,
showNoteStats: events.showNoteStats,
galleryReleasedAt: events.galleryReleasedAt, galleryReleasedAt: events.galleryReleasedAt,
}) })
.from(events) .from(events)
.where(eq(events.slug, input)) .where(eq(events.slug, input))
.limit(1); .limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") { if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
} }
return event; return {
...event,
bannerUrl: event.customBannerId
? await customBannerUrl(event.id, event.customBannerId)
: galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt) ? await eventBannerUrl(event.id, event.bannerPhotoId) : null,
};
}), }),
gallery: publicProcedure.input(eventSlugSchema).query(async ({ input }) => { gallery: publicProcedure.input(eventSlugSchema).query(async ({ input }) => {
const [event] = await getDb() const [stored] = await getDb()
.select({ .select()
id: events.id,
status: events.status,
galleryReleasedAt: events.galleryReleasedAt,
})
.from(events) .from(events)
.where(eq(events.slug, input)) .where(eq(events.slug, input))
.limit(1); .limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") { if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
} }
if (!event.galleryReleasedAt) { if (!galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) {
return []; return [];
} }
const rows = await getDb() const rows = await getDb()
+15 -4
View File
@@ -1,17 +1,20 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { events, getDb, guests, submissions } from "@album/database"; import { events, getDb, guests, submissions } from "@album/database";
import { ensureGuestInputSchema, startSubmissionInputSchema } from "@album/contracts"; import { ensureGuestInputSchema, startSubmissionInputSchema } from "@album/contracts";
import { createTRPCRouter, publicProcedure } from "../trpc"; import { createTRPCRouter, publicProcedure } from "../trpc";
import { guestCookieName, serializeCookie } from "@/server/cookies"; import { guestCookieName, serializeCookie } from "@/server/cookies";
import { hashToken, newToken } from "@/server/tokens"; import { hashToken, newToken } from "@/server/tokens";
import { consumeRateLimit } from "@/server/rate-limit";
import { effectiveEvent } from "@/lib/event-lifecycle";
async function requirePublishedEvent(slug: string) { async function requirePublishedEvent(slug: string) {
const [event] = await getDb() const [stored] = await getDb()
.select() .select()
.from(events) .from(events)
.where(eq(events.slug, slug)) .where(eq(events.slug, slug))
.limit(1); .limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") { if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
} }
@@ -23,13 +26,19 @@ export const guestRouter = createTRPCRouter({
.input(ensureGuestInputSchema) .input(ensureGuestInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const event = await requirePublishedEvent(input.eventSlug); const event = await requirePublishedEvent(input.eventSlug);
if (input.note && event.status !== "published") throw new TRPCError({ code: "FORBIDDEN", message: "Notes are closed for this event" });
if (input.note && event.submissionsOpenAt && event.submissionsOpenAt > new Date()) throw new TRPCError({ code: "FORBIDDEN", message: "Submissions have not opened yet" });
if (input.note) {
const limit = await consumeRateLimit({ namespace: `notes:${event.id}`, identifier: ctx.clientIdentifier, limit: 20, windowMs: 10 * 60 * 1000 });
if (!limit.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many notes. Try again shortly." });
}
const existingToken = ctx.guestTokenForEvent(event.id); const existingToken = ctx.guestTokenForEvent(event.id);
let guest = null; let guest = null;
if (existingToken) { if (existingToken) {
const [row] = await getDb() const [row] = await getDb()
.select() .select()
.from(guests) .from(guests)
.where(eq(guests.tokenHash, hashToken(existingToken))) .where(and(eq(guests.eventId, event.id), eq(guests.tokenHash, hashToken(existingToken))))
.limit(1); .limit(1);
if (row && row.eventId === event.id) guest = row; if (row && row.eventId === event.id) guest = row;
} }
@@ -43,9 +52,10 @@ export const guestRouter = createTRPCRouter({
notifyWhenReady: input.notifyWhenReady ?? guest.notifyWhenReady, notifyWhenReady: input.notifyWhenReady ?? guest.notifyWhenReady,
note: note:
input.note === undefined ? guest.note : input.note || null, input.note === undefined ? guest.note : input.note || null,
noteApproved: input.note === undefined || input.note === guest.note ? guest.noteApproved : event.notesPolicy === "automatic",
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(guests.id, guest.id)) .where(and(eq(guests.eventId, event.id), eq(guests.id, guest.id)))
.returning(); .returning();
return { return {
guestId: updated!.id, guestId: updated!.id,
@@ -62,6 +72,7 @@ export const guestRouter = createTRPCRouter({
email: input.email ?? null, email: input.email ?? null,
notifyWhenReady: Boolean(input.notifyWhenReady && input.email), notifyWhenReady: Boolean(input.notifyWhenReady && input.email),
note: input.note || null, note: input.note || null,
noteApproved: Boolean(input.note) && event.notesPolicy === "automatic",
tokenHash: hashToken(token), tokenHash: hashToken(token),
}) })
.returning(); .returning();
+194 -43
View File
@@ -1,7 +1,15 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm"; import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
import { searchLocations } from "@/server/location-search";
import { notifyEventGuests } from "@/server/guest-notifications";
import { renderAlbumReadyEmail, emailBrowserPreview } from "@album/email";
import { emailDeliveryOutcome } from "@album/email/webhooks";
import { galleryIsPublic } from "@/lib/publishing";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { and, desc, eq, sql, inArray, like } from "drizzle-orm";
import { import {
auditEvents, auditEvents,
emailDeliveries,
eventMemberships, eventMemberships,
events, events,
getDb, getDb,
@@ -13,7 +21,11 @@ import {
} from "@album/database"; } from "@album/database";
import { import {
createEventInputSchema, createEventInputSchema,
checkEventSlugInputSchema,
generateEventSlugInputSchema,
locationSearchInputSchema,
moderatePhotoInputSchema, moderatePhotoInputSchema,
moderateNoteInputSchema,
moderateSubmissionInputSchema, moderateSubmissionInputSchema,
setEventMemberInputSchema, setEventMemberInputSchema,
updateEventInputSchema, updateEventInputSchema,
@@ -23,7 +35,6 @@ import {
deletePrefix, deletePrefix,
photoObjectPrefix, photoObjectPrefix,
} from "@album/storage"; } from "@album/storage";
import { sendAlbumReadyEmail } from "@album/email";
import { z } from "zod"; import { z } from "zod";
import { import {
createTRPCRouter, createTRPCRouter,
@@ -68,6 +79,16 @@ async function signedPhotoUrls(photo: {
} }
export const managerRouter = createTRPCRouter({ export const managerRouter = createTRPCRouter({
searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
try {
return await searchLocations(input.query);
} catch {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Location search is unavailable. Try again, or keep a text-only location." });
}
}),
events: protectedProcedure.query(async ({ ctx }) => { events: protectedProcedure.query(async ({ ctx }) => {
const platformRole = await getPlatformRole(ctx.session.user.id); const platformRole = await getPlatformRole(ctx.session.user.id);
const memberships = await getDb() const memberships = await getDb()
@@ -81,15 +102,21 @@ export const managerRouter = createTRPCRouter({
.orderBy(desc(events.createdAt)); .orderBy(desc(events.createdAt));
if (ctx.activeGroupId) { if (ctx.activeGroupId) {
return memberships return Promise.all(memberships
.filter((row) => row.event.groupId === ctx.activeGroupId) .filter((row) => row.event.groupId === ctx.activeGroupId)
.map((row) => ({ ...row.event, role: row.role })); .map(async (row) => ({ ...row.event, role: row.role,
bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId),
})));
} }
if (platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.EVENTS_READ)) { if (platformRole && hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.EVENTS_READ)) {
const all = await getDb().select().from(events).orderBy(desc(events.createdAt)); const all = await getDb().select().from(events).orderBy(desc(events.createdAt));
return all.map((event) => ({ ...event, role: "platform" as const })); return Promise.all(all.map(async (event) => ({ ...event, role: "platform" as const,
bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId),
})));
} }
return memberships.map((row) => ({ ...row.event, role: row.role })); return Promise.all(memberships.map(async (row) => ({ ...row.event, role: row.role,
bannerUrl: row.event.customBannerId ? await customBannerUrl(row.event.id, row.event.customBannerId) : await eventBannerUrl(row.event.id, row.event.bannerPhotoId),
})));
}), }),
event: protectedProcedure event: protectedProcedure
@@ -103,6 +130,7 @@ export const managerRouter = createTRPCRouter({
); );
return { return {
...event, ...event,
bannerUrl: event.customBannerId ? await customBannerUrl(event.id, event.customBannerId) : await eventBannerUrl(event.id, event.bannerPhotoId),
guestUrl: `${publicAppOrigin()}/e/${event.slug}`, guestUrl: `${publicAppOrigin()}/e/${event.slug}`,
permissions: access.permissions, permissions: access.permissions,
role: access.role, role: access.role,
@@ -225,6 +253,19 @@ export const managerRouter = createTRPCRouter({
return event; return event;
}), }),
checkSlug: protectedProcedure.input(checkEventSlugInputSchema).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, input.slug)).limit(1);
return { available: !existing || existing.id === input.eventId };
}),
generateSlug: protectedProcedure.input(generateEventSlugInputSchema).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
return { slug: await uniqueEventSlug(slugify(input.title), input.eventId) };
}),
updateEvent: protectedProcedure updateEvent: protectedProcedure
.input(updateEventInputSchema) .input(updateEventInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -235,9 +276,26 @@ export const managerRouter = createTRPCRouter({
platformRole, platformRole,
); );
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE); requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const slug = input.slug if (input.bannerPhotoId) {
? await uniqueEventSlug(input.slug, event.id) const bannerUrl = await eventBannerUrl(event.id, input.bannerPhotoId);
: event.slug; if (!bannerUrl) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a processed, public photo from this event for the banner." });
}
}
if (input.customBannerId && !(await customBannerUrl(event.id, input.customBannerId))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a finished banner upload from this event." });
}
if (input.customBannerId && input.bannerPhotoId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Choose one banner source." });
}
const slug = input.slug ?? event.slug;
const schedule = { ...event, ...input };
if (schedule.startsAt && schedule.endsAt && schedule.endsAt <= schedule.startsAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Event end must be after its start." });
if (schedule.submissionsOpenAt && schedule.submissionsCloseAt && schedule.submissionsCloseAt <= schedule.submissionsOpenAt) throw new TRPCError({ code: "BAD_REQUEST", message: "Submission closing must be after opening." });
if (slug !== event.slug) {
const [existing] = await getDb().select({ id: events.id }).from(events).where(eq(events.slug, slug)).limit(1);
if (existing) throw new TRPCError({ code: "CONFLICT", message: "That guest link is already taken. Choose another or generate one." });
}
const [updated] = await getDb() const [updated] = await getDb()
.update(events) .update(events)
.set({ .set({
@@ -245,15 +303,41 @@ export const managerRouter = createTRPCRouter({
slug, slug,
description: description:
input.description === undefined ? event.description : input.description, input.description === undefined ? event.description : input.description,
location: input.location === undefined ? event.location : input.location,
latitude: input.locationCoordinates === undefined
? (input.location !== undefined && input.location !== event.location ? null : event.latitude)
: input.locationCoordinates?.latitude ?? null,
longitude: input.locationCoordinates === undefined
? (input.location !== undefined && input.location !== event.location ? null : event.longitude)
: input.locationCoordinates?.longitude ?? null,
bannerPhotoId: input.customBannerId ? null : input.bannerPhotoId === undefined ? event.bannerPhotoId : input.bannerPhotoId,
customBannerId: input.customBannerId === undefined ? (input.bannerPhotoId ? null : event.customBannerId) : input.customBannerId,
startsAt: input.startsAt === undefined ? event.startsAt : input.startsAt, startsAt: input.startsAt === undefined ? event.startsAt : input.startsAt,
endsAt: input.endsAt === undefined ? event.endsAt : input.endsAt, endsAt: input.endsAt === undefined ? event.endsAt : input.endsAt,
status: input.status ?? event.status, status: input.status ?? event.status,
listed: input.listed ?? event.listed, listed: input.listed ?? event.listed,
uploadEnabled: input.uploadEnabled ?? event.uploadEnabled, uploadEnabled: input.uploadEnabled ?? event.uploadEnabled,
publishAt: input.status ? null : input.publishAt === undefined ? event.publishAt : input.publishAt,
submissionsOpenAt: input.submissionsOpenAt === undefined ? event.submissionsOpenAt : input.submissionsOpenAt,
submissionsCloseAt: input.submissionsCloseAt === undefined ? (input.status === "published" && event.submissionsCloseAt && event.submissionsCloseAt <= new Date() ? null : event.submissionsCloseAt) : input.submissionsCloseAt,
galleryVisibleAt: input.galleryVisibleAt === undefined ? event.galleryVisibleAt : input.galleryVisibleAt,
notesVisibleAt: input.notesVisibleAt === undefined ? event.notesVisibleAt : input.notesVisibleAt,
completedAt: input.status === "published" ? null : event.completedAt,
notesPolicy: input.notesPolicy ?? event.notesPolicy,
galleryPolicy: input.galleryPolicy ?? event.galleryPolicy,
showPhotoStats: input.showPhotoStats ?? event.showPhotoStats,
showSubmitterStats: input.showSubmitterStats ?? event.showSubmitterStats,
showNoteStats: input.showNoteStats ?? event.showNoteStats,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(events.id, event.id)) .where(eq(events.id, event.id))
.returning(); .returning().catch((error: unknown) => {
const dbError = error as { code?: string; cause?: { code?: string } };
if (dbError.code === "23505" || dbError.cause?.code === "23505") {
throw new TRPCError({ code: "CONFLICT", message: "That guest link was just taken. Choose another or generate one." });
}
throw error;
});
await writeAudit({ await writeAudit({
groupId: event.groupId, groupId: event.groupId,
eventId: event.id, eventId: event.id,
@@ -261,6 +345,11 @@ export const managerRouter = createTRPCRouter({
action: "event.update", action: "event.update",
subjectType: "event", subjectType: "event",
subjectId: event.id, subjectId: event.id,
metadata: Object.fromEntries(Object.keys(input).filter((key) => key !== "eventId" && key in event && key in updated!).flatMap((key) => {
const before = JSON.stringify(event[key as keyof typeof event]);
const after = JSON.stringify(updated![key as keyof typeof event]);
return before === after ? [] : [[`${key}.before`, before ?? null], [`${key}.after`, after ?? null]];
})),
}); });
return updated!; return updated!;
}), }),
@@ -275,6 +364,8 @@ export const managerRouter = createTRPCRouter({
platformRole, platformRole,
); );
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE); requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
if (event.galleryPolicy === "never") throw new TRPCError({ code: "BAD_REQUEST", message: "Enable gallery publishing before releasing it." });
if ((event.publishAt && event.publishAt > new Date()) || (event.galleryVisibleAt && event.galleryVisibleAt > new Date())) throw new TRPCError({ code: "BAD_REQUEST", message: "Clear the future visibility schedule before releasing now." });
const releasedAt = event.galleryReleasedAt ?? new Date(); const releasedAt = event.galleryReleasedAt ?? new Date();
await getDb() await getDb()
.update(events) .update(events)
@@ -284,31 +375,9 @@ export const managerRouter = createTRPCRouter({
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(events.id, event.id)); .where(eq(events.id, event.id));
let notified = 0; let queued = 0;
if (input.notifyGuests !== false) { if (input.notifyGuests !== false) {
const waiting = await getDb() queued = (await notifyEventGuests(event, ctx.session.user.id)).queued;
.select()
.from(guests)
.where(
and(
eq(guests.eventId, event.id),
eq(guests.notifyWhenReady, true),
),
);
const galleryUrl = `${publicAppOrigin()}/e/${event.slug}`;
for (const guest of waiting) {
if (!guest.email || guest.notifiedAt) continue;
await sendAlbumReadyEmail({
to: guest.email,
eventTitle: event.title,
galleryUrl,
});
await getDb()
.update(guests)
.set({ notifiedAt: new Date(), updatedAt: new Date() })
.where(eq(guests.id, guest.id));
notified += 1;
}
} }
await writeAudit({ await writeAudit({
groupId: event.groupId, groupId: event.groupId,
@@ -317,9 +386,9 @@ export const managerRouter = createTRPCRouter({
action: "gallery.release", action: "gallery.release",
subjectType: "event", subjectType: "event",
subjectId: event.id, subjectId: event.id,
metadata: { notified }, metadata: { queued },
}); });
return { ok: true as const, notified }; return { ok: true as const, queued };
}), }),
photos: protectedProcedure photos: protectedProcedure
@@ -405,7 +474,7 @@ export const managerRouter = createTRPCRouter({
action: "photo.visibility", action: "photo.visibility",
subjectType: "photo", subjectType: "photo",
subjectId: photo.id, subjectId: photo.id,
metadata: { visibility: input.visibility }, metadata: { "visibility.before": photo.visibility, "visibility.after": input.visibility },
}); });
return updated!; return updated!;
}), }),
@@ -515,6 +584,16 @@ export const managerRouter = createTRPCRouter({
return { ok: true as const }; return { ok: true as const };
}), }),
moderateNote: protectedProcedure.input(moderateNoteInputSchema).mutation(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
const rows = await getDb().update(guests).set({ noteApproved: input.approved, updatedAt: new Date() })
.where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.guestId))).returning({ id: guests.id });
if (!rows.length) throw new TRPCError({ code: "NOT_FOUND" });
await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "note.approval", subjectType: "guest", subjectId: input.guestId, metadata: { approved: input.approved } });
return { ok: true };
}),
notes: protectedProcedure notes: protectedProcedure
.input(z.object({ eventId: z.string().uuid() })) .input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -530,6 +609,7 @@ export const managerRouter = createTRPCRouter({
id: guests.id, id: guests.id,
displayName: guests.displayName, displayName: guests.displayName,
note: guests.note, note: guests.note,
noteApproved: guests.noteApproved,
createdAt: guests.createdAt, createdAt: guests.createdAt,
}) })
.from(guests) .from(guests)
@@ -537,6 +617,53 @@ export const managerRouter = createTRPCRouter({
.orderBy(desc(guests.createdAt)); .orderBy(desc(guests.createdAt));
}), }),
completeEvent: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
if (effectiveEvent(event).status === "draft") throw new TRPCError({ code: "BAD_REQUEST", message: "Publish the event before completing it." });
await getDb().update(events).set({ completedAt: new Date(), status: "closed", updatedAt: new Date() }).where(eq(events.id, event.id));
await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "event.complete", subjectType: "event", subjectId: event.id });
return { ok: true };
}),
notifyGuests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
if (effectiveEvent(event).status !== "closed") throw new TRPCError({ code: "BAD_REQUEST", message: "Complete the event before sending completion emails." });
if ((event.publishAt && event.publishAt > new Date()) || !galleryIsPublic(event.galleryPolicy, event.galleryReleasedAt, event.galleryVisibleAt)) throw new TRPCError({ code: "BAD_REQUEST", message: "Make the gallery public before notifying guests." });
return notifyEventGuests(event, ctx.session.user.id);
}),
emailPreview: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { event, access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
const rendered = renderAlbumReadyEmail({ to: "preview@manyangles.test", eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` });
return { html: emailBrowserPreview(rendered.html), subject: rendered.subject };
}),
emailHistory: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
return getDb().select({ id: emailDeliveries.id, recipient: emailDeliveries.recipient, status: emailDeliveries.status, outcome: emailDeliveryOutcome, attempts: emailDeliveries.attempts, providerId: emailDeliveries.providerId, lastError: emailDeliveries.lastError, updatedAt: emailDeliveries.updatedAt, provider: emailDeliveries.provider, firstAttemptAt: emailDeliveries.firstAttemptAt })
.from(emailDeliveries).where(eq(emailDeliveries.eventId, input.eventId)).orderBy(desc(emailDeliveries.createdAt)).limit(100);
}),
retryEmail: protectedProcedure.input(z.object({ eventId: z.string().uuid(), deliveryId: z.string().uuid() })).mutation(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.GALLERY_RELEASE);
const rows = await getDb().update(emailDeliveries).set({ status: "pending", nextAttemptAt: new Date(), updatedAt: new Date() }).where(and(eq(emailDeliveries.eventId, input.eventId), eq(emailDeliveries.id, input.deliveryId), eq(emailDeliveries.status, "review"), eq(emailDeliveries.provider, "resend"), sql`${emailDeliveries.firstAttemptAt} > now() - interval '23 hours'`)).returning({ id: emailDeliveries.id });
if (!rows.length) throw new TRPCError({ code: "BAD_REQUEST", message: "This delivery cannot safely retry. Check the provider delivery record; its retry window may have expired." });
await writeAudit({ eventId: input.eventId, actorUserId: ctx.session.user.id, action: "guest.email.retry", subjectType: "email", subjectId: input.deliveryId });
return { ok: true };
}),
guests: protectedProcedure.input(z.object({ eventId: z.string().uuid() })).query(async ({ ctx, input }) => {
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_READ);
return getDb().select({ id: guests.id, displayName: guests.displayName, email: guests.email, notifyWhenReady: guests.notifyWhenReady, notifiedAt: guests.notifiedAt, notificationClaimedAt: guests.notificationClaimedAt, createdAt: guests.createdAt })
.from(guests).where(eq(guests.eventId, input.eventId)).orderBy(desc(guests.createdAt));
}),
members: protectedProcedure members: protectedProcedure
.input(z.object({ eventId: z.string().uuid() })) .input(z.object({ eventId: z.string().uuid() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -618,7 +745,7 @@ export const managerRouter = createTRPCRouter({
action: "event.member.set", action: "event.member.set",
subjectType: "user", subjectType: "user",
subjectId: target.id, subjectId: target.id,
metadata: { role: input.role }, metadata: { "role.before": existing?.role ?? null, "role.after": input.role },
}); });
return { ok: true as const }; return { ok: true as const };
}), }),
@@ -669,7 +796,7 @@ export const managerRouter = createTRPCRouter({
}), }),
audit: protectedProcedure audit: protectedProcedure
.input(z.object({ eventId: z.string().uuid() })) .input(z.object({ eventId: z.string().uuid(), page: z.number().int().min(0).max(10000).default(0), category: z.enum(["all", "event", "photo", "note", "guest", "gallery", "submission"]).default("all") }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id); const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess( const { access } = await loadEventAccess(
@@ -678,9 +805,10 @@ export const managerRouter = createTRPCRouter({
platformRole, platformRole,
); );
requireEventPermission(access.permissions, EVENT_PERMISSIONS.AUDIT_READ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.AUDIT_READ);
return getDb() const rows = await getDb()
.select({ .select({
id: auditEvents.id, id: auditEvents.id,
actorName: user.name,
action: auditEvents.action, action: auditEvents.action,
subjectType: auditEvents.subjectType, subjectType: auditEvents.subjectType,
subjectId: auditEvents.subjectId, subjectId: auditEvents.subjectId,
@@ -689,9 +817,32 @@ export const managerRouter = createTRPCRouter({
actorUserId: auditEvents.actorUserId, actorUserId: auditEvents.actorUserId,
}) })
.from(auditEvents) .from(auditEvents)
.where(eq(auditEvents.eventId, input.eventId)) .leftJoin(user, eq(user.id, auditEvents.actorUserId))
.orderBy(desc(auditEvents.createdAt)) .where(and(eq(auditEvents.eventId, input.eventId), input.category === "all" ? undefined : like(auditEvents.action, `${input.category}.%`)))
.limit(100); .orderBy(desc(auditEvents.createdAt), desc(auditEvents.id))
.limit(50).offset(input.page * 50);
const ids = (type: string) => rows.filter((row) => row.subjectType === type).map((row) => row.subjectId);
const [accounts, attendees, assets] = await Promise.all([
ids("user").length ? getDb().select({ id: user.id, name: user.name }).from(user).where(inArray(user.id, ids("user"))) : [],
ids("guest").length ? getDb().select({ id: guests.id, name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), inArray(guests.id, ids("guest")))) : [],
ids("photo").length ? getDb().select({ id: photos.id, displayKey: photos.displayKey, visibility: photos.visibility }).from(photos).where(and(eq(photos.eventId, input.eventId), inArray(photos.id, ids("photo")))) : [],
]);
return Promise.all(rows.map(async (row) => {
let subjectLabel = typeof row.metadata.subjectLabel === "string" ? row.metadata.subjectLabel : `${row.subjectType} ${row.subjectId}`;
let assetUrl: string | null = null;
if (row.subjectType === "user") {
const person = accounts.find((person) => person.id === row.subjectId);
if (person) subjectLabel = person.name;
} else if (row.subjectType === "guest") {
const person = attendees.find((person) => person.id === row.subjectId);
if (person) subjectLabel = person.name ?? "Anonymous guest";
} else if (row.subjectType === "photo") {
const photo = assets.find((photo) => photo.id === row.subjectId);
const allowed = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ) ? PRIVATE_VISIBILITIES : MEMBER_VISIBILITIES;
if (access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_READ) && photo?.displayKey && allowed.includes(photo.visibility)) assetUrl = await createPresignedGetUrl(photo.displayKey);
}
return { ...row, subjectLabel, assetUrl };
}));
}), }),
deleteEvent: protectedProcedure deleteEvent: protectedProcedure
+17 -7
View File
@@ -15,16 +15,18 @@ import { createTRPCRouter, publicProcedure } from "../trpc";
import { consumeRateLimit } from "@/server/rate-limit"; import { consumeRateLimit } from "@/server/rate-limit";
import { hashToken } from "@/server/tokens"; import { hashToken } from "@/server/tokens";
import { guests } from "@album/database"; import { guests } from "@album/database";
import { effectiveEvent } from "@/lib/event-lifecycle";
export const photosRouter = createTRPCRouter({ export const photosRouter = createTRPCRouter({
create: publicProcedure create: publicProcedure
.input(createPhotoInputSchema) .input(createPhotoInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [event] = await getDb() const [stored] = await getDb()
.select() .select()
.from(events) .from(events)
.where(eq(events.slug, input.eventSlug)) .where(eq(events.slug, input.eventSlug))
.limit(1); .limit(1);
const event = stored ? effectiveEvent(stored) : null;
if (!event || event.status === "draft") { if (!event || event.status === "draft") {
throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" });
} }
@@ -37,7 +39,7 @@ export const photosRouter = createTRPCRouter({
const [submission] = await getDb() const [submission] = await getDb()
.select() .select()
.from(submissions) .from(submissions)
.where(eq(submissions.id, input.submissionId)) .where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, event.id)))
.limit(1); .limit(1);
if (!submission || submission.eventId !== event.id) { if (!submission || submission.eventId !== event.id) {
throw new TRPCError({ throw new TRPCError({
@@ -58,6 +60,7 @@ export const photosRouter = createTRPCRouter({
.where( .where(
and( and(
eq(guests.id, submission.guestId), eq(guests.id, submission.guestId),
eq(guests.eventId, event.id),
eq(guests.tokenHash, hashToken(token)), eq(guests.tokenHash, hashToken(token)),
), ),
) )
@@ -86,7 +89,7 @@ export const photosRouter = createTRPCRouter({
eventId: event.id, eventId: event.id,
submissionId: submission.id, submissionId: submission.id,
processingStatus: "uploading", processingStatus: "uploading",
visibility: "pending", visibility: event.galleryPolicy === "automatic" ? "public" : "pending",
originalKey: "pending", originalKey: "pending",
contentType: input.contentType, contentType: input.contentType,
byteSize: input.byteSize, byteSize: input.byteSize,
@@ -97,7 +100,7 @@ export const photosRouter = createTRPCRouter({
await getDb() await getDb()
.update(photos) .update(photos)
.set({ originalKey: key, updatedAt: new Date() }) .set({ originalKey: key, updatedAt: new Date() })
.where(eq(photos.id, photo.id)); .where(and(eq(photos.id, photo.id), eq(photos.eventId, event.id)));
const uploadUrl = await createPresignedPutUrl({ const uploadUrl = await createPresignedPutUrl({
key, key,
contentType: input.contentType, contentType: input.contentType,
@@ -107,13 +110,18 @@ export const photosRouter = createTRPCRouter({
complete: publicProcedure complete: publicProcedure
.input(completePhotoInputSchema) .input(completePhotoInputSchema)
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const [photo] = await getDb() const [photo] = await getDb()
.select() .select()
.from(photos) .from(photos)
.where(eq(photos.id, input.photoId)) .where(eq(photos.id, input.photoId))
.limit(1); .limit(1);
if (!photo) throw new TRPCError({ code: "NOT_FOUND" }); if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
const token = ctx.guestTokenForEvent(photo.eventId);
const [owner] = token ? await getDb().select({ id: guests.id }).from(guests)
.innerJoin(submissions, and(eq(submissions.guestId, guests.id), eq(submissions.eventId, photo.eventId)))
.where(and(eq(submissions.id, photo.submissionId), eq(guests.eventId, photo.eventId), eq(guests.tokenHash, hashToken(token)))).limit(1) : [];
if (!owner) throw new TRPCError({ code: "FORBIDDEN", message: "Guest session does not match this upload" });
if (photo.processingStatus !== "uploading") { if (photo.processingStatus !== "uploading") {
return { return {
photoId: photo.id, photoId: photo.id,
@@ -135,14 +143,16 @@ export const photosRouter = createTRPCRouter({
}); });
} }
await getDb().transaction(async (tx) => { await getDb().transaction(async (tx) => {
await tx const changed = await tx
.update(photos) .update(photos)
.set({ .set({
processingStatus: "processing", processingStatus: "processing",
byteSize: size, byteSize: size,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(photos.id, photo.id)); .where(and(eq(photos.id, photo.id), eq(photos.eventId, photo.eventId), eq(photos.processingStatus, "uploading")))
.returning({ id: photos.id });
if (!changed.length) return;
await tx.insert(photoJobs).values({ await tx.insert(photoJobs).values({
photoId: photo.id, photoId: photo.id,
kind: "transcode", kind: "transcode",
+13 -3
View File
@@ -1,4 +1,5 @@
import { auditEvents, type Database } from "@album/database"; import { auditEvents, user, guests, type Database } from "@album/database";
import { and, eq } from "drizzle-orm";
import { getDb } from "@album/database"; import { getDb } from "@album/database";
export async function writeAudit( export async function writeAudit(
@@ -11,8 +12,17 @@ export async function writeAudit(
subjectId: string; subjectId: string;
metadata?: Record<string, string | number | boolean | null>; metadata?: Record<string, string | number | boolean | null>;
}, },
db: Database = getDb(), db: Pick<Database, "insert" | "select"> = getDb(),
) { ) {
const [actor] = input.actorUserId ? await db.select({ name: user.name }).from(user).where(eq(user.id, input.actorUserId)).limit(1) : [];
let subjectLabel = `${input.subjectType} ${input.subjectId}`;
if (input.subjectType === "user") {
const [subject] = await db.select({ name: user.name }).from(user).where(eq(user.id, input.subjectId)).limit(1);
if (subject) subjectLabel = subject.name;
} else if (input.subjectType === "guest" && input.eventId) {
const [subject] = await db.select({ name: guests.displayName }).from(guests).where(and(eq(guests.eventId, input.eventId), eq(guests.id, input.subjectId))).limit(1);
if (subject) subjectLabel = subject.name ?? "Anonymous guest";
}
await db.insert(auditEvents).values({ await db.insert(auditEvents).values({
groupId: input.groupId ?? null, groupId: input.groupId ?? null,
eventId: input.eventId ?? null, eventId: input.eventId ?? null,
@@ -20,6 +30,6 @@ export async function writeAudit(
action: input.action, action: input.action,
subjectType: input.subjectType, subjectType: input.subjectType,
subjectId: input.subjectId, subjectId: input.subjectId,
metadata: input.metadata ?? {}, metadata: { ...input.metadata, subjectLabel, actorName: actor?.name ?? "System" },
}); });
} }
+21
View File
@@ -0,0 +1,21 @@
import { and, eq } from "drizzle-orm";
import { eventBanners, getDb, photos } from "@album/database";
import { createPresignedGetUrl } from "@album/storage";
// Resolve each time so hiding or deleting a photo also removes its banner.
export async function eventBannerUrl(eventId: string, photoId: string | null) {
if (!photoId) return null;
const [photo] = await getDb().select({ displayKey: photos.displayKey })
.from(photos).where(and(
eq(photos.id, photoId), eq(photos.eventId, eventId),
eq(photos.visibility, "public"), eq(photos.processingStatus, "ready"),
)).limit(1);
return photo?.displayKey ? createPresignedGetUrl(photo.displayKey) : null;
}
export async function customBannerUrl(eventId: string, bannerId: string | null) {
if (!bannerId) return null;
const [banner] = await getDb().select({ displayKey: eventBanners.displayKey }).from(eventBanners)
.where(and(eq(eventBanners.id, bannerId), eq(eventBanners.eventId, eventId), eq(eventBanners.status, "ready"))).limit(1);
return banner?.displayKey ? createPresignedGetUrl(banner.displayKey) : null;
}
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test";
import { and, eq } from "drizzle-orm";
import { events, getDb, groups, guests, groupMemberships, auditEvents, emailDeliveries } from "@album/database";
import { processEmailDelivery } from "@album/email/queue";
import { notifyEventGuests } from "./guest-notifications";
test.skipIf(process.env.NOTIFICATIONS_INTEGRATION !== "1")("Mailpit completion email is opt-in and deduplicated", async () => {
if (process.env.EMAIL_PROVIDER !== "mailpit" || process.env.NODE_ENV === "production" || !["localhost", "127.0.0.1"].includes(process.env.SMTP_HOST ?? "")) throw new Error("This test requires local, non-production Mailpit");
const db = getDb();
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
if (!group) throw new Error("Seed a development group first");
const [event] = await db.insert(events).values({ groupId: group.id, title: "Notification integration test", slug: `mail-test-${crypto.randomUUID()}`, status: "closed", galleryPolicy: "automatic" }).returning();
if (!event) throw new Error("Could not create fixture");
try {
const [member] = await db.select({ userId: groupMemberships.userId }).from(groupMemberships).where(eq(groupMemberships.groupId, group.id)).limit(1);
if (!member) throw new Error("Seed a group member first");
await db.insert(guests).values([
{ eventId: event.id, email: "publishing-test@manyangles.test", notifyWhenReady: true, tokenHash: crypto.randomUUID() },
{ eventId: event.id, email: "PUBLISHING-test@manyangles.test", notifyWhenReady: true, tokenHash: crypto.randomUUID() },
{ eventId: event.id, email: "not-subscribed@manyangles.test", notifyWhenReady: false, tokenHash: crypto.randomUUID() },
]);
const results = await Promise.all([notifyEventGuests(event, member.userId), notifyEventGuests(event, member.userId)]);
expect(results.reduce((sum, result) => sum + result.queued, 0)).toBe(1);
await processEmailDelivery();
const [delivery] = await db.select().from(emailDeliveries).where(eq(emailDeliveries.eventId, event.id));
expect(delivery?.status).toBe("sent");
expect(await notifyEventGuests(event, member.userId)).toEqual({ queued: 0 });
const [excluded] = await db.select({ notifiedAt: guests.notifiedAt }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.notifyWhenReady, false)));
expect(excluded?.notifiedAt).toBeNull();
} finally {
await db.delete(auditEvents).where(eq(auditEvents.eventId, event.id));
await db.delete(events).where(eq(events.id, event.id));
}
});
@@ -0,0 +1,25 @@
import { and, eq, sql } from "drizzle-orm";
import { emailDeliveries, getDb, guests } from "@album/database";
import { renderAlbumReadyEmail } from "@album/email";
import { writeAudit } from "./audit";
import { publicAppOrigin } from "./public-app-url";
export async function notifyEventGuests(event: { id: string; groupId: string; slug: string; title: string }, actorUserId: string) {
return getDb().transaction(async (tx) => {
const rows = await tx.select().from(guests).where(eq(guests.eventId, event.id));
const previous = new Set(rows.filter((g) => g.notifiedAt || g.notificationClaimedAt).flatMap((g) => g.email ? [g.email.toLowerCase()] : []));
const recipients = new Set(rows.filter((g) => g.notifyWhenReady && g.email && !previous.has(g.email.toLowerCase())).map((g) => g.email!.toLowerCase()));
let queued = 0;
for (const recipient of recipients) {
const inserted = await tx.insert(emailDeliveries).values({ eventId: event.id, recipient,
provider: process.env.EMAIL_PROVIDER ?? "resend",
payload: renderAlbumReadyEmail({ to: recipient, eventTitle: event.title, galleryUrl: `${publicAppOrigin()}/e/${event.slug}` }),
}).onConflictDoNothing().returning({ id: emailDeliveries.id });
if (!inserted.length) continue;
await tx.update(guests).set({ notificationClaimedAt: new Date() }).where(and(eq(guests.eventId, event.id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${recipient}`));
queued++;
}
await writeAudit({ eventId: event.id, groupId: event.groupId, actorUserId, action: "guest.email.queued", subjectType: "event", subjectId: event.id, metadata: { queued } }, tx);
return { queued };
});
}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { parseLocationResults } from "./location-search";
import { updateEventInputSchema } from "@album/contracts";
import { openStreetMapEmbedUrl } from "@/lib/maps";
describe("event locations", () => {
test("maps GeoJSON longitude/latitude correctly and deduplicates matches", () => {
const feature = { geometry: { coordinates: [-72.7, 40.9] }, properties: { name: "Raceway", city: "Riverhead" } };
expect(parseLocationResults({ features: [feature, feature] })).toEqual([
{ address: "Raceway, Riverhead", latitude: 40.9, longitude: -72.7 },
]);
});
test("rejects impossible coordinates from the provider and event input", () => {
expect(parseLocationResults({ features: [{ geometry: { coordinates: [200, 95] }, properties: { name: "Bad" } }] })).toEqual([]);
expect(updateEventInputSchema.safeParse({ eventId: "7fa618fc-3fcb-4fd4-a0af-03f14124cccc", locationCoordinates: { latitude: 95, longitude: 0 } }).success).toBe(false);
});
test("keeps zero coordinates and uses latitude first in the OSM marker", () => {
const url = new URL(openStreetMapEmbedUrl(0, 12));
expect(url.searchParams.get("marker")).toBe("0,12");
expect(url.searchParams.get("bbox")).toBe("11.992,-0.008,12.008,0.008");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod";
import { locationSuggestionSchema, type LocationSuggestion } from "@album/contracts";
const photonSchema = z.object({
features: z.array(z.object({
geometry: z.object({ coordinates: z.tuple([z.number(), z.number()]) }),
properties: z.record(z.unknown()),
})),
});
export function parseLocationResults(payload: unknown): LocationSuggestion[] {
const parsed = photonSchema.parse(payload);
const seen = new Set<string>();
return parsed.features.flatMap(({ geometry, properties }) => {
const text = (key: string) => typeof properties[key] === "string" ? properties[key] as string : "";
const street = [text("housenumber"), text("street")].filter(Boolean).join(" ");
const address = [...new Set([
text("name"), street, text("city") || text("district") || text("county"),
text("state"), text("postcode"), text("country"),
].filter(Boolean))].join(", ");
const result = locationSuggestionSchema.safeParse({
address, longitude: geometry.coordinates[0], latitude: geometry.coordinates[1],
});
const key = `${address}:${geometry.coordinates.join(",")}`;
if (!address || !result.success || seen.has(key)) return [];
seen.add(key);
return [result.data];
});
}
export async function searchLocations(query: string) {
const url = new URL("https://photon.komoot.io/api/");
url.searchParams.set("q", query);
url.searchParams.set("limit", "6");
url.searchParams.set("lang", "en");
const response = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "Manyangles event location search" },
next: { revalidate: 86_400 },
signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error("Location search unavailable");
return parseLocationResults(await response.json());
}
+3 -2
View File
@@ -30,7 +30,8 @@ export async function uniqueEventSlug(
excludeEventId?: string, excludeEventId?: string,
db: Database = getDb(), db: Database = getDb(),
) { ) {
let candidate = desired.slice(0, 60) || "event"; const base = desired.slice(0, 64).replace(/-+$/, "") || "event";
let candidate = base;
let suffix = 2; let suffix = 2;
while (true) { while (true) {
const [existing] = await db const [existing] = await db
@@ -39,7 +40,7 @@ export async function uniqueEventSlug(
.where(eq(events.slug, candidate)) .where(eq(events.slug, candidate))
.limit(1); .limit(1);
if (!existing || existing.id === excludeEventId) return candidate; if (!existing || existing.id === excludeEventId) return candidate;
candidate = `${desired.slice(0, 50)}-${suffix}`; candidate = `${base.slice(0, 64 - String(suffix).length - 1).replace(/-+$/, "")}-${suffix}`;
suffix += 1; suffix += 1;
} }
} }
@@ -0,0 +1,110 @@
import { expect, test } from "bun:test";
import { and, eq } from "drizzle-orm";
import { events, getDb, groups, guests, photos, photoJobs } from "@album/database";
import { deletePrefix, getObjectBuffer, photoObjectPrefix, headObject } from "@album/storage";
import { photosRouter } from "./api/routers/photos";
import { eventRouter } from "./api/routers/event";
import { guestRouter } from "./api/routers/guest";
import type { TrpcContext } from "./api/trpc";
// Opt in against a migrated development database; creates and removes its own event.
test.skipIf(process.env.PUBLISHING_INTEGRATION !== "1")("standalone notes, approval reset, hidden stats, and gallery policies", async () => {
const db = getDb();
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
if (!group) throw new Error("Seed a development group first");
const [event] = await db.insert(events).values({ groupId: group.id, title: "Publishing test", slug: `publishing-test-${crypto.randomUUID()}`, status: "published", uploadEnabled: false }).returning();
if (!event) throw new Error("Could not create test event");
let token: string | null = null;
const ctx: TrpcContext = { session: null, requestOrigin: "http://localhost:3000", clientIdentifier: "publishing-test", cookies: new Map(), activeGroupId: null,
guestTokenForEvent: () => token, setCookies: [], appendSetCookie: (value) => { token = decodeURIComponent(value.split(";")[0]!.split("=").slice(1).join("=")); } };
const guest = guestRouter.createCaller(ctx);
const publicEvent = eventRouter.createCaller(ctx);
let uploadedPhotoId: string | null = null;
try {
const result = await guest.ensure({ eventSlug: event.slug, note: "Private note without a photo" });
expect((await publicEvent.community(event.slug))).toEqual({ notes: [], stats: { photos: null, submitters: null, notes: null } });
await db.update(events).set({ notesPolicy: "automatic", showPhotoStats: true, showSubmitterStats: true, showNoteStats: true }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
await guest.ensure({ eventSlug: event.slug, note: "New automatic note" });
const automatic = await publicEvent.community(event.slug);
expect(automatic.notes).toHaveLength(1);
expect(automatic.stats).toEqual({ photos: 0, submitters: 1, notes: 1 });
expect(Object.keys(automatic.notes[0]!)).toEqual(["id", "displayName", "note"]);
await db.update(events).set({ notesPolicy: "approved" }).where(eq(events.id, event.id));
await guest.ensure({ eventSlug: event.slug, note: "Edited note requires approval" });
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
await db.update(guests).set({ noteApproved: true }).where(and(eq(guests.eventId, event.id), eq(guests.id, result.guestId)));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(1);
const future = new Date(Date.now() + 3600000);
const past = new Date(Date.now() - 3600000);
await db.update(events).set({ notesVisibleAt: future, galleryVisibleAt: future, publishAt: future }).where(eq(events.id, event.id));
await expect(publicEvent.community(event.slug)).rejects.toThrow();
await db.update(events).set({ publishAt: past }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(events).set({ notesVisibleAt: past, galleryVisibleAt: past, submissionsOpenAt: future }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(1);
await expect(guest.ensure({ eventSlug: event.slug, note: "Too early" })).rejects.toThrow();
await db.update(events).set({ submissionsOpenAt: past }).where(eq(events.id, event.id));
const emailOnly = guestRouter.createCaller({ ...ctx, guestTokenForEvent: () => null, appendSetCookie: () => {} });
const emailGuest = await emailOnly.ensure({ eventSlug: event.slug, email: "email-only@manyangles.test", notifyWhenReady: true });
const [saved] = await db.select({ note: guests.note, optIn: guests.notifyWhenReady }).from(guests).where(and(eq(guests.eventId, event.id), eq(guests.id, emailGuest.guestId)));
expect(saved).toEqual({ note: null, optIn: true });
await db.update(events).set({ galleryVisibleAt: null }).where(eq(events.id, event.id));
await db.update(events).set({ galleryPolicy: "automatic", uploadEnabled: true }).where(eq(events.id, event.id));
const submission = await guest.startSubmission({ eventSlug: event.slug });
const photoCaller = photosRouter.createCaller(ctx);
let original: Buffer | null = null;
if (process.env.REAL_UPLOAD_INTEGRATION === "1") {
const [demo] = await db.select({ id: events.id }).from(events).where(eq(events.slug, "demo"));
const [source] = await db.select({ key: photos.originalKey }).from(photos).where(and(eq(photos.eventId, demo!.id), eq(photos.processingStatus, "ready"))).limit(1);
original = await getObjectBuffer(source!.key);
}
const created = await photoCaller.create({ eventSlug: event.slug, submissionId: submission.submissionId, contentType: "image/jpeg", fileName: "test.jpg", byteSize: original?.length ?? 100 });
await expect(photosRouter.createCaller({ ...ctx, guestTokenForEvent: () => null }).complete({ photoId: created.photoId })).rejects.toThrow("Guest session");
expect(await publicEvent.gallery(event.slug)).toEqual([]);
if (original) {
uploadedPhotoId = created.photoId;
expect((await fetch(created.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: new Uint8Array(original) })).ok).toBe(true);
await Promise.all([photoCaller.complete({ photoId: created.photoId }), photoCaller.complete({ photoId: created.photoId })]);
expect(await db.select({ id: photoJobs.id }).from(photoJobs).where(eq(photoJobs.photoId, created.photoId))).toHaveLength(1);
let ready = false;
for (let attempt = 0; attempt < 45; attempt++) {
const [photo] = await db.select().from(photos).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
if (photo?.processingStatus === "failed") throw new Error("Image processing failed");
if (photo?.processingStatus === "ready") {
expect(photo.displayKey).toBeTruthy();
expect(photo.thumbKey).toBeTruthy();
expect(Number((await headObject(photo.displayKey!))?.ContentLength)).toBeLessThan(original.length);
expect(Number((await headObject(photo.thumbKey!))?.ContentLength)).toBeLessThan(original.length);
ready = true;
break;
}
await Bun.sleep(500);
}
expect(ready).toBe(true);
} else {
// Fast policy-only test; opt in above for real storage and worker processing.
await db.update(photos).set({ processingStatus: "ready" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
}
expect(await publicEvent.gallery(event.slug)).toHaveLength(1);
await db.update(photos).set({ visibility: "pending" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(photos).set({ visibility: "public" }).where(and(eq(photos.eventId, event.id), eq(photos.id, created.photoId)));
await db.update(events).set({ galleryPolicy: "approved" }).where(eq(events.id, event.id));
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(events).set({ galleryReleasedAt: new Date() }).where(eq(events.id, event.id));
expect(await publicEvent.gallery(event.slug)).toHaveLength(1);
await db.update(events).set({ notesPolicy: "never", galleryPolicy: "never", galleryReleasedAt: new Date() }).where(eq(events.id, event.id));
expect((await publicEvent.community(event.slug)).notes).toHaveLength(0);
expect(await publicEvent.gallery(event.slug)).toEqual([]);
await db.update(events).set({ status: "closed" }).where(eq(events.id, event.id));
await expect(guest.ensure({ eventSlug: event.slug, note: "Closed" })).rejects.toThrow("Notes are closed");
await db.update(events).set({ status: "draft", publishAt: null }).where(eq(events.id, event.id));
await expect(publicEvent.community(event.slug)).rejects.toThrow();
} finally {
if (uploadedPhotoId) await deletePrefix(photoObjectPrefix(event.id, uploadedPhotoId));
await db.delete(events).where(eq(events.id, event.id));
}
}, 30000);
+1
View File
@@ -11,6 +11,7 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@album/email": "workspace:*",
"@album/database": "workspace:*", "@album/database": "workspace:*",
"@album/storage": "workspace:*", "@album/storage": "workspace:*",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
+43 -6
View File
@@ -1,8 +1,9 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import sharp from "sharp"; import sharp from "sharp";
import { processEmailDelivery } from "@album/email/queue";
import convert from "heic-convert"; import convert from "heic-convert";
import { getDb, photoJobs, photos } from "@album/database"; import { eventBanners, getDb, photoJobs, photos } from "@album/database";
import { import {
displayObjectKey, displayObjectKey,
getObjectBuffer, getObjectBuffer,
@@ -113,7 +114,6 @@ async function processJob(job: ClaimedJob) {
.update(photos) .update(photos)
.set({ .set({
processingStatus: "ready", processingStatus: "ready",
visibility: photo.visibility === "pending" ? "pending" : photo.visibility,
displayKey, displayKey,
thumbKey, thumbKey,
width: metadata.width ?? null, width: metadata.width ?? null,
@@ -150,6 +150,7 @@ async function workerLoop(workerId: number) {
const started = Date.now(); const started = Date.now();
const job = await claimJob(); const job = await claimJob();
if (!job) { if (!job) {
if (await processBanner()) continue;
await Bun.sleep(POLL_MS); await Bun.sleep(POLL_MS);
continue; continue;
} }
@@ -167,10 +168,46 @@ async function workerLoop(workerId: number) {
} }
} }
async function processBanner() {
const rows = await getDb().execute(sql`
UPDATE event_banners SET status = 'processing', updated_at = now()
WHERE id = (
SELECT id FROM event_banners
WHERE status = 'pending' OR (status = 'processing' AND updated_at < now() - interval '10 minutes')
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING id, event_id, original_key, content_type
`);
const banner = (rows as unknown as { id: string; event_id: string; original_key: string; content_type: string }[])[0];
if (!banner) return false;
const scope = and(eq(eventBanners.id, banner.id), eq(eventBanners.eventId, banner.event_id));
try {
const original = await getObjectBuffer(banner.original_key);
const image = await decodeImage(original, banner.content_type);
const display = await image.resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82, mozjpeg: true }).toBuffer();
const displayKey = `events/${banner.event_id}/banners/${banner.id}/display.jpg`;
await putObject({ key: displayKey, body: display, contentType: "image/jpeg" });
await getDb().update(eventBanners).set({ status: "ready", displayKey, updatedAt: new Date() }).where(scope);
console.info(`Banner processed for event ${banner.event_id}`);
} catch {
await getDb().update(eventBanners).set({ status: "failed", updatedAt: new Date() }).where(scope);
console.error(`Banner processing failed for event ${banner.event_id}`);
}
return true;
}
console.info( console.info(
`Manyangles worker listening for photo jobs (concurrency ${CONCURRENCY}, min interval ${MIN_INTERVAL_MS}ms)`, `Manyangles worker listening for photo jobs (concurrency ${CONCURRENCY}, min interval ${MIN_INTERVAL_MS}ms)`,
); );
await Promise.all( async function emailLoop() {
Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)), while (true) {
); try { await processEmailDelivery(); } catch { console.error("Email queue check failed"); }
await Bun.sleep(1000);
}
}
await Promise.all([
emailLoop(),
...Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)),
]);
+5
View File
@@ -58,6 +58,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@album/database": "workspace:*", "@album/database": "workspace:*",
"@album/email": "workspace:*",
"@album/storage": "workspace:*", "@album/storage": "workspace:*",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
@@ -81,6 +82,7 @@
"name": "@album/database", "name": "@album/database",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@album/contracts": "workspace:*",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
@@ -94,6 +96,9 @@
"name": "@album/email", "name": "@album/email",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@album/contracts": "workspace:*",
"@album/database": "workspace:*",
"drizzle-orm": "^0.45.2",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"resend": "^6.18.0", "resend": "^6.18.0",
}, },
+81
View File
@@ -0,0 +1,81 @@
# Email delivery
Manyangles uses the RaceTix-style branded shell for verification, password resets,
account invitations, and gallery-ready messages. HTML has plain-text alternatives,
escaped dynamic content, and system-font fallbacks. People previews the actual
gallery-ready HTML before queueing.
## Production Resend configuration
Set `EMAIL_PROVIDER=resend`, `RESEND_API_KEY`, `EMAIL_FROM` (a verified sender), and
`NEXT_PUBLIC_APP_URL` (the public HTTPS origin). Run the image/email worker with
the same environment as the web process. Configuration changes do not migrate
pending emails between providers. No production test sends are needed.
## Completion outbox
Organizer actions enqueue one immutable payload per event/normalized email
address. A unique database constraint deduplicates concurrent requests. Only
opted-in guests qualify. Before sending, the worker checks consent and gallery
visibility again. Messages whose guest link changed are held for review.
The worker records provider acceptance, attempts and sanitized errors. “Sent”
means accepted by Resend/SMTP, not delivered to an inbox. No automatic event
schedule sends email.
Configure a Resend webhook at `/api/webhooks/resend` and set
`RESEND_WEBHOOK_SECRET` in the web process. Subscribe to `email.sent`,
`email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`,
and `email.failed`. The endpoint verifies the bounded raw body with the Resend
SDK and deduplicates by signed `svix-id`. It stores only message identifiers,
outcomes and timestamps, never raw recipient payloads. Failed database writes
return 503 for provider retry. Callbacks arriving before the worker saves its
provider reference are retained and matched when email history is read.
People shows provider outcomes separately from queue status. Complaints,
bounces and failures take precedence over delivered/delayed/sent regardless of
callback order. Delivered means acceptance by the recipient mail server, not
inbox placement. This tracks gallery-ready outbox messages; auth email outcomes
do not have a management UI. Tracking does not change guest consent or implement
a cross-event suppression list. Mailpit does not produce Resend callbacks.
Resend retries reuse `gallery-ready/<delivery-id>` and the original payload.
Retries back off, stop after five attempts, and stay inside a conservative
23-hour window (Resend retains idempotency keys for 24 hours). Expired/uncertain
deliveries require provider review. The People tab permits safe Resend retries
inside that window. SMTP ambiguity is not automatically retried.
Account verification, password resets and account invitations remain synchronous
transactional sends. They also use stable, recipient-specific idempotency keys.
## Local verification
Keep `EMAIL_PROVIDER=mailpit`, `SMTP_HOST=127.0.0.1`, `SMTP_PORT=1027`.
Open Mailpit at http://localhost:8027. Test scripts reject nonlocal Mailpit settings.
`bun run email:preview` sends all four templates to the local inbox, like RaceTix's
preview workflow. Authentication/invitation links in these samples are deliberately
nonfunctional; the gallery sample links to `/e/demo`.
Docker Compose already supplies Postgres (5439), Mailpit (SMTP 1027 / inbox 8027),
and Garage (3900 / 3903). Use `bun run docker:up`, `bun run docker:logs`, and
`bun run docker:down`. The web app and worker run on the host with Bun, just as
RaceTix's development setup does. Stopping this project's Compose stack does not
stop Colima or disrupt other projects.
```sh
bun run typecheck
bun test
WEBHOOK_INTEGRATION=1 bun --env-file=.env test packages/email/src/webhooks.test.ts
cd apps/web
NOTIFICATIONS_INTEGRATION=1 PUBLISHING_INTEGRATION=1 bun --env-file=../../.env test src/server/guest-notifications.integration.test.ts src/server/publishing.integration.test.ts
```
With the local worker running, add `REAL_UPLOAD_INTEGRATION=1` to the publishing
test command to copy one existing `/e/demo` original through a presigned PUT,
verify a single queued job and smaller generated variants, then remove only the
temporary event and its uploaded objects. The original demo assets are untouched.
Run `bun --env-file=.env packages/database/src/verify-migrations.ts` from the repo
root to migrate a temporary local database; the script removes that database
after verification. It never migrates a remote database.
+24
View File
@@ -0,0 +1,24 @@
# Local verification — September 9, 2026
- `bun run check`: unit tests, typecheck, lint and production build passed.
- Clean-database migration check passed, including the webhook inbox migration.
- Opt-in publishing integration passed with real storage/worker processing:
note-only and email-only guests, approval reset, hidden statistics, scheduled
publication and note/gallery visibility, submission opening, gallery policies,
unauthorized upload completion rejection, concurrent completion deduplication,
direct presigned upload, smaller display/thumbnail outputs, and closed/draft
rejection. Source: one existing demo original, left untouched.
- Mailpit integration passed: opt-in only, concurrent enqueue deduplication,
provider acceptance and no repeat notification.
- Webhook tests passed: valid/invalid/stale signatures, ignored event types,
duplicate and early callbacks, out-of-order terminal outcomes, event-scoped
query and provider separation. No production Resend requests were sent.
- Browser: guest form checked at 390×844 with no horizontal overflow or console
warnings/errors; organizer People tab loaded delivery history successfully.
Temporary viewport override was reset.
Temporary test events and uploaded copies were removed by fixture cleanup.
Mailpit test messages remain available. These checks do not replace real-device
camera/HEIC testing or production deliverability verification. Resend webhook
registration and its signing secret are deployment configuration, not activated
by local tests. See `email-delivery.md`.
+1
View File
@@ -15,6 +15,7 @@
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"lint": "turbo lint", "lint": "turbo lint",
"test": "bun test", "test": "bun test",
"email:preview": "bun --env-file=.env scripts/send-email-previews.ts",
"check": "bun test && bun run typecheck && bun run lint && bun run build", "check": "bun test && bun run typecheck && bun run lint && bun run build",
"db:generate": "bun --env-file=.env run --filter @album/database generate", "db:generate": "bun --env-file=.env run --filter @album/database generate",
"db:migrate": "bun --env-file=.env run --filter @album/database migrate", "db:migrate": "bun --env-file=.env run --filter @album/database migrate",
+46
View File
@@ -91,11 +91,35 @@ export const createEventInputSchema = z.object({
inviteCode: z.string().trim().max(80).optional(), inviteCode: z.string().trim().max(80).optional(),
}); });
export const publishingPolicySchema = z.enum(["automatic", "approved", "never"]);
export type EmailPayload = { to: string; from: string; subject: string; html: string; text: string; referenceId: string; attachments?: { filename: string; content: string; contentId: string }[] };
export const checkEventSlugInputSchema = z.object({ eventId: z.string().uuid(), slug: eventSlugSchema });
export const generateEventSlugInputSchema = z.object({ eventId: z.string().uuid(), title: z.string().trim().min(1).max(120) });
export type PublishingPolicy = z.infer<typeof publishingPolicySchema>;
export const moderateNoteInputSchema = z.object({ eventId: z.string().uuid(), guestId: z.string().uuid(), approved: z.boolean() });
export const updateEventInputSchema = z.object({ export const updateEventInputSchema = z.object({
publishAt: z.coerce.date().nullable().optional(),
submissionsOpenAt: z.coerce.date().nullable().optional(),
submissionsCloseAt: z.coerce.date().nullable().optional(),
galleryVisibleAt: z.coerce.date().nullable().optional(),
notesVisibleAt: z.coerce.date().nullable().optional(),
notesPolicy: publishingPolicySchema.optional(),
galleryPolicy: publishingPolicySchema.optional(),
showPhotoStats: z.boolean().optional(),
showSubmitterStats: z.boolean().optional(),
showNoteStats: z.boolean().optional(),
eventId: z.string().uuid(), eventId: z.string().uuid(),
title: z.string().trim().min(1).max(120).optional(), title: z.string().trim().min(1).max(120).optional(),
slug: eventSlugSchema.optional(), slug: eventSlugSchema.optional(),
description: z.string().trim().max(2000).nullable().optional(), description: z.string().trim().max(2000).nullable().optional(),
location: z.string().trim().max(300).nullable().optional(),
locationCoordinates: z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}).nullable().optional(),
bannerPhotoId: z.string().uuid().nullable().optional(),
customBannerId: z.string().uuid().nullable().optional(),
startsAt: z.coerce.date().nullable().optional(), startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(), endsAt: z.coerce.date().nullable().optional(),
status: eventStatusSchema.optional(), status: eventStatusSchema.optional(),
@@ -103,6 +127,18 @@ export const updateEventInputSchema = z.object({
uploadEnabled: z.boolean().optional(), uploadEnabled: z.boolean().optional(),
}); });
export const locationSearchInputSchema = z.object({
eventId: z.string().uuid(),
query: z.string().trim().min(3).max(300),
});
export const locationSuggestionSchema = z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
});
export type LocationSuggestion = z.infer<typeof locationSuggestionSchema>;
export const ensureGuestInputSchema = z.object({ export const ensureGuestInputSchema = z.object({
eventSlug: eventSlugSchema, eventSlug: eventSlugSchema,
displayName: contributorNameSchema, displayName: contributorNameSchema,
@@ -136,6 +172,16 @@ export const completePhotoInputSchema = z.object({
photoId: z.string().uuid(), photoId: z.string().uuid(),
}); });
export const createBannerInputSchema = z.object({
eventId: z.string().uuid(),
contentType: allowedImageTypeSchema,
byteSize: z.number().int().positive().max(MAX_PHOTO_BYTES),
});
export const bannerInputSchema = z.object({
eventId: z.string().uuid(),
bannerId: z.string().uuid(),
});
export const moderatePhotoInputSchema = z.object({ export const moderatePhotoInputSchema = z.object({
photoId: z.string().uuid(), photoId: z.string().uuid(),
visibility: photoVisibilitySchema, visibility: photoVisibilitySchema,
@@ -0,0 +1,3 @@
ALTER TABLE "events" ADD COLUMN "location" text;
--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "banner_photo_id" uuid;
@@ -0,0 +1,8 @@
ALTER TABLE "events" ADD COLUMN "latitude" double precision;
--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "longitude" double precision;
--> statement-breakpoint
ALTER TABLE "events" ADD CONSTRAINT "events_coordinates_check" CHECK (
("latitude" IS NULL AND "longitude" IS NULL) OR
("latitude" IS NOT NULL AND "longitude" IS NOT NULL AND "latitude" BETWEEN -90 AND 90 AND "longitude" BETWEEN -180 AND 180)
);
@@ -0,0 +1,17 @@
ALTER TABLE "events" ADD COLUMN "custom_banner_id" uuid;
--> statement-breakpoint
CREATE TABLE "event_banners" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"event_id" uuid NOT NULL REFERENCES "events"("id") ON DELETE CASCADE,
"original_key" text NOT NULL,
"display_key" text,
"content_type" text NOT NULL,
"byte_size" integer NOT NULL,
"status" text NOT NULL DEFAULT 'uploading',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX "event_banners_event_idx" ON "event_banners" ("event_id");
--> statement-breakpoint
CREATE INDEX "event_banners_status_idx" ON "event_banners" ("status");
@@ -0,0 +1,6 @@
ALTER TABLE events ADD COLUMN notes_policy text NOT NULL DEFAULT 'never' CHECK (notes_policy IN ('automatic', 'approved', 'never'));
ALTER TABLE events ADD COLUMN gallery_policy text NOT NULL DEFAULT 'approved' CHECK (gallery_policy IN ('automatic', 'approved', 'never'));
ALTER TABLE events ADD COLUMN show_photo_stats boolean NOT NULL DEFAULT false;
ALTER TABLE events ADD COLUMN show_submitter_stats boolean NOT NULL DEFAULT false;
ALTER TABLE events ADD COLUMN show_note_stats boolean NOT NULL DEFAULT false;
ALTER TABLE guests ADD COLUMN note_approved boolean NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
ALTER TABLE events ADD COLUMN publish_at timestamptz;
ALTER TABLE events ADD COLUMN submissions_open_at timestamptz;
ALTER TABLE events ADD COLUMN submissions_close_at timestamptz;
ALTER TABLE events ADD COLUMN gallery_visible_at timestamptz;
ALTER TABLE events ADD COLUMN notes_visible_at timestamptz;
ALTER TABLE events ADD COLUMN completed_at timestamptz;
ALTER TABLE guests ADD COLUMN notification_claimed_at timestamptz;
@@ -0,0 +1,9 @@
CREATE TABLE email_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
recipient text NOT NULL, payload jsonb NOT NULL, provider text NOT NULL,
status text NOT NULL DEFAULT 'pending', attempts integer NOT NULL DEFAULT 0,
first_attempt_at timestamptz, next_attempt_at timestamptz NOT NULL DEFAULT now(),
provider_id text, last_error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX email_deliveries_event_recipient_idx ON email_deliveries(event_id, recipient);
CREATE INDEX email_deliveries_queue_idx ON email_deliveries(status, next_attempt_at);
@@ -0,0 +1,8 @@
CREATE TABLE "email_webhook_events" (
"id" text PRIMARY KEY,
"provider_id" text NOT NULL,
"outcome" text NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"received_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "email_webhook_events_provider_idx" ON "email_webhook_events" ("provider_id");
@@ -15,6 +15,55 @@
"when": 1788819000000, "when": 1788819000000,
"tag": "0001_groups_roles_guests", "tag": "0001_groups_roles_guests",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788905400000,
"tag": "0002_event_information",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1788905500000,
"tag": "0003_event_coordinates",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1788905600000,
"tag": "0004_event_banner_uploads",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1788905700000,
"tag": "0005_publishing_preferences",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1788905800000,
"tag": "0006_event_lifecycle",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788905900000,
"tag": "0007_email_outbox",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788906000000,
"tag": "0008_email_webhooks",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -18,6 +18,7 @@
"seed": "bun src/seed.ts" "seed": "bun src/seed.ts"
}, },
"dependencies": { "dependencies": {
"@album/contracts": "workspace:*",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"postgres": "^3.4.7" "postgres": "^3.4.7"
-13
View File
@@ -1,21 +1,8 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as appSchema from "./schema"; import * as appSchema from "./schema";
import * as authSchema from "./auth-schema"; import * as authSchema from "./auth-schema";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const schema = { ...appSchema, ...authSchema }; const schema = { ...appSchema, ...authSchema };
type PostgresClient = ReturnType<typeof postgres>; type PostgresClient = ReturnType<typeof postgres>;
+59
View File
@@ -1,5 +1,7 @@
import { import {
boolean, boolean,
check,
doublePrecision,
index, index,
integer, integer,
jsonb, jsonb,
@@ -138,21 +140,76 @@ export const events = pgTable(
slug: text("slug").notNull(), slug: text("slug").notNull(),
title: text("title").notNull(), title: text("title").notNull(),
description: text("description"), description: text("description"),
location: text("location"),
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
bannerPhotoId: uuid("banner_photo_id"),
customBannerId: uuid("custom_banner_id"),
startsAt: timestamp("starts_at", { withTimezone: true }), startsAt: timestamp("starts_at", { withTimezone: true }),
endsAt: timestamp("ends_at", { withTimezone: true }), endsAt: timestamp("ends_at", { withTimezone: true }),
status: eventStatus("status").notNull().default("draft"), status: eventStatus("status").notNull().default("draft"),
listed: boolean("listed").notNull().default(false), listed: boolean("listed").notNull().default(false),
uploadEnabled: boolean("upload_enabled").notNull().default(true), uploadEnabled: boolean("upload_enabled").notNull().default(true),
galleryReleasedAt: timestamp("gallery_released_at", { withTimezone: true }), galleryReleasedAt: timestamp("gallery_released_at", { withTimezone: true }),
publishAt: timestamp("publish_at", { withTimezone: true }),
submissionsOpenAt: timestamp("submissions_open_at", { withTimezone: true }),
submissionsCloseAt: timestamp("submissions_close_at", { withTimezone: true }),
galleryVisibleAt: timestamp("gallery_visible_at", { withTimezone: true }),
notesVisibleAt: timestamp("notes_visible_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
notesPolicy: text("notes_policy").$type<"automatic" | "approved" | "never">().notNull().default("never"),
galleryPolicy: text("gallery_policy").$type<"automatic" | "approved" | "never">().notNull().default("approved"),
showPhotoStats: boolean("show_photo_stats").notNull().default(false),
showSubmitterStats: boolean("show_submitter_stats").notNull().default(false),
showNoteStats: boolean("show_note_stats").notNull().default(false),
...timestamps, ...timestamps,
}, },
(table) => [ (table) => [
uniqueIndex("events_slug_idx").on(table.slug), uniqueIndex("events_slug_idx").on(table.slug),
check("events_notes_policy_check", sql`${table.notesPolicy} IN ('automatic', 'approved', 'never')`),
check("events_gallery_policy_check", sql`${table.galleryPolicy} IN ('automatic', 'approved', 'never')`),
check("events_coordinates_check", sql`(${table.latitude} IS NULL AND ${table.longitude} IS NULL) OR (${table.latitude} IS NOT NULL AND ${table.longitude} IS NOT NULL AND ${table.latitude} BETWEEN -90 AND 90 AND ${table.longitude} BETWEEN -180 AND 180)`),
index("events_group_id_idx").on(table.groupId), index("events_group_id_idx").on(table.groupId),
index("events_listed_idx").on(table.listed, table.status), index("events_listed_idx").on(table.listed, table.status),
], ],
); );
export const emailDeliveries = pgTable("email_deliveries", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
recipient: text("recipient").notNull(),
payload: jsonb("payload").$type<import("@album/contracts").EmailPayload>().notNull(),
provider: text("provider").notNull(),
status: text("status").notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
firstAttemptAt: timestamp("first_attempt_at", { withTimezone: true }),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
providerId: text("provider_id"),
lastError: text("last_error"),
...timestamps,
}, (table) => [uniqueIndex("email_deliveries_event_recipient_idx").on(table.eventId, table.recipient), index("email_deliveries_queue_idx").on(table.status, table.nextAttemptAt)]);
// Provider callbacks may precede the worker's providerId write. Keep a minimal
// inbox independently, without storing recipient addresses or raw payloads.
export const emailWebhookEvents = pgTable("email_webhook_events", {
id: text("id").primaryKey(),
providerId: text("provider_id").notNull(),
outcome: text("outcome").notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index("email_webhook_events_provider_idx").on(table.providerId)]);
export const eventBanners = pgTable("event_banners", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
originalKey: text("original_key").notNull(),
displayKey: text("display_key"),
contentType: text("content_type").notNull(),
byteSize: integer("byte_size").notNull(),
status: text("status").notNull().default("uploading"),
...timestamps,
}, (table) => [index("event_banners_event_idx").on(table.eventId), index("event_banners_status_idx").on(table.status)]);
export const eventMemberships = pgTable( export const eventMemberships = pgTable(
"event_memberships", "event_memberships",
{ {
@@ -272,7 +329,9 @@ export const guests = pgTable(
email: text("email"), email: text("email"),
notifyWhenReady: boolean("notify_when_ready").notNull().default(false), notifyWhenReady: boolean("notify_when_ready").notNull().default(false),
notifiedAt: timestamp("notified_at", { withTimezone: true }), notifiedAt: timestamp("notified_at", { withTimezone: true }),
notificationClaimedAt: timestamp("notification_claimed_at", { withTimezone: true }),
note: text("note"), note: text("note"),
noteApproved: boolean("note_approved").notNull().default(false),
tokenHash: text("token_hash").notNull(), tokenHash: text("token_hash").notNull(),
...timestamps, ...timestamps,
}, },
+10 -3
View File
@@ -117,14 +117,15 @@ if (group) {
.values({ .values({
groupId: group.id, groupId: group.id,
slug: "demo", slug: "demo",
title: "Demo gathering", title: "Riverhead Raceway Championship Night",
description: description:
"A sample event so you can try guest uploads and host moderation.", "A late-summer night under the lights at Riverhead Raceway — feature winners, victory lane, and the people who make race night happen.",
status: "published", status: "published",
listed: true, listed: true,
uploadEnabled: true, uploadEnabled: true,
galleryReleasedAt: new Date(), galleryReleasedAt: new Date(),
startsAt: new Date(), startsAt: new Date("2026-09-06T22:00:00.000Z"),
endsAt: new Date("2026-09-07T02:00:00.000Z"),
}) })
.returning(); .returning();
console.info("Seeded published event /e/demo"); console.info("Seeded published event /e/demo");
@@ -133,8 +134,14 @@ if (group) {
.update(events) .update(events)
.set({ .set({
groupId: group.id, groupId: group.id,
title: "Riverhead Raceway Championship Night",
description:
"A late-summer night under the lights at Riverhead Raceway — feature winners, victory lane, and the people who make race night happen.",
listed: true, listed: true,
uploadEnabled: true,
galleryReleasedAt: event.galleryReleasedAt ?? new Date(), galleryReleasedAt: event.galleryReleasedAt ?? new Date(),
startsAt: new Date("2026-09-06T22:00:00.000Z"),
endsAt: new Date("2026-09-07T02:00:00.000Z"),
status: "published", status: "published",
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -0,0 +1,21 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
const url = new URL(process.env.DATABASE_URL ?? "postgres://album:album@localhost:5439/album");
if (!["localhost", "127.0.0.1"].includes(url.hostname)) throw new Error("Clean migration verification is local-only");
const admin = postgres(url.toString(), { max: 1 });
const databaseName = `manyangles_verify_${crypto.randomUUID().replaceAll("-", "")}`;
await admin`CREATE DATABASE ${admin(databaseName)}`;
url.pathname = `/${databaseName}`;
const client = postgres(url.toString(), { max: 1 });
try {
await migrate(drizzle(client), { migrationsFolder: new URL("../drizzle", import.meta.url).pathname });
const rows = await client`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('email_deliveries', 'events', 'guests', 'event_banners')`;
if (rows.length !== 4) throw new Error("Expected lifecycle/outbox tables were not created");
console.info("Clean-database migrations passed");
} finally {
await client.end();
await admin`DROP DATABASE ${admin(databaseName)}`;
await admin.end();
}
+6 -1
View File
@@ -4,7 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts",
"./queue": "./src/queue.ts",
"./webhooks": "./src/webhooks.ts"
}, },
"scripts": { "scripts": {
"build": "tsc --noEmit", "build": "tsc --noEmit",
@@ -12,6 +14,9 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@album/contracts": "workspace:*",
"@album/database": "workspace:*",
"drizzle-orm": "^0.45.2",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"resend": "^6.18.0" "resend": "^6.18.0"
}, },
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { renderAlbumReadyEmail, emailBrowserPreview } from "./index";
test("gallery email escapes content and includes a plain-text alternative", () => {
const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: '<script>alert("x")</script>', galleryUrl: "https://manyangles.test/e/demo" });
expect(message.html).not.toContain("<script>");
expect(message.html).toContain("&lt;script&gt;");
expect(message.html).toContain('role="presentation"');
expect(message.html).toContain("Manyangles");
expect(message.text).toContain("https://manyangles.test/e/demo");
expect(message.html).toContain("Arial,Helvetica,sans-serif");
expect(message.html).toContain("cid:manyangles-mark-v1");
expect(message.attachments[0]?.contentId).toBe("manyangles-mark-v1");
expect(Buffer.from(message.attachments[0]!.content, "base64").subarray(1, 4).toString()).toBe("PNG");
expect(emailBrowserPreview(message.html)).toContain("data:image/png;base64,");
});
+40 -15
View File
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { Resend } from "resend"; import { Resend } from "resend";
import { EMAIL_LOGO_CID, emailLogoAttachment } from "./logo";
export { emailBrowserPreview } from "./logo";
const PRIMARY = "#8b5a4a"; const PRIMARY = "#8b5a4a";
@@ -16,25 +18,33 @@ function referenceHash(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 16); return createHash("sha256").update(value).digest("hex").slice(0, 16);
} }
function chrome(bodyHtml: string) { export function chrome(bodyHtml: string) {
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@500;600;700&amp;family=Geologica:wght@400;600;700&amp;display=swap" rel="stylesheet">
<style>h1,h2 {font-family:'Funnel Display',Arial,Helvetica,sans-serif;line-height:1.15;letter-spacing:-.035em} @media(max-width:480px){body{padding:12px 6px!important}}</style>
</head> </head>
<body style="margin:0;padding:24px 12px;background:#f6f1ea"> <body style="margin:0;padding:24px 12px;background:#f6f1ea">
<div style="max-width:560px;margin:0 auto;color:#2c2416;background:#fff;font-family:Georgia,serif"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;color:#292524;background:#fff;font-family:'Geologica',Arial,Helvetica,sans-serif"><tr><td>
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}"> <div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
<strong style="font-size:22px;letter-spacing:.04em">Manyangles</strong> <table role="presentation" cellspacing="0" cellpadding="0"><tr>
<td style="vertical-align:middle;padding-right:8px"><img src="cid:${EMAIL_LOGO_CID}" width="32" height="32" alt="" style="display:block;border:0;width:32px;height:32px"></td>
<td style="vertical-align:middle"><strong style="font-size:26px;line-height:32px;letter-spacing:-.04em;color:${PRIMARY}">Manyangles</strong></td>
</tr></table>
<p style="margin:8px 0 0;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#78716c">Every angle. One shared album.</p>
</div> </div>
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0"> <div style="padding:32px;border:1px solid #e8dfd2;border-top:0;font-size:16px;line-height:1.65">
${bodyHtml} ${bodyHtml}
</div> </div>
<div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0"> <div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0">
<strong style="font-size:18px;color:${PRIMARY}">Manyangles</strong>
<p style="margin:8px 0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#7a6e5e">Keep the moment.</p>
<p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p> <p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p>
</div> </div>
</div> </td></tr></table>
</body> </body>
</html>`; </html>`;
} }
@@ -63,15 +73,21 @@ export function getEmailReadiness() {
}; };
} }
async function sendEmail(input: { export async function sendEmail(input: {
to: string; to: string;
subject: string; subject: string;
html: string; html: string;
text: string; text: string;
referenceId: string; referenceId: string;
}) { from?: string;
const provider = process.env.EMAIL_PROVIDER ?? "resend"; attachments?: { filename: string; content: string; contentId: string }[];
const from = }, options?: { provider?: string; idempotencyKey?: string }) {
const attachments = input.attachments ?? (input.html.includes(`cid:${EMAIL_LOGO_CID}`) ? [emailLogoAttachment] : []);
const provider = options?.provider ?? process.env.EMAIL_PROVIDER ?? "resend";
if (options?.provider && provider !== (process.env.EMAIL_PROVIDER ?? "resend")) throw new Error("Queued email provider differs from configured provider");
if (!["mailpit", "smtp", "resend"].includes(provider)) throw new Error("Unsupported email provider");
if (provider === "mailpit" && process.env.NODE_ENV === "production") throw new Error("Mailpit is development-only");
const from = input.from ??
process.env.EMAIL_FROM ?? process.env.EMAIL_FROM ??
process.env.RESEND_FROM ?? process.env.RESEND_FROM ??
"Manyangles <photos@manyangles.test>"; "Manyangles <photos@manyangles.test>";
@@ -87,6 +103,8 @@ async function sendEmail(input: {
host: process.env.SMTP_HOST ?? "127.0.0.1", host: process.env.SMTP_HOST ?? "127.0.0.1",
port: Number(process.env.SMTP_PORT ?? 1025), port: Number(process.env.SMTP_PORT ?? 1025),
secure: process.env.SMTP_SECURE === "true", secure: process.env.SMTP_SECURE === "true",
connectionTimeout: 15_000,
socketTimeout: 30_000,
}); });
const result = await transporter.sendMail({ const result = await transporter.sendMail({
from, from,
@@ -95,14 +113,14 @@ async function sendEmail(input: {
html: input.html, html: input.html,
text: input.text, text: input.text,
headers: { "X-Entity-Ref-ID": input.referenceId }, headers: { "X-Entity-Ref-ID": input.referenceId },
attachments: attachments.map((attachment) => ({ filename: attachment.filename, content: Buffer.from(attachment.content, "base64"), cid: attachment.contentId, contentType: "image/png", contentDisposition: "inline" as const })),
}); });
return { id: result.messageId }; return { id: result.messageId };
} }
const apiKey = process.env.RESEND_API_KEY; const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) { if (!apiKey) {
console.info(`[email:demo] ${input.subject} -> ${input.to}`); throw new Error("RESEND_API_KEY is required; use Mailpit for local email");
return { id: `demo-${input.referenceId}` };
} }
const resend = new Resend(apiKey); const resend = new Resend(apiKey);
@@ -113,7 +131,8 @@ async function sendEmail(input: {
html: input.html, html: input.html,
text: input.text, text: input.text,
headers: { "X-Entity-Ref-ID": input.referenceId }, headers: { "X-Entity-Ref-ID": input.referenceId },
}); attachments,
}, { idempotencyKey: options?.idempotencyKey ?? `${input.referenceId}-${referenceHash(input.to.toLowerCase())}` });
if (error) throw new Error(error.message); if (error) throw new Error(error.message);
return { id: data?.id ?? input.referenceId }; return { id: data?.id ?? input.referenceId };
} }
@@ -192,12 +211,14 @@ export async function sendStaffInviteEmail(message: {
}); });
} }
export async function sendAlbumReadyEmail(message: { export function renderAlbumReadyEmail(message: {
to: string; to: string;
eventTitle: string; eventTitle: string;
galleryUrl: string; galleryUrl: string;
}) { }) {
return sendEmail({ return {
from: process.env.EMAIL_FROM ?? process.env.RESEND_FROM ?? "Manyangles <photos@manyangles.test>",
attachments: [emailLogoAttachment],
to: message.to, to: message.to,
subject: `The gallery for ${message.eventTitle} is ready`, subject: `The gallery for ${message.eventTitle} is ready`,
html: chrome(` html: chrome(`
@@ -212,5 +233,9 @@ export async function sendAlbumReadyEmail(message: {
`), `),
text: `The gallery for ${message.eventTitle} is ready: ${message.galleryUrl}`, text: `The gallery for ${message.eventTitle} is ready: ${message.galleryUrl}`,
referenceId: `album-ready-${referenceHash(message.galleryUrl)}`, referenceId: `album-ready-${referenceHash(message.galleryUrl)}`,
}); };
}
export async function sendAlbumReadyEmail(message: { to: string; eventTitle: string; galleryUrl: string }) {
return sendEmail(renderAlbumReadyEmail(message));
} }
+7
View File
@@ -0,0 +1,7 @@
// 3x PNG rendering of BrandMark. Keep v1 immutable for queued email retries.
export const EMAIL_LOGO_CID = "manyangles-mark-v1";
export const EMAIL_LOGO_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAACXBIWXMAAAsTAAALEwEAmpwYAAAHmklEQVR4nO1de4hUVRg/PexFbymxMgsioxfEhvYgt8B25juzmhDTzvnubkLhVkTgZo9/qpFI3TlnxkfvjfIV9IcPMrWCzEIpCrQgKqs/gjIRMlwlcs1QN76Z2Yezs3vPvXPP3Dsz5wcH5K5zHr/fPd853znfOZcxCwsLCwsLCwsLi9pHtxO/IiPizVkRdxTyLoWJZ4NJvKtbJKa6li8SU4Mul9pCbaK2sSgiK1qvk8jTSsA3Cnm/4fThcid+fmkd6Bn9zXj51EbBX6A2s7CRbWuZJAWsUgKOV4H4/iES+ObSutCz6tYBjisBK4mDUMhXgj+tBD9S1UbjsJTi0wbrkuLTwqqHROhTCPOrRnx6TvNZCuHd0IjHYsMFPDlQJ/p3BOqzhrgxT76AHWE3VkVQgGKdthsVIQpvvoqYCSqTVpuz+eE3rj8yg7Bm7wwE3R2tV4Y64GKEpqE6AiD0Beoz5KeanioBvVLwdQp5TiJ0B5GUR0cswHJzhbZAr8de+k4g5JPDoTvPl4L/KRHm9nQ2jWN1hp7OpnFZTHQq5Ps1e8GxjBObUnHBeQ9XT/UfyFSxOodCPpnaqjkWPF95gRrLC/TmNwL5JSK49gSJsItVAhpIJPITGgXNZQ0GKfgj7rzwE5nUrMt8F5JBfo/OgFuPNt8N6WTyDCngoBs/tIrK/EIioIb5WccaFFLABjd+siku/BeA/Cn36RYo1qCQgmc1LIT/hTolYIGGAAtYg0KZ5scKMDasAD5Ag6ISsDS/dpRPsNTvQGkF8IDcg4nLlYDPxpgybvM6ZbQCeCBfIt+jMW/f40UEK4AmpIDPNZdTaNayVTdfK4Cuzdcmv+g8OYm7dPK2AmggP+B6FICWoDXzttNQN0iELV4FkMg3uWZsBdCDn61KKeADvbxtD3CFNUEhI2MH4fAhkW/zYP8/0c3XmiBNkHOl44gpAb8vcWITdfO1AngUgZyssd58L+QTrAA+QE5WIVyGb8onwbO6jhdrdAH6GTsl3dl6DosIGkKAjBObogTPKOTfFULB80FPRxTCbtqNyyHcxEKCcX50oo4Dj4MsIvPQrPMU8tddozKKByUWdcwez6oM4/xoRR0Pi1IOCkuc2EQl+M+ePFj6/+0zr2ZVRFX4GdOVLxOlXCkWdcweLwX/0RP5Q2/bXp0AsYXJ+CUK+cMK4U0aiJWAjRL5K3QYrzs544JI8TNG1HHZKOVKkG5uPl0K/qkf8ocS7M4lWy4ul/+y1MwJSvC3KG5zVBGR/ysRpK4QVeNn6PinXpSyH0gBSyojf/DN+yKXTJ59ct6tt0uEvzz0pt+UE78+SvwYhULeEQj5gyLAxrXJ5GmUd1YkYoMzKG+9qTebit/C6h2ZFNzqjyCXtxihJ+94CTjsOw/B9y1ui10VNkfGutiy1MwJWus0w2y0RwL/C0DIn9ymucZMkMlBpqezaZynU5cCdpBtl8jfD7q3aIjwZem4Ypof49MsKfgbHt78Par93kvpd0REKMdlBd9MM7Vq8WPU0aAzBUr/7evLYqJp+O9pqunXX6iwJ7xWDX6MutrZNn6HQjiq3egUby+fT8skJfgflZEKL9Fg7ek3Kf6ASX6MLjblo9QE3xdUiIhqT9yoc1BiNPIpj3Q6fapCWKv9Qgj+lSl+jApAx/glwtceCNpaanPL1hP5dO/nmOHlEXUTsF1LAIQ+E/yMbFjABUjkK7TfMoRfvaxwSgH36x+nhVW0t1Cax2JMXKR5AnK/CX5GIMgCZIo/4aGL/yNT8Zu91lciPK6R//oBD3mMO5D2ugi4Jmh+yiKoApSTuFvXIZLIT0hMJH3XGfmLY5idj+lwnVsetMkjkR8YpX4HBpa9a0IA3TO1aqiBC1mFkE58nkJ+aBjxR8nme7lKpiAC7Coxi7uG78BFXgDav5XIv9UnH7bQjMR3hUeUDXdS76tkt4xWRCUmZpRbGY28ABLhPQ9v/i9L59x3IashRFoAifCMLvlKwN/dqdgNrMYQWQGKmx+j7jqV/P541knMYjWICAvAP9I2PSKAG0VCQmQFIJOiSf6Gcg5RraCmBZACvn812Xwuq2HUsAmC3lx7yzWsxhFZAXJO/LbRPF96Lp14C6sDqKgKMLg4dpI3mk+H6DmrE6goC0Cg4KZCuEn+GvgOr1FnUUfkBah3KOMCIMx3n83wLGtQKOQ5o1uS9sqykK8so5VE1y7WwJf2qZGTjGAv7Ste8+J6bSXdKMsaDFLEHzN+bWW+oJJNiVESbbhMZg2CxW2xqzQjrXdWXBh9sEajoP7iZvbkhiBfaAeEPRfQF5I0l5aR76cbZXX2XWvT5sOjumcMiLNsG1wbSOF0CE5TgIGp10GaIRQOXNT2d8Rk4dDIep0B18j19YOhGgbi91Udf8CBJjAsSOg4ZVVLIuKfMHHi8wIlf0gEvjrsxqmof8RHwEpmCl7iJxv1M1bL4/EzjQkwIEIUeoKMmAAUb2qc/OGgRoc6MKciYoIEHDZm8zW/sLHCg59QN4OwRDgmBbwd+GzH740mFF5CyxY6a0e1Og2VhbbtJA83MCfLyCXZyKcXl7K76uA7Yl20pEznjSteWLOwsLCwsLCwsLBgkcD/A+2/fsplol4AAAAASUVORK5CYII=";
export const emailLogoAttachment = { filename: "manyangles-logo.png", content: EMAIL_LOGO_BASE64, contentId: EMAIL_LOGO_CID };
export function emailBrowserPreview(html: string) {
return html.replaceAll(`cid:${EMAIL_LOGO_CID}`, `data:image/png;base64,${EMAIL_LOGO_BASE64}`);
}
+44
View File
@@ -0,0 +1,44 @@
import { and, eq, sql } from "drizzle-orm";
import { auditEvents, emailDeliveries, events, getDb, guests } from "@album/database";
import { sendEmail } from "./index";
export async function processEmailDelivery() {
const db = getDb();
const provider = process.env.EMAIL_PROVIDER ?? "resend";
await db.execute(sql`UPDATE email_deliveries SET status = CASE WHEN provider = 'resend' AND first_attempt_at > now() - interval '23 hours' AND attempts < 5 THEN 'pending' ELSE 'review' END,
last_error = 'Worker interrupted; delivery needs reconciliation', updated_at = now()
WHERE provider = ${provider} AND status = 'sending' AND updated_at < now() - interval '5 minutes'`);
const rows = await db.execute<{ id: string; event_id: string }>(sql`UPDATE email_deliveries SET status = 'sending', attempts = attempts + 1,
first_attempt_at = coalesce(first_attempt_at, now()), updated_at = now()
WHERE id = (SELECT id FROM email_deliveries WHERE provider = ${provider} AND status = 'pending' AND next_attempt_at <= now() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, event_id`);
const job = rows[0];
if (!job) return false;
const scope = and(eq(emailDeliveries.id, job.id), eq(emailDeliveries.eventId, job.event_id));
const [delivery] = await db.select().from(emailDeliveries).where(scope);
if (!delivery) return true;
try {
if (delivery.attempts > 1 && (!delivery.firstAttemptAt || Date.now() - delivery.firstAttemptAt.getTime() >= 23 * 3600000)) {
await db.update(emailDeliveries).set({ status: "review", lastError: "Idempotency window expired", updatedAt: new Date() }).where(scope);
return true;
}
const [event] = await db.select().from(events).where(eq(events.id, job.event_id));
const now = new Date();
const visible = event && (event.status !== "draft" || (event.publishAt && event.publishAt <= now)) && (!event.publishAt || event.publishAt <= now) && event.galleryPolicy !== "never" && (!event.galleryVisibleAt || event.galleryVisibleAt <= now) && (event.galleryPolicy === "automatic" || event.galleryReleasedAt || event.galleryVisibleAt) && delivery.payload.text.endsWith(`/e/${event.slug}`);
const optedIn = await db.select({ id: guests.id }).from(guests).where(and(eq(guests.eventId, job.event_id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${delivery.recipient}`)).limit(1);
if (!visible || !optedIn.length) {
await db.update(emailDeliveries).set({ status: "review", lastError: "Gallery visibility or recipient consent changed", updatedAt: now }).where(scope);
return true;
}
const result = await sendEmail(delivery.payload, { provider: delivery.provider, idempotencyKey: `gallery-ready/${delivery.id}` });
await db.transaction(async (tx) => {
await tx.update(emailDeliveries).set({ status: "sent", providerId: result.id, lastError: null, updatedAt: new Date() }).where(scope);
await tx.update(guests).set({ notifiedAt: new Date(), updatedAt: new Date() }).where(and(eq(guests.eventId, job.event_id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${delivery.recipient}`));
await tx.insert(auditEvents).values({ eventId: job.event_id, action: "guest.email.sent", subjectType: "email", subjectId: delivery.id, metadata: { providerId: result.id } });
});
} catch {
const retry = delivery.provider === "resend" && delivery.attempts < 5 && delivery.firstAttemptAt && Date.now() - delivery.firstAttemptAt.getTime() < 23 * 3600000;
await db.update(emailDeliveries).set({ status: retry ? "pending" : "review", nextAttemptAt: new Date(Date.now() + Math.min(3600, 30 * 2 ** delivery.attempts) * 1000), lastError: "Delivery could not be confirmed; no recipient data logged", updatedAt: new Date() }).where(scope);
}
return true;
}
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { createHmac } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { emailDeliveries, emailWebhookEvents, events, getDb, groups } from "@album/database";
import { emailDeliveryOutcome, recordEmailWebhook, verifyEmailWebhook } from "./webhooks";
const key = Buffer.from("local-test-signing-secret-not-production");
const secret = `whsec_${key.toString("base64")}`;
function signed(type = "email.delivered", timestamp = Math.floor(Date.now() / 1000)) {
const id = `test_${crypto.randomUUID()}`;
const payload = JSON.stringify({ type, created_at: new Date().toISOString(), data: { email_id: crypto.randomUUID() } });
const signature = createHmac("sha256", key).update(`${id}.${timestamp}.${payload}`).digest("base64");
return { payload, headers: new Headers({ "svix-id": id, "svix-timestamp": `${timestamp}`, "svix-signature": `v1,${signature}` }) };
}
test("webhooks require an authentic, fresh, unmodified signature", () => {
const input = signed();
expect(verifyEmailWebhook(input.payload, input.headers, secret)?.outcome).toBe("delivered");
expect(() => verifyEmailWebhook(input.payload + " ", input.headers, secret)).toThrow();
expect(() => verifyEmailWebhook(input.payload, new Headers(), secret)).toThrow();
const stale = signed("email.delivered", 1);
expect(() => verifyEmailWebhook(stale.payload, stale.headers, secret)).toThrow();
const ignored = signed("email.opened");
expect(verifyEmailWebhook(ignored.payload, ignored.headers, secret)).toBeNull();
});
test.skipIf(process.env.WEBHOOK_INTEGRATION !== "1")("early, duplicate and reordered callbacks preserve terminal outcome and event scope", async () => {
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const db = getDb();
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
const input = signed("email.bounced");
const callback = verifyEmailWebhook(input.payload, input.headers, secret)!;
const ids = [callback.id, `${callback.id}-delivered`];
const [event] = await db.insert(events).values({ groupId: group!.id, title: "Webhook test", slug: `webhook-${crypto.randomUUID()}` }).returning();
try {
await Promise.all([recordEmailWebhook(callback), recordEmailWebhook(callback)]);
expect(await db.select().from(emailWebhookEvents).where(eq(emailWebhookEvents.id, callback.id))).toHaveLength(1);
const [delivery] = await db.insert(emailDeliveries).values({ eventId: event!.id, recipient: "webhook@manyangles.test", provider: "resend", payload: { to: "webhook@manyangles.test", from: "test@manyangles.test", subject: "Test", html: "", text: "", referenceId: "test" } }).returning();
const scope = and(eq(emailDeliveries.eventId, event!.id), eq(emailDeliveries.id, delivery!.id));
expect((await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(scope))[0]?.outcome).toBeNull();
await db.update(emailDeliveries).set({ providerId: callback.providerId }).where(scope);
await recordEmailWebhook({ ...callback, id: ids[1]!, outcome: "delivered", occurredAt: new Date(Date.now() + 1000) });
expect((await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(scope))[0]?.outcome).toBe("bounced");
expect(await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(eq(emailDeliveries.eventId, crypto.randomUUID()))).toHaveLength(0);
await db.update(emailDeliveries).set({ provider: "mailpit" }).where(scope);
expect((await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(scope))[0]?.outcome).toBeNull();
} finally {
await db.delete(events).where(eq(events.id, event!.id));
await db.delete(emailWebhookEvents).where(inArray(emailWebhookEvents.id, ids));
}
});
+31
View File
@@ -0,0 +1,31 @@
import { Resend } from "resend";
import { sql } from "drizzle-orm";
import { emailWebhookEvents, getDb } from "@album/database";
const outcomes = new Set(["sent", "delivery_delayed", "delivered", "failed", "bounced", "complained"]);
export function verifyEmailWebhook(payload: string, headers: Headers, secret: string) {
const event = new Resend("webhook-verification-only").webhooks.verify({
payload,
headers: { id: headers.get("svix-id") ?? "", timestamp: headers.get("svix-timestamp") ?? "", signature: headers.get("svix-signature") ?? "" },
webhookSecret: secret,
});
const outcome = event.type.replace(/^email\./, "");
if (!event.type.startsWith("email.") || !outcomes.has(outcome)) return null;
const occurredAt = new Date(event.created_at);
if (!("email_id" in event.data) || typeof event.data.email_id !== "string" || !event.data.email_id || !Number.isFinite(occurredAt.getTime())) throw new Error("Invalid event");
return { id: headers.get("svix-id")!, providerId: event.data.email_id, outcome, occurredAt };
}
export async function recordEmailWebhook(event: NonNullable<ReturnType<typeof verifyEmailWebhook>>) {
await getDb().insert(emailWebhookEvents).values(event).onConflictDoNothing();
}
// Read through the inbox so early and duplicate callbacks need no reconciliation
// job. Negative terminal outcomes win even if delivered/sent arrives afterward.
// Only evaluate this expression inside an authorized, event-scoped delivery query.
export const emailDeliveryOutcome = sql<string | null>`(SELECT outcome FROM email_webhook_events
WHERE provider_id = "email_deliveries"."provider_id" AND "email_deliveries"."provider" = 'resend'
ORDER BY CASE outcome WHEN 'complained' THEN 6 WHEN 'bounced' THEN 5 WHEN 'failed' THEN 4
WHEN 'delivered' THEN 3 WHEN 'delivery_delayed' THEN 2 ELSE 1 END DESC,
occurred_at DESC, id DESC LIMIT 1)`;
-13
View File
@@ -1,6 +1,3 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import { import {
CreateBucketCommand, CreateBucketCommand,
DeleteObjectsCommand, DeleteObjectsCommand,
@@ -15,16 +12,6 @@ import {
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const PRESIGN_PUT_SECONDS = 10 * 60; const PRESIGN_PUT_SECONDS = 10 * 60;
const PRESIGN_GET_SECONDS = 60 * 60; const PRESIGN_GET_SECONDS = 60 * 60;
+13
View File
@@ -0,0 +1,13 @@
import { sendAccountVerificationEmail, sendPasswordResetEmail, sendStaffInviteEmail, sendAlbumReadyEmail } from "../packages/email/src/index";
if (process.env.NODE_ENV === "production" || process.env.EMAIL_PROVIDER !== "mailpit" || !["localhost", "127.0.0.1"].includes(process.env.SMTP_HOST ?? "") || process.env.SMTP_PORT !== "1027") {
throw new Error("Email previews require this project's local Mailpit (127.0.0.1:1027). Production sending is forbidden.");
}
const to = "email-previews@manyangles.test";
const origin = "http://localhost:3000";
await sendAccountVerificationEmail({ to, name: "Alex", verificationUrl: `${origin}/sign-in?preview=verification` });
await sendPasswordResetEmail({ to, name: "Alex", resetUrl: `${origin}/reset-password?token=preview-not-a-real-token` });
await sendStaffInviteEmail({ to, inviterName: "Riverhead Raceway", inviteUrl: `${origin}/invitations/preview-not-a-real-invite` });
await sendAlbumReadyEmail({ to, eventTitle: "Riverhead Raceway Championship Night", galleryUrl: `${origin}/e/demo` });
console.info("Four email previews sent to local Mailpit: http://localhost:8027");