Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Test and deploy / test-and-deploy (push) Successful in 1m6s

Brewing:
- roasted_beans + brews tables; /beans (bean management with LLM URL
  prefill) and /brews (silhouette brewer picker across immersion/
  percolation/espresso, recipe fields, auto ratio, 0-10 rating, tasting
  notes); bean remaining weight derived from logged brew doses
- Green inventory lot form also prefills from a product URL

Navigation/UX:
- Side nav is now generated from one definition in nav.js, grouped
  Roasting / Brewing / account, consistent on every page

LLM:
- 'Ask the LLM' chat drawer on the planner (stateless /api/plan-chat)
  grounded in the plan, computed ledger, learned pace, and a new
  roaster-behavior profile aggregated from uploaded .alogs
  (/api/roaster-profile: TP lag, phase RoR, median milestone temps)
- The profile also feeds roast reviews and the planner curve's fallback
  milestone temps

API platform:
- User-generated bearer tokens (rpt_…) with account-page management;
  token requests skip CSRF; hand-authored OpenAPI 3 spec at
  /api/openapi.json rendered by self-hosted Swagger UI at /api-docs
- Full-database backup export/import (admin) + per-user data export

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-08 22:37:42 -04:00
co-authored by Claude Fable 5
parent 5efaeb63c9
commit 38c7d01e03
33 changed files with 3498 additions and 205 deletions
+802 -10
View File
@@ -8,6 +8,11 @@ import { parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { evaluateRoast as defaultEvaluateRoast } from "./evaluate-roast.js";
import { listAvailableModels as defaultListModels } from "./llm.js";
import { buildOpenApiSpec } from "./openapi.js";
import { computeRoasterProfile } from "./roaster-profile.js";
import { coerceChatMessages, runPlanChat as defaultRunPlanChat } from "./plan-chat.js";
import { BREW_CATEGORIES, BREW_METHODS } from "../shared/brew-data.js";
import { createRequire } from "node:module";
import { sendMail } from "./mailer.js";
import { coerceSession, computeTotalScore, blankSession } from "../shared/cupping.js";
import { computeMachineProfile } from "../shared/learn.js";
@@ -41,6 +46,9 @@ const PUBLIC_SHELL_FILES = new Set([
"/inventory.html",
"/cupping.html",
"/roasts.html",
"/beans.html",
"/brews.html",
"/api-docs.html",
]);
/** Creates the HTTP app separately from listening, so tests can use an isolated database. */
@@ -51,6 +59,7 @@ export function createApp({
// Injectable so tests can stub the LLM calls; production always uses the real ones.
evaluateRoast = defaultEvaluateRoast,
listModels = defaultListModels,
runPlanChat = defaultRunPlanChat,
} = {}) {
const app = express();
const production = env.NODE_ENV === "production";
@@ -130,7 +139,10 @@ export function createApp({
req.path === "/account" ||
req.path === "/inventory" ||
req.path === "/cupping" ||
req.path === "/roasts"
req.path === "/roasts" ||
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/api-docs"
)
res.set("Cache-Control", "no-store, private");
res.set({
@@ -145,17 +157,21 @@ export function createApp({
next();
});
// Finished-roast uploads carry a whole Artisan .alog (full telemetry arrays) inside a JSON
// string — those legitimately run to a few MB, so that one route gets a larger body cap
// without loosening the 1mb limit everything else keeps.
// string — those legitimately run to a few MB — and a full-database restore can be far
// larger still. Those specific routes get bigger body caps without loosening the 1mb limit
// everything else keeps.
const jsonBody = express.json({ limit: "1mb" });
const jsonBodyLarge = express.json({ limit: "8mb" });
app.use((req, res, next) =>
(req.method === "POST" && req.path === "/api/roasts" ? jsonBodyLarge : jsonBody)(
req,
res,
next,
),
);
const jsonBodyBackup = express.json({ limit: "256mb" });
app.use((req, res, next) => {
const parser =
req.method === "POST" && req.path === "/api/admin/backup/import"
? jsonBodyBackup
: req.method === "POST" && req.path === "/api/roasts"
? jsonBodyLarge
: jsonBody;
return parser(req, res, next);
});
// express.json() leaves req.body undefined when the request has no body or a non-JSON
// content-type (Express 5 no longer defaults it to {}), so every route below that reads
// req.body.<field> would 500 instead of validating and returning 400.
@@ -187,6 +203,25 @@ export function createApp({
});
};
async function session(req) {
// API tokens: an Authorization: Bearer header authenticates exactly like a session for
// the owning user. Header-borne credentials can't be sent by a cross-site form, so
// token-authenticated requests are exempt from CSRF (see csrf() below).
const authHeader = req.get("authorization") || "";
if (authHeader.startsWith("Bearer ")) {
const rawToken = authHeader.slice(7).trim();
if (!rawToken) return null;
const r = await db.query(
"SELECT u.id,u.email,u.role,u.created_at FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=$1 AND u.disabled_at IS NULL",
[hash(rawToken)],
);
const row = r.rows[0];
if (!row) return null;
db.query(
"UPDATE api_tokens SET last_used_at=now() WHERE token_hash=$1",
[hash(rawToken)],
).catch(() => {});
return { ...row, token_auth: true };
}
const raw = cookie(req, "rp_session");
if (!raw) return null;
const r = await db.query(
@@ -214,6 +249,8 @@ export function createApp({
}
}
const csrf = (req, res, next) => {
// Bearer-token requests carry no cookies for a cross-site attacker to ride on.
if (req.user?.token_auth) return next();
if (origin && req.get("origin") && req.get("origin") !== origin)
return res.status(403).json({ ok: false, code: "bad_origin" });
const value = req.get("x-csrf-token");
@@ -382,6 +419,21 @@ export function createApp({
next(error);
}
});
for (const [route, file] of [
["/beans", "beans.html"],
["/brews", "brews.html"],
["/api-docs", "api-docs.html"],
]) {
app.get(route, async (req, res, next) => {
try {
const user = await session(req);
if (!user) return res.redirect("/login");
res.sendFile(path.join(root, "public", file));
} catch (error) {
next(error);
}
});
}
app.get("/admin", async (req, res, next) => {
try {
const user = await session(req);
@@ -1128,6 +1180,54 @@ export function createApp({
await db.query("SELECT value FROM app_settings WHERE key='llm_model'")
).rows[0]?.value || "";
// Learned roaster behavior aggregated from this user's uploaded .alogs — grounds the plan
// chat, the roast evaluations, and the planner's suggested curve temps.
const userRoasterProfile = async (userId) =>
computeRoasterProfile(
(
await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId])
).rows.map((r) => r.parsed),
);
app.get("/api/roaster-profile", requireAuth, async (req, res, next) => {
try {
res.json({ ok: true, profile: await userRoasterProfile(req.user.id) });
} catch (e) {
next(e);
}
});
app.post("/api/plan-chat", requireAuth, csrf, async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
return res.status(400).json({ ok: false, code: "bad_plan" });
let messages;
try {
messages = coerceChatMessages(req.body.messages);
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_messages", error: e.message });
}
const [roasterProfile, planRows, preferredModel] = await Promise.all([
userRoasterProfile(req.user.id),
db
.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id])
.then((r) => r.rows.map((row) => row.plan)),
llmModelSetting(),
]);
const result = await runPlanChat({
plan: req.body.plan,
messages,
machineProfile: computeMachineProfile(planRows),
roasterProfile,
preferredModel,
});
res.json({ ok: true, ...result });
} catch (err) {
const code = err.code ?? "chat_failed";
res
.status(code === "no_model" ? 503 : 422)
.json({ ok: false, code, error: err.message });
}
});
// ─── Actual roasts (finished .alog uploads) ────────────────────────────
const toRoastRow = (row, { full = false } = {}) => {
const parsed = row.parsed || {};
@@ -1167,6 +1267,7 @@ export function createApp({
row.parsed,
row.plan ?? null,
await llmModelSetting(),
await userRoasterProfile(userId),
);
await db.query(
"UPDATE actual_roasts SET evaluation=$1, evaluation_status='done', evaluation_error=NULL, updated_at=now() WHERE id=$2",
@@ -1329,6 +1430,508 @@ export function createApp({
},
);
// ─── Roasted beans (brewing-side bean management) ──────────────────────
// Remaining weight is DERIVED (initial Σ brew doses), never stored, so concurrent brew
// logging can't corrupt it and deleting a brew automatically "returns" its dose.
const BEAN_TEXT_FIELDS = [
"roaster",
"origin",
"process",
"variety",
"roastLevel",
"url",
"tastingNotes",
"notes",
];
const BEAN_COLUMN = {
roaster: "roaster",
origin: "origin",
process: "process",
variety: "variety",
roastLevel: "roast_level",
url: "url",
tastingNotes: "tasting_notes",
notes: "notes",
};
const toBeanRow = (row) => ({
id: row.id,
name: row.name,
roaster: row.roaster,
origin: row.origin,
process: row.process,
variety: row.variety,
roastLevel: row.roast_level,
roastDate: row.roast_date,
initialWeightG: row.initial_weight_g == null ? null : Number(row.initial_weight_g),
remainingWeightG:
row.initial_weight_g == null
? null
: Number(row.initial_weight_g) - Number(row.used_g ?? 0),
url: row.url,
tastingNotes: row.tasting_notes,
notes: row.notes,
roastPlanId: row.roast_plan_id,
archived: row.archived,
brewCount: row.brew_count == null ? undefined : Number(row.brew_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
// Two plain queries merged in JS (not a correlated subquery/GROUP BY b.*) so the identical
// SQL runs on both real PostgreSQL and the pg-mem test database.
async function beanUsage(userId) {
const rows = (
await db.query(
"SELECT bean_id, SUM(dose_g) AS used_g, COUNT(*) AS brew_count FROM brews WHERE user_id=$1 AND bean_id IS NOT NULL GROUP BY bean_id",
[userId],
)
).rows;
return new Map(rows.map((r) => [r.bean_id, r]));
}
app.get("/api/beans", requireAuth, async (req, res, next) => {
try {
const [rows, usage] = await Promise.all([
db
.query(
"SELECT * FROM roasted_beans WHERE user_id=$1 ORDER BY archived ASC, created_at DESC",
[req.user.id],
)
.then((r) => r.rows),
beanUsage(req.user.id),
]);
res.json({
ok: true,
beans: rows.map((row) => toBeanRow({ ...row, ...usage.get(row.id) })),
});
} catch (e) {
next(e);
}
});
app.post("/api/beans", requireAuth, csrf, async (req, res, next) => {
try {
const name = String(req.body.name || "").trim();
if (!name) return res.status(400).json({ ok: false, code: "bad_bean" });
let initialWeightG, roastDate;
try {
initialWeightG = parseOptionalNumber(req.body.initialWeightG, "initialWeightG").value;
roastDate = parseOptionalDate(req.body.roastDate).value;
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_bean", error: e.message });
}
let roastPlanId = null;
if (req.body.roastPlanId) {
if (!UUID_RE.test(req.body.roastPlanId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query("SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", [
req.body.roastPlanId,
req.user.id,
])
).rowCount;
if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
roastPlanId = req.body.roastPlanId;
}
const row = (
await db.query(
`INSERT INTO roasted_beans
(user_id,name,roaster,origin,process,variety,roast_level,roast_date,initial_weight_g,url,tasting_notes,notes,roast_plan_id)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
[
req.user.id,
name,
...BEAN_TEXT_FIELDS.slice(0, 4).map((f) => String(req.body[f] || "")),
String(req.body.roastLevel || ""),
roastDate,
initialWeightG,
String(req.body.url || ""),
String(req.body.tastingNotes || ""),
String(req.body.notes || ""),
roastPlanId,
],
)
).rows[0];
res.status(201).json({ ok: true, bean: toBeanRow(row) });
} catch (e) {
next(e);
}
});
app.put(
"/api/beans/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const existing = (
await db.query(
"SELECT * FROM roasted_beans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
if (!existing)
return res.status(404).json({ ok: false, code: "not_found" });
const b = req.body;
const name =
b.name === undefined ? existing.name : String(b.name || "").trim();
if (!name) return res.status(400).json({ ok: false, code: "bad_bean" });
let initialWeightG, roastDate;
try {
initialWeightG =
b.initialWeightG === undefined
? existing.initial_weight_g
: parseOptionalNumber(b.initialWeightG, "initialWeightG").value;
roastDate =
b.roastDate === undefined
? existing.roast_date
: parseOptionalDate(b.roastDate).value;
} catch (e) {
return res.status(400).json({ ok: false, code: "bad_bean", error: e.message });
}
const text = {};
for (const f of BEAN_TEXT_FIELDS)
text[f] =
b[f] === undefined ? existing[BEAN_COLUMN[f]] : String(b[f] || "");
const row = (
await db.query(
`UPDATE roasted_beans SET
name=$1, roaster=$2, origin=$3, process=$4, variety=$5, roast_level=$6,
roast_date=$7, initial_weight_g=$8, url=$9, tasting_notes=$10, notes=$11,
archived=$12, updated_at=now()
WHERE id=$13 AND user_id=$14 RETURNING *`,
[
name,
text.roaster,
text.origin,
text.process,
text.variety,
text.roastLevel,
roastDate,
initialWeightG,
text.url,
text.tastingNotes,
text.notes,
b.archived === undefined ? existing.archived : Boolean(b.archived),
req.params.id,
req.user.id,
],
)
).rows[0];
res.json({ ok: true, bean: toBeanRow(row) });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/beans/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM roasted_beans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// ─── Brews ─────────────────────────────────────────────────────────────
const BREW_METHOD_KEYS = new Set(BREW_METHODS.map((m) => m.key));
const toBrewRow = (row) => ({
id: row.id,
beanId: row.bean_id,
beanName: row.bean_name ?? null,
method: row.method,
doseG: row.dose_g == null ? null : Number(row.dose_g),
waterG: row.water_g == null ? null : Number(row.water_g),
yieldG: row.yield_g == null ? null : Number(row.yield_g),
grinder: row.grinder,
grindSetting: row.grind_setting,
waterTempC: row.water_temp_c == null ? null : Number(row.water_temp_c),
brewTimeS: row.brew_time_s,
bloomTimeS: row.bloom_time_s,
rating: row.rating == null ? null : Number(row.rating),
tastingNotes: row.tasting_notes,
notes: row.notes,
brewedAt: row.brewed_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
/** Shared validation for create/update; returns {error} or the normalized values. */
function parseBrewBody(b, existing = null) {
const method = b.method === undefined ? existing?.method : String(b.method || "");
if (!method || !BREW_METHOD_KEYS.has(method)) return { error: "unknown brew method" };
const values = { method };
try {
for (const [field, name] of [
["doseG", "dose_g"],
["waterG", "water_g"],
["yieldG", "yield_g"],
["waterTempC", "water_temp_c"],
["rating", "rating"],
])
values[name] =
b[field] === undefined
? (existing?.[name] ?? null)
: parseOptionalNumber(b[field], field).value;
} catch (e) {
return { error: e.message };
}
if (values.rating !== null && (values.rating < 0 || values.rating > 10))
return { error: "rating must be 0-10" };
for (const [field, name] of [
["brewTimeS", "brew_time_s"],
["bloomTimeS", "bloom_time_s"],
]) {
const v = b[field] === undefined ? (existing?.[name] ?? null) : b[field];
if (v === null || v === undefined || v === "") values[name] = null;
else if (!Number.isInteger(v) || v < 0 || v > 86_400)
return { error: `${field} must be a whole number of seconds` };
else values[name] = v;
}
for (const [field, name] of [
["grinder", "grinder"],
["grindSetting", "grind_setting"],
["tastingNotes", "tasting_notes"],
["notes", "notes"],
])
values[name] =
b[field] === undefined ? (existing?.[name] ?? "") : String(b[field] || "");
return { values };
}
app.get("/api/brews", requireAuth, async (req, res, next) => {
try {
const beanFilter =
typeof req.query.bean === "string" && UUID_RE.test(req.query.bean)
? req.query.bean
: null;
const rows = (
await db.query(
`SELECT w.*, b.name AS bean_name FROM brews w
LEFT JOIN roasted_beans b ON b.id=w.bean_id AND b.user_id=w.user_id
WHERE w.user_id=$1 AND ($2::uuid IS NULL OR w.bean_id=$2)
ORDER BY w.brewed_at DESC`,
[req.user.id, beanFilter],
)
).rows;
res.json({ ok: true, brews: rows.map(toBrewRow) });
} catch (e) {
next(e);
}
});
app.post("/api/brews", requireAuth, csrf, async (req, res, next) => {
try {
let beanId = null;
if (req.body.beanId) {
if (!UUID_RE.test(req.body.beanId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query("SELECT 1 FROM roasted_beans WHERE id=$1 AND user_id=$2", [
req.body.beanId,
req.user.id,
])
).rowCount;
if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
beanId = req.body.beanId;
}
const parsedBody = parseBrewBody(req.body);
if (parsedBody.error)
return res
.status(400)
.json({ ok: false, code: "bad_brew", error: parsedBody.error });
const v = parsedBody.values;
const row = (
await db.query(
`INSERT INTO brews
(user_id,bean_id,method,dose_g,water_g,yield_g,grinder,grind_setting,water_temp_c,brew_time_s,bloom_time_s,rating,tasting_notes,notes)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[
req.user.id,
beanId,
v.method,
v.dose_g,
v.water_g,
v.yield_g,
v.grinder,
v.grind_setting,
v.water_temp_c,
v.brew_time_s,
v.bloom_time_s,
v.rating,
v.tasting_notes,
v.notes,
],
)
).rows[0];
res.status(201).json({ ok: true, brew: toBrewRow(row) });
} catch (e) {
next(e);
}
});
app.put(
"/api/brews/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const existing = (
await db.query("SELECT * FROM brews WHERE id=$1 AND user_id=$2", [
req.params.id,
req.user.id,
])
).rows[0];
if (!existing)
return res.status(404).json({ ok: false, code: "not_found" });
let beanId = existing.bean_id;
if (req.body.beanId !== undefined) {
if (req.body.beanId === null || req.body.beanId === "") beanId = null;
else {
if (!UUID_RE.test(req.body.beanId))
return res.status(404).json({ ok: false, code: "not_found" });
const owns = (
await db.query(
"SELECT 1 FROM roasted_beans WHERE id=$1 AND user_id=$2",
[req.body.beanId, req.user.id],
)
).rowCount;
if (!owns)
return res.status(404).json({ ok: false, code: "not_found" });
beanId = req.body.beanId;
}
}
const parsedBody = parseBrewBody(req.body, existing);
if (parsedBody.error)
return res
.status(400)
.json({ ok: false, code: "bad_brew", error: parsedBody.error });
const v = parsedBody.values;
const row = (
await db.query(
`UPDATE brews SET bean_id=$1, method=$2, dose_g=$3, water_g=$4, yield_g=$5,
grinder=$6, grind_setting=$7, water_temp_c=$8, brew_time_s=$9, bloom_time_s=$10,
rating=$11, tasting_notes=$12, notes=$13, updated_at=now()
WHERE id=$14 AND user_id=$15 RETURNING *`,
[
beanId,
v.method,
v.dose_g,
v.water_g,
v.yield_g,
v.grinder,
v.grind_setting,
v.water_temp_c,
v.brew_time_s,
v.bloom_time_s,
v.rating,
v.tasting_notes,
v.notes,
req.params.id,
req.user.id,
],
)
).rows[0];
res.json({ ok: true, brew: toBrewRow(row) });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/brews/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM brews WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// ─── API tokens ────────────────────────────────────────────────────────
app.get("/api/tokens", requireAuth, async (req, res, next) => {
try {
const rows = (
await db.query(
"SELECT token_hash,name,created_at,last_used_at FROM api_tokens WHERE user_id=$1 ORDER BY created_at DESC",
[req.user.id],
)
).rows;
res.json({
ok: true,
tokens: rows.map((row) => ({
id: row.token_hash.slice(0, 16),
name: row.name,
createdAt: row.created_at,
lastUsedAt: row.last_used_at,
})),
});
} catch (e) {
next(e);
}
});
app.post("/api/tokens", requireAuth, csrf, async (req, res, next) => {
try {
const name = String(req.body.name || "").slice(0, 100);
const count = (
await db.query(
"SELECT count(*)::int AS count FROM api_tokens WHERE user_id=$1",
[req.user.id],
)
).rows[0].count;
if (count >= 20)
return res.status(409).json({ ok: false, code: "too_many_tokens" });
// "rpt_" marks the string as a Roast Planner token in secret scanners and logs.
const raw = `rpt_${token()}`;
await db.query(
"INSERT INTO api_tokens(token_hash,user_id,name) VALUES($1,$2,$3)",
[hash(raw), req.user.id, name],
);
await audit(req.user.id, "api_token_created", name || null);
// The raw token is shown exactly once and never stored.
res.status(201).json({
ok: true,
token: raw,
id: hash(raw).slice(0, 16),
name,
});
} catch (e) {
next(e);
}
});
app.delete("/api/tokens/:id", requireAuth, csrf, async (req, res, next) => {
try {
const id = String(req.params.id || "");
if (!/^[0-9a-f]{16}$/i.test(id))
return res.status(404).json({ ok: false, code: "not_found" });
const r = await db.query(
"DELETE FROM api_tokens WHERE user_id=$1 AND token_hash LIKE $2",
[req.user.id, `${id}%`],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
await audit(req.user.id, "api_token_revoked");
res.json({ ok: true });
} catch (e) {
next(e);
}
});
// ─── Cupping ───────────────────────────────────────────────────────────
const toSessionRow = (row, { full = false } = {}) => {
const data = row.data || {};
@@ -1740,6 +2343,180 @@ export function createApp({
res.json({ ok: true, links });
},
);
// ─── Backup (full-database export/import) ──────────────────────────────
// Per-table column allowlists, in FK-dependency order (parents first). Insert follows this
// order, delete runs it reversed. sessions and password_reset_tokens are deliberately not
// part of a backup: they are short-lived secrets, and restoring them would resurrect revoked
// access. Deleting users cascades both away on import anyway.
const BACKUP_TABLES = [
["users", ["id", "email", "password_hash", "role", "created_at", "disabled_at"]],
["app_settings", ["key", "value"]],
["roast_plans", ["id", "user_id", "plan", "created_at", "updated_at"]],
[
"green_bean_lots",
[
"id", "user_id", "origin", "variety", "process", "producer", "purchase_date",
"initial_weight_g", "remaining_weight_g", "cost_total", "moisture_pct",
"density_g_l", "notes", "archived", "created_at", "updated_at",
],
],
["bean_consumption", ["id", "lot_id", "user_id", "roast_plan_id", "weight_g", "created_at"]],
["cupping_sessions", ["id", "user_id", "roast_plan_id", "data", "total_score", "created_at", "updated_at"]],
[
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "filename", "original_content", "parsed",
"evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
[
"roasted_beans",
[
"id", "user_id", "name", "roaster", "origin", "process", "variety", "roast_level",
"roast_date", "initial_weight_g", "url", "tasting_notes", "notes", "roast_plan_id",
"archived", "created_at", "updated_at",
],
],
[
"brews",
[
"id", "user_id", "bean_id", "method", "dose_g", "water_g", "yield_g", "grinder",
"grind_setting", "water_temp_c", "brew_time_s", "bloom_time_s", "rating",
"tasting_notes", "notes", "brewed_at", "created_at", "updated_at",
],
],
["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
["audit_events", ["id", "actor_user_id", "action", "target", "created_at"]],
];
const BACKUP_VERSION = 1;
app.get("/api/admin/backup", requireAuth, admin, async (req, res, next) => {
try {
const tables = {};
for (const [table, columns] of BACKUP_TABLES)
tables[table] = (
await db.query(`SELECT ${columns.join(",")} FROM ${table}`)
).rows;
await audit(req.user.id, "backup_exported");
res.set(
"Content-Disposition",
`attachment; filename="roast-planner-backup-${new Date().toISOString().slice(0, 10)}.json"`,
);
res.json({
ok: true,
format: "roast-planner-backup",
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
tables,
});
} catch (e) {
next(e);
}
});
app.post(
"/api/admin/backup/import",
requireAuth,
csrf,
admin,
async (req, res, next) => {
try {
const body = req.body;
if (
body.format !== "roast-planner-backup" ||
body.version !== BACKUP_VERSION ||
!body.tables ||
typeof body.tables !== "object"
)
return res.status(400).json({ ok: false, code: "bad_backup" });
// The restored database must still contain at least one active admin, or the
// import would permanently lock everyone (including this caller) out.
const users = Array.isArray(body.tables.users) ? body.tables.users : [];
if (!users.some((u) => u.role === "admin" && !u.disabled_at))
return res.status(400).json({ ok: false, code: "backup_has_no_admin" });
const counts = {};
const currentSessionHash = req.user.token_auth
? null
: hash(cookie(req, "rp_session"));
const currentCsrfHash = req.user.csrf_hash ?? null;
const currentUserId = req.user.id;
await withTransaction(async (client) => {
for (const [table] of [...BACKUP_TABLES].reverse())
await client.query(`DELETE FROM ${table}`);
for (const [table, columns] of BACKUP_TABLES) {
const rows = Array.isArray(body.tables[table]) ? body.tables[table] : [];
for (const row of rows) {
const params = columns.map((column) => {
const value = row[column];
if (value === undefined) return null;
// jsonb columns arrive as objects; everything else is scalar.
return value !== null && typeof value === "object"
? JSON.stringify(value)
: value;
});
await client.query(
`INSERT INTO ${table}(${columns.join(",")}) VALUES(${columns.map((_, i) => `$${i + 1}`).join(",")})`,
params,
);
}
counts[table] = rows.length;
}
// Deleting users cascaded this caller's session away. If the same user id
// exists in the restored data, re-create the session so the admin who ran the
// import stays signed in; otherwise they must log in with restored credentials.
if (
currentSessionHash &&
currentCsrfHash &&
users.some((u) => u.id === currentUserId)
)
await client.query(
"INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at,last_seen_at) VALUES($1,$2,$3,now()+interval '14 days',now())",
[currentSessionHash, currentUserId, currentCsrfHash],
);
});
await audit(currentUserId, "backup_imported", JSON.stringify(counts).slice(0, 500));
res.json({
ok: true,
counts,
sessionKept: users.some((u) => u.id === currentUserId),
});
} catch (e) {
next(e);
}
},
);
// Per-user export: the same shape, restricted to the caller's own rows (no password hashes,
// no tokens) — a personal data takeout rather than a restorable server backup.
app.get("/api/account/export", requireAuth, async (req, res, next) => {
try {
const mine = async (table, columns, userColumn = "user_id") =>
(
await db.query(
`SELECT ${columns.join(",")} FROM ${table} WHERE ${userColumn}=$1`,
[req.user.id],
)
).rows;
const tables = {};
for (const [table, columns] of BACKUP_TABLES) {
if (table === "users" || table === "app_settings" || table === "api_tokens" || table === "audit_events")
continue;
tables[table] = await mine(table, columns);
}
res.set(
"Content-Disposition",
`attachment; filename="roast-planner-my-data-${new Date().toISOString().slice(0, 10)}.json"`,
);
res.json({
ok: true,
format: "roast-planner-user-export",
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
user: { email: req.user.email },
tables,
});
} catch (e) {
next(e);
}
});
// The LLM model picker: which configured model runs prefill and roast reviews. Listing can
// legitimately fail (no models configured yet) — the GET still succeeds so the admin page can
// say so instead of erroring, and only a non-empty PUT requires the list for validation.
@@ -1937,6 +2714,16 @@ export function createApp({
.json({ ok: false, code: "unparseable_alog", error: err.message });
}
});
app.get("/api/brew-methods", requireAuth, (_req, res) =>
res.json({
ok: true,
categories: BREW_CATEGORIES,
methods: BREW_METHODS,
}),
);
app.get("/api/openapi.json", requireAuth, (req, res) =>
res.json(buildOpenApiSpec({ origin })),
);
app.get("/api/alog/library", requireAuth, async (_q, res) =>
res.json({ ok: true, files: await listAlogLibrary() }),
);
@@ -1972,6 +2759,11 @@ export function createApp({
// above the same way a direct filename request would.
app.use(express.static(path.join(root, "public"), { index: false }));
app.use("/shared", express.static(path.join(root, "shared")));
// Self-hosted Swagger UI assets for /api-docs (CSP forbids CDN scripts).
app.use(
"/swagger",
express.static(createRequire(import.meta.url).resolve("swagger-ui-dist/swagger-ui.css").replace(/swagger-ui\.css$/, "")),
);
app.use((err, _req, res, _next) => {
// Malformed JSON / an oversized body are client errors (from express.json()'s parser),
// not server faults — respect the status it already picked instead of masking every