Add bulk gallery management and direct organizer uploads

This commit is contained in:
2026-09-11 18:23:54 -04:00
parent 815640d919
commit 4bc48db656
12 changed files with 333 additions and 6 deletions
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { CheckIcon, EyeOffIcon, LockIcon, Trash2Icon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
type Action = "public" | "hidden" | "private" | "rejected" | "delete";
const labels: Record<Action, string> = { public: "Approve", hidden: "Hide", private: "Make private", rejected: "Reject", delete: "Delete" };
export function BulkPhotoActions({ eventId, rows, selected, setSelected, canModerate, canDelete, canPrivate }: {
eventId: string; rows: { id: string; visibility: string; thumbUrl?: string | null }[];
selected: string[]; setSelected: (ids: string[]) => void; canModerate: boolean; canDelete: boolean; canPrivate: boolean;
}) {
const utils = api.useUtils();
const [pending, setPending] = useState<{ eventId: string; photoIds: string[]; action: Action } | null>(null);
const [report, setReport] = useState<{ photoId: string; status: string; reason?: string }[]>([]);
const preview = api.manager.previewBulkPhotos.useQuery(pending!, { enabled: !!pending, retry: false });
const apply = api.manager.bulkPhotos.useMutation({
onSuccess: async result => {
const allResults = [...result.results, ...(preview.data?.results.filter(row => row.status !== "eligible") ?? [])];
setReport(allResults);
setSelected(allResults.filter(row => row.status === "failed" || row.status === "skipped").map(row => row.photoId));
setPending(null);
toast(`${result.affected} photos changed; ${allResults.length - result.affected} skipped or failed`);
await utils.manager.photos.invalidate({ eventId });
}, onError: error => toast.error(error.message),
});
if (!canModerate && !canDelete) return null;
const actions: Action[] = [...(canModerate ? ["public", "hidden", ...(canPrivate ? ["private"] : []), "rejected"] as Action[] : []), ...(canDelete ? ["delete" as const] : [])];
const icons = { public: CheckIcon, hidden: EyeOffIcon, private: LockIcon, rejected: XIcon, delete: Trash2Icon };
return <div className="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground" aria-live="polite">{selected.length} selected · up to 100 at a time</span>
<Button variant="outline" disabled={apply.isPending} onClick={() => setSelected([...new Set([...selected, ...rows.map(row => row.id)])].slice(0, 100))}>Select this page</Button>
{canModerate ? <Button variant="outline" disabled={apply.isPending} onClick={() => setSelected([...new Set([...selected, ...rows.filter(row => row.visibility === "pending").map(row => row.id)])].slice(0, 100))}>Select awaiting review on page</Button> : null}
<Button variant="ghost" disabled={!selected.length || apply.isPending} onClick={() => setSelected([])}>Clear selection</Button>
</div>
{selected.length ? <div className="flex flex-wrap gap-2">{actions.map(action => {
const Icon = icons[action];
return <Button key={action} variant={action === "delete" ? "destructive" : "outline"} disabled={apply.isPending} onClick={() => setPending({ eventId, photoIds: [...selected], action })}><Icon data-icon="inline-start" />{labels[action]} selected</Button>;
})}</div> : null}
{report.length ? <details><summary className="cursor-pointer text-sm">Last bulk action · {report.length} results</summary><ul className="max-h-48 overflow-auto text-sm">{report.map(row => <li key={row.photoId}>{row.photoId.slice(0, 8)} · {row.status}{row.reason ? `${row.reason}` : ""}</li>)}</ul></details> : null}
<Dialog open={!!pending} onOpenChange={open => { if (!open && !apply.isPending) setPending(null); }}>
<DialogContent><DialogHeader><DialogTitle>{pending ? labels[pending.action] : "Update"} selected photos?</DialogTitle><DialogDescription>
{pending?.action === "delete" ? "Deletion is permanent and removes originals and generated images." : "Only the selected photos will change. New uploads will not be included."}
</DialogDescription></DialogHeader>
{preview.isLoading ? <p>Checking selection</p> : preview.isError ? <p role="alert">{preview.error.message}</p> : <>
<p>{preview.data?.affected ?? 0} eligible · {(preview.data?.results.length ?? 0) - (preview.data?.affected ?? 0)} skipped</p>
<div className="grid max-h-48 grid-cols-4 gap-2 overflow-auto">{pending?.photoIds.map(id => {
const photo = rows.find(row => row.id === id);
// eslint-disable-next-line @next/next/no-img-element
return photo?.thumbUrl ? <img key={id} src={photo.thumbUrl} alt={`Selected photo ${id.slice(0, 8)}`} className="aspect-square size-full rounded-md object-cover" /> : <span key={id}>{id.slice(0, 8)}</span>;
})}</div>
{preview.data?.results.filter(row => row.reason).map(row => <p className="text-sm text-muted-foreground" key={row.photoId}>{row.photoId.slice(0, 8)}: {row.reason}</p>)}
</>}
<DialogFooter><Button variant="outline" disabled={apply.isPending} onClick={() => setPending(null)}>Cancel</Button>
<Button variant={pending?.action === "delete" ? "destructive" : "default"} disabled={apply.isPending || preview.isFetching || !preview.data?.affected || preview.isError} onClick={() => {
if (pending && preview.data) apply.mutate({ ...pending, photoIds: preview.data.results.filter(row => row.status === "eligible").map(row => row.photoId), confirm: true });
}}>{apply.isPending ? "Applying…" : `Confirm ${pending ? labels[pending.action].toLowerCase() : "changes"}`}</Button></DialogFooter>
</DialogContent>
</Dialog>
</div>;
}
@@ -0,0 +1,51 @@
"use client";
import { useRef, useState } from "react";
import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { createGalleryPhotosInputSchema } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
export function GalleryUpload({ eventId }: { eventId: string }) {
const input = useRef<HTMLInputElement>(null);
const utils = api.useUtils();
const create = api.manager.createGalleryPhotos.useMutation();
const complete = api.manager.completeGalleryPhotos.useMutation();
const [busy, setBusy] = useState(false);
const [items, setItems] = useState<{ name: string; status: string }[]>([]);
async function upload(files: File[]) {
if (busy || !files.length) return;
const parsed = createGalleryPhotosInputSchema.safeParse({ eventId, files: files.map(file => ({ fileName: file.name, contentType: file.type, byteSize: file.size })) });
if (!parsed.success) { toast.error("Choose up to 25 supported images, each under 25 MB."); return; }
setBusy(true);
setItems(files.map(file => ({ name: file.name, status: "Waiting" })));
const status = (index: number, value: string) => setItems(current => current.map((item, n) => n === index ? { ...item, status: value } : item));
try {
const uploads = await create.mutateAsync(parsed.data);
for (const [index, file] of files.entries()) {
try {
status(index, "Uploading");
const uploaded = await fetch(uploads[index]!.uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, signal: AbortSignal.timeout(120_000) });
if (!uploaded.ok) throw new Error("Upload failed");
const [result] = await complete.mutateAsync({ eventId, photoIds: [uploads[index]!.photoId] });
status(index, result?.status === "processing" || result?.status === "ready" ? "Queued" : "Failed");
} catch { status(index, "Failed"); }
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not prepare uploads");
setItems(current => current.map(item => ({ ...item, status: "Failed" })));
} finally {
setBusy(false);
await utils.manager.photos.invalidate({ eventId });
}
}
const finished = items.filter(item => ["Queued", "Failed"].includes(item.status)).length;
return <div className="mb-4 flex flex-col gap-2">
<input ref={input} className="sr-only" type="file" multiple accept="image/jpeg,image/png,image/webp,image/heic,image/heif" aria-label="Upload gallery photos" disabled={busy}
onChange={event => { const files = Array.from(event.target.files ?? []); event.target.value = ""; void upload(files); }} />
<Button className="self-start" variant="outline" disabled={busy} onClick={() => input.current?.click()}><UploadIcon data-icon="inline-start" />{busy ? "Uploading…" : "Upload photos"}</Button>
{items.length ? <details><summary className="cursor-pointer text-sm">{Math.round(finished / items.length * 100)}% processed · {items.filter(item => item.status === "Queued").length}/{items.length} queued · {items.filter(item => item.status === "Failed").length} failed</summary>
<ul className="max-h-40 overflow-auto text-sm">{items.map((item, index) => <li key={index}>{item.name} {item.status}</li>)}</ul></details> : null}
</div>;
}
@@ -4,6 +4,8 @@ import { useState } from "react";
import { toast } from "sonner";
import {
CheckIcon,
ChevronDownIcon,
SquareIcon,
CopyIcon,
DownloadIcon,
EyeOffIcon,
@@ -14,6 +16,8 @@ import {
XIcon,
} from "lucide-react";
import { api } from "@/trpc/react";
import { BulkPhotoActions } from "./bulk-photo-actions";
import { GalleryUpload } from "./gallery-upload";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -55,14 +59,18 @@ export function ModerationGrid({
canModerate,
canDelete,
canPrivate,
canUpload,
}: {
eventId: string;
canModerate: boolean;
canDelete: boolean;
canPrivate: boolean;
canUpload: boolean;
}) {
const utils = api.useUtils();
const photos = api.manager.photos.useQuery({ eventId });
const [page, setPage] = useState(0);
const [filter, setFilter] = useState<keyof typeof visibilityLabels | undefined>();
const photos = api.manager.photos.useQuery({ eventId, offset: page * 24, limit: 25, visibility: filter });
const moderate = api.manager.moderatePhoto.useMutation({
onSuccess: async () => {
await utils.manager.photos.invalidate({ eventId });
@@ -86,6 +94,7 @@ export function ModerationGrid({
});
const [previewId, setPreviewId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [selected, setSelected] = useState<string[]>([]);
if (photos.isLoading) {
return (
@@ -97,7 +106,7 @@ export function ModerationGrid({
);
}
const rows = [...(photos.data ?? [])].sort((left, right) => {
const rows = [...(photos.data ?? []).slice(0, 24)].sort((left, right) => {
if (left.visibility === "pending" && right.visibility !== "pending") return -1;
if (right.visibility === "pending" && left.visibility !== "pending") return 1;
return (
@@ -114,9 +123,10 @@ export function ModerationGrid({
<Button variant="outline" onClick={() => photos.refetch()}>Try Again</Button></Empty>;
}
if (rows.length === 0) {
if (rows.length === 0 && !filter && page === 0) {
return (
<Empty className="border">
{canUpload ? <GalleryUpload eventId={eventId} /> : null}
<EmptyHeader>
<EmptyTitle>No uploads yet</EmptyTitle>
<EmptyDescription>
@@ -129,6 +139,19 @@ export function ModerationGrid({
return (
<>
{canUpload ? <GalleryUpload eventId={eventId} /> : null}
<div className="mb-4 flex flex-wrap items-center gap-2">
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="outline">{filter ? visibilityLabels[filter] : "All photos"}<ChevronDownIcon data-icon="inline-end" /></Button></DropdownMenuTrigger>
<DropdownMenuContent><DropdownMenuGroup>
<DropdownMenuItem onSelect={() => { setFilter(undefined); setPage(0); }}>All photos</DropdownMenuItem>
{(Object.keys(visibilityLabels) as (keyof typeof visibilityLabels)[]).filter(value => value !== "private" || canPrivate).map(value => <DropdownMenuItem key={value} onSelect={() => { setFilter(value); setPage(0); }}>{visibilityLabels[value]}</DropdownMenuItem>)}
</DropdownMenuGroup></DropdownMenuContent></DropdownMenu>
<Button variant="outline" disabled={page === 0} onClick={() => setPage(value => value - 1)}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {page + 1}</span>
<Button variant="outline" disabled={(photos.data?.length ?? 0) <= 24} onClick={() => setPage(value => value + 1)}>Next</Button>
</div>
<BulkPhotoActions eventId={eventId} selected={selected} setSelected={setSelected} rows={rows} canModerate={canModerate} canDelete={canDelete} canPrivate={canPrivate} />
{!rows.length ? <p className="py-6 text-sm text-muted-foreground">No photos on this page. Change the filter or go back a page.</p> : null}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((photo) => (
<article
@@ -162,6 +185,11 @@ export function ModerationGrid({
) : null}
</button>
<div className="flex min-h-16 items-center gap-3 px-4 py-3">
{canModerate || canDelete ? <Button variant="outline" size="icon" aria-label={`Select photo from ${photo.contributorName ?? "Anonymous"}`} aria-pressed={selected.includes(photo.id)}
disabled={!selected.includes(photo.id) && selected.length >= 100}
onClick={() => setSelected(current => current.includes(photo.id) ? current.filter(id => id !== photo.id) : [...current, photo.id])}>
{selected.includes(photo.id) ? <CheckIcon /> : <SquareIcon />}
</Button> : null}
<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">
@@ -48,6 +48,7 @@ export default async function EventDashboardPage({
canModerate={canModerate}
canDelete={event.permissions.includes("photos.delete")}
canPrivate={event.permissions.includes("photos.private.read")}
canUpload={event.permissions.includes("settings.manage")}
/>
</div>
),
+19 -3
View File
@@ -1,4 +1,6 @@
import { TRPCError } from "@trpc/server";
import { bulkPhotos } from "@/server/bulk-photos";
import { createGalleryPhotos, completeGalleryPhotos } from "@/server/gallery-uploads";
import { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
import { searchLocations } from "@/server/location-search";
import { notifyEventGuests } from "@/server/guest-notifications";
@@ -21,6 +23,10 @@ import {
user,
} from "@album/database";
import {
bulkPhotosInputSchema,
createGalleryPhotosInputSchema,
completeGalleryPhotosInputSchema,
applyBulkPhotosInputSchema,
createEventInputSchema,
exportPhotosInputSchema,
checkEventSlugInputSchema,
@@ -430,7 +436,9 @@ export const managerRouter = createTRPCRouter({
}),
photos: protectedProcedure
.input(z.object({ eventId: z.string().uuid() }))
.input(z.object({ eventId: z.string().uuid(), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).optional(),
visibility: z.enum(["pending", "public", "hidden", "private", "rejected"]).optional(),
processingStatus: z.enum(["uploading", "processing", "ready", "failed"]).optional() }))
.query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(
@@ -452,8 +460,11 @@ export const managerRouter = createTRPCRouter({
.from(photos)
.innerJoin(submissions, eq(photos.submissionId, submissions.id))
.innerJoin(guests, eq(submissions.guestId, guests.id))
.where(eq(photos.eventId, input.eventId))
.orderBy(desc(photos.createdAt));
.where(and(eq(photos.eventId, input.eventId), inArray(photos.visibility, [...allowed]),
input.visibility ? eq(photos.visibility, input.visibility) : undefined,
input.processingStatus ? eq(photos.processingStatus, input.processingStatus) : undefined))
.orderBy(desc(photos.createdAt), desc(photos.id))
.offset(input.offset).limit(input.limit ?? 2147483647);
return Promise.all(
rows
.filter((row) => allowed.includes(row.photo.visibility))
@@ -466,6 +477,11 @@ export const managerRouter = createTRPCRouter({
);
}),
previewBulkPhotos: protectedProcedure.input(bulkPhotosInputSchema).query(({ ctx, input }) => bulkPhotos(ctx.session.user.id, input, false)),
createGalleryPhotos: protectedProcedure.input(createGalleryPhotosInputSchema).mutation(({ ctx, input }) => createGalleryPhotos(ctx.session.user.id, input)),
completeGalleryPhotos: protectedProcedure.input(completeGalleryPhotosInputSchema).mutation(({ ctx, input }) => completeGalleryPhotos(ctx.session.user.id, input)),
bulkPhotos: protectedProcedure.input(applyBulkPhotosInputSchema).mutation(({ ctx, input }) => bulkPhotos(ctx.session.user.id, input, true)),
moderatePhoto: protectedProcedure
.input(moderatePhotoInputSchema)
.mutation(async ({ ctx, input }) => {
+45
View File
@@ -0,0 +1,45 @@
import { and, eq } from "drizzle-orm";
import { auditEvents, getDb, photos } from "@album/database";
import { deletePrefix, photoObjectPrefix } from "@album/storage";
import { bulkPhotosInputSchema } from "@album/contracts";
import type { z } from "zod";
import { canTransitionVisibility } from "@/lib/photo-status";
import { EVENT_PERMISSIONS } from "./permissions";
import { loadEventAccess, requireEventPermission } from "./api/trpc";
import { getPlatformRole } from "./roles";
export async function bulkPhotos(userId: string, input: z.infer<typeof bulkPhotosInputSchema>, apply: boolean) {
const { event, access } = await loadEventAccess(userId, input.eventId, await getPlatformRole(userId));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_READ);
requireEventPermission(access.permissions, input.action === "delete" ? EVENT_PERMISSIONS.PHOTOS_DELETE : EVENT_PERMISSIONS.PHOTOS_MODERATE);
const canPrivate = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
if (input.action === "private") requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "skipped" | "failed"; reason?: string }[] = [];
// Each photo is its own transaction: failures never hide earlier successful work.
for (const photoId of input.photoIds) {
try {
results.push(await getDb().transaction(async tx => {
const predicate = and(eq(photos.eventId, input.eventId), eq(photos.id, photoId));
const [photo] = await tx.select().from(photos).where(predicate).for("update");
const skip = (reason: string) => ({ photoId, status: "skipped" as const, reason });
if (!photo || (photo.visibility === "private" && !canPrivate)) return skip("Unavailable or not permitted");
if (input.action === "delete" && !["ready", "failed"].includes(photo.processingStatus)) return skip("Wait for upload and processing to finish");
if (input.action !== "delete" && !canTransitionVisibility(photo.visibility, input.action)) return skip("Already set or transition not allowed");
if (!apply) return { photoId, status: "eligible" as const };
if (input.action === "delete") {
await deletePrefix(photoObjectPrefix(input.eventId, photo.id));
await tx.delete(photos).where(predicate);
} else {
await tx.update(photos).set({ visibility: input.action, updatedAt: new Date() }).where(predicate);
}
await tx.insert(auditEvents).values({ groupId: event.groupId, eventId: input.eventId, actorUserId: userId,
action: input.action === "delete" ? "photo.delete" : "photo.visibility", subjectType: "photo", subjectId: photoId,
metadata: { bulk: true, "visibility.before": photo.visibility, "visibility.after": input.action } });
return { photoId, status: input.action === "delete" ? "deleted" as const : "updated" as const };
}));
} catch {
results.push({ photoId, status: "failed", reason: "Operation failed; refresh before retrying" });
}
}
return { results, affected: results.filter(result => ["eligible", "updated", "deleted"].includes(result.status)).length };
}
+61
View File
@@ -0,0 +1,61 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { auditEvents, getDb, guests, submissions, photos, photoJobs } from "@album/database";
import { createPresignedPutUrl, headObject, originalObjectKey } from "@album/storage";
import { MAX_PHOTO_BYTES, createGalleryPhotosInputSchema, completeGalleryPhotosInputSchema } from "@album/contracts";
import type { z } from "zod";
import { loadEventAccess, requireEventPermission } from "./api/trpc";
import { EVENT_PERMISSIONS } from "./permissions";
import { getPlatformRole } from "./roles";
import { consumeRateLimit } from "./rate-limit";
async function authorize(userId: string, eventId: string) {
const { event, access } = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.SETTINGS_MANAGE);
return event;
}
export async function createGalleryPhotos(userId: string, input: z.infer<typeof createGalleryPhotosInputSchema>) {
const event = await authorize(userId, input.eventId);
const rate = await consumeRateLimit({ namespace: `gallery-upload:${event.id}`, identifier: userId, limit: 10, windowMs: 600_000 });
if (!rate.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many upload batches. Try again shortly." });
const uploads = await Promise.all(input.files.map(async file => {
const photoId = crypto.randomUUID();
const key = originalObjectKey(event.id, photoId);
return { photoId, key, uploadUrl: await createPresignedPutUrl({ key, contentType: file.contentType }) };
}));
await getDb().transaction(async tx => {
const [guest] = await tx.insert(guests).values({ eventId: event.id, displayName: "Event organizer", tokenHash: crypto.randomUUID() }).returning();
const [submission] = await tx.insert(submissions).values({ eventId: event.id, guestId: guest!.id }).returning();
await tx.insert(photos).values(uploads.map((upload, index) => ({ id: upload.photoId, eventId: event.id, submissionId: submission!.id,
originalKey: upload.key, contentType: input.files[index]!.contentType, byteSize: input.files[index]!.byteSize, processingStatus: "uploading" as const, visibility: "pending" as const })));
await tx.insert(auditEvents).values(uploads.map(upload => ({ groupId: event.groupId, eventId: event.id, actorUserId: userId, action: "photo.create", subjectType: "photo", subjectId: upload.photoId })));
});
return uploads.map(({ photoId, uploadUrl }) => ({ photoId, uploadUrl }));
}
export async function completeGalleryPhotos(userId: string, input: z.infer<typeof completeGalleryPhotosInputSchema>) {
const event = await authorize(userId, input.eventId);
const results: { photoId: string; status: string }[] = [];
for (const photoId of [...new Set(input.photoIds)]) {
try {
const predicate = and(eq(photos.id, photoId), eq(photos.eventId, event.id));
const [photo] = await getDb().select().from(photos).where(predicate);
if (!photo) { results.push({ photoId, status: "unavailable" }); continue; }
if (photo.processingStatus !== "uploading") { results.push({ photoId, status: photo.processingStatus }); continue; }
const head = await headObject(photo.originalKey);
const size = Number(head?.ContentLength ?? 0);
if (!size || size > MAX_PHOTO_BYTES) { results.push({ photoId, status: "invalid-upload" }); continue; }
await getDb().transaction(async tx => {
const changed = await tx.update(photos).set({ processingStatus: "processing", byteSize: size, updatedAt: new Date() })
.where(and(predicate, eq(photos.processingStatus, "uploading"))).returning({ id: photos.id });
if (changed.length) {
await tx.insert(photoJobs).values({ photoId, kind: "transcode", status: "pending" });
await tx.insert(auditEvents).values({ groupId: event.groupId, eventId: event.id, actorUserId: userId, action: "photo.upload.complete", subjectType: "photo", subjectId: photoId });
}
});
results.push({ photoId, status: "processing" });
} catch { results.push({ photoId, status: "failed" }); }
}
return results;
}
+4
View File
@@ -34,6 +34,10 @@ export const assistantTools: ToolDefinition[] = [
tool("manager.event", "Read event settings, schedule, and access.", [E.OVERVIEW_READ]),
tool("manager.stats", "Read event upload, guest, note, and processing counts.", [E.OVERVIEW_READ]),
tool("manager.photos", "List event photos. Private photos require photos.private.read.", [E.PHOTOS_READ]),
tool("manager.createGalleryPhotos", "Create up to 25 pending organizer photos and return presigned PUT URLs in file order. Upload original bytes directly to those URLs, then call completeGalleryPhotos. Does not publish photos.", [E.SETTINGS_MANAGE], true),
tool("manager.completeGalleryPhotos", "Verify direct uploads and queue image processing for up to 25 photos in an event. Returns per-photo results.", [E.SETTINGS_MANAGE], true),
tool("manager.previewBulkPhotos", "Preview up to 100 explicit photo IDs for a bulk action. Returns eligible and skipped IDs; permission checked for the chosen action.", [E.PHOTOS_READ]),
tool("manager.bulkPhotos", "Apply approval, hiding, rejection, privacy, or deletion to up to 100 explicit photo IDs. Preview first; pass only eligible IDs and input.confirm=true. Returns per-photo results. Delete requires photos.delete; other actions require photos.moderate.", [E.PHOTOS_READ], true),
tool("manager.notes", "List guest notes.", [E.NOTES_READ]),
tool("manager.guests", "List event guests and contact details.", [E.PEOPLE_READ]),
tool("manager.members", "List accounts with event access.", [E.PEOPLE_READ]),
@@ -4,6 +4,7 @@ import { getDb, user, groups, guests, submissions, photos, events, eventMembersh
import { groupRouter } from "./api/routers/group";
import { managerRouter } from "./api/routers/manager";
import type { TrpcContext } from "./api/trpc";
import { deletePrefix, photoObjectPrefix, headObject, originalObjectKey } from "@album/storage";
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-scoped submission approval", async () => {
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
@@ -12,6 +13,7 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s
const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Workflow test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning();
const ctx = (n: number): TrpcContext => ({ session: { user: people[n]!, session: {} } as TrpcContext["session"], cookies: new Map(), activeGroupId: null, requestOrigin: "http://localhost:3000", clientIdentifier: id, guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {} });
let groupId: string | undefined;
const cleanupUploads: { eventId: string; photoId: string }[] = [];
try {
const owner = groupRouter.createCaller(ctx(0));
const group = await owner.create({ name: "Workflow test" });
@@ -41,7 +43,40 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s
const [preserved] = await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.id, privatePhoto!.id)));
expect(preserved!.visibility).toBe("private");
await expect(moderator.moderateSubmission({ ...input, visibility: "private" })).rejects.toThrow();
const bulk = { eventId: event!.id, photoIds: [...result.map(photo => photo.id), privatePhoto!.id], action: "public" as const };
const preview = await moderator.previewBulkPhotos(bulk);
expect(preview.affected).toBe(2);
expect(preview.results.find(row => row.photoId === privatePhoto!.id)?.status).toBe("skipped");
await expect(moderator.bulkPhotos({ ...bulk, confirm: false as true })).rejects.toThrow();
expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2);
expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(0);
await expect(moderator.bulkPhotos({ ...bulk, action: "private", confirm: true })).rejects.toThrow();
const [otherEvent] = await db.insert(events).values({ groupId, title: "Other event", slug: `${id}-other` }).returning();
await db.insert(eventMemberships).values({ eventId: otherEvent!.id, userId: people[0]!.id, role: "owner" });
const crossEvent = await manager.previewBulkPhotos({ ...bulk, eventId: otherEvent!.id });
expect(crossEvent.affected).toBe(0);
const deletion = await manager.previewBulkPhotos({ ...bulk, action: "delete" });
expect(deletion.results.find(row => row.photoId === result.find(photo => photo.processingStatus === "processing")!.id)?.status).toBe("skipped");
await expect(manager.bulkPhotos({ ...bulk, photoIds: [result[0]!.id, result[0]!.id], confirm: true })).rejects.toThrow();
await expect(moderator.createGalleryPhotos({ eventId: event!.id, files: [{ fileName: "test.png", contentType: "image/png", byteSize: 5 }] })).rejects.toThrow();
expect((await moderator.photos({ eventId: event!.id, limit: 1, visibility: "private" })).length).toBe(0);
expect((await manager.photos({ eventId: event!.id, limit: 1 })).length).toBe(1);
if (process.env.GALLERY_STORAGE_INTEGRATION === "1") {
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.S3_ENDPOINT!).hostname)) throw new Error("Local object storage required");
const bytes = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRZkAAAAASUVORK5CYII=", "base64");
const uploads = await manager.createGalleryPhotos({ eventId: event!.id, files: [{ fileName: "test.png", contentType: "image/png", byteSize: bytes.length }] });
for (const upload of uploads) cleanupUploads.push({ eventId: event!.id, photoId: upload.photoId });
expect((await fetch(uploads[0]!.uploadUrl, { method: "PUT", headers: { "Content-Type": "image/png" }, body: bytes })).ok).toBe(true);
const batch = { eventId: event!.id, photoIds: uploads.map(upload => upload.photoId) };
expect((await manager.completeGalleryPhotos(batch))[0]!.status).toBe("processing");
expect((await manager.previewBulkPhotos({ ...batch, action: "delete" })).affected).toBe(0);
// Simulate the worker's terminal state; never modify a real upload.
await db.update(photos).set({ processingStatus: "ready" }).where(and(eq(photos.eventId, event!.id), eq(photos.id, uploads[0]!.photoId)));
expect((await manager.bulkPhotos({ ...batch, action: "delete", confirm: true })).affected).toBe(1);
expect(await headObject(originalObjectKey(event!.id, uploads[0]!.photoId))).toBeNull();
}
} finally {
for (const upload of cleanupUploads) await deletePrefix(photoObjectPrefix(upload.eventId, upload.photoId));
if (groupId) await db.delete(groups).where(eq(groups.id, groupId));
await db.delete(user).where(inArray(user.id, people.map(person => person.id)));
}