import { rm } from 'node:fs/promises'; import { join } from 'node:path'; import { addToCollection, collectionImages, createCollection, deleteCollection, deleteGenerations, getCollection, getCollectionByName, listCollections, removeFromCollection, renameCollection, } from '../db'; import { createRouter } from '../create-router'; import { IMAGES_DIR } from '../env'; import { openaiError, UUID_PATTERN } from '../errors'; import { serializeCollection, serializeCollectionSummary, serializeGeneration } from '../serialize'; export const collectionsRouter = createRouter(); const notFound = (id: string) => openaiError(`No collection with id "${id}".`, 'not_found'); /** Trimmed, and required to be something. A collection called " " is a collection nobody can name again. */ function cleanName(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } collectionsRouter.get('/', async (ctx) => ctx.json({ data: (await listCollections()).map(serializeCollectionSummary) }), ); collectionsRouter.post('/', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null; const name = cleanName(body?.name); if (!name) return ctx.json(openaiError("'name' is required.", 'invalid_request_error', 'name'), 400); // Answered as a conflict rather than silently returning the existing one: POST here is an explicit // "make a new collection", and quietly handing back someone else's is worse than refusing. // The generation API's ?collection= is the find-or-create path. if (await getCollectionByName(name)) { return ctx.json(openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'), 409); } const description = typeof body?.description === 'string' ? body.description : null; return ctx.json({ data: serializeCollection(await createCollection(name, description)) }, 201); }); collectionsRouter.get('/:id', async (ctx) => { const id = ctx.req.param('id'); if (!UUID_PATTERN.test(id)) return ctx.json(notFound(id), 404); const collection = await getCollection(id); if (!collection) return ctx.json(notFound(id), 404); return ctx.json({ data: { ...serializeCollection(collection), images: (await collectionImages(id)).map(serializeGeneration) }, }); }); collectionsRouter.patch('/:id', async (ctx) => { const id = ctx.req.param('id'); if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404); const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null; if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400); const values: { name?: string; description?: string | null } = {}; if (body.name !== undefined) { const name = cleanName(body.name); if (!name) return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400); const existing = await getCollectionByName(name); if (existing && existing.id !== id) { return ctx.json( openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'), 409, ); } values.name = name; } if (body.description !== undefined) { values.description = typeof body.description === 'string' ? body.description : null; } if (Object.keys(values).length === 0) { return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400); } const updated = await renameCollection(id, values); return ctx.json({ data: updated ? serializeCollection(updated) : null }); }); /** * Delete a collection. `?images=delete` deletes what was in it as well; the default keeps them. * * The two are genuinely different intentions and only the caller knows which one this is. "I filed * these wrongly" wants the folder gone and the images kept; "this whole afternoon was a dead end" * wants the images gone too, and doing that by hand is one delete per image. Which is why the * destructive one has to be asked for by name rather than inferred. * * Order matters: read the members BEFORE the collection goes, because deleting it takes the * memberships with it and there is then nothing left that says which images were in it. * * A file that will not unlink is not an error the caller can act on — the row is already gone and * retrying deletes nothing. It is left on disk and logged rather than failing a delete that, from * every side the user can see, succeeded. */ collectionsRouter.delete('/:id', async (ctx) => { const id = ctx.req.param('id'); if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404); const alsoImages = ctx.req.query('images') === 'delete'; const members = alsoImages ? await collectionImages(id) : []; await deleteCollection(id); let deletedImages = 0; if (alsoImages && members.length > 0) { const removed = await deleteGenerations(members.map((row) => row.id)); deletedImages = removed.length; for (const row of removed) { if (!row.imagePath) continue; await rm(join(IMAGES_DIR, row.imagePath), { force: true }).catch((err: unknown) => { console.error(`[atelier] could not unlink ${row.imagePath}:`, err); }); } } return ctx.json({ data: { id, deleted: true, deletedImages } }); }); /** Add images. Ones already in the collection are counted as added and change nothing. */ collectionsRouter.post('/:id/items', async (ctx) => { const id = ctx.req.param('id'); if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404); const body = (await ctx.req.json().catch(() => null)) as { generationIds?: unknown } | null; const ids = Array.isArray(body?.generationIds) ? body.generationIds : null; if (!ids || ids.some((value) => typeof value !== 'string' || !UUID_PATTERN.test(value))) { return ctx.json( openaiError("'generationIds' must be an array of uuids.", 'invalid_request_error', 'generationIds'), 400, ); } try { const added = await addToCollection(id, ids as string[]); return ctx.json({ data: { added, images: (await collectionImages(id)).map(serializeGeneration) } }); } catch { // The foreign key is the check: an id that names no generation cannot be filed against one. return ctx.json( openaiError('One or more generationIds do not exist.', 'invalid_request_error', 'generationIds'), 400, ); } }); collectionsRouter.delete('/:id/items/:generationId', async (ctx) => { const id = ctx.req.param('id'); const generationId = ctx.req.param('generationId'); if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404); if (!UUID_PATTERN.test(generationId)) { return ctx.json(openaiError('Not a valid generation id.', 'invalid_request_error', 'generationId'), 400); } await removeFromCollection(id, [generationId]); return ctx.json({ data: { removed: generationId, images: (await collectionImages(id)).map(serializeGeneration) } }); });