diff --git a/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx b/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx new file mode 100644 index 0000000..9ada8f1 --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/bulk-photo-actions.tsx @@ -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 = { 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
+
+ {selected.length} selected · up to 100 at a time + + {canModerate ? : null} + +
+ {selected.length ?
{actions.map(action => { + const Icon = icons[action]; + return ; + })}
: null} + {report.length ?
Last bulk action · {report.length} results
    {report.map(row =>
  • {row.photoId.slice(0, 8)} · {row.status}{row.reason ? ` — ${row.reason}` : ""}
  • )}
: null} + { if (!open && !apply.isPending) setPending(null); }}> + {pending ? labels[pending.action] : "Update"} selected photos? + {pending?.action === "delete" ? "Deletion is permanent and removes originals and generated images." : "Only the selected photos will change. New uploads will not be included."} + + {preview.isLoading ?

Checking selection…

: preview.isError ?

{preview.error.message}

: <> +

{preview.data?.affected ?? 0} eligible · {(preview.data?.results.length ?? 0) - (preview.data?.affected ?? 0)} skipped

+
{pending?.photoIds.map(id => { + const photo = rows.find(row => row.id === id); + // eslint-disable-next-line @next/next/no-img-element + return photo?.thumbUrl ? {`Selected : {id.slice(0, 8)}; + })}
+ {preview.data?.results.filter(row => row.reason).map(row =>

{row.photoId.slice(0, 8)}: {row.reason}

)} + } + + +
+
+
; +} diff --git a/apps/web/src/app/dashboard/events/[id]/gallery-upload.tsx b/apps/web/src/app/dashboard/events/[id]/gallery-upload.tsx new file mode 100644 index 0000000..a10fddf --- /dev/null +++ b/apps/web/src/app/dashboard/events/[id]/gallery-upload.tsx @@ -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(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
+ { const files = Array.from(event.target.files ?? []); event.target.value = ""; void upload(files); }} /> + + {items.length ?
{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 +
    {items.map((item, index) =>
  • {item.name} — {item.status}
  • )}
: null} +
; +} diff --git a/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx b/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx index b933626..af42f36 100644 --- a/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx +++ b/apps/web/src/app/dashboard/events/[id]/moderation-grid.tsx @@ -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(); + 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(null); const [pendingDelete, setPendingDelete] = useState(null); + const [selected, setSelected] = useState([]); 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({ ; } - if (rows.length === 0) { + if (rows.length === 0 && !filter && page === 0) { return ( + {canUpload ? : null} No uploads yet @@ -129,6 +139,19 @@ export function ModerationGrid({ return ( <> + {canUpload ? : null} +
+ + + { setFilter(undefined); setPage(0); }}>All photos + {(Object.keys(visibilityLabels) as (keyof typeof visibilityLabels)[]).filter(value => value !== "private" || canPrivate).map(value => { setFilter(value); setPage(0); }}>{visibilityLabels[value]})} + + + Page {page + 1} + +
+ + {!rows.length ?

No photos on this page. Change the filter or go back a page.

: null}
{rows.map((photo) => (
+ {canModerate || canDelete ? : null}

{photo.contributorName ?? "Anonymous"}

diff --git a/apps/web/src/app/dashboard/events/[id]/page.tsx b/apps/web/src/app/dashboard/events/[id]/page.tsx index a6cda5e..a1456fd 100644 --- a/apps/web/src/app/dashboard/events/[id]/page.tsx +++ b/apps/web/src/app/dashboard/events/[id]/page.tsx @@ -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")} />

), diff --git a/apps/web/src/server/api/routers/manager.ts b/apps/web/src/server/api/routers/manager.ts index b2e4d96..61ba7ba 100644 --- a/apps/web/src/server/api/routers/manager.ts +++ b/apps/web/src/server/api/routers/manager.ts @@ -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 }) => { diff --git a/apps/web/src/server/bulk-photos.ts b/apps/web/src/server/bulk-photos.ts new file mode 100644 index 0000000..e2e84da --- /dev/null +++ b/apps/web/src/server/bulk-photos.ts @@ -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, 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 }; +} diff --git a/apps/web/src/server/gallery-uploads.ts b/apps/web/src/server/gallery-uploads.ts new file mode 100644 index 0000000..c77e92d --- /dev/null +++ b/apps/web/src/server/gallery-uploads.ts @@ -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) { + 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) { + 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; +} diff --git a/apps/web/src/server/mcp/catalog.ts b/apps/web/src/server/mcp/catalog.ts index e2e0c01..2fcf49d 100644 --- a/apps/web/src/server/mcp/catalog.ts +++ b/apps/web/src/server/mcp/catalog.ts @@ -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]), diff --git a/apps/web/src/server/submission-groups.integration.test.ts b/apps/web/src/server/submission-groups.integration.test.ts index 39c4fbb..452ed9b 100644 --- a/apps/web/src/server/submission-groups.integration.test.ts +++ b/apps/web/src/server/submission-groups.integration.test.ts @@ -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))); } diff --git a/docs/mcp.md b/docs/mcp.md index f309255..1061a22 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. diff --git a/packages/contracts/src/bulk-photos.ts b/packages/contracts/src/bulk-photos.ts new file mode 100644 index 0000000..2b894b8 --- /dev/null +++ b/packages/contracts/src/bulk-photos.ts @@ -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) }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 02ba2d8..64d53ec 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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; export type PhotoVisibility = z.infer; export type AllowedImageType = z.infer; export { createAssistantTokenInputSchema } from "./assistant"; +export * from "./bulk-photos";