import { addToCollection, collectionImages, createCollection, deleteCollection, getCollection, getCollectionByName, listCollections, removeFromCollection, renameCollection, } from '../db'; import { createRouter } from '../create-router'; 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 }); }); /** Removes the collection and its memberships. The images are untouched and stay in the gallery. */ 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); await deleteCollection(id); return ctx.json({ data: { id, deleted: true } }); }); /** 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) } }); });