Files
roast_command_center/server/openapi.js
T
snowspeeder a9358048ef
Test and deploy / test-and-deploy (push) Successful in 1m52s
Add independent roast plan cloning from the plans table
2026-09-06 16:58:41 -04:00

238 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Hand-authored OpenAPI 3.0 spec for every API surface (including admin), served at
// /api/openapi.json and rendered by the self-hosted Swagger UI at /api-docs. Kept in one
// place, built with small helpers so route coverage stays readable — update this file
// whenever a route is added or its shape changes.
const ok = (description, schema) => ({
description,
content: schema ? { "application/json": { schema } } : undefined,
});
const errorResponse = {
type: "object",
properties: { ok: { type: "boolean", example: false }, code: { type: "string" } },
};
const err = (description) => ok(description, errorResponse);
const jsonBody = (schema, required = true) => ({
required,
content: { "application/json": { schema } },
});
const idParam = {
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
};
const obj = (properties, required) => ({ type: "object", properties, ...(required ? { required } : {}) });
const str = { type: "string" };
const num = { type: "number", nullable: true };
const bool = { type: "boolean" };
const arr = (items) => ({ type: "array", items });
const bean = obj({
id: str, name: str, roaster: str, origin: str, process: str, variety: str,
roastLevel: str, roastDate: { ...str, nullable: true }, initialWeightG: num,
remainingWeightG: num, url: str, tastingNotes: str, notes: str,
roastPlanId: { ...str, nullable: true }, archived: bool,
});
const beanBody = obj({
name: str, roaster: str, origin: str, process: str, variety: str, roastLevel: str,
roastDate: { ...str, description: "YYYY-MM-DD", nullable: true }, initialWeightG: num,
url: str, tastingNotes: str, notes: str, roastPlanId: { ...str, nullable: true },
archived: bool,
});
const brew = obj({
id: str, beanId: { ...str, nullable: true }, beanName: { ...str, nullable: true },
method: str, doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str,
waterTempC: num, brewTimeS: { type: "integer", nullable: true },
bloomTimeS: { type: "integer", nullable: true }, rating: num, recipe: str,
tastingNotes: str, notes: str, brewedAt: str,
});
const brewBody = obj({
beanId: { ...str, nullable: true },
method: { ...str, description: "One of the brew-method keys from GET /api/brew-methods" },
doseG: num, waterG: num, yieldG: num, grinder: str, grindSetting: str, waterTempC: num,
brewTimeS: { type: "integer", nullable: true }, bloomTimeS: { type: "integer", nullable: true },
rating: { ...num, description: "0-10" },
recipe: { ...str, description: "Free-text steps (bloom, pours, timings)" },
tastingNotes: str, notes: str,
});
const lot = obj({
id: str, name: str, origin: str, variety: str, process: str, producer: str,
purchaseDate: { ...str, nullable: true }, initialWeightG: { type: "number" },
remainingWeightG: { type: "number" }, costTotal: num, moisturePct: num, densityGL: num,
notes: str, archived: bool,
});
const roast = obj({
id: str, roastPlanId: { ...str, nullable: true }, planTitle: { ...str, nullable: true },
filename: str, roast: { type: "object", nullable: true }, derived: { type: "object", nullable: true },
evaluationStatus: { ...str, enum: ["pending", "done", "failed"] },
evaluationError: { ...str, nullable: true }, evaluationGrade: { ...str, nullable: true },
evaluationSummary: { ...str, nullable: true },
});
/** @param {{origin?: string}} options */
export function buildOpenApiSpec({ origin = "" } = {}) {
const security = [{ cookieAuth: [] }, { bearerAuth: [] }];
const paths = {
// ── Auth ──
"/api/auth/signup-enabled": { get: { tags: ["auth"], summary: "Whether self-signup is enabled", security: [], responses: { 200: ok("Flag", obj({ ok: bool, enabled: bool })) } } },
"/api/auth/signup": { post: { tags: ["auth"], summary: "Create an account (when enabled)", security: [], requestBody: jsonBody(obj({ email: str, password: { ...str, minLength: 12 } }, ["email", "password"])), responses: { 201: ok("Signed up; session cookie set"), 400: err("Invalid credentials"), 403: err("Signups disabled"), 409: err("Email exists") } } },
"/api/auth/login": { post: { tags: ["auth"], summary: "Log in", security: [], requestBody: jsonBody(obj({ email: str, password: str }, ["email", "password"])), responses: { 200: ok("Logged in; session cookie set"), 401: err("Invalid credentials"), 429: err("Too many attempts") } } },
"/api/auth/logout": { post: { tags: ["auth"], summary: "Log out the current session", responses: { 200: ok("Logged out") } } },
"/api/auth/me": { get: { tags: ["auth"], summary: "Current user", responses: { 200: ok("User", obj({ ok: bool, user: obj({ id: str, email: str, role: str }) })), 401: err("Unauthorized") } } },
"/api/auth/forgot": { post: { tags: ["auth"], summary: "Request a password reset", security: [], requestBody: jsonBody(obj({ email: str }, ["email"])), responses: { 200: ok("Always ok") } } },
"/api/auth/reset": { post: { tags: ["auth"], summary: "Reset password with a token", security: [], requestBody: jsonBody(obj({ token: str, password: str }, ["token", "password"])), responses: { 200: ok("Password reset"), 400: err("Invalid token/password") } } },
// ── API tokens ──
"/api/tokens": {
get: { tags: ["tokens"], summary: "List your API tokens", responses: { 200: ok("Tokens", obj({ ok: bool, tokens: arr(obj({ id: str, name: str, createdAt: str, lastUsedAt: { ...str, nullable: true } })) })) } },
post: { tags: ["tokens"], summary: "Create an API token (raw value returned exactly once)", requestBody: jsonBody(obj({ name: str }), false), responses: { 201: ok("Token created", obj({ ok: bool, token: { ...str, description: "rpt_… bearer token — store it now, it is never shown again" }, id: str, name: str })), 409: err("Too many tokens") } },
},
"/api/tokens/{id}": { delete: { tags: ["tokens"], summary: "Revoke an API token", parameters: [{ ...idParam, schema: str }], responses: { 200: ok("Revoked"), 404: err("Not found") } } },
// ── Roast plans ──
"/api/plans": {
get: { tags: ["roast plans"], summary: "List your roast plans; view=summary returns paginated lightweight rows", parameters: ["view","q","process","level","sort","page","limit"].map(name=>({name,in:"query",schema:{type:"string"}})), responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } },
},
"/api/plans/{id}/clone": { post: { tags: ["roast plans"], summary: "Clone your plan for a new roast; clears recorded outcomes and inventory consumption", parameters: [idParam], responses: { 201: ok("Independent copy created"), 404: err("Not found") } } },
"/api/plans/{id}": {
get: {tags:["roast plans"],summary:"Read one of your plans",parameters:[idParam],responses:{200:ok("Plan"),404:err("Not found")}},
put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["roast plans"], summary: "Delete a roast plan", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/machine-profile": { get: { tags: ["roast plans"], summary: "Learned per-user machine profile", responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
// ── Actual roasts ──
"/api/roasts": {
get: { tags: ["actual roasts"], summary: "List uploaded finished roasts", parameters: [{ name: "plan", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Roasts", obj({ ok: bool, roasts: arr(roast) })) } },
post: { tags: ["actual roasts"], summary: "Upload a finished Artisan .alog (starts async LLM review)", requestBody: jsonBody(obj({ roastPlanId: { ...str, nullable: true }, filename: str, content: { ...str, description: "raw .alog file text" } }, ["content"])), responses: { 201: ok("Stored", obj({ ok: bool, roast })), 422: err("Unparseable .alog") } },
},
"/api/roasts/{id}": {
get: { tags: ["actual roasts"], summary: "Roast detail incl. curve, LLM review, linked plan", parameters: [idParam], responses: { 200: ok("Detail"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Update the roast’s plan, machine, milestone annotations or after-roast notes", parameters: [idParam], requestBody: jsonBody(obj({ roastPlanId:{...str,nullable:true}, observations:{type:"object",description:"Milestone keys charge/tp/yellow/fc/drop, each containing timeS, tempC (nullable numbers), and note"}, roasterId: { ...str, nullable: true }, after: { type: "object", description: "greenIn/out/weightLossPct/vsTarget/actualDtrPct/colour/restedDays/brewRatio/method/cupNotes/oneChange/disproof (strings)" } })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["actual roasts"], summary: "Delete an uploaded roast", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the .alog — original, or ?variant=updated for a supplemental copy with the app's data (weights, notes, cupping) written back as Artisan fields, importable into Artisan", parameters: [idParam, { name: "variant", in: "query", schema: { type: "string", enum: ["updated"] } }], responses: { 200: { description: "File as attachment" }, 404: err("Not found") } } },
"/api/roasts/{id}/evaluate": { post: { tags: ["actual roasts"], summary: "Queue a re-review", parameters: [idParam], responses: { 200: ok("Queued"), 404: err("Not found") } } },
// ── Green inventory ──
"/api/inventory": {
get: { tags: ["green inventory"], summary: "List green bean lots", responses: { 200: ok("Lots", obj({ ok: bool, lots: arr(lot) })) } },
post: { tags: ["green inventory"], summary: "Add a lot", requestBody: jsonBody(obj({ origin: str, initialWeightG: { type: "number" } }, ["origin", "initialWeightG"])), responses: { 201: ok("Created") } },
},
"/api/inventory/{id}": {
get: { tags: ["green inventory"], summary: "Lot detail + consumption log", parameters: [idParam], responses: { 200: ok("Lot"), 404: err("Not found") } },
put: { tags: ["green inventory"], summary: "Update a lot", parameters: [idParam], responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["green inventory"], summary: "Delete a lot", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/inventory/{id}/consume": { post: { tags: ["green inventory"], summary: "Draw weight from a lot (idempotent per plan)", parameters: [idParam], requestBody: jsonBody(obj({ weightG: { type: "number" }, roastPlanId: { ...str, nullable: true } }, ["weightG"])), responses: { 201: ok("Drawn"), 409: err("Already consumed for that plan") } } },
"/api/inventory/{id}/last-refine": { get: { tags: ["green inventory"], summary: "Most recent 'one change next batch' note for the lot", parameters: [idParam], responses: { 200: ok("Refine suggestion"), 404: err("Not found") } } },
// ── Cupping ──
"/api/cupping": {
get: { tags: ["cupping"], summary: "List cupping sessions", parameters: [{ name: "plan", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Sessions") } },
post: { tags: ["cupping"], summary: "Create a session", requestBody: jsonBody(obj({ roastPlanId: { ...str, nullable: true }, cupCount: { type: "integer" } }), false), responses: { 201: ok("Created") } },
},
"/api/cupping/{id}": {
get: { tags: ["cupping"], summary: "Session detail", parameters: [idParam], responses: { 200: ok("Session"), 404: err("Not found") } },
put: { tags: ["cupping"], summary: "Update a session", parameters: [idParam], requestBody: jsonBody(obj({ data: { type: "object" } }, ["data"])), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["cupping"], summary: "Delete a session", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
// ── Roasted beans (brewing) ──
"/api/beans": {
get: { tags: ["beans"], summary: "List roasted/purchased beans", responses: { 200: ok("Beans", obj({ ok: bool, beans: arr(bean) })) } },
post: { tags: ["beans"], summary: "Add a bean", requestBody: jsonBody(beanBody), responses: { 201: ok("Created", obj({ ok: bool, bean })), 400: err("Invalid") } },
},
"/api/beans/{id}": {
put: { tags: ["beans"], summary: "Update a bean", parameters: [idParam], requestBody: jsonBody(beanBody), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["beans"], summary: "Delete a bean", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/gear": {
get: { tags: ["brews"], summary: "Owned equipment (brewers + grinders)", responses: { 200: ok("Gear", obj({ ok: bool, gear: obj({ brewers: arr(str), grinders: arr(str) }) })) } },
put: { tags: ["brews"], summary: "Set owned equipment", requestBody: jsonBody(obj({ brewers: arr({ ...str, description: "brew-method keys" }), grinders: arr(str) }, ["brewers", "grinders"])), responses: { 200: ok("Saved"), 400: err("Unknown brewer key") } },
},
"/api/brew-methods": { get: { tags: ["brews"], summary: "Brew-method taxonomy (categories + methods)", responses: { 200: ok("Methods", obj({ ok: bool, categories: arr(obj({ key: str, name: str })), methods: arr(obj({ key: str, name: str, category: str })) })) } } },
// ── Brews ──
"/api/brews": {
get: { tags: ["brews"], summary: "List brews (newest first)", parameters: [{ name: "bean", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Brews", obj({ ok: bool, brews: arr(brew) })) } },
post: { tags: ["brews"], summary: "Log a brew", requestBody: jsonBody(brewBody), responses: { 201: ok("Created", obj({ ok: bool, brew })), 400: err("Invalid") } },
},
"/api/brews/{id}": {
put: { tags: ["brews"], summary: "Update a brew", parameters: [idParam], requestBody: jsonBody(brewBody), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["brews"], summary: "Delete a brew", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
// ── LLM helpers ──
"/api/plan-chat": { post: { tags: ["llm"], summary: "Chat with the LLM about a roast plan (stateless; send the full visible conversation)", requestBody: jsonBody(obj({ plan: { type: "object" }, messages: arr(obj({ role: { ...str, enum: ["user", "assistant"] }, content: str }, ["role", "content"])) }, ["plan", "messages"])), responses: { 200: ok("Reply", obj({ ok: bool, reply: str })), 400: err("Bad plan/messages"), 503: err("No LLM model configured") } } },
"/api/roaster-profile": { get: { tags: ["llm"], summary: "Learned behavior for a machine (?roaster=id; default roaster otherwise), with user overrides applied", parameters: [{ name: "roaster", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
"/api/roasters": {
get: { tags: ["roasters"], summary: "List your roasting machines", responses: { 200: ok("Roasters") } },
post: { tags: ["roasters"], summary: "Add a machine (first one becomes default)", requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool }, ["name"])), responses: { 201: ok("Created") } },
},
"/api/roasters/{id}": {
put: { tags: ["roasters"], summary: "Update a machine (name/model/notes/default/override tweaks)", parameters: [idParam], requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool, overrides: { type: "object", description: "chargeTempC/turningPointS/turningPointTempC/yellowTempC/firstCrackTempC/dropTempC/paceFactor" } }), false), responses: { 200: ok("Updated"), 400: err("Bad override") } },
delete: { tags: ["roasters"], summary: "Delete a machine (its roasts detach)", parameters: [idParam], responses: { 200: ok("Deleted") } },
},
"/api/prefill": { post: { tags: ["llm"], summary: "Extract coffee facts from a product URL (used by roast planner and bean form)", requestBody: jsonBody(obj({ url: str }, ["url"])), responses: { 200: ok("Extraction + derived worksheet fields"), 422: err("Extraction failed"), 503: err("No LLM model configured") } } },
"/api/alog": { post: { tags: ["llm"], summary: "Parse an Artisan .alog for the reference-curve overlay (no storage)", requestBody: jsonBody(obj({ filename: str, content: str }, ["content"])), responses: { 200: ok("Parsed"), 422: err("Unparseable") } } },
// ── Account ──
"/api/account/email": { put: { tags: ["account"], summary: "Change email", requestBody: jsonBody(obj({ email: str, password: str }, ["email", "password"])), responses: { 200: ok("Changed") } } },
"/api/account/password": { put: { tags: ["account"], summary: "Change password", requestBody: jsonBody(obj({ currentPassword: str, newPassword: str }, ["currentPassword", "newPassword"])), responses: { 200: ok("Changed") } } },
"/api/account/sessions": { get: { tags: ["account"], summary: "List sessions", responses: { 200: ok("Sessions") } } },
"/api/account/sessions/{id}": { delete: { tags: ["account"], summary: "Revoke a session", parameters: [{ ...idParam, schema: str }], responses: { 200: ok("Revoked") } } },
"/api/account/sessions/revoke-others": { post: { tags: ["account"], summary: "Revoke all other sessions", responses: { 200: ok("Revoked") } } },
"/api/account/export": { get: { tags: ["account"], summary: "Download all of your own data as JSON", responses: { 200: ok("Personal data export") } } },
"/api/account/avatar": {
get: { tags: ["account"], summary: "Your profile picture (image response)", responses: { 200: { description: "Image" }, 404: err("No picture set") } },
put: { tags: ["account"], summary: "Set your profile picture (small data URL)", requestBody: jsonBody(obj({ dataUrl: { ...str, description: "data:image/png|jpeg|webp;base64,… (≤300KB)" } }, ["dataUrl"])), responses: { 200: ok("Saved"), 400: err("Bad image") } },
delete: { tags: ["account"], summary: "Remove your profile picture", responses: { 200: ok("Removed") } },
},
"/api/account": { delete: { tags: ["account"], summary: "Delete your account", requestBody: jsonBody(obj({ password: str }, ["password"])), responses: { 200: ok("Deleted") } } },
// ── Admin ──
"/api/admin/users": { get: { tags: ["admin"], summary: "List users", responses: { 200: ok("Users"), 403: err("Forbidden") } } },
"/api/admin/metrics": { get: { tags: ["admin"], summary: "Instance metrics", responses: { 200: ok("Metrics") } } },
"/api/admin/plans": { get: { tags: ["admin"], summary: "All plans (optional ?user=)", parameters: [{ name: "user", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Plans") } } },
"/api/admin/audit": { get: { tags: ["admin"], summary: "Recent audit events", responses: { 200: ok("Events") } } },
"/api/admin/password-resets": { get: { tags: ["admin"], summary: "Pending reset links (no-SMTP deployments)", responses: { 200: ok("Links") } } },
"/api/admin/signup-enabled": { put: { tags: ["admin"], summary: "Toggle signups", requestBody: jsonBody(obj({ enabled: bool }, ["enabled"])), responses: { 200: ok("Toggled") } } },
"/api/admin/llm": {
get: { tags: ["admin"], summary: "Configured LLM models + current choice", responses: { 200: ok("Models", obj({ ok: bool, current: str, models: arr(obj({ key: str, name: str, provider: str })), modelsError: { ...str, nullable: true } })) } },
put: { tags: ["admin"], summary: "Choose the LLM model ('' = auto)", requestBody: jsonBody(obj({ model: str }, ["model"])), responses: { 200: ok("Saved"), 400: err("Unknown model"), 503: err("Model listing unavailable") } },
},
"/api/admin/users/{id}/role": { put: { tags: ["admin"], summary: "Change a user's role", parameters: [idParam], requestBody: jsonBody(obj({ role: { ...str, enum: ["user", "admin"] } }, ["role"])), responses: { 200: ok("Changed") } } },
"/api/admin/users/{id}/disabled": { put: { tags: ["admin"], summary: "Disable/enable a user", parameters: [idParam], requestBody: jsonBody(obj({ disabled: bool }, ["disabled"])), responses: { 200: ok("Changed") } } },
"/api/admin/users/{id}": { delete: { tags: ["admin"], summary: "Delete a user and their data", parameters: [idParam], responses: { 200: ok("Deleted") } } },
"/api/admin/backup": { get: { tags: ["admin"], summary: "Export the entire database as JSON", responses: { 200: ok("Backup file (attachment)") } } },
"/api/admin/backup/import": { post: { tags: ["admin"], summary: "REPLACE the entire database from a backup export", requestBody: jsonBody(obj({ format: { ...str, example: "roast-planner-backup" }, version: { type: "integer", example: 1 }, tables: { type: "object" } }, ["format", "version", "tables"])), responses: { 200: ok("Imported", obj({ ok: bool, counts: { type: "object" }, sessionKept: bool })), 400: err("Bad backup / no admin in backup") } } },
};
return {
openapi: "3.0.3",
info: {
title: "Roast Planner API",
version: "1.0.0",
description:
"Every capability of the app — roast planning, finished-roast uploads with LLM review, green inventory, cupping, roasted-bean management, brew logging, account, and admin — over JSON. Authenticate with the browser session cookie or an API token (`Authorization: Bearer rpt_…`, generated on the Account page). Bearer requests skip CSRF; cookie-based write requests must send the `x-csrf-token` header.",
},
servers: [{ url: origin || "/" }],
tags: [
{ name: "auth" }, { name: "tokens" }, { name: "roast plans" }, { name: "actual roasts" }, { name: "roasters" },
{ name: "green inventory" }, { name: "cupping" }, { name: "beans" }, { name: "brews" },
{ name: "llm" }, { name: "account" }, { name: "admin" },
],
components: {
securitySchemes: {
cookieAuth: { type: "apiKey", in: "cookie", name: "rp_session" },
bearerAuth: { type: "http", scheme: "bearer", description: "API token from the Account page (rpt_…)" },
},
},
security,
paths,
};
}