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.
139 lines
5.5 KiB
TypeScript
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) } });
|
|
});
|