Harden bulk retries, parallelize guest uploads, and move guest map below gallery

This commit is contained in:
2026-09-11 18:54:46 -04:00
parent 4bc48db656
commit 1e299a9914
25 changed files with 376 additions and 62 deletions
@@ -15,12 +15,12 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
selected: string[]; setSelected: (ids: string[]) => void; canModerate: boolean; canDelete: boolean; canPrivate: boolean; selected: string[]; setSelected: (ids: string[]) => void; canModerate: boolean; canDelete: boolean; canPrivate: boolean;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const [pending, setPending] = useState<{ eventId: string; photoIds: string[]; action: Action } | null>(null); const [pending, setPending] = useState<{ eventId: string; photoIds: string[]; action: Action; requestId: string } | null>(null);
const [report, setReport] = useState<{ photoId: string; status: string; reason?: string }[]>([]); const [report, setReport] = useState<{ photoId: string; status: string; reason?: string }[]>([]);
const preview = api.manager.previewBulkPhotos.useQuery(pending!, { enabled: !!pending, retry: false }); const preview = api.manager.previewBulkPhotos.useQuery(pending!, { enabled: !!pending, retry: false, refetchOnWindowFocus: false, staleTime: Infinity });
const apply = api.manager.bulkPhotos.useMutation({ const apply = api.manager.bulkPhotos.useMutation({
onSuccess: async result => { onSuccess: async result => {
const allResults = [...result.results, ...(preview.data?.results.filter(row => row.status !== "eligible") ?? [])]; const allResults = result.results;
setReport(allResults); setReport(allResults);
setSelected(allResults.filter(row => row.status === "failed" || row.status === "skipped").map(row => row.photoId)); setSelected(allResults.filter(row => row.status === "failed" || row.status === "skipped").map(row => row.photoId));
setPending(null); setPending(null);
@@ -40,7 +40,7 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
</div> </div>
{selected.length ? <div className="flex flex-wrap gap-2">{actions.map(action => { {selected.length ? <div className="flex flex-wrap gap-2">{actions.map(action => {
const Icon = icons[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>; return <Button key={action} variant={action === "delete" ? "destructive" : "outline"} disabled={apply.isPending} onClick={() => setPending({ eventId, photoIds: [...selected], action, requestId: crypto.randomUUID() })}><Icon data-icon="inline-start" />{labels[action]} selected</Button>;
})}</div> : null} })}</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} {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); }}> <Dialog open={!!pending} onOpenChange={open => { if (!open && !apply.isPending) setPending(null); }}>
@@ -58,7 +58,7 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
</>} </>}
<DialogFooter><Button variant="outline" disabled={apply.isPending} onClick={() => setPending(null)}>Cancel</Button> <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={() => { <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 }); if (pending && preview.data) apply.mutate({ ...pending, confirm: true });
}}>{apply.isPending ? "Applying…" : `Confirm ${pending ? labels[pending.action].toLowerCase() : "changes"}`}</Button></DialogFooter> }}>{apply.isPending ? "Applying…" : `Confirm ${pending ? labels[pending.action].toLowerCase() : "changes"}`}</Button></DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -12,22 +12,30 @@ export function GalleryUpload({ eventId }: { eventId: string }) {
const utils = api.useUtils(); const utils = api.useUtils();
const create = api.manager.createGalleryPhotos.useMutation(); const create = api.manager.createGalleryPhotos.useMutation();
const complete = api.manager.completeGalleryPhotos.useMutation(); const complete = api.manager.completeGalleryPhotos.useMutation();
const retry = api.manager.retryGalleryPhoto.useMutation();
const batch = useRef<{ files: File[]; requestId: string; uploads?: { photoId: string }[] } | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [items, setItems] = useState<{ name: string; status: string }[]>([]); const [items, setItems] = useState<{ name: string; status: string }[]>([]);
async function upload(files: File[]) { async function upload(files: File[], resume = false) {
if (busy || !files.length) return; if (busy || !files.length) return;
const parsed = createGalleryPhotosInputSchema.safeParse({ eventId, files: files.map(file => ({ fileName: file.name, contentType: file.type, byteSize: file.size })) }); const requestId = resume && batch.current ? batch.current.requestId : crypto.randomUUID();
const parsed = createGalleryPhotosInputSchema.safeParse({ eventId, requestId, 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; } if (!parsed.success) { toast.error("Choose up to 25 supported images, each under 25 MB."); return; }
setBusy(true); setBusy(true);
setItems(files.map(file => ({ name: file.name, status: "Waiting" }))); if (!resume) { batch.current = { files, requestId }; 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)); const status = (index: number, value: string) => setItems(current => current.map((item, n) => n === index ? { ...item, status: value } : item));
try { try {
const uploads = await create.mutateAsync(parsed.data); const uploads = batch.current?.uploads ?? await create.mutateAsync(parsed.data);
if (batch.current) batch.current.uploads = uploads;
for (const [index, file] of files.entries()) { for (const [index, file] of files.entries()) {
if (resume && items[index]?.status === "Queued") continue;
try { try {
status(index, "Uploading"); status(index, "Uploading");
const uploaded = await fetch(uploads[index]!.uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, signal: AbortSignal.timeout(120_000) }); const target = await retry.mutateAsync({ eventId, photoId: uploads[index]!.photoId });
if (!uploaded.ok) throw new Error("Upload failed"); if (target.uploadUrl) {
const uploaded = await fetch(target.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] }); const [result] = await complete.mutateAsync({ eventId, photoIds: [uploads[index]!.photoId] });
status(index, result?.status === "processing" || result?.status === "ready" ? "Queued" : "Failed"); status(index, result?.status === "processing" || result?.status === "ready" ? "Queued" : "Failed");
} catch { status(index, "Failed"); } } catch { status(index, "Failed"); }
@@ -45,6 +53,7 @@ export function GalleryUpload({ eventId }: { eventId: string }) {
<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} <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); }} /> 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> <Button className="self-start" variant="outline" disabled={busy} onClick={() => input.current?.click()}><UploadIcon data-icon="inline-start" />{busy ? "Uploading…" : "Upload photos"}</Button>
{items.some(item => item.status === "Failed") ? <Button className="self-start" variant="outline" disabled={busy} onClick={() => { if (batch.current) void upload(batch.current.files, true); }}>Retry failed files</Button> : null}
{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> {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} <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>; </div>;
@@ -54,7 +54,11 @@ const processingOrder = {
failed: 4, failed: 4,
} as const; } as const;
export function ModerationGrid({ export function ModerationGrid(props: { eventId: string; canModerate: boolean; canDelete: boolean; canPrivate: boolean; canUpload: boolean }) {
return <>{props.canUpload ? <GalleryUpload eventId={props.eventId} /> : null}<GalleryGrid {...props} /></>;
}
function GalleryGrid({
eventId, eventId,
canModerate, canModerate,
canDelete, canDelete,
@@ -126,7 +130,6 @@ export function ModerationGrid({
if (rows.length === 0 && !filter && page === 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>
@@ -139,7 +142,6 @@ export function ModerationGrid({
return ( return (
<> <>
{canUpload ? <GalleryUpload eventId={eventId} /> : null}
<div className="mb-4 flex flex-wrap items-center gap-2"> <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> <DropdownMenu><DropdownMenuTrigger asChild><Button variant="outline">{filter ? visibilityLabels[filter] : "All photos"}<ChevronDownIcon data-icon="inline-end" /></Button></DropdownMenuTrigger>
<DropdownMenuContent><DropdownMenuGroup> <DropdownMenuContent><DropdownMenuGroup>
+12 -8
View File
@@ -19,6 +19,7 @@ import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { uploadProgress } from "@/lib/upload-progress"; import { uploadProgress } from "@/lib/upload-progress";
import { runUploadQueue } from "@/lib/upload-queue";
type QueueItem = { type QueueItem = {
file?: File; file?: File;
@@ -106,7 +107,7 @@ export function GuestUpload({
} }
if (!accepted.length) return; if (!accepted.length) return;
uploading.current = true; uploading.current = true;
const items: QueueItem[] = retryItems ?? accepted.map((file) => ({ const items: QueueItem[] = retryItems?.map((item) => ({ ...item })) ?? accepted.map((file) => ({
file, file,
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: file.name, name: file.name,
@@ -131,13 +132,15 @@ export function GuestUpload({
notifyWhenReady: Boolean(trimmedEmail) && notify, notifyWhenReady: Boolean(trimmedEmail) && notify,
note: trimmedNote || undefined, note: trimmedNote || undefined,
}); });
const submission = await startSubmission.mutateAsync({ eventSlug: slug }); // Retrying known photos does not need another (empty) submission.
const submission = items.some((item) => !item.created)
? await startSubmission.mutateAsync({ eventSlug: slug }) : null;
for (const [index, file] of accepted.entries()) { await runUploadQueue(accepted, async (file, index) => {
const item = items[index]; const item = items[index];
if (!item) continue; if (!item) return;
const contentType = imageContentType(file); const contentType = imageContentType(file);
if (!contentType) continue; if (!contentType) return;
setQueue((current) => setQueue((current) =>
current.map((entry) => current.map((entry) =>
entry.id === item.id ? { ...entry, status: "uploading" } : entry, entry.id === item.id ? { ...entry, status: "uploading" } : entry,
@@ -146,7 +149,7 @@ export function GuestUpload({
try { try {
const created = item.created ?? await createPhoto.mutateAsync({ const created = item.created ?? await createPhoto.mutateAsync({
eventSlug: slug, eventSlug: slug,
submissionId: submission.submissionId, submissionId: submission!.submissionId,
contentType, contentType,
fileName: file.name, fileName: file.name,
byteSize: file.size, byteSize: file.size,
@@ -181,8 +184,9 @@ export function GuestUpload({
), ),
); );
} }
} });
await utils.event.gallery.invalidate(slug); // Gallery refresh is not part of upload completion (nor is compression).
void utils.event.gallery.invalidate(slug).catch(() => undefined);
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Could not start upload"); toast.error(error instanceof Error ? error.message : "Could not start upload");
+6 -6
View File
@@ -70,12 +70,6 @@ export default async function EventPage({
community.stats[key] !== null ? <div key={key}><dt className="text-sm text-muted-foreground">{label}</dt><dd className="text-2xl font-semibold">{community.stats[key]}</dd></div> : null)} community.stats[key] !== null ? <div key={key}><dt className="text-sm text-muted-foreground">{label}</dt><dd className="text-2xl font-semibold">{community.stats[key]}</dd></div> : null)}
</dl> </dl>
) : null} ) : null}
{event.latitude !== null && event.longitude !== null ? (
<section aria-label="Event location" className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Getting Here</h2>
<EventMap latitude={event.latitude} longitude={event.longitude} location={event.location ?? event.title} />
</section>
) : null}
<section id="upload" className="reveal-2 scroll-mt-24 flex flex-col gap-4"> <section id="upload" className="reveal-2 scroll-mt-24 flex flex-col gap-4">
<h2 className="sr-only">Add photos or send a note</h2> <h2 className="sr-only">Add photos or send a note</h2>
<GuestUpload <GuestUpload
@@ -104,6 +98,12 @@ export default async function EventPage({
</p> </p>
)} )}
</section></> : null} </section></> : null}
{event.latitude !== null && event.longitude !== null ? (
<section aria-label="Event location" className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Getting Here</h2>
<EventMap latitude={event.latitude} longitude={event.longitude} location={event.location ?? event.title} />
</section>
) : null}
</main> </main>
); );
} }
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test";
import { runUploadQueue, UPLOAD_CONCURRENCY } from "./upload-queue";
function gate() {
let release!: () => void;
const promise = new Promise<void>((resolve) => { release = resolve; });
return { promise, release };
}
test("three uploads start immediately and a free slot refills without waiting for a slow file", async () => {
const gates = Array.from({ length: 7 }, gate);
const fourthStarted = gate();
const started: number[] = [];
let active = 0;
let peak = 0;
let finished = false;
const batch = runUploadQueue(gates, async (item, index) => {
active++;
peak = Math.max(peak, active);
started.push(index);
if (index === 3) fourthStarted.release();
await item.promise;
active--;
}).then(() => { finished = true; });
expect(started).toEqual([0, 1, 2]);
expect(finished).toBe(false);
gates[1]!.release();
await fourthStarted.promise;
expect(started).toEqual([0, 1, 2, 3]);
expect(finished).toBe(false);
for (const item of gates) item.release();
await batch;
expect(started).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(peak).toBe(UPLOAD_CONCURRENCY);
expect(active).toBe(0);
});
test("a failed file does not stop the queue or release the batch before other uploads finish", async () => {
const slow = gate();
const lastStarted = gate();
const visited: number[] = [];
let settled = false;
const batch = runUploadQueue([0, 1, 2, 3, 4], async (item) => {
visited.push(item);
if (item === 0) throw new Error("Connection lost");
if (item === 1) await slow.promise;
if (item === 4) lastStarted.release();
}).catch((error: unknown) => { settled = true; return error; });
await lastStarted.promise;
expect(settled).toBe(false);
slow.release();
expect(await batch).toBeInstanceOf(AggregateError);
expect(visited.sort()).toEqual([0, 1, 2, 3, 4]);
});
test("empty and single-file selections complete without extra work", async () => {
const visited: string[] = [];
const upload = async (item: string) => { visited.push(item); };
await runUploadQueue([], upload);
expect(visited).toEqual([]);
await runUploadQueue(["photo"], upload);
expect(visited).toEqual(["photo"]);
});
+23
View File
@@ -0,0 +1,23 @@
// Keep a few transfers in flight without flooding mobile connections or allocating
// a promise/network request for every selected file at once.
export const UPLOAD_CONCURRENCY = 3;
export async function runUploadQueue<T>(
items: readonly T[],
upload: (item: T, index: number) => Promise<void>,
) {
let next = 0;
const errors: unknown[] = [];
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, items.length) }, async () => {
while (next < items.length) {
const index = next++;
try {
await upload(items[index]!, index);
} catch (error) {
errors.push(error);
}
}
}));
// Never release the batch's busy guard while other transfers are still running.
if (errors.length) throw new AggregateError(errors, "Some uploads failed");
}
+3 -1
View File
@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { bulkPhotos } from "@/server/bulk-photos"; import { bulkPhotos } from "@/server/bulk-photos";
import { createGalleryPhotos, completeGalleryPhotos } from "@/server/gallery-uploads"; import { createGalleryPhotos, completeGalleryPhotos, retryGalleryPhoto } 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";
@@ -25,6 +25,7 @@ import {
import { import {
bulkPhotosInputSchema, bulkPhotosInputSchema,
createGalleryPhotosInputSchema, createGalleryPhotosInputSchema,
retryGalleryPhotoInputSchema,
completeGalleryPhotosInputSchema, completeGalleryPhotosInputSchema,
applyBulkPhotosInputSchema, applyBulkPhotosInputSchema,
createEventInputSchema, createEventInputSchema,
@@ -479,6 +480,7 @@ export const managerRouter = createTRPCRouter({
previewBulkPhotos: protectedProcedure.input(bulkPhotosInputSchema).query(({ ctx, input }) => bulkPhotos(ctx.session.user.id, input, false)), 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)), createGalleryPhotos: protectedProcedure.input(createGalleryPhotosInputSchema).mutation(({ ctx, input }) => createGalleryPhotos(ctx.session.user.id, input)),
retryGalleryPhoto: protectedProcedure.input(retryGalleryPhotoInputSchema).mutation(({ ctx, input }) => retryGalleryPhoto(ctx.session.user.id, input)),
completeGalleryPhotos: protectedProcedure.input(completeGalleryPhotosInputSchema).mutation(({ ctx, input }) => completeGalleryPhotos(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)), bulkPhotos: protectedProcedure.input(applyBulkPhotosInputSchema).mutation(({ ctx, input }) => bulkPhotos(ctx.session.user.id, input, true)),
+10 -3
View File
@@ -7,18 +7,24 @@ import { canTransitionVisibility } from "@/lib/photo-status";
import { EVENT_PERMISSIONS } from "./permissions"; import { EVENT_PERMISSIONS } from "./permissions";
import { loadEventAccess, requireEventPermission } from "./api/trpc"; import { loadEventAccess, requireEventPermission } from "./api/trpc";
import { getPlatformRole } from "./roles"; import { getPlatformRole } from "./roles";
import { runOnce } from "./operation-receipts";
export async function bulkPhotos(userId: string, input: z.infer<typeof bulkPhotosInputSchema>, apply: boolean) { export async function bulkPhotos(userId: string, input: z.infer<typeof bulkPhotosInputSchema> & { requestId?: string }, apply: boolean) {
const { event, access } = await loadEventAccess(userId, input.eventId, await getPlatformRole(userId)); const { event, access } = await loadEventAccess(userId, input.eventId, await getPlatformRole(userId));
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_READ); requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_READ);
requireEventPermission(access.permissions, input.action === "delete" ? EVENT_PERMISSIONS.PHOTOS_DELETE : EVENT_PERMISSIONS.PHOTOS_MODERATE); requireEventPermission(access.permissions, input.action === "delete" ? EVENT_PERMISSIONS.PHOTOS_DELETE : EVENT_PERMISSIONS.PHOTOS_MODERATE);
const canPrivate = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); const canPrivate = access.permissions.includes(EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
if (input.action === "private") requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ); if (input.action === "private") requireEventPermission(access.permissions, EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
const identity = { userId, eventId: input.eventId, key: `bulk:${input.requestId}` };
if (apply) {
if (!input.requestId) throw new Error("Request ID required");
await runOnce(identity, { action: input.action, photoIds: [...input.photoIds].sort() }, async () => true);
}
const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "skipped" | "failed"; reason?: string }[] = []; const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "skipped" | "failed"; reason?: string }[] = [];
// Each photo is its own transaction: failures never hide earlier successful work. // Each photo is its own transaction: failures never hide earlier successful work.
for (const photoId of input.photoIds) { for (const photoId of input.photoIds) {
try { try {
results.push(await getDb().transaction(async tx => { const execute = async (tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0]) => {
const predicate = and(eq(photos.eventId, input.eventId), eq(photos.id, photoId)); const predicate = and(eq(photos.eventId, input.eventId), eq(photos.id, photoId));
const [photo] = await tx.select().from(photos).where(predicate).for("update"); const [photo] = await tx.select().from(photos).where(predicate).for("update");
const skip = (reason: string) => ({ photoId, status: "skipped" as const, reason }); const skip = (reason: string) => ({ photoId, status: "skipped" as const, reason });
@@ -36,7 +42,8 @@ export async function bulkPhotos(userId: string, input: z.infer<typeof bulkPhoto
action: input.action === "delete" ? "photo.delete" : "photo.visibility", subjectType: "photo", subjectId: photoId, action: input.action === "delete" ? "photo.delete" : "photo.visibility", subjectType: "photo", subjectId: photoId,
metadata: { bulk: true, "visibility.before": photo.visibility, "visibility.after": input.action } }); metadata: { bulk: true, "visibility.before": photo.visibility, "visibility.after": input.action } });
return { photoId, status: input.action === "delete" ? "deleted" as const : "updated" as const }; return { photoId, status: input.action === "delete" ? "deleted" as const : "updated" as const };
})); };
results.push(apply ? await runOnce({ ...identity, key: `${identity.key}:${photoId}` }, input.action, execute) : await getDb().transaction(execute));
} catch { } catch {
results.push({ photoId, status: "failed", reason: "Operation failed; refresh before retrying" }); results.push({ photoId, status: "failed", reason: "Operation failed; refresh before retrying" });
} }
+25 -7
View File
@@ -8,6 +8,7 @@ import { loadEventAccess, requireEventPermission } from "./api/trpc";
import { EVENT_PERMISSIONS } from "./permissions"; import { EVENT_PERMISSIONS } from "./permissions";
import { getPlatformRole } from "./roles"; import { getPlatformRole } from "./roles";
import { consumeRateLimit } from "./rate-limit"; import { consumeRateLimit } from "./rate-limit";
import { runOnce } from "./operation-receipts";
async function authorize(userId: string, eventId: string) { async function authorize(userId: string, eventId: string) {
const { event, access } = await loadEventAccess(userId, eventId, await getPlatformRole(userId)); const { event, access } = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
@@ -19,19 +20,36 @@ export async function createGalleryPhotos(userId: string, input: z.infer<typeof
const event = await authorize(userId, input.eventId); const event = await authorize(userId, input.eventId);
const rate = await consumeRateLimit({ namespace: `gallery-upload:${event.id}`, identifier: userId, limit: 10, windowMs: 600_000 }); 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." }); 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 uploads = await runOnce({ userId, eventId: input.eventId, key: `upload:${input.requestId}` }, input.files, async tx => {
const photoId = crypto.randomUUID(); const uploads = input.files.map(() => {
const key = originalObjectKey(event.id, photoId); const photoId = crypto.randomUUID();
return { photoId, key, uploadUrl: await createPresignedPutUrl({ key, contentType: file.contentType }) }; return { photoId, key: originalObjectKey(event.id, photoId) };
})); });
await getDb().transaction(async tx => {
const [guest] = await tx.insert(guests).values({ eventId: event.id, displayName: "Event organizer", tokenHash: crypto.randomUUID() }).returning(); 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(); 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, 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 }))); 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 }))); 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 }) => ({ photoId }));
}); });
return uploads.map(({ photoId, uploadUrl }) => ({ photoId, uploadUrl })); return Promise.all(uploads.map(upload => prepareRetry(userId, event.id, upload.photoId)));
}
async function prepareRetry(userId: string, eventId: string, photoId: string) {
const [photo] = await getDb().select().from(photos).where(and(eq(photos.eventId, eventId), eq(photos.id, photoId)));
const [created] = await getDb().select({ id: auditEvents.id }).from(auditEvents).where(and(eq(auditEvents.eventId, eventId), eq(auditEvents.actorUserId, userId), eq(auditEvents.subjectId, photoId), eq(auditEvents.action, "photo.create"))).limit(1);
if (!photo || !created) throw new TRPCError({ code: "NOT_FOUND", message: "Your organizer upload was not found" });
if (photo.processingStatus !== "uploading") return { photoId, uploadUrl: null, status: photo.processingStatus };
// A PUT may have succeeded even when its response was lost. Never overwrite it.
const uploaded = await headObject(photo.originalKey);
return { photoId, status: "uploading", uploadUrl: uploaded ? null : await createPresignedPutUrl({ key: photo.originalKey, contentType: photo.contentType }) };
}
export async function retryGalleryPhoto(userId: string, input: { eventId: string; photoId: string }) {
await authorize(userId, input.eventId);
const rate = await consumeRateLimit({ namespace: `gallery-retry:${input.eventId}`, identifier: userId, limit: 100, windowMs: 600_000 });
if (!rate.allowed) throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
return prepareRetry(userId, input.eventId, input.photoId);
} }
export async function completeGalleryPhotos(userId: string, input: z.infer<typeof completeGalleryPhotosInputSchema>) { export async function completeGalleryPhotos(userId: string, input: z.infer<typeof completeGalleryPhotosInputSchema>) {
+1
View File
@@ -36,6 +36,7 @@ export const assistantTools: ToolDefinition[] = [
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.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.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.retryGalleryPhoto", "Resume your organizer upload using the same photo ID. A null uploadUrl means bytes already exist or processing started; do not upload again. Call completeGalleryPhotos if status is uploading.", [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.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.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]),
@@ -6,6 +6,8 @@ import { appRouter } from "../api/root";
import { handleMcp } from "./server"; import { handleMcp } from "./server";
import { assistantTools, permissionOptions } from "./catalog"; import { assistantTools, permissionOptions } from "./catalog";
import type { TrpcContext } from "../api/trpc"; import type { TrpcContext } from "../api/trpc";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
test("MCP allowlist references real procedures and has unique names", () => { test("MCP allowlist references real procedures and has unique names", () => {
const procedures = appRouter._def.procedures as unknown as Record<string, unknown>; const procedures = appRouter._def.procedures as unknown as Record<string, unknown>;
@@ -39,6 +41,15 @@ test.skipIf(process.env.MCP_INTEGRATION !== "1")("MCP protocol, permission ceili
otherGroupId = otherGroup!.id; otherGroupId = otherGroup!.id;
const [otherEvent] = await db.insert(events).values({ groupId: otherGroupId, title: "Not accessible", slug: `${id}-other` }).returning(); const [otherEvent] = await db.insert(events).values({ groupId: otherGroupId, title: "Not accessible", slug: `${id}-other` }).returning();
const read = await owner.create({ name: "Read only", permissions: [...permissionOptions], readOnly: true, days: 1 }); const read = await owner.create({ name: "Read only", permissions: [...permissionOptions], readOnly: true, days: 1 });
const http = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handleMcp });
const sdk = new Client({ name: "Manyangles compatibility test", version: "1" });
try {
await sdk.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${http.port}/api/mcp`), { requestInit: { headers: { Authorization: `Bearer ${read.token}` } } }));
expect((await sdk.listTools()).tools.some(tool => tool.name === "manager_stats")).toBe(true);
const result = await sdk.callTool({ name: "manager_stats", arguments: { input: { eventId: event!.id } } });
expect(result.isError).not.toBe(true);
expect((await sdk.callTool({ name: "manager_bulkPhotos", arguments: {} })).isError).toBe(true);
} finally { await sdk.close(); http.stop(true); }
const write = await owner.create({ name: "Writer", permissions: ["group.manage", "group.read", "overview.read"], readOnly: false, days: 1 }); const write = await owner.create({ name: "Writer", permissions: ["group.manage", "group.read", "overview.read"], readOnly: false, days: 1 });
const limited = await owner.create({ name: "Limited", permissions: ["group.read"], readOnly: false, days: 1 }); const limited = await owner.create({ name: "Limited", permissions: ["group.read"], readOnly: false, days: 1 });
const init = await request(read.token, "initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } }); const init = await request(read.token, "initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } });
+24
View File
@@ -0,0 +1,24 @@
import { createHash } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { and, eq, sql } from "drizzle-orm";
import { getDb, operationReceipts } from "@album/database";
type Transaction = Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0];
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
export async function runOnce<T>(identity: { userId: string; eventId: string; key: string }, payload: unknown, run: (tx: Transaction) => Promise<T>): Promise<T> {
const id = hash(JSON.stringify(identity));
const requestHash = hash(JSON.stringify(payload));
return getDb().transaction(async tx => {
// Serializes concurrent deliveries, including across web instances.
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${id}, 0))`);
const [existing] = await tx.select().from(operationReceipts).where(and(eq(operationReceipts.id, id), eq(operationReceipts.eventId, identity.eventId), eq(operationReceipts.userId, identity.userId)));
if (existing) {
if (existing.requestHash !== requestHash) throw new TRPCError({ code: "CONFLICT", message: "This request ID was already used with different inputs. Use a new request ID for a new action." });
return existing.result as T;
}
const result = await run(tx);
await tx.insert(operationReceipts).values({ id, eventId: identity.eventId, userId: identity.userId, requestHash, result });
return result;
});
}
@@ -5,6 +5,7 @@ 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"; import { deletePrefix, photoObjectPrefix, headObject, originalObjectKey } from "@album/storage";
import { runOnce } from "./operation-receipts";
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");
@@ -24,6 +25,13 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s
await expect(groupRouter.createCaller(ctx(1)).rename({ groupId, name: "Denied" })).rejects.toThrow(); await expect(groupRouter.createCaller(ctx(1)).rename({ groupId, name: "Denied" })).rejects.toThrow();
const [event] = await db.insert(events).values({ groupId, title: "Workflow test", slug: id }).returning(); const [event] = await db.insert(events).values({ groupId, title: "Workflow test", slug: id }).returning();
await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" }); await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" });
const receipt = { userId: people[0]!.id, eventId: event!.id, key: `rollback-${id}` };
await expect(runOnce(receipt, "same", async tx => { await tx.update(events).set({ title: "Rolled back" }).where(eq(events.id, event!.id)); throw new Error("simulated interruption"); })).rejects.toThrow();
expect((await db.select().from(events).where(eq(events.id, event!.id)))[0]!.title).toBe("Workflow test");
let executions = 0;
const receipts = await Promise.all([0, 1, 2].map(() => runOnce(receipt, "same", async () => { executions++; return { ok: true }; })));
expect(executions).toBe(1);
expect(receipts).toEqual([{ ok: true }, { ok: true }, { ok: true }]);
const [guest] = await db.insert(guests).values({ eventId: event!.id, tokenHash: id }).returning(); const [guest] = await db.insert(guests).values({ eventId: event!.id, tokenHash: id }).returning();
const [submission] = await db.insert(submissions).values({ eventId: event!.id, guestId: guest!.id }).returning(); const [submission] = await db.insert(submissions).values({ eventId: event!.id, guestId: guest!.id }).returning();
await db.insert(photos).values((["ready", "processing"] as const).map(processingStatus => ({ eventId: event!.id, submissionId: submission!.id, originalKey: `test/${id}/${processingStatus}`, contentType: "image/jpeg", processingStatus }))); await db.insert(photos).values((["ready", "processing"] as const).map(processingStatus => ({ eventId: event!.id, submissionId: submission!.id, originalKey: `test/${id}/${processingStatus}`, contentType: "image/jpeg", processingStatus })));
@@ -43,13 +51,18 @@ 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 bulk = { eventId: event!.id, photoIds: [...result.map(photo => photo.id), privatePhoto!.id], action: "public" as const, requestId: crypto.randomUUID() };
const preview = await moderator.previewBulkPhotos(bulk); const preview = await moderator.previewBulkPhotos(bulk);
expect(preview.affected).toBe(2); expect(preview.affected).toBe(2);
expect(preview.results.find(row => row.photoId === privatePhoto!.id)?.status).toBe("skipped"); expect(preview.results.find(row => row.photoId === privatePhoto!.id)?.status).toBe("skipped");
await expect(moderator.bulkPhotos({ ...bulk, confirm: false as true })).rejects.toThrow(); 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(2);
expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(0); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2);
await expect(moderator.bulkPhotos({ ...bulk, action: "hidden", confirm: true })).rejects.toThrow("different inputs");
// A replay cannot undo a later, intentional change.
await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), action: "hidden", confirm: true });
await moderator.bulkPhotos({ ...bulk, confirm: true });
expect((await db.select().from(photos).where(and(eq(photos.eventId, event!.id), eq(photos.id, result[0]!.id))))[0]!.visibility).toBe("hidden");
await expect(moderator.bulkPhotos({ ...bulk, action: "private", confirm: true })).rejects.toThrow(); 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(); 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" }); await db.insert(eventMemberships).values({ eventId: otherEvent!.id, userId: people[0]!.id, role: "owner" });
@@ -58,21 +71,29 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s
const deletion = await manager.previewBulkPhotos({ ...bulk, action: "delete" }); 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"); 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(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(); await expect(moderator.createGalleryPhotos({ eventId: event!.id, requestId: crypto.randomUUID(), 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 moderator.photos({ eventId: event!.id, limit: 1, visibility: "private" })).length).toBe(0);
expect((await manager.photos({ eventId: event!.id, limit: 1 })).length).toBe(1); expect((await manager.photos({ eventId: event!.id, limit: 1 })).length).toBe(1);
if (process.env.GALLERY_STORAGE_INTEGRATION === "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"); 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 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 }] }); const createInput = { eventId: event!.id, requestId: crypto.randomUUID(), files: [{ fileName: "test.png", contentType: "image/png" as const, byteSize: bytes.length }] };
const [uploads, replay] = await Promise.all([manager.createGalleryPhotos(createInput), manager.createGalleryPhotos(createInput)]);
expect(replay.map(row => row.photoId)).toEqual(uploads.map(row => row.photoId));
for (const upload of uploads) cleanupUploads.push({ eventId: event!.id, photoId: upload.photoId }); 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); 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.retryGalleryPhoto({ eventId: event!.id, photoId: uploads[0]!.photoId })).uploadUrl).toBeNull();
expect((await manager.createGalleryPhotos(createInput))[0]!.uploadUrl).toBeNull();
await expect(manager.createGalleryPhotos({ ...createInput, files: [{ ...createInput.files[0]!, byteSize: 1 }] })).rejects.toThrow("different inputs");
const batch = { eventId: event!.id, photoIds: uploads.map(upload => upload.photoId), requestId: crypto.randomUUID() };
expect((await manager.completeGalleryPhotos(batch))[0]!.status).toBe("processing"); expect((await manager.completeGalleryPhotos(batch))[0]!.status).toBe("processing");
expect((await manager.completeGalleryPhotos(batch))[0]!.status).toBe("processing");
expect((await manager.retryGalleryPhoto({ eventId: event!.id, photoId: uploads[0]!.photoId })).uploadUrl).toBeNull();
expect((await manager.previewBulkPhotos({ ...batch, action: "delete" })).affected).toBe(0); expect((await manager.previewBulkPhotos({ ...batch, action: "delete" })).affected).toBe(0);
// Simulate the worker's terminal state; never modify a real upload. // 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))); 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 manager.bulkPhotos({ ...batch, action: "delete", confirm: true })).affected).toBe(1);
expect((await manager.bulkPhotos({ ...batch, action: "delete", confirm: true })).affected).toBe(1);
expect(await headObject(originalObjectKey(event!.id, uploads[0]!.photoId))).toBeNull(); expect(await headObject(originalObjectKey(event!.id, uploads[0]!.photoId))).toBeNull();
} }
} finally { } finally {
+37
View File
@@ -10,3 +10,40 @@
unchanged. The final image must pass the Sharp WebP smoke test. unchanged. The final image must pass the Sharp WebP smoke test.
- If memory pressure remains high, move builds to a separate builder rather than - If memory pressure remains high, move builds to a separate builder rather than
increasing concurrency or running more diagnostic builds on the live VM. increasing concurrency or running more diagnostic builds on the live VM.
## Coolify recovery and build isolation (2026-09-11)
Docker 29.3.0 crashed with SIGSEGV in its embedded BuildKit mount/read-entrypoint
path during the `4bc48db` deployment. The VM did not reboot, and the application
containers were not OOM-killed. Restarting the existing Manyangles containers
restored service. The trace identifies the failing process, not a confirmed
upstream defect or hardware cause.
- `/etc/docker/daemon.json` now enables `live-restore`. It was validated and
applied with `systemctl reload docker`, without restarting running containers.
The prior configuration is backed up at
`/etc/docker/daemon.json.before-manyangles-recovery-20260911`.
- Manyangles uses the named `manyangles-isolated` buildx builder with the
`docker-container` driver and `moby/buildkit:v0.33.0`, instead of the builder
embedded inside dockerd. Other applications' build selection is unchanged.
- The builder has a 3 GiB RAM limit, 4 GiB combined RAM/swap limit, two-CPU quota,
and two BuildKit execution slots. Images automatically load into the local
Docker image store. Its cache is stored in a dedicated Docker volume.
- Coolify's application-level custom Compose build command is:
```sh
docker compose --parallel 1 --project-name nzuqxqw47tbt117lrpw3f8ch build --builder manyangles-isolated --pull
```
Coolify injects the project directory, Compose file, and build environment.
Builder metadata lives under `/root/.docker/buildx`, which Coolify mounts into
its helper. Do not select this builder globally or prune its volume during builds.
Verify using `docker info` (Live Restore Enabled), `docker buildx inspect
manyangles-isolated` as root, container health, and the public
`https://ma.hadlock.tech/api/health/ready` endpoint. Live restore mitigates daemon
outages; it does not provide zero-downtime Compose rollouts or protect against VM
failure. Do not deliberately crash/restart the shared daemon to test it in production.
References: [live restore](https://docs.docker.com/engine/daemon/live-restore/),
[containerized builders](https://docs.docker.com/build/builders/drivers/docker-container/).
+16 -2
View File
@@ -81,8 +81,22 @@ 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 ## 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. 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. 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. No file bytes pass through MCP or Next.js.
Both `manager_bulkPhotos` and `manager_createGalleryPhotos` require `input.requestId` (a UUID). Generate it once per intentional operation and reuse it with identical input after connection failures. Durable event/user-scoped receipts prevent duplicate creation and prevent an old bulk retry from undoing a newer action. A changed payload with the same request ID is rejected. For a new action, use a new UUID. Successful and skipped per-photo results are replayed; interrupted/failed transactions can retry. Permissions are always checked before replay. The receipts migration must run before deploying this code.
`manager_retryGalleryPhoto` resumes an organizer upload created by the same account. If `uploadUrl` is null, bytes already exist or processing has begun: do not PUT again. Call completion if its status is still `uploading`. Retrying a completed upload never returns a replacement PUT URL. The gallery UI keeps its batch ID and files for retries while the page remains open.
## Full local verification
Start the local web server and Docker development services (including Mailpit), then run:
```sh
POLISH_INTEGRATION=1 GALLERY_STORAGE_INTEGRATION=1 MCP_INTEGRATION=1 EXPORT_INTEGRATION=1 EXPORT_PERMISSIONS_INTEGRATION=1 WEBHOOK_INTEGRATION=1 NOTIFICATIONS_INTEGRATION=1 PUBLISHING_INTEGRATION=1 INVITE_JOURNEY_INTEGRATION=1 bun --env-file=.env test
```
Use only local database/storage and non-production Mailpit. The MCP tests also exercise initialization and tool calls using the official SDK client over loopback HTTP. They do not configure an external assistant app or mint production tokens.
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import { applyBulkPhotosInputSchema, bulkPhotosInputSchema, createGalleryPhotosInputSchema } from "./index";
test("bulk requests require confirmation, a receipt ID, a bounded unique selection, and a known action", () => {
const input = { eventId: crypto.randomUUID(), photoIds: [crypto.randomUUID()], action: "public", confirm: true, requestId: crypto.randomUUID() };
expect(applyBulkPhotosInputSchema.safeParse(input).success).toBe(true);
for (const change of [{ confirm: false }, { requestId: undefined }, { photoIds: [] }, { photoIds: [input.photoIds[0], input.photoIds[0]] }, { photoIds: Array.from({ length: 101 }, () => crypto.randomUUID()) }, { action: "publish-everything" }]) {
expect(applyBulkPhotosInputSchema.safeParse({ ...input, ...change }).success).toBe(false);
}
expect(bulkPhotosInputSchema.safeParse({ eventId: input.eventId, photoIds: input.photoIds, action: "delete" }).success).toBe(true);
});
test("organizer upload batches require bounded files and stable request IDs", () => {
const file = { fileName: "photo.jpg", contentType: "image/jpeg", byteSize: 500 };
const input = { eventId: crypto.randomUUID(), requestId: crypto.randomUUID(), files: [file] };
expect(createGalleryPhotosInputSchema.safeParse(input).success).toBe(true);
for (const change of [{ requestId: undefined }, { files: [] }, { files: Array(26).fill(file) }, { files: [{ ...file, byteSize: 0 }] }, { files: [{ ...file, contentType: "text/html" }] }]) {
expect(createGalleryPhotosInputSchema.safeParse({ ...input, ...change }).success).toBe(false);
}
});
+1 -1
View File
@@ -5,4 +5,4 @@ export const bulkPhotosInputSchema = z.object({
photoIds: z.array(z.string().uuid()).min(1).max(100).refine(ids => new Set(ids).size === ids.length, "Duplicate photo IDs"), 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"]), action: z.enum(["public", "hidden", "private", "rejected", "delete"]),
}); });
export const applyBulkPhotosInputSchema = bulkPhotosInputSchema.extend({ confirm: z.literal(true) }); export const applyBulkPhotosInputSchema = bulkPhotosInputSchema.extend({ confirm: z.literal(true), requestId: z.string().uuid() });
+2
View File
@@ -177,9 +177,11 @@ export const completePhotoInputSchema = z.object({
export const createGalleryPhotosInputSchema = z.object({ export const createGalleryPhotosInputSchema = z.object({
eventId: z.string().uuid(), eventId: z.string().uuid(),
requestId: z.string().uuid(),
files: z.array(createPhotoInputSchema.pick({ contentType: true, byteSize: true, fileName: true })).min(1).max(25), 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 completeGalleryPhotosInputSchema = z.object({ eventId: z.string().uuid(), photoIds: z.array(z.string().uuid()).min(1).max(25) });
export const retryGalleryPhotoInputSchema = z.object({ eventId: z.string().uuid(), photoId: z.string().uuid() });
export const BANNER_ASPECT_RATIO = 8 / 3; export const BANNER_ASPECT_RATIO = 8 / 3;
export const bannerCropSchema = z.object({ export const bannerCropSchema = z.object({
@@ -0,0 +1,8 @@
CREATE TABLE "operation_receipts" (
"id" text PRIMARY KEY NOT NULL,
"event_id" uuid NOT NULL REFERENCES "events"("id") ON DELETE CASCADE,
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"request_hash" text NOT NULL,
"result" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
+2 -1
View File
@@ -81,6 +81,7 @@
}, },
{ "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true }, { "idx": 11, "version": "7", "when": 1789086000000, "tag": "0011_event_signs", "breakpoints": true },
{ "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true }, { "idx": 12, "version": "7", "when": 1789086100000, "tag": "0012_invite_token", "breakpoints": true },
{ "idx": 13, "version": "7", "when": 1789164000000, "tag": "0013_assistant_tokens", "breakpoints": true } { "idx": 13, "version": "7", "when": 1789164000000, "tag": "0013_assistant_tokens", "breakpoints": true },
{ "idx": 14, "version": "7", "when": 1789164100000, "tag": "0014_operation_receipts", "breakpoints": true }
] ]
} }
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test";
import { createReadinessProbe } from "./health";
test("readiness catches failures and recovers on the next query", async () => {
let fail = true;
const ready = createReadinessProbe(async () => { if (fail) throw new Error("offline"); });
expect(await ready()).toBe(false);
fail = false;
expect(await ready()).toBe(true);
});
test("timed-out concurrent probes share one query rather than exhausting the pool", async () => {
let calls = 0;
let resolve!: () => void;
const ready = createReadinessProbe(() => { calls++; return new Promise<void>(done => { resolve = done; }); }, 5);
expect(await Promise.all([ready(), ready(), ready()])).toEqual([false, false, false]);
expect(await ready()).toBe(false);
expect(calls).toBe(1);
resolve();
await Promise.resolve();
});
+15 -11
View File
@@ -2,15 +2,19 @@ import { sql } from "drizzle-orm";
import { getDb } from "./db"; import { getDb } from "./db";
// Bound probes and share an in-flight query so a DB outage cannot fill the pool. // Bound probes and share an in-flight query so a DB outage cannot fill the pool.
let pending: Promise<void> | undefined; export function createReadinessProbe(query: () => Promise<unknown>, timeoutMs = 2500) {
export async function databaseReady() { let pending: Promise<void> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined; return async function ready() {
try { let timer: ReturnType<typeof setTimeout> | undefined;
pending ??= getDb().execute(sql`select 1`).then(() => {}).finally(() => { pending = undefined; }); try {
await Promise.race([pending, new Promise<never>((_, reject) => { pending ??= Promise.resolve().then(query).then(() => {}).finally(() => { pending = undefined; });
timer = setTimeout(() => reject(new Error("Database probe timed out")), 2500); await Promise.race([pending, new Promise<never>((_, reject) => {
})]); timer = setTimeout(() => reject(new Error("Database probe timed out")), timeoutMs);
return true; })]);
} catch { return false; } return true;
finally { clearTimeout(timer); } } catch { return false; }
finally { clearTimeout(timer); }
};
} }
export const databaseReady = createReadinessProbe(() => getDb().execute(sql`select 1`));
+10
View File
@@ -16,6 +16,16 @@ import { sql } from "drizzle-orm";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { user } from "./auth-schema"; import { user } from "./auth-schema";
// Durable receipts are kept with their owning event; no upload URLs or file contents.
export const operationReceipts = pgTable("operation_receipts", {
id: text("id").primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
requestHash: text("request_hash").notNull(),
result: jsonb("result").$type<unknown>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
const timestamps = { const timestamps = {
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()
+14 -2
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { renderAlbumReadyEmail, emailBrowserPreview } from "./index"; import { renderAlbumReadyEmail, emailBrowserPreview } from "./index";
import { EMAIL_LOGO_CID } from "./logo";
test("gallery email escapes content and includes a plain-text alternative", () => { test("gallery email escapes content and includes a plain-text alternative", () => {
const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: '<script>alert("x")</script>', galleryUrl: "https://manyangles.test/e/demo" }); const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: '<script>alert("x")</script>', galleryUrl: "https://manyangles.test/e/demo" });
@@ -9,8 +10,19 @@ test("gallery email escapes content and includes a plain-text alternative", () =
expect(message.html).toContain("Manyangles"); expect(message.html).toContain("Manyangles");
expect(message.text).toContain("https://manyangles.test/e/demo"); expect(message.text).toContain("https://manyangles.test/e/demo");
expect(message.html).toContain("Arial,Helvetica,sans-serif"); expect(message.html).toContain("Arial,Helvetica,sans-serif");
expect(message.html).toContain("cid:manyangles-mark-v1"); expect(message.html).toContain(`cid:${EMAIL_LOGO_CID}`);
expect(message.attachments[0]?.contentId).toBe("manyangles-mark-v1"); expect(message.attachments[0]?.contentId).toBe(EMAIL_LOGO_CID);
expect(Buffer.from(message.attachments[0]!.content, "base64").subarray(1, 4).toString()).toBe("PNG"); expect(Buffer.from(message.attachments[0]!.content, "base64").subarray(1, 4).toString()).toBe("PNG");
expect(emailBrowserPreview(message.html)).toContain("data:image/png;base64,"); expect(emailBrowserPreview(message.html)).toContain("data:image/png;base64,");
}); });
test("logo stays inline in email and becomes a data URI only in browser previews", () => {
const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: "Wedding", galleryUrl: "https://manyangles.test/e/demo" });
const preview = emailBrowserPreview(message.html);
expect(message.attachments).toHaveLength(1);
expect(message.html).not.toContain("data:image");
expect(preview).not.toContain(`cid:${EMAIL_LOGO_CID}`);
expect(preview).toContain(message.attachments[0]!.content);
expect(emailBrowserPreview(preview)).toBe(preview);
expect(message.html).toContain(`cid:${EMAIL_LOGO_CID}`);
});