Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
236 lines
22 KiB
JavaScript
236 lines
22 KiB
JavaScript
// 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", 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}": {
|
|
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 machine and/or after-the-roast observations", parameters: [idParam], requestBody: jsonBody(obj({ 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,
|
|
};
|
|
}
|