Refine workspaces and event publishing; harden uploads and email delivery
This commit is contained in:
+6
-2
@@ -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
|
||||||
|
|||||||
@@ -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="reveal">{children}</div>
|
||||||
<div className="mb-6 hidden items-center gap-1 sm:flex">
|
</BackendShell>
|
||||||
<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>
|
|
||||||
</main>
|
|
||||||
<DashboardTabBar showAdmin area="admin" />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 recipient’s 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 device’s 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 3–64 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" ? (
|
||||||
{canModerate && photo.processingStatus === "ready" ? (
|
<Button className="min-h-11" disabled={busy}
|
||||||
<>
|
onClick={() => moderate.mutate({ photoId: photo.id, visibility: "public" })}>
|
||||||
<Button
|
<CheckIcon data-icon="inline-start" aria-hidden="true" />Approve
|
||||||
size="sm"
|
</Button>
|
||||||
disabled={moderate.isPending}
|
) : null}
|
||||||
onClick={() =>
|
{canModerate || canDelete || photo.originalUrl ? (
|
||||||
moderate.mutate({ photoId: photo.id, visibility: "public" })
|
<DropdownMenu>
|
||||||
}
|
<DropdownMenuTrigger asChild>
|
||||||
>
|
<Button variant="outline" size="icon" className="size-11 shrink-0"
|
||||||
<CheckIcon data-icon="inline-start" />
|
aria-label={`Photo actions for ${photo.contributorName ?? "Anonymous"}`} disabled={busy}>
|
||||||
Public
|
<MoreHorizontalIcon aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</DropdownMenuTrigger>
|
||||||
size="sm"
|
<DropdownMenuContent align="end" className="w-60">
|
||||||
variant="outline"
|
{canModerate && photo.processingStatus === "ready" ? (
|
||||||
disabled={moderate.isPending}
|
<>
|
||||||
onClick={() =>
|
<DropdownMenuLabel>Photo Visibility</DropdownMenuLabel>
|
||||||
moderate.mutate({ photoId: photo.id, visibility: "hidden" })
|
<DropdownMenuGroup>
|
||||||
}
|
{([
|
||||||
>
|
{ value: "public", label: "Make Public", icon: CheckIcon },
|
||||||
<EyeOffIcon data-icon="inline-start" />
|
{ value: "hidden", label: "Hide from Gallery", icon: EyeOffIcon },
|
||||||
Hide
|
...(canPrivate ? [{ value: "private" as const, label: "Keep Private", icon: LockIcon }] : []),
|
||||||
</Button>
|
{ value: "rejected", label: "Reject Photo", icon: XIcon },
|
||||||
{canPrivate ? (
|
] as const).map(({ value, label, icon: Icon }) => (
|
||||||
<Button
|
<DropdownMenuItem key={value} className="min-h-11 px-3"
|
||||||
size="sm"
|
disabled={photo.visibility === value}
|
||||||
variant="outline"
|
onSelect={() => moderate.mutate({ photoId: photo.id, visibility: value })}>
|
||||||
disabled={moderate.isPending}
|
<Icon aria-hidden="true" />{label}
|
||||||
onClick={() =>
|
</DropdownMenuItem>
|
||||||
moderate.mutate({
|
))}
|
||||||
photoId: photo.id,
|
</DropdownMenuGroup>
|
||||||
visibility: "private",
|
<DropdownMenuSeparator />
|
||||||
})
|
</>
|
||||||
}
|
|
||||||
>
|
|
||||||
<LockIcon data-icon="inline-start" />
|
|
||||||
Keep
|
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<DropdownMenuGroup>
|
||||||
size="sm"
|
{photo.originalUrl ? (
|
||||||
variant="outline"
|
<DropdownMenuItem asChild className="min-h-11 px-3">
|
||||||
disabled={moderate.isPending}
|
<a href={photo.originalUrl} target="_blank" rel="noreferrer">
|
||||||
onClick={() =>
|
<DownloadIcon aria-hidden="true" />Open Original
|
||||||
moderate.mutate({
|
</a>
|
||||||
photoId: photo.id,
|
</DropdownMenuItem>
|
||||||
visibility: "rejected",
|
) : null}
|
||||||
})
|
{canModerate && photo.processingStatus === "ready" ? (
|
||||||
}
|
<DropdownMenuItem className="min-h-11 px-3"
|
||||||
>
|
onSelect={() => moderateSubmission.mutate({ submissionId: photo.submissionId, visibility: "hidden" })}>
|
||||||
<XIcon data-icon="inline-start" />
|
<EyeOffIcon aria-hidden="true" />Hide Entire Submission
|
||||||
Reject
|
</DropdownMenuItem>
|
||||||
</Button>
|
) : null}
|
||||||
<Button
|
</DropdownMenuGroup>
|
||||||
size="sm"
|
{canDelete ? (
|
||||||
variant="ghost"
|
<>
|
||||||
disabled={moderateSubmission.isPending}
|
<DropdownMenuSeparator />
|
||||||
onClick={() =>
|
<DropdownMenuGroup>
|
||||||
moderateSubmission.mutate({
|
<DropdownMenuItem variant="destructive" className="min-h-11 px-3" onSelect={() => setPendingDelete(photo.id)}>
|
||||||
submissionId: photo.submissionId,
|
<Trash2Icon aria-hidden="true" />Delete Photo…
|
||||||
visibility: "hidden",
|
</DropdownMenuItem>
|
||||||
})
|
</DropdownMenuGroup>
|
||||||
}
|
</>
|
||||||
>
|
) : null}
|
||||||
Hide batch
|
</DropdownMenuContent>
|
||||||
</Button>
|
</DropdownMenu>
|
||||||
</>
|
) : null}
|
||||||
) : null}
|
|
||||||
{photo.originalUrl ? (
|
|
||||||
<Button size="sm" variant="ghost" asChild>
|
|
||||||
<a href={photo.originalUrl} target="_blank" rel="noreferrer">
|
|
||||||
<DownloadIcon data-icon="inline-start" />
|
|
||||||
Original
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canDelete ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setPendingDelete(photo.id)}
|
|
||||||
>
|
|
||||||
<Trash2Icon data-icon="inline-start" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
) : 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>
|
||||||
|
|||||||
@@ -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">
|
groups={viewer.groups}
|
||||||
<nav className="flex items-center gap-1">
|
activeGroupId={viewer.activeGroupId}
|
||||||
<Button asChild variant="ghost" size="sm">
|
showAdmin={Boolean(viewer.platformRole)}
|
||||||
<Link href="/dashboard">Events</Link>
|
>
|
||||||
</Button>
|
{children}
|
||||||
{viewer.groups.length > 0 ? (
|
</BackendShell>
|
||||||
<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}
|
|
||||||
activeGroupId={viewer.activeGroupId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-5 sm:hidden">
|
|
||||||
<GroupSwitcher
|
|
||||||
groups={viewer.groups}
|
|
||||||
activeGroupId={viewer.activeGroupId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<DashboardTabBar showAdmin={Boolean(platformRole)} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,88 +29,114 @@ 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>
|
||||||
<nav className="hidden items-center gap-1 sm:flex">
|
|
||||||
{links.map((link) => (
|
{signedIn && backend && groups.length > 0 && !pathname.startsWith("/admin") ? (
|
||||||
<Button key={link.href} asChild variant="ghost" size="sm">
|
<>
|
||||||
<Link href={link.href}>{link.label}</Link>
|
<span className="mx-1 h-7 w-px bg-border" />
|
||||||
</Button>
|
<div className="min-w-0 max-w-56 flex-1">
|
||||||
))}
|
<GroupSwitcher groups={groups} activeGroupId={activeGroupId} compact />
|
||||||
{primary ? (
|
</div>
|
||||||
<Button asChild size="sm">
|
</>
|
||||||
<Link href={primary.href}>{primary.label}</Link>
|
) : null}
|
||||||
</Button>
|
|
||||||
) : null}
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<ThemeToggle />
|
{signedIn ? (
|
||||||
{signedIn ? <SignOutButton /> : null}
|
<>
|
||||||
</nav>
|
<ApplicationSwitcher links={links} pathname={pathname} />
|
||||||
<div className="flex items-center gap-1 sm:hidden">
|
<ThemeToggle />
|
||||||
<ThemeToggle />
|
<DropdownMenu>
|
||||||
<Sheet>
|
<DropdownMenuTrigger asChild>
|
||||||
<SheetTrigger asChild>
|
<button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
aria-label={`Account menu for ${user?.name ?? "user"}`}
|
||||||
variant="outline"
|
className={cn(topBarControlClass, "w-11 shrink-0 justify-center px-1.5 sm:w-auto sm:justify-start sm:px-3")}
|
||||||
size="icon-lg"
|
>
|
||||||
className="tap-target"
|
<span className="grid size-8 place-items-center rounded-full border bg-background text-xs font-bold tracking-wider">
|
||||||
aria-label="Open menu"
|
{user?.name ? initials(user.name) : <UserRoundIcon />}
|
||||||
>
|
</span>
|
||||||
<MenuIcon aria-hidden="true" />
|
<span className="hidden max-w-32 truncate text-sm font-semibold lg:block">{user?.name}</span>
|
||||||
</Button>
|
<ChevronDownIcon aria-hidden="true" className="hidden size-3.5 text-muted-foreground sm:block" />
|
||||||
</SheetTrigger>
|
</button>
|
||||||
<SheetContent side="right" className="w-[min(20rem,90vw)]">
|
</DropdownMenuTrigger>
|
||||||
<SheetHeader>
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
<SheetTitle className="text-2xl">
|
<DropdownMenuLabel>Signed in</DropdownMenuLabel>
|
||||||
<BrandLockup />
|
<div className="px-1.5 pb-2">
|
||||||
</SheetTitle>
|
<p className="truncate text-sm font-semibold">{user?.name}</p>
|
||||||
</SheetHeader>
|
<p className="truncate text-xs text-muted-foreground">{user?.email}</p>
|
||||||
<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>
|
</div>
|
||||||
) : null}
|
<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">
|
||||||
|
{links.map((link) => (
|
||||||
|
<Button key={link.href} asChild variant="ghost"><Link href={link.href}>{link.label}</Link></Button>
|
||||||
|
))}
|
||||||
|
{primary ? <Button asChild><Link href={primary.href}>{primary.label}</Link></Button> : null}
|
||||||
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
</SheetContent>
|
<div className="flex items-center gap-1 sm:hidden">
|
||||||
</Sheet>
|
<ThemeToggle />
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size="icon" aria-label="Open menu"><MenuIcon /></Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="right" className="w-[min(20rem,90vw)]">
|
||||||
|
<SheetHeader><SheetTitle><BrandLockup /></SheetTitle></SheetHeader>
|
||||||
|
<nav className="flex flex-col gap-2 px-4">
|
||||||
|
{links.map((link) => <Button asChild key={link.href} variant="ghost" className="justify-start"><Link href={link.href}>{link.label}</Link></Button>)}
|
||||||
|
{primary ? <Button asChild className="justify-start"><Link href={primary.href}>{primary.label}</Link></Button> : null}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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?" }
|
}}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
<Button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-lg"
|
type="button"
|
||||||
className="tap-target"
|
variant="ghost"
|
||||||
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
|
size="icon"
|
||||||
disabled={!mounted}
|
className={cn(topBarControlClass, "justify-center px-0")}
|
||||||
onClick={() => setTheme(dark ? "light" : "dark")}
|
aria-label={`Theme: ${selected}`}
|
||||||
>
|
>
|
||||||
{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";
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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("<script>");
|
||||||
|
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
@@ -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&family=Geologica:wght@400;600;700&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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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)`;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user