Files
atelier/sidecar/api/collections.ts
T
pastilhas f23ec1283b manifest, thin proxy router, and the sidecar backend
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only
adapter today), the generation queue, collections, and the database layer — kept on its own
Postgres database rather than folded into officer_db, since both collections and provider_settings
had rows on the machine this was extracted from.

what changed rather than just moved:
- images are served from an authenticated GET /files/:filename instead of an unauthenticated
  PUBLIC_URL-prefixed static route
- the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter
- provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has
  no callers left anywhere in the server code, only a migration script that also did not move

ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers
liveness.

not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not
been written, so this is not yet a discoverable plugin.
2026-08-20 03:53:53 +00:00

139 lines
5.5 KiB
TypeScript

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) } });
});