Add brewing section, plan chat, roaster learning, API tokens, Swagger docs, and full backup
Test and deploy / test-and-deploy (push) Successful in 1m6s
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:
co-authored by
Claude Fable 5
parent
5efaeb63c9
commit
38c7d01e03
+802
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user