Add bulk gallery management and direct organizer uploads
This commit is contained in:
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
health port is published. Docker health status by itself does not automatically
|
||||
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.
|
||||
|
||||
@@ -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) });
|
||||
@@ -175,6 +175,12 @@ export const completePhotoInputSchema = z.object({
|
||||
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 bannerCropSchema = z.object({
|
||||
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 AllowedImageType = z.infer<typeof allowedImageTypeSchema>;
|
||||
export { createAssistantTokenInputSchema } from "./assistant";
|
||||
export * from "./bulk-photos";
|
||||
|
||||
Reference in New Issue
Block a user