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 { toast } from "sonner";
import { import {
CheckIcon, CheckIcon,
ChevronDownIcon,
SquareIcon,
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
EyeOffIcon, EyeOffIcon,
@@ -14,6 +16,8 @@ import {
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { BulkPhotoActions } from "./bulk-photo-actions";
import { GalleryUpload } from "./gallery-upload";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -55,14 +59,18 @@ export function ModerationGrid({
canModerate, canModerate,
canDelete, canDelete,
canPrivate, canPrivate,
canUpload,
}: { }: {
eventId: string; eventId: string;
canModerate: boolean; canModerate: boolean;
canDelete: boolean; canDelete: boolean;
canPrivate: boolean; canPrivate: boolean;
canUpload: boolean;
}) { }) {
const utils = api.useUtils(); 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({ const moderate = api.manager.moderatePhoto.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.manager.photos.invalidate({ eventId }); await utils.manager.photos.invalidate({ eventId });
@@ -86,6 +94,7 @@ export function ModerationGrid({
}); });
const [previewId, setPreviewId] = useState<string | null>(null); const [previewId, setPreviewId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null); const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [selected, setSelected] = useState<string[]>([]);
if (photos.isLoading) { if (photos.isLoading) {
return ( 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 (left.visibility === "pending" && right.visibility !== "pending") return -1;
if (right.visibility === "pending" && left.visibility !== "pending") return 1; if (right.visibility === "pending" && left.visibility !== "pending") return 1;
return ( return (
@@ -114,9 +123,10 @@ export function ModerationGrid({
<Button variant="outline" onClick={() => photos.refetch()}>Try Again</Button></Empty>; <Button variant="outline" onClick={() => photos.refetch()}>Try Again</Button></Empty>;
} }
if (rows.length === 0) { if (rows.length === 0 && !filter && page === 0) {
return ( return (
<Empty className="border"> <Empty className="border">
{canUpload ? <GalleryUpload eventId={eventId} /> : null}
<EmptyHeader> <EmptyHeader>
<EmptyTitle>No uploads yet</EmptyTitle> <EmptyTitle>No uploads yet</EmptyTitle>
<EmptyDescription> <EmptyDescription>
@@ -129,6 +139,19 @@ export function ModerationGrid({
return ( 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"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((photo) => ( {rows.map((photo) => (
<article <article
@@ -162,6 +185,11 @@ export function ModerationGrid({
) : null} ) : null}
</button> </button>
<div className="flex min-h-16 items-center gap-3 px-4 py-3"> <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"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{photo.contributorName ?? "Anonymous"}</p> <p className="truncate text-sm font-medium">{photo.contributorName ?? "Anonymous"}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -48,6 +48,7 @@ export default async function EventDashboardPage({
canModerate={canModerate} canModerate={canModerate}
canDelete={event.permissions.includes("photos.delete")} canDelete={event.permissions.includes("photos.delete")}
canPrivate={event.permissions.includes("photos.private.read")} canPrivate={event.permissions.includes("photos.private.read")}
canUpload={event.permissions.includes("settings.manage")}
/> />
</div> </div>
), ),
+19 -3
View File
@@ -1,4 +1,6 @@
import { TRPCError } from "@trpc/server"; 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 { customBannerUrl, eventBannerUrl } from "@/server/event-banner";
import { searchLocations } from "@/server/location-search"; import { searchLocations } from "@/server/location-search";
import { notifyEventGuests } from "@/server/guest-notifications"; import { notifyEventGuests } from "@/server/guest-notifications";
@@ -21,6 +23,10 @@ import {
user, user,
} from "@album/database"; } from "@album/database";
import { import {
bulkPhotosInputSchema,
createGalleryPhotosInputSchema,
completeGalleryPhotosInputSchema,
applyBulkPhotosInputSchema,
createEventInputSchema, createEventInputSchema,
exportPhotosInputSchema, exportPhotosInputSchema,
checkEventSlugInputSchema, checkEventSlugInputSchema,
@@ -430,7 +436,9 @@ export const managerRouter = createTRPCRouter({
}), }),
photos: protectedProcedure 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 }) => { .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(
@@ -452,8 +460,11 @@ export const managerRouter = createTRPCRouter({
.from(photos) .from(photos)
.innerJoin(submissions, eq(photos.submissionId, submissions.id)) .innerJoin(submissions, eq(photos.submissionId, submissions.id))
.innerJoin(guests, eq(submissions.guestId, guests.id)) .innerJoin(guests, eq(submissions.guestId, guests.id))
.where(eq(photos.eventId, input.eventId)) .where(and(eq(photos.eventId, input.eventId), inArray(photos.visibility, [...allowed]),
.orderBy(desc(photos.createdAt)); 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( return Promise.all(
rows rows
.filter((row) => allowed.includes(row.photo.visibility)) .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 moderatePhoto: protectedProcedure
.input(moderatePhotoInputSchema) .input(moderatePhotoInputSchema)
.mutation(async ({ ctx, input }) => { .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.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.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.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.notes", "List guest notes.", [E.NOTES_READ]),
tool("manager.guests", "List event guests and contact details.", [E.PEOPLE_READ]), tool("manager.guests", "List event guests and contact details.", [E.PEOPLE_READ]),
tool("manager.members", "List accounts with event access.", [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 { groupRouter } from "./api/routers/group";
import { managerRouter } from "./api/routers/manager"; import { managerRouter } from "./api/routers/manager";
import type { TrpcContext } from "./api/trpc"; 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 () => { 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"); 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 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: () => {} }); 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; let groupId: string | undefined;
const cleanupUploads: { eventId: string; photoId: string }[] = [];
try { try {
const owner = groupRouter.createCaller(ctx(0)); const owner = groupRouter.createCaller(ctx(0));
const group = await owner.create({ name: "Workflow test" }); 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))); const [preserved] = await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.id, privatePhoto!.id)));
expect(preserved!.visibility).toBe("private"); expect(preserved!.visibility).toBe("private");
await expect(moderator.moderateSubmission({ ...input, visibility: "private" })).rejects.toThrow(); 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 { } finally {
for (const upload of cleanupUploads) await deletePrefix(photoObjectPrefix(upload.eventId, upload.photoId));
if (groupId) await db.delete(groups).where(eq(groups.id, groupId)); if (groupId) await db.delete(groups).where(eq(groups.id, groupId));
await db.delete(user).where(inArray(user.id, people.map(person => person.id))); await db.delete(user).where(inArray(user.id, people.map(person => person.id)));
} }
+5
View File
@@ -81,3 +81,8 @@ unhealthy. Long exports can exceed this threshold and should be investigated.
Checks run every 30 seconds, with a 5-second timeout and three retries. No worker Checks run every 30 seconds, with a 5-second timeout and three retries. No worker
health port is published. Docker health status by itself does not automatically health port is published. Docker health status by itself does not automatically
restart an unhealthy container; it supplies readiness information to Coolify. restart an unhealthy container; it supplies readiness information to Coolify.
# Bulk gallery workflows
Use `manager_photos` with `input.limit`, `input.offset`, optional `input.visibility` and `input.processingStatus` to inspect a page. Capture explicit photo IDs; a bulk request never expands to later uploads. `manager_previewBulkPhotos` checks up to 100 IDs for an action (`public`, `hidden`, `private`, `rejected`, `delete`). Pass eligible IDs to `manager_bulkPhotos` with both `input.confirm=true` and the MCP write wrapper's `confirm=true`. Results are per photo, including skipped/failed items. Permissions are rechecked during execution. Deletion skips photos still uploading or processing, permanently removes originals and variants, and can partially succeed; inspect results before retrying.
For organizer uploads, `manager_createGalleryPhotos` accepts up to 25 file descriptors and returns presigned PUT URLs in input order. PUT each original directly to storage with its matching Content-Type, then call `manager_completeGalleryPhotos` with successful IDs to validate size and queue transcoding. These actions require existing `settings.manage` permission. Photos start pending review, even when guest publication is automatic. Do not blindly retry creation: it creates a new batch. No file bytes pass through MCP or Next.js.
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const bulkPhotosInputSchema = z.object({
eventId: z.string().uuid(),
photoIds: z.array(z.string().uuid()).min(1).max(100).refine(ids => new Set(ids).size === ids.length, "Duplicate photo IDs"),
action: z.enum(["public", "hidden", "private", "rejected", "delete"]),
});
export const applyBulkPhotosInputSchema = bulkPhotosInputSchema.extend({ confirm: z.literal(true) });
+7
View File
@@ -175,6 +175,12 @@ export const completePhotoInputSchema = z.object({
photoId: z.string().uuid(), photoId: z.string().uuid(),
}); });
export const createGalleryPhotosInputSchema = z.object({
eventId: z.string().uuid(),
files: z.array(createPhotoInputSchema.pick({ contentType: true, byteSize: true, fileName: true })).min(1).max(25),
});
export const completeGalleryPhotosInputSchema = z.object({ eventId: z.string().uuid(), photoIds: z.array(z.string().uuid()).min(1).max(25) });
export const BANNER_ASPECT_RATIO = 8 / 3; export const BANNER_ASPECT_RATIO = 8 / 3;
export const bannerCropSchema = z.object({ export const bannerCropSchema = z.object({
x: z.number().finite().min(0).max(100), x: z.number().finite().min(0).max(100),
@@ -275,3 +281,4 @@ export type PhotoProcessingStatus = z.infer<typeof photoProcessingStatusSchema>;
export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>; export type PhotoVisibility = z.infer<typeof photoVisibilitySchema>;
export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>; export type AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
export { createAssistantTokenInputSchema } from "./assistant"; export { createAssistantTokenInputSchema } from "./assistant";
export * from "./bulk-photos";