Test and deploy / test-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Fable 5 <[email protected]>
3313 lines
110 KiB
JavaScript
3313 lines
110 KiB
JavaScript
import express from "express";
|
||
import path from "node:path";
|
||
import crypto from "node:crypto";
|
||
import bcrypt from "bcryptjs";
|
||
import { fetchPageText } from "./fetch-page.js";
|
||
import { runPrefill } from "./prefill.js";
|
||
import { buildUpdatedAlog, 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";
|
||
|
||
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
||
const token = () => crypto.randomBytes(32).toString("base64url");
|
||
const ADMIN_EMAIL = "[email protected]";
|
||
const UUID_RE =
|
||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
const emailOf = (value) =>
|
||
String(value || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
const PASSWORD_OK = (value) =>
|
||
typeof value === "string" && value.length >= 12 && value.length <= 256;
|
||
// Compared against when no user row exists, so a non-existent email costs the same bcrypt work
|
||
// as a real one and login can't be used to enumerate accounts by response timing.
|
||
const DUMMY_PASSWORD_HASH = bcrypt.hashSync("no-such-account-password", 12);
|
||
// Pages the browser reaches only through the routes below — never served directly by name,
|
||
// so anonymous-vs-authenticated redirects and no-store headers can never be bypassed.
|
||
const PUBLIC_SHELL_FILES = new Set([
|
||
"/index.html",
|
||
"/admin.html",
|
||
"/account.html",
|
||
"/landing.html",
|
||
"/login.html",
|
||
"/signup.html",
|
||
"/forgot.html",
|
||
"/reset.html",
|
||
"/setup.html",
|
||
"/inventory.html",
|
||
"/cupping.html",
|
||
"/roasts.html",
|
||
"/beans.html",
|
||
"/brews.html",
|
||
"/gear.html",
|
||
"/roasters.html",
|
||
"/academy.html",
|
||
"/api-docs.html",
|
||
]);
|
||
|
||
/** Creates the HTTP app separately from listening, so tests can use an isolated database. */
|
||
export function createApp({
|
||
db,
|
||
root,
|
||
env = process.env,
|
||
// 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";
|
||
const cookieSecure = env.COOKIE_SECURE
|
||
? env.COOKIE_SECURE === "true"
|
||
: production;
|
||
const origin = env.APP_ORIGIN || (production ? "https://roast.srmr.xyz" : "");
|
||
const mailFrom = env.SMTP_FROM || "Roast Planner <[email protected]>";
|
||
// Password-reset links when no SMTP is configured: kept only in memory (never persisted),
|
||
// so an admin can hand a user their link without the raw token ever touching the database.
|
||
const pendingResets = new Map(); // token_hash -> { email, url, expiresAt }
|
||
const buckets = new Map();
|
||
const MAX_RATE_BUCKETS = 10_000;
|
||
const rateLimit = (name, max, windowMs) => {
|
||
if (
|
||
!Number.isInteger(max) ||
|
||
max < 1 ||
|
||
max > 1_000 ||
|
||
!Number.isInteger(windowMs) ||
|
||
windowMs < 1_000 ||
|
||
windowMs > 3_600_000
|
||
)
|
||
throw new Error("Invalid rate-limit configuration");
|
||
return (req, res, next) => {
|
||
const key = `${name}:${req.ip}`;
|
||
const now = Date.now();
|
||
for (const [bucketKey, bucket] of buckets) {
|
||
if (bucket.reset <= now) buckets.delete(bucketKey);
|
||
}
|
||
if (buckets.size >= MAX_RATE_BUCKETS && !buckets.has(key))
|
||
return res.status(429).json({ ok: false, code: "rate_limited" });
|
||
const bucket = buckets.get(key) || { count: 0, reset: now + windowMs };
|
||
bucket.count++;
|
||
buckets.set(key, bucket);
|
||
res.set("RateLimit-Limit", String(max));
|
||
res.set("RateLimit-Reset", String(Math.ceil(bucket.reset / 1_000)));
|
||
if (bucket.count > max)
|
||
return res.status(429).json({ ok: false, code: "rate_limited" });
|
||
next();
|
||
};
|
||
};
|
||
// Per-email login lockout: independent of the IP-keyed rateLimit above, so many accounts
|
||
// sharing one proxy IP cannot lock each other out, and one attacker cannot brute-force a
|
||
// single account from many IPs either.
|
||
const loginFailures = new Map();
|
||
const LOGIN_MAX_FAILURES = 10;
|
||
const LOGIN_LOCK_WINDOW = 15 * 60_000;
|
||
const MAX_LOGIN_TRACKED = 10_000;
|
||
function emailLocked(email) {
|
||
const entry = loginFailures.get(email);
|
||
return !!entry && entry.reset > Date.now() && entry.count >= LOGIN_MAX_FAILURES;
|
||
}
|
||
function recordLoginFailure(email) {
|
||
const now = Date.now();
|
||
for (const [key, entry] of loginFailures) {
|
||
if (entry.reset <= now) loginFailures.delete(key);
|
||
}
|
||
if (loginFailures.size >= MAX_LOGIN_TRACKED && !loginFailures.has(email))
|
||
return;
|
||
let entry = loginFailures.get(email);
|
||
if (!entry || entry.reset <= now)
|
||
entry = { count: 0, reset: now + LOGIN_LOCK_WINDOW };
|
||
entry.count++;
|
||
loginFailures.set(email, entry);
|
||
}
|
||
const clearLoginFailures = (email) => loginFailures.delete(email);
|
||
|
||
app.disable("x-powered-by");
|
||
// Do not accept client-supplied forwarding headers unless the deployment explicitly
|
||
// identifies its proxy. A numeric hop count is unsafe when the topology changes.
|
||
app.set("trust proxy", env.TRUST_PROXY || false);
|
||
app.use((req, res, next) => {
|
||
if (
|
||
req.path.startsWith("/api/") ||
|
||
req.path === "/app" ||
|
||
req.path === "/admin" ||
|
||
req.path === "/account" ||
|
||
req.path === "/inventory" ||
|
||
req.path === "/cupping" ||
|
||
req.path === "/roasts" ||
|
||
req.path === "/beans" ||
|
||
req.path === "/brews" ||
|
||
req.path === "/gear" ||
|
||
req.path === "/roasters" ||
|
||
req.path === "/academy" ||
|
||
req.path === "/api-docs"
|
||
)
|
||
res.set("Cache-Control", "no-store, private");
|
||
res.set({
|
||
"X-Content-Type-Options": "nosniff",
|
||
"X-Frame-Options": "DENY",
|
||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||
"Cross-Origin-Opener-Policy": "same-origin",
|
||
"Content-Security-Policy":
|
||
"default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
|
||
});
|
||
next();
|
||
});
|
||
// Finished-roast uploads carry a whole Artisan .alog (full telemetry arrays) inside a JSON
|
||
// 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" });
|
||
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.
|
||
app.use((req, _res, next) => {
|
||
req.body ??= {};
|
||
next();
|
||
});
|
||
const cookie = (req, name) =>
|
||
Object.fromEntries(
|
||
(req.headers.cookie || "")
|
||
.split(";")
|
||
.map((x) => x.trim().split("="))
|
||
.filter((x) => x[0]),
|
||
)[name];
|
||
const setSessionCookie = (res, value, maxAge, csrfToken = "") => {
|
||
res.cookie("rp_session", value, {
|
||
httpOnly: true,
|
||
secure: cookieSecure,
|
||
sameSite: "lax",
|
||
path: "/",
|
||
maxAge,
|
||
});
|
||
res.cookie("rp_csrf", csrfToken, {
|
||
httpOnly: false,
|
||
secure: cookieSecure,
|
||
sameSite: "lax",
|
||
path: "/",
|
||
maxAge,
|
||
});
|
||
};
|
||
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(
|
||
"SELECT s.csrf_hash,u.id,u.email,u.role,u.created_at,(u.avatar IS NOT NULL) AS has_avatar FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now() AND u.disabled_at IS NULL",
|
||
[hash(raw)],
|
||
);
|
||
const row = r.rows[0];
|
||
if (!row) return null;
|
||
// Sliding expiry + last-seen, throttled to once/hour so an active user is never logged
|
||
// out mid-session; best-effort and never blocks the request.
|
||
db.query(
|
||
"UPDATE sessions SET expires_at=now()+interval '14 days', last_seen_at=now() WHERE token_hash=$1 AND (last_seen_at IS NULL OR last_seen_at<now()-interval '1 hour')",
|
||
[hash(raw)],
|
||
).catch(() => {});
|
||
return row;
|
||
}
|
||
async function requireAuth(req, res, next) {
|
||
try {
|
||
req.user = await session(req);
|
||
if (!req.user)
|
||
return res.status(401).json({ ok: false, code: "unauthorized" });
|
||
next();
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
}
|
||
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");
|
||
if (
|
||
!value ||
|
||
value !== cookie(req, "rp_csrf") ||
|
||
!req.user ||
|
||
!crypto.timingSafeEqual(
|
||
Buffer.from(hash(value)),
|
||
Buffer.from(req.user.csrf_hash),
|
||
)
|
||
)
|
||
return res.status(403).json({ ok: false, code: "csrf_failed" });
|
||
next();
|
||
};
|
||
const admin = (req, res, next) =>
|
||
req.user.role === "admin"
|
||
? next()
|
||
: res.status(403).json({ ok: false, code: "forbidden" });
|
||
const requireUuidParam = (name) => (req, res, next) =>
|
||
UUID_RE.test(req.params[name])
|
||
? next()
|
||
: res.status(404).json({ ok: false, code: "not_found" });
|
||
const isSelf = (req) => req.params.id === req.user.id;
|
||
const isBootstrapAdmin = async (id) => {
|
||
const r = await db.query("SELECT 1 FROM users WHERE id=$1 AND email=$2", [
|
||
id,
|
||
ADMIN_EMAIL,
|
||
]);
|
||
return !!r.rowCount;
|
||
};
|
||
async function audit(actorUserId, action, target = null) {
|
||
try {
|
||
await db.query(
|
||
"INSERT INTO audit_events(actor_user_id,action,target) VALUES($1,$2,$3)",
|
||
[actorUserId, action, target],
|
||
);
|
||
} catch (e) {
|
||
console.error("audit_log_failed", action, e);
|
||
}
|
||
}
|
||
/** Runs `fn(client)` inside BEGIN/COMMIT on a single checked-out connection — required for
|
||
* genuine atomicity (pool.query() calls are not guaranteed to share a connection, so a bare
|
||
* sequence of db.query("BEGIN")/db.query(...)/db.query("COMMIT") does not actually protect
|
||
* against a crash or error between statements the way it looks like it does). */
|
||
async function withTransaction(fn) {
|
||
const client = await db.connect();
|
||
try {
|
||
await client.query("BEGIN");
|
||
const result = await fn(client);
|
||
await client.query("COMMIT");
|
||
return result;
|
||
} catch (e) {
|
||
await client.query("ROLLBACK").catch(() => {});
|
||
throw e;
|
||
} finally {
|
||
client.release();
|
||
}
|
||
}
|
||
const createSession = async (user, req) => {
|
||
const raw = token(),
|
||
csrfToken = token();
|
||
await db.query("DELETE FROM sessions WHERE expires_at < now()").catch(() => {});
|
||
await db.query(
|
||
"INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at,user_agent,ip,last_seen_at) VALUES($1,$2,$3,now()+interval '14 days',$4,$5,now())",
|
||
[
|
||
hash(raw),
|
||
user.id,
|
||
hash(csrfToken),
|
||
req?.get("user-agent")?.slice(0, 256) || null,
|
||
req?.ip || null,
|
||
],
|
||
);
|
||
return { raw, csrfToken };
|
||
};
|
||
|
||
// ─── Public shell pages ────────────────────────────────────────────────
|
||
app.get("/", async (req, res, next) => {
|
||
try {
|
||
// Returning users should not be left on the marketing page after signing in.
|
||
if (await session(req)) return res.status(302).location("/app").end();
|
||
res.set("Cache-Control", "no-store, private");
|
||
res.sendFile(path.join(root, "public", "landing.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
for (const route of ["/login", "/signup", "/forgot"]) {
|
||
app.get(route, async (req, res, next) => {
|
||
try {
|
||
if (await session(req)) return res.status(302).location("/app").end();
|
||
res.set("Cache-Control", "no-store, private");
|
||
res.sendFile(path.join(root, "public", `${route.slice(1)}.html`));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
}
|
||
app.get("/reset", async (req, res, next) => {
|
||
try {
|
||
if (await session(req)) return res.status(302).location("/app").end();
|
||
res.set("Cache-Control", "no-store, private");
|
||
res.sendFile(path.join(root, "public", "reset.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
app.get("/setup", async (req, res, next) => {
|
||
try {
|
||
if (await session(req)) return res.status(302).location("/app").end();
|
||
const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [
|
||
ADMIN_EMAIL,
|
||
]);
|
||
if (exists.rowCount) return res.status(302).location("/login").end();
|
||
res.set("Cache-Control", "no-store, private");
|
||
res.sendFile(path.join(root, "public", "setup.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
// These three serve HTML to a browser navigation, so on failure they redirect to another
|
||
// page rather than the requireAuth/admin API middleware's JSON 401/403 — a signed-out visit
|
||
// or an expired session must land back on /login, not a bare JSON error page.
|
||
app.get("/app", async (req, res, next) => {
|
||
try {
|
||
const user = await session(req);
|
||
if (!user) return res.redirect("/login");
|
||
res.sendFile(path.join(root, "public", "index.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
app.get("/account", async (req, res, next) => {
|
||
try {
|
||
const user = await session(req);
|
||
if (!user) return res.redirect("/login");
|
||
res.sendFile(path.join(root, "public", "account.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
app.get("/inventory", async (req, res, next) => {
|
||
try {
|
||
const user = await session(req);
|
||
if (!user) return res.redirect("/login");
|
||
res.sendFile(path.join(root, "public", "inventory.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
app.get("/cupping", async (req, res, next) => {
|
||
try {
|
||
const user = await session(req);
|
||
if (!user) return res.redirect("/login");
|
||
res.sendFile(path.join(root, "public", "cupping.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
app.get("/roasts", async (req, res, next) => {
|
||
try {
|
||
const user = await session(req);
|
||
if (!user) return res.redirect("/login");
|
||
res.sendFile(path.join(root, "public", "roasts.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
for (const [route, file] of [
|
||
["/beans", "beans.html"],
|
||
["/brews", "brews.html"],
|
||
["/gear", "gear.html"],
|
||
["/roasters", "roasters.html"],
|
||
["/academy", "academy.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);
|
||
if (!user) return res.redirect("/login");
|
||
if (user.role !== "admin") return res.redirect("/app");
|
||
res.sendFile(path.join(root, "public", "admin.html"));
|
||
} catch (error) {
|
||
next(error);
|
||
}
|
||
});
|
||
|
||
// ─── Auth ──────────────────────────────────────────────────────────────
|
||
app.get("/api/auth/signup-enabled", async (_req, res, next) => {
|
||
try {
|
||
const setting = await db.query(
|
||
"SELECT value FROM app_settings WHERE key='signup_enabled'",
|
||
);
|
||
res.json({ ok: true, enabled: setting.rows[0]?.value === "true" });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.post(
|
||
"/api/auth/signup",
|
||
rateLimit("signup", 8, 60_000),
|
||
async (req, res, next) => {
|
||
try {
|
||
const email = emailOf(req.body.email),
|
||
password = req.body.password;
|
||
if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password))
|
||
return res.status(400).json({
|
||
ok: false,
|
||
code: "invalid_credentials",
|
||
error:
|
||
"Use a valid email and a password of at least 12 characters.",
|
||
});
|
||
// Reserved for the one-time /api/auth/bootstrap flow — never claimable by regular
|
||
// signup, or a fresh deployment's first visitor could permanently lock out the
|
||
// real administrator before they ever get to set it up.
|
||
if (email === ADMIN_EMAIL)
|
||
return res.status(409).json({ ok: false, code: "email_exists" });
|
||
const setting = await db.query(
|
||
"SELECT value FROM app_settings WHERE key='signup_enabled'",
|
||
);
|
||
if (setting.rows[0]?.value !== "true")
|
||
return res.status(403).json({ ok: false, code: "signup_disabled" });
|
||
const password_hash = await bcrypt.hash(password, 12);
|
||
const user = (
|
||
await db.query(
|
||
"INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role",
|
||
[email, password_hash],
|
||
)
|
||
).rows[0];
|
||
const s = await createSession(user, req);
|
||
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
|
||
await audit(user.id, "signup");
|
||
res.status(201).json({
|
||
ok: true,
|
||
user: { email: user.email, role: user.role },
|
||
csrfToken: s.csrfToken,
|
||
});
|
||
} catch (e) {
|
||
if (e.code === "23505")
|
||
return res.status(409).json({ ok: false, code: "email_exists" });
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.post(
|
||
"/api/auth/bootstrap",
|
||
rateLimit("bootstrap", 4, 60_000),
|
||
async (req, res, next) => {
|
||
try {
|
||
const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [
|
||
ADMIN_EMAIL,
|
||
]);
|
||
// Once the administrator exists, the deployment no longer needs to retain
|
||
// the bootstrap secret. Do not reveal whether a supplied token was valid.
|
||
if (exists.rowCount)
|
||
return res.status(409).json({ ok: false, code: "bootstrap_used" });
|
||
const bootstrap = String(req.body.setupToken || "");
|
||
if (!env.BOOTSTRAP_SETUP_TOKEN)
|
||
return res
|
||
.status(503)
|
||
.json({ ok: false, code: "bootstrap_unavailable" });
|
||
if (
|
||
bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length ||
|
||
!crypto.timingSafeEqual(
|
||
Buffer.from(bootstrap),
|
||
Buffer.from(env.BOOTSTRAP_SETUP_TOKEN),
|
||
)
|
||
)
|
||
return res
|
||
.status(403)
|
||
.json({ ok: false, code: "invalid_setup_token" });
|
||
if (
|
||
emailOf(req.body.email) !== ADMIN_EMAIL ||
|
||
!PASSWORD_OK(req.body.password)
|
||
)
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "invalid_credentials" });
|
||
const user = (
|
||
await db.query(
|
||
"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role",
|
||
[ADMIN_EMAIL, await bcrypt.hash(req.body.password, 12)],
|
||
)
|
||
).rows[0];
|
||
const s = await createSession(user, req);
|
||
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
|
||
await audit(user.id, "bootstrap_admin");
|
||
res.status(201).json({
|
||
ok: true,
|
||
user: { email: user.email, role: user.role },
|
||
csrfToken: s.csrfToken,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.post(
|
||
"/api/auth/login",
|
||
rateLimit("login", 10, 60_000),
|
||
async (req, res, next) => {
|
||
try {
|
||
const email = emailOf(req.body.email);
|
||
const user = (
|
||
await db.query(
|
||
"SELECT id,email,role,password_hash,disabled_at FROM users WHERE email=$1",
|
||
[email],
|
||
)
|
||
).rows[0];
|
||
// Always run bcrypt against something, even for an unknown email, so the
|
||
// response time doesn't reveal whether the account exists; and check the lock
|
||
// only *after* a failed compare, so a correct password always gets the owner in
|
||
// even while other guesses have it locked — the lock throttles guessing, it must
|
||
// never let an attacker who merely knows the address lock the owner out.
|
||
const passwordOk = await bcrypt.compare(
|
||
String(req.body.password || ""),
|
||
user?.password_hash || DUMMY_PASSWORD_HASH,
|
||
);
|
||
if (!user || user.disabled_at || !passwordOk) {
|
||
if (emailLocked(email))
|
||
return res
|
||
.status(429)
|
||
.json({ ok: false, code: "too_many_attempts" });
|
||
recordLoginFailure(email);
|
||
return res
|
||
.status(401)
|
||
.json({ ok: false, code: "invalid_credentials" });
|
||
}
|
||
clearLoginFailures(email);
|
||
const s = await createSession(user, req);
|
||
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
|
||
await audit(user.id, "login");
|
||
res.json({
|
||
ok: true,
|
||
user: { email: user.email, role: user.role },
|
||
csrfToken: s.csrfToken,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.get("/api/auth/me", requireAuth, (req, res) =>
|
||
res.json({
|
||
ok: true,
|
||
user: {
|
||
id: req.user.id,
|
||
email: req.user.email,
|
||
role: req.user.role,
|
||
createdAt: req.user.created_at,
|
||
hasAvatar: Boolean(req.user.has_avatar),
|
||
},
|
||
}),
|
||
);
|
||
// Logout deliberately does not require CSRF: the worst a forged cross-site request can do
|
||
// is log the visitor out, and requiring CSRF here can strand a user with a stale/missing
|
||
// csrf cookie in a state where they can neither use the app nor sign out of it.
|
||
app.post("/api/auth/logout", requireAuth, async (req, res, next) => {
|
||
try {
|
||
await db.query("DELETE FROM sessions WHERE token_hash=$1", [
|
||
hash(cookie(req, "rp_session")),
|
||
]);
|
||
setSessionCookie(res, "", 0, "");
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.post(
|
||
"/api/auth/forgot",
|
||
rateLimit("forgot", 5, 15 * 60_000),
|
||
async (req, res, next) => {
|
||
try {
|
||
const email = emailOf(req.body.email);
|
||
const user = (
|
||
await db.query("SELECT id,email,role FROM users WHERE email=$1", [
|
||
email,
|
||
])
|
||
).rows[0];
|
||
// Always answer the same way regardless of whether the account exists.
|
||
if (!user) return res.json({ ok: true });
|
||
await db.query(
|
||
"DELETE FROM password_reset_tokens WHERE user_id=$1",
|
||
[user.id],
|
||
);
|
||
const resetToken = token();
|
||
await db.query(
|
||
"INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES($1,$2,now()+interval '1 hour')",
|
||
[hash(resetToken), user.id],
|
||
);
|
||
const resetUrl = `${origin}/reset?token=${resetToken}`;
|
||
const sent = await sendMail({
|
||
smtpUrl: env.SMTP_URL,
|
||
from: mailFrom,
|
||
to: user.email,
|
||
subject: "Reset your Roast Planner password",
|
||
text: `Reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
|
||
}).catch((e) => {
|
||
console.error("password_reset_email_failed", e);
|
||
return false;
|
||
});
|
||
if (!sent) {
|
||
console.log(`[password reset] ${user.email}: ${resetUrl}`);
|
||
// Admin-account resets are deliberately kept out of the shared admin panel:
|
||
// any other admin could otherwise read the link and take over that account
|
||
// (including the bootstrap admin, which every other guard protects).
|
||
// Recovering an admin without SMTP configured requires server console access.
|
||
if (user.role !== "admin")
|
||
pendingResets.set(hash(resetToken), {
|
||
email: user.email,
|
||
url: resetUrl,
|
||
expiresAt: Date.now() + 60 * 60_000,
|
||
});
|
||
}
|
||
await audit(user.id, "password_reset_requested");
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.post(
|
||
"/api/auth/reset",
|
||
rateLimit("reset", 10, 15 * 60_000),
|
||
async (req, res, next) => {
|
||
try {
|
||
const rawToken = String(req.body.token || "");
|
||
const password = req.body.password;
|
||
if (!rawToken || !PASSWORD_OK(password))
|
||
return res.status(400).json({ ok: false, code: "invalid_request" });
|
||
const tokenHash = hash(rawToken);
|
||
const row = (
|
||
await db.query(
|
||
"SELECT user_id FROM password_reset_tokens WHERE token_hash=$1 AND expires_at>now()",
|
||
[tokenHash],
|
||
)
|
||
).rows[0];
|
||
if (!row)
|
||
return res.status(400).json({ ok: false, code: "invalid_token" });
|
||
const password_hash = await bcrypt.hash(password, 12);
|
||
await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [
|
||
password_hash,
|
||
row.user_id,
|
||
]);
|
||
await db.query("DELETE FROM password_reset_tokens WHERE user_id=$1", [
|
||
row.user_id,
|
||
]);
|
||
await db.query("DELETE FROM sessions WHERE user_id=$1", [
|
||
row.user_id,
|
||
]);
|
||
pendingResets.delete(tokenHash);
|
||
const user = (
|
||
await db.query(
|
||
"SELECT id,email,role,disabled_at FROM users WHERE id=$1",
|
||
[row.user_id],
|
||
)
|
||
).rows[0];
|
||
await audit(user.id, "password_reset_completed");
|
||
// The password is changed either way, but a disabled account still can't sign
|
||
// in — say so plainly instead of handing back a session the very next request
|
||
// will reject with a bare 401.
|
||
if (user.disabled_at)
|
||
return res
|
||
.status(403)
|
||
.json({ ok: false, code: "account_disabled" });
|
||
const s = await createSession(user, req);
|
||
setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
|
||
res.json({
|
||
ok: true,
|
||
user: { email: user.email, role: user.role },
|
||
csrfToken: s.csrfToken,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
|
||
// ─── Plans ─────────────────────────────────────────────────────────────
|
||
app.get("/api/plans", requireAuth, async (req, res, next) => {
|
||
try {
|
||
res.json({
|
||
ok: true,
|
||
plans: (
|
||
await db.query(
|
||
"SELECT id,plan,name,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",
|
||
[req.user.id],
|
||
)
|
||
).rows,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.post("/api/plans", 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" });
|
||
const name = String(req.body.name ?? "").trim().slice(0, 120);
|
||
const p = (
|
||
await db.query(
|
||
"INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3) RETURNING id,plan,name,created_at,updated_at",
|
||
[req.user.id, req.body.plan, name],
|
||
)
|
||
).rows[0];
|
||
res.status(201).json({ ok: true, plan: p });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.put(
|
||
"/api/plans/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const hasPlan = req.body.plan !== undefined;
|
||
const hasName = req.body.name !== undefined;
|
||
if (hasPlan && (!req.body.plan || typeof req.body.plan !== "object"))
|
||
return res.status(400).json({ ok: false, code: "bad_plan" });
|
||
if (!hasPlan && !hasName)
|
||
return res.status(400).json({ ok: false, code: "bad_plan" });
|
||
const existing = hasPlan && hasName
|
||
? null
|
||
: (
|
||
await db.query(
|
||
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2",
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
const plan = hasPlan ? req.body.plan : existing?.plan;
|
||
const name = hasName
|
||
? String(req.body.name ?? "").trim().slice(0, 120)
|
||
: (existing?.name ?? "");
|
||
const r = await db.query(
|
||
"UPDATE roast_plans SET plan=$1,name=$2,updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING id,plan,name,updated_at",
|
||
[plan, name, req.params.id, req.user.id],
|
||
);
|
||
if (!r.rowCount)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.json({ ok: true, plan: r.rows[0] });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete(
|
||
"/api/plans/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const r = await db.query(
|
||
"DELETE FROM roast_plans 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);
|
||
}
|
||
},
|
||
);
|
||
|
||
// Learned per-user machine profile (pace factor + temperature bands) from this account's own
|
||
// completed roasts — see shared/learn.js. Deliberately reads every one of the user's plans
|
||
// rather than paginating: a personal roast log tops out at low hundreds of rows, and this is
|
||
// the only place that number gets reduced, so there's nothing to cache incrementally against.
|
||
app.get("/api/machine-profile", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const rows = (
|
||
await db.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id])
|
||
).rows;
|
||
res.json({ ok: true, profile: computeMachineProfile(rows.map((r) => r.plan)) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
|
||
// ─── Inventory ─────────────────────────────────────────────────────────
|
||
// Strict on purpose: JSON.stringify silently turns a client-side NaN (e.g. a non-numeric
|
||
// weight/cost typed into a field that isn't really constrained to digits) into null, which
|
||
// is indistinguishable from "the user explicitly cleared this field" unless the server
|
||
// requires the *type* to be number, not just coerces whatever arrives with Number(). A loose
|
||
// Number(value) here previously let a garbage weight silently become 0 and corrupt
|
||
// remaining_weight_g.
|
||
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
||
function parseOptionalNumber(value, fieldName) {
|
||
if (value === undefined || value === null || value === "") return { value: null };
|
||
if (!isFiniteNumber(value))
|
||
throw new Error(`${fieldName} must be a number`);
|
||
return { value };
|
||
}
|
||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||
function parseOptionalDate(value) {
|
||
if (value === undefined || value === null || value === "") return { value: null };
|
||
if (typeof value !== "string" || !DATE_RE.test(value) || Number.isNaN(Date.parse(value)))
|
||
throw new Error("purchaseDate must be a YYYY-MM-DD date");
|
||
return { value };
|
||
}
|
||
const toLotRow = (row) => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
origin: row.origin,
|
||
variety: row.variety,
|
||
process: row.process,
|
||
producer: row.producer,
|
||
purchaseDate: row.purchase_date,
|
||
initialWeightG: Number(row.initial_weight_g),
|
||
remainingWeightG: Number(row.remaining_weight_g),
|
||
costTotal: row.cost_total == null ? null : Number(row.cost_total),
|
||
moisturePct: row.moisture_pct == null ? null : Number(row.moisture_pct),
|
||
densityGL: row.density_g_l == null ? null : Number(row.density_g_l),
|
||
notes: row.notes,
|
||
archived: row.archived,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
});
|
||
app.get("/api/inventory", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const rows = (
|
||
await db.query(
|
||
"SELECT * FROM green_bean_lots WHERE user_id=$1 ORDER BY archived ASC, purchase_date DESC NULLS LAST, created_at DESC",
|
||
[req.user.id],
|
||
)
|
||
).rows;
|
||
res.json({ ok: true, lots: rows.map(toLotRow) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.post("/api/inventory", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
const origin = String(req.body.origin || "").trim();
|
||
if (!origin || !isFiniteNumber(req.body.initialWeightG) || req.body.initialWeightG < 0)
|
||
return res.status(400).json({ ok: false, code: "bad_lot" });
|
||
let costTotal, moisturePct, densityGL, purchaseDate;
|
||
try {
|
||
costTotal = parseOptionalNumber(req.body.costTotal, "costTotal");
|
||
moisturePct = parseOptionalNumber(req.body.moisturePct, "moisturePct");
|
||
densityGL = parseOptionalNumber(req.body.densityGL, "densityGL");
|
||
purchaseDate = parseOptionalDate(req.body.purchaseDate);
|
||
} catch (e) {
|
||
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
|
||
}
|
||
const name = String(req.body.name ?? "").trim().slice(0, 120);
|
||
const row = (
|
||
await db.query(
|
||
`INSERT INTO green_bean_lots
|
||
(user_id,origin,name,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes)
|
||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$8,$9,$10,$11,$12) RETURNING *`,
|
||
[
|
||
req.user.id,
|
||
origin,
|
||
name,
|
||
String(req.body.variety || ""),
|
||
String(req.body.process || ""),
|
||
String(req.body.producer || ""),
|
||
purchaseDate.value,
|
||
req.body.initialWeightG,
|
||
costTotal.value,
|
||
moisturePct.value,
|
||
densityGL.value,
|
||
String(req.body.notes || ""),
|
||
],
|
||
)
|
||
).rows[0];
|
||
res.status(201).json({ ok: true, lot: toLotRow(row) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get(
|
||
"/api/inventory/:id",
|
||
requireAuth,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const lot = (
|
||
await db.query(
|
||
"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
|
||
const log = (
|
||
await db.query(
|
||
`SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title
|
||
FROM bean_consumption c LEFT JOIN roast_plans p ON p.id=c.roast_plan_id AND p.user_id=c.user_id
|
||
WHERE c.lot_id=$1 ORDER BY c.created_at DESC`,
|
||
[req.params.id],
|
||
)
|
||
).rows;
|
||
res.json({
|
||
ok: true,
|
||
lot: toLotRow(lot),
|
||
log: log.map((r) => ({
|
||
id: r.id,
|
||
weightG: Number(r.weight_g),
|
||
roastPlanId: r.roast_plan_id,
|
||
planTitle: r.plan_title,
|
||
createdAt: r.created_at,
|
||
})),
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
// The auto-refine-carry-forward feature: the most recent past roast against this lot whose
|
||
// "One change next batch" note was actually filled in, so the planner can offer it as a
|
||
// starting ± Refine value instead of the user retyping their own prior conclusion. Reads
|
||
// inventory.lotId straight off the plan JSONB — no join needed, and scoping to this user's own
|
||
// roast_plans is what keeps this ownership-safe regardless of whether :id even belongs to them.
|
||
app.get(
|
||
"/api/inventory/:id/last-refine",
|
||
requireAuth,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const lot = (
|
||
await db.query(
|
||
"SELECT id FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
|
||
// Two sources, newest wins: legacy plans that stored the note in the worksheet's
|
||
// afterRoast, and actual roasts (where after-the-roast now lives) linked to a
|
||
// plan against this lot.
|
||
const row = (
|
||
await db.query(
|
||
`SELECT one_change, plan_title, updated_at FROM (
|
||
SELECT plan->'afterRoast'->>'oneChange' AS one_change,
|
||
CASE WHEN COALESCE(name,'')<>'' THEN name ELSE plan->'fields'->>'0.1' END AS plan_title, updated_at
|
||
FROM roast_plans
|
||
WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
|
||
AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
|
||
UNION ALL
|
||
SELECT a.after->>'oneChange' AS one_change,
|
||
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, a.updated_at
|
||
FROM actual_roasts a
|
||
JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
|
||
WHERE a.user_id=$1 AND p.plan->'inventory'->>'lotId'=$2
|
||
AND coalesce(a.after->>'oneChange','')<>''
|
||
) src ORDER BY updated_at DESC LIMIT 1`,
|
||
[req.user.id, req.params.id],
|
||
)
|
||
).rows[0];
|
||
res.json({
|
||
ok: true,
|
||
refine: row
|
||
? { oneChange: row.one_change, planTitle: row.plan_title, atIso: row.updated_at }
|
||
: null,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.put(
|
||
"/api/inventory/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const existing = (
|
||
await db.query(
|
||
"SELECT * FROM green_bean_lots 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 origin =
|
||
b.origin === undefined ? existing.origin : String(b.origin || "").trim();
|
||
if (!origin) return res.status(400).json({ ok: false, code: "bad_lot" });
|
||
if (
|
||
b.initialWeightG !== undefined &&
|
||
(!isFiniteNumber(b.initialWeightG) || b.initialWeightG < 0)
|
||
)
|
||
return res.status(400).json({ ok: false, code: "bad_lot" });
|
||
let costTotal, moisturePct, densityGL, purchaseDate;
|
||
try {
|
||
costTotal =
|
||
b.costTotal === undefined
|
||
? existing.cost_total
|
||
: parseOptionalNumber(b.costTotal, "costTotal").value;
|
||
moisturePct =
|
||
b.moisturePct === undefined
|
||
? existing.moisture_pct
|
||
: parseOptionalNumber(b.moisturePct, "moisturePct").value;
|
||
densityGL =
|
||
b.densityGL === undefined
|
||
? existing.density_g_l
|
||
: parseOptionalNumber(b.densityGL, "densityGL").value;
|
||
purchaseDate =
|
||
b.purchaseDate === undefined
|
||
? existing.purchase_date
|
||
: parseOptionalDate(b.purchaseDate).value;
|
||
} catch (e) {
|
||
return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
|
||
}
|
||
// remainingWeightG/consumptionLog are never read from the request body: remaining
|
||
// only ever moves via consume(), except for the delta below, which is the
|
||
// sanctioned correction path (recount the bag, fix the paperwork). The delta is
|
||
// computed inside the UPDATE itself (against the pre-statement initial_weight_g)
|
||
// rather than from the `existing` row read above, so a concurrent PUT can't lose
|
||
// the other's delta the way a JS-side read-then-write would.
|
||
const name =
|
||
b.name === undefined ? existing.name : String(b.name || "").trim().slice(0, 120);
|
||
const row = (
|
||
await db.query(
|
||
`UPDATE green_bean_lots SET
|
||
origin=$1, name=$2, variety=$3, process=$4, producer=$5, purchase_date=$6,
|
||
remaining_weight_g = remaining_weight_g + COALESCE($7::numeric, initial_weight_g) - initial_weight_g,
|
||
initial_weight_g = COALESCE($7::numeric, initial_weight_g),
|
||
cost_total=$8, moisture_pct=$9, density_g_l=$10, notes=$11, archived=$12,
|
||
updated_at=now()
|
||
WHERE id=$13 AND user_id=$14 RETURNING *`,
|
||
[
|
||
origin,
|
||
name,
|
||
b.variety === undefined ? existing.variety : String(b.variety || ""),
|
||
b.process === undefined ? existing.process : String(b.process || ""),
|
||
b.producer === undefined ? existing.producer : String(b.producer || ""),
|
||
purchaseDate,
|
||
b.initialWeightG === undefined ? null : b.initialWeightG,
|
||
costTotal,
|
||
moisturePct,
|
||
densityGL,
|
||
b.notes === undefined ? existing.notes : String(b.notes || ""),
|
||
b.archived === undefined ? existing.archived : Boolean(b.archived),
|
||
req.params.id,
|
||
req.user.id,
|
||
],
|
||
)
|
||
).rows[0];
|
||
res.json({ ok: true, lot: toLotRow(row) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete(
|
||
"/api/inventory/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const r = await db.query(
|
||
"DELETE FROM green_bean_lots 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);
|
||
}
|
||
},
|
||
);
|
||
app.post(
|
||
"/api/inventory/:id/consume",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const weightG = Number(req.body.weightG);
|
||
if (!Number.isFinite(weightG) || weightG <= 0)
|
||
return res.status(400).json({ ok: false, code: "bad_weight" });
|
||
const lotExists = (
|
||
await db.query(
|
||
"SELECT 1 FROM green_bean_lots WHERE id=$1 AND user_id=$2",
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rowCount;
|
||
if (!lotExists)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
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 ownsPlan = (
|
||
await db.query(
|
||
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
|
||
[req.body.roastPlanId, req.user.id],
|
||
)
|
||
).rowCount;
|
||
if (!ownsPlan)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
roastPlanId = req.body.roastPlanId;
|
||
}
|
||
// The log insert and the balance update must commit together — on separate pool
|
||
// connections a crash or error between them would leave a consumption row logged
|
||
// with no corresponding decrement, and the unique index means that plan could
|
||
// never be retried. See withTransaction's own comment for why plain sequential
|
||
// db.query("BEGIN")/db.query(...) calls do not actually guarantee this.
|
||
let row;
|
||
try {
|
||
row = await withTransaction(async (client) => {
|
||
try {
|
||
await client.query(
|
||
"INSERT INTO bean_consumption(lot_id,user_id,roast_plan_id,weight_g) VALUES($1,$2,$3,$4)",
|
||
[req.params.id, req.user.id, roastPlanId, weightG],
|
||
);
|
||
} catch (e) {
|
||
if (e.code === "23505") {
|
||
const err = new Error("already_consumed");
|
||
err.httpCode = "already_consumed";
|
||
throw err;
|
||
}
|
||
throw e;
|
||
}
|
||
const updated = (
|
||
await client.query(
|
||
"UPDATE green_bean_lots SET remaining_weight_g=remaining_weight_g-$1, updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING *",
|
||
[weightG, req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!updated) {
|
||
// The lot was deleted by a concurrent request between the ownership
|
||
// check above and here — roll back the log insert too.
|
||
const err = new Error("not_found");
|
||
err.httpCode = "not_found";
|
||
throw err;
|
||
}
|
||
return updated;
|
||
});
|
||
} catch (e) {
|
||
if (e.httpCode === "already_consumed")
|
||
return res.status(409).json({ ok: false, code: "already_consumed" });
|
||
if (e.httpCode === "not_found")
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
throw e;
|
||
}
|
||
res.status(201).json({ ok: true, lot: toLotRow(row) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
|
||
// Admin-chosen LLM model ("provider:id"), empty = auto. Read per call so a change takes
|
||
// effect immediately without restarting the app.
|
||
const llmModelSetting = async () =>
|
||
(
|
||
await db.query("SELECT value FROM app_settings WHERE key='llm_model'")
|
||
).rows[0]?.value || "";
|
||
|
||
// ─── Roasters (per-user machines) ──────────────────────────────────────
|
||
// Numeric override keys the user may tweak on a roaster — these replace the corresponding
|
||
// LEARNED values wherever the profile is applied (plan curve fallbacks, pace).
|
||
const OVERRIDE_KEYS = {
|
||
chargeTempC: [0, 400],
|
||
turningPointS: [10, 300],
|
||
turningPointTempC: [0, 400],
|
||
yellowTempC: [0, 400],
|
||
firstCrackTempC: [0, 400],
|
||
dropTempC: [0, 400],
|
||
paceFactor: [0.5, 2],
|
||
};
|
||
function parseOverrides(raw, existing = {}) {
|
||
if (raw === undefined) return { value: existing };
|
||
if (!raw || typeof raw !== "object") return { error: "overrides must be an object" };
|
||
const out = {};
|
||
for (const [key, [lo, hi]] of Object.entries(OVERRIDE_KEYS)) {
|
||
const v = raw[key];
|
||
if (v === undefined || v === null || v === "") continue;
|
||
if (typeof v !== "number" || !Number.isFinite(v) || v < lo || v > hi)
|
||
return { error: `${key} must be a number between ${lo} and ${hi}` };
|
||
out[key] = v;
|
||
}
|
||
return { value: out };
|
||
}
|
||
const toRoasterRow = (row) => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
model: row.model,
|
||
notes: row.notes,
|
||
isDefault: row.is_default,
|
||
overrides: row.overrides ?? {},
|
||
roastCount: row.roast_count == null ? undefined : Number(row.roast_count),
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
});
|
||
app.get("/api/roasters", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const [rows, counts] = await Promise.all([
|
||
db
|
||
.query(
|
||
"SELECT * FROM roasters WHERE user_id=$1 ORDER BY created_at",
|
||
[req.user.id],
|
||
)
|
||
.then((r) => r.rows),
|
||
db
|
||
.query(
|
||
"SELECT roaster_id, COUNT(*) AS roast_count FROM actual_roasts WHERE user_id=$1 AND roaster_id IS NOT NULL GROUP BY roaster_id",
|
||
[req.user.id],
|
||
)
|
||
.then((r) => new Map(r.rows.map((x) => [x.roaster_id, x.roast_count]))),
|
||
]);
|
||
res.json({
|
||
ok: true,
|
||
roasters: rows.map((row) =>
|
||
toRoasterRow({ ...row, roast_count: counts.get(row.id) ?? 0 }),
|
||
),
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.post("/api/roasters", 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_roaster" });
|
||
const overrides = parseOverrides(req.body.overrides, {});
|
||
if (overrides.error)
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "bad_roaster", error: overrides.error });
|
||
// The user's first roaster becomes the default automatically.
|
||
const hasAny = (
|
||
await db.query("SELECT 1 FROM roasters WHERE user_id=$1 LIMIT 1", [req.user.id])
|
||
).rowCount;
|
||
const makeDefault = !hasAny || Boolean(req.body.isDefault);
|
||
if (makeDefault && hasAny)
|
||
await db.query(
|
||
"UPDATE roasters SET is_default=false WHERE user_id=$1",
|
||
[req.user.id],
|
||
);
|
||
const row = (
|
||
await db.query(
|
||
"INSERT INTO roasters(user_id,name,model,notes,is_default,overrides) VALUES($1,$2,$3,$4,$5,$6) RETURNING *",
|
||
[
|
||
req.user.id,
|
||
name,
|
||
String(req.body.model || ""),
|
||
String(req.body.notes || ""),
|
||
makeDefault,
|
||
JSON.stringify(overrides.value),
|
||
],
|
||
)
|
||
).rows[0];
|
||
res.status(201).json({ ok: true, roaster: toRoasterRow(row) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.put(
|
||
"/api/roasters/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const existing = (
|
||
await db.query("SELECT * FROM roasters 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_roaster" });
|
||
const overrides = parseOverrides(b.overrides, existing.overrides ?? {});
|
||
if (overrides.error)
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "bad_roaster", error: overrides.error });
|
||
const makeDefault =
|
||
b.isDefault === undefined ? existing.is_default : Boolean(b.isDefault);
|
||
if (makeDefault && !existing.is_default)
|
||
await db.query(
|
||
"UPDATE roasters SET is_default=false WHERE user_id=$1",
|
||
[req.user.id],
|
||
);
|
||
const row = (
|
||
await db.query(
|
||
"UPDATE roasters SET name=$1, model=$2, notes=$3, is_default=$4, overrides=$5, updated_at=now() WHERE id=$6 AND user_id=$7 RETURNING *",
|
||
[
|
||
name,
|
||
b.model === undefined ? existing.model : String(b.model || ""),
|
||
b.notes === undefined ? existing.notes : String(b.notes || ""),
|
||
makeDefault,
|
||
JSON.stringify(overrides.value),
|
||
req.params.id,
|
||
req.user.id,
|
||
],
|
||
)
|
||
).rows[0];
|
||
res.json({ ok: true, roaster: toRoasterRow(row) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete(
|
||
"/api/roasters/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const r = await db.query(
|
||
"DELETE FROM roasters 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);
|
||
}
|
||
},
|
||
);
|
||
|
||
// Learned roaster behavior aggregated from this user's uploaded .alogs — grounds the plan
|
||
// chat, the roast evaluations, and the planner's suggested curve temps. Scoped to one
|
||
// machine: an explicit roasterId, else the user's default roaster, else all uploads
|
||
// (covers pre-roaster history and users with a single unmanaged machine). The returned
|
||
// medians already have that roaster's user overrides applied; the raw learned values ride
|
||
// along so the report page can show learned-vs-applied.
|
||
async function userRoasterProfile(userId, roasterId = null) {
|
||
let roaster = null;
|
||
if (roasterId) {
|
||
roaster = (
|
||
await db.query("SELECT * FROM roasters WHERE id=$1 AND user_id=$2", [
|
||
roasterId,
|
||
userId,
|
||
])
|
||
).rows[0];
|
||
if (!roaster) return null;
|
||
} else {
|
||
roaster =
|
||
(
|
||
await db.query(
|
||
"SELECT * FROM roasters WHERE user_id=$1 AND is_default=true",
|
||
[userId],
|
||
)
|
||
).rows[0] ?? null;
|
||
}
|
||
const rows = roaster
|
||
? (
|
||
await db.query(
|
||
"SELECT parsed FROM actual_roasts WHERE user_id=$1 AND roaster_id=$2",
|
||
[userId, roaster.id],
|
||
)
|
||
).rows
|
||
: (
|
||
await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId])
|
||
).rows;
|
||
const learned = computeRoasterProfile(rows.map((r) => r.parsed));
|
||
const overrides = roaster?.overrides ?? {};
|
||
const { paceFactor, ...tempOverrides } = overrides;
|
||
return {
|
||
...learned,
|
||
medians: { ...(learned.medians ?? {}), ...tempOverrides },
|
||
learnedMedians: learned.medians ?? null,
|
||
overrides,
|
||
paceFactorOverride: Number.isFinite(paceFactor) ? paceFactor : null,
|
||
roaster: roaster
|
||
? { id: roaster.id, name: roaster.name, isDefault: roaster.is_default }
|
||
: null,
|
||
};
|
||
}
|
||
app.get("/api/roaster-profile", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const roasterId =
|
||
typeof req.query.roaster === "string" && UUID_RE.test(req.query.roaster)
|
||
? req.query.roaster
|
||
: null;
|
||
const profile = await userRoasterProfile(req.user.id, roasterId);
|
||
if (!profile) return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.json({ ok: true, profile });
|
||
} 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 || {};
|
||
const evaluation = row.evaluation || null;
|
||
const base = {
|
||
id: row.id,
|
||
roastPlanId: row.roast_plan_id,
|
||
planTitle: row.plan_title ?? null,
|
||
roasterId: row.roaster_id ?? null,
|
||
roasterName: row.roaster_name ?? null,
|
||
filename: row.filename,
|
||
roast: parsed.roast ?? null,
|
||
derived: parsed.derived ?? null,
|
||
evaluationStatus: row.evaluation_status,
|
||
evaluationError: row.evaluation_error,
|
||
evaluationGrade: evaluation?.grade ?? null,
|
||
evaluationSummary: evaluation?.summary ?? null,
|
||
createdAt: row.created_at,
|
||
};
|
||
return full
|
||
? { ...base, parsed, evaluation, after: row.after ?? {}, plan: row.plan ?? null }
|
||
: base;
|
||
};
|
||
// "After the roast" observations recorded against an actual roast (moved here from the
|
||
// plan worksheet). All free-text/short strings — the numbers among them (weights, DTR)
|
||
// stay strings like the worksheet always stored them.
|
||
const AFTER_FIELDS = [
|
||
"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
|
||
"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
|
||
];
|
||
function sanitizeAfter(raw) {
|
||
if (!raw || typeof raw !== "object") return null;
|
||
const out = {};
|
||
for (const field of AFTER_FIELDS)
|
||
if (raw[field] !== undefined && raw[field] !== null)
|
||
out[field] = String(raw[field]).slice(0, 2_000);
|
||
return out;
|
||
}
|
||
// Fire-and-forget: the upload response never waits on the model (a deep review takes tens of
|
||
// seconds, and uploads arrive in batches); the row starts 'pending' and the client polls.
|
||
// Failure is recorded on the row rather than lost — 'failed' + evaluation_error, and the
|
||
// re-evaluate endpoint below is the retry path (e.g. once a model is configured).
|
||
function startEvaluation(roastId, userId) {
|
||
const run = (async () => {
|
||
const row = (
|
||
await db.query(
|
||
`SELECT a.parsed, a.roaster_id, p.plan FROM actual_roasts a
|
||
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
|
||
WHERE a.id=$1 AND a.user_id=$2`,
|
||
[roastId, userId],
|
||
)
|
||
).rows[0];
|
||
if (!row) return;
|
||
try {
|
||
const evaluation = await evaluateRoast(
|
||
row.parsed,
|
||
row.plan ?? null,
|
||
await llmModelSetting(),
|
||
await userRoasterProfile(userId, row.roaster_id ?? null),
|
||
);
|
||
await db.query(
|
||
"UPDATE actual_roasts SET evaluation=$1, evaluation_status='done', evaluation_error=NULL, updated_at=now() WHERE id=$2",
|
||
[evaluation, roastId],
|
||
);
|
||
} catch (e) {
|
||
await db.query(
|
||
"UPDATE actual_roasts SET evaluation_status='failed', evaluation_error=$1, updated_at=now() WHERE id=$2",
|
||
[`${e.code ?? "error"}: ${e.message}`.slice(0, 500), roastId],
|
||
);
|
||
}
|
||
})().catch((e) => console.error("roast_evaluation_failed", roastId, e));
|
||
return run;
|
||
}
|
||
app.post("/api/roasts", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
const content = req.body.content;
|
||
if (typeof content !== "string" || !content.trim())
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
const filename = String(req.body.filename || "upload.alog").slice(0, 200);
|
||
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 ownsPlan = (
|
||
await db.query(
|
||
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
|
||
[req.body.roastPlanId, req.user.id],
|
||
)
|
||
).rowCount;
|
||
if (!ownsPlan)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
roastPlanId = req.body.roastPlanId;
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = parseAlog(content, filename);
|
||
} catch (err) {
|
||
return res
|
||
.status(422)
|
||
.json({ ok: false, code: "unparseable_alog", error: err.message });
|
||
}
|
||
// Attach to the requested roaster (must be the user's own), else the default one.
|
||
let roasterId = null;
|
||
if (req.body.roasterId) {
|
||
if (!UUID_RE.test(req.body.roasterId))
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
const owns = (
|
||
await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [
|
||
req.body.roasterId,
|
||
req.user.id,
|
||
])
|
||
).rowCount;
|
||
if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
|
||
roasterId = req.body.roasterId;
|
||
} else {
|
||
roasterId =
|
||
(
|
||
await db.query(
|
||
"SELECT id FROM roasters WHERE user_id=$1 AND is_default=true",
|
||
[req.user.id],
|
||
)
|
||
).rows[0]?.id ?? null;
|
||
}
|
||
const row = (
|
||
await db.query(
|
||
`INSERT INTO actual_roasts(user_id,roast_plan_id,roaster_id,filename,original_content,parsed)
|
||
VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||
[req.user.id, roastPlanId, roasterId, filename, content, parsed],
|
||
)
|
||
).rows[0];
|
||
startEvaluation(row.id, req.user.id);
|
||
res.status(201).json({ ok: true, roast: toRoastRow(row) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get("/api/roasts", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const planFilter =
|
||
typeof req.query.plan === "string" && UUID_RE.test(req.query.plan)
|
||
? req.query.plan
|
||
: null;
|
||
const rows = (
|
||
await db.query(
|
||
`SELECT a.id,a.roast_plan_id,a.roaster_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
|
||
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, m.name AS roaster_name
|
||
FROM actual_roasts a
|
||
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
|
||
LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
|
||
WHERE a.user_id=$1 AND ($2::uuid IS NULL OR a.roast_plan_id=$2)
|
||
ORDER BY a.created_at DESC`,
|
||
[req.user.id, planFilter],
|
||
)
|
||
).rows;
|
||
res.json({ ok: true, roasts: rows.map((r) => toRoastRow(r)) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get(
|
||
"/api/roasts/:id",
|
||
requireAuth,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const row = (
|
||
await db.query(
|
||
`SELECT a.*, CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, p.plan, m.name AS roaster_name
|
||
FROM actual_roasts a
|
||
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
|
||
LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
|
||
WHERE a.id=$1 AND a.user_id=$2`,
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.json({ ok: true, roast: toRoastRow(row, { full: true }) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.get(
|
||
"/api/roasts/:id/download",
|
||
requireAuth,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const row = (
|
||
await db.query(
|
||
`SELECT a.filename, a.original_content, a.after, a.evaluation, a.parsed,
|
||
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title, a.roast_plan_id
|
||
FROM actual_roasts a
|
||
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
|
||
WHERE a.id=$1 AND a.user_id=$2`,
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
||
let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
|
||
name = name.replace(/\.alog$/i, "");
|
||
const updated = req.query.variant === "updated";
|
||
let content = row.original_content;
|
||
if (updated) {
|
||
// Supplemental copy: everything this app tracks about the roast written back
|
||
// as valid Artisan fields (Python-literal serialization — Artisan re-imports
|
||
// it with ast.literal_eval). The original stays untouched.
|
||
const after = row.after ?? {};
|
||
const num = (v) => {
|
||
const n = Number.parseFloat(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
const greenIn = num(after.greenIn) ?? row.parsed?.roast?.weightInG ?? null;
|
||
const out = num(after.out) ?? row.parsed?.roast?.weightOutG ?? null;
|
||
const cupping = row.roast_plan_id
|
||
? (
|
||
await db.query(
|
||
"SELECT data,total_score FROM cupping_sessions WHERE roast_plan_id=$1 AND user_id=$2 ORDER BY updated_at DESC LIMIT 1",
|
||
[row.roast_plan_id, req.user.id],
|
||
)
|
||
).rows[0]
|
||
: null;
|
||
const cuppingParts = [
|
||
after.cupNotes,
|
||
cupping ? `Cupping score ${Number(cupping.total_score).toFixed(2)}` : null,
|
||
cupping?.data?.flavor_tags?.length
|
||
? `Flavors: ${cupping.data.flavor_tags.join(", ")}`
|
||
: null,
|
||
after.brewRatio || after.method
|
||
? `Brewed ${[after.method, after.brewRatio].filter(Boolean).join(" ")}`
|
||
: null,
|
||
after.restedDays ? `Rested ${after.restedDays} days` : null,
|
||
].filter(Boolean);
|
||
const roastingParts = [
|
||
after.oneChange ? `One change next batch: ${after.oneChange}` : null,
|
||
after.disproof ? `Would disprove it: ${after.disproof}` : null,
|
||
after.colour ? `Colour ${after.colour}` : null,
|
||
after.actualDtrPct ? `Actual DTR ${after.actualDtrPct}%` : null,
|
||
row.evaluation?.summary ? `LLM review: ${row.evaluation.summary}` : null,
|
||
].filter(Boolean);
|
||
content = buildUpdatedAlog(row.original_content, {
|
||
weight:
|
||
greenIn !== null && out !== null ? [greenIn, out, "g"] : undefined,
|
||
beans: row.plan_title || undefined,
|
||
cuppingnotes: cuppingParts.length ? cuppingParts.join("\n") : undefined,
|
||
roastingnotes: roastingParts.length ? roastingParts.join("\n") : undefined,
|
||
});
|
||
}
|
||
res.set({
|
||
"Content-Type": "application/octet-stream",
|
||
"Content-Disposition": `attachment; filename="${name}${updated ? "-updated" : ""}.alog"`,
|
||
});
|
||
res.send(content);
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
// Mutable parts of an upload: which machine it belongs to, and the after-the-roast
|
||
// observations. The file and its parse are immutable history.
|
||
app.put(
|
||
"/api/roasts/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const sets = [];
|
||
const params = [];
|
||
if (req.body.roasterId !== undefined) {
|
||
let roasterId = null;
|
||
if (req.body.roasterId !== null && req.body.roasterId !== "") {
|
||
if (!UUID_RE.test(req.body.roasterId))
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
const owns = (
|
||
await db.query(
|
||
"SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2",
|
||
[req.body.roasterId, req.user.id],
|
||
)
|
||
).rowCount;
|
||
if (!owns)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
roasterId = req.body.roasterId;
|
||
}
|
||
params.push(roasterId);
|
||
sets.push(`roaster_id=$${params.length}`);
|
||
}
|
||
if (req.body.roastPlanId !== undefined) {
|
||
let roastPlanId = null;
|
||
if (req.body.roastPlanId !== null && 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;
|
||
}
|
||
params.push(roastPlanId);
|
||
sets.push(`roast_plan_id=$${params.length}`);
|
||
}
|
||
if (req.body.after !== undefined) {
|
||
const after = sanitizeAfter(req.body.after);
|
||
if (!after)
|
||
return res.status(400).json({ ok: false, code: "bad_after" });
|
||
params.push(after);
|
||
sets.push(`after=$${params.length}`);
|
||
}
|
||
if (!sets.length)
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
params.push(req.params.id, req.user.id);
|
||
const r = await db.query(
|
||
`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after`,
|
||
params,
|
||
);
|
||
if (!r.rowCount)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.json({
|
||
ok: true,
|
||
roasterId: r.rows[0].roaster_id,
|
||
roastPlanId: r.rows[0].roast_plan_id,
|
||
after: r.rows[0].after,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.post(
|
||
"/api/roasts/:id/evaluate",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const r = await db.query(
|
||
"UPDATE actual_roasts SET evaluation_status='pending', evaluation_error=NULL, updated_at=now() 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" });
|
||
startEvaluation(req.params.id, req.user.id);
|
||
res.json({ ok: true, evaluationStatus: "pending" });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete(
|
||
"/api/roasts/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const r = await db.query(
|
||
"DELETE FROM actual_roasts 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);
|
||
}
|
||
},
|
||
);
|
||
|
||
// ─── 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),
|
||
recipe: row.recipe,
|
||
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"],
|
||
["recipe", "recipe"],
|
||
["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,recipe,tasting_notes,notes)
|
||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) 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.recipe,
|
||
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, recipe=$12, tasting_notes=$13, notes=$14, updated_at=now()
|
||
WHERE id=$15 AND user_id=$16 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.recipe,
|
||
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 || {};
|
||
const base = {
|
||
id: row.id,
|
||
roastPlanId: row.roast_plan_id,
|
||
planTitle: row.plan_title ?? undefined,
|
||
cupCount: data.cup_count,
|
||
totalScore: Number(row.total_score),
|
||
flavorTags: data.flavor_tags || [],
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
};
|
||
if (row.plan_title === undefined) delete base.planTitle;
|
||
return full ? { ...base, data } : base;
|
||
};
|
||
app.get("/api/cupping", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const planFilter =
|
||
typeof req.query.plan === "string" && UUID_RE.test(req.query.plan)
|
||
? req.query.plan
|
||
: null;
|
||
const rows = (
|
||
await db.query(
|
||
`SELECT s.id,s.roast_plan_id,s.data,s.total_score,s.created_at,s.updated_at,
|
||
CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title
|
||
FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
|
||
WHERE s.user_id=$1 AND ($2::uuid IS NULL OR s.roast_plan_id=$2)
|
||
ORDER BY s.updated_at DESC`,
|
||
[req.user.id, planFilter],
|
||
)
|
||
).rows;
|
||
res.json({ ok: true, sessions: rows.map((r) => toSessionRow(r)) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.post("/api/cupping", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
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 ownsPlan = (
|
||
await db.query(
|
||
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
|
||
[req.body.roastPlanId, req.user.id],
|
||
)
|
||
).rowCount;
|
||
if (!ownsPlan)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
roastPlanId = req.body.roastPlanId;
|
||
}
|
||
const cupCount = req.body.cupCount == null ? undefined : Number(req.body.cupCount);
|
||
let data;
|
||
try {
|
||
data = coerceSession(blankSession(Number.isFinite(cupCount) ? cupCount : undefined));
|
||
} catch (e) {
|
||
return res.status(400).json({ ok: false, code: "bad_session", error: e.message });
|
||
}
|
||
const row = (
|
||
await db.query(
|
||
"INSERT INTO cupping_sessions(user_id,roast_plan_id,data,total_score) VALUES($1,$2,$3,0) RETURNING *",
|
||
[req.user.id, roastPlanId, data],
|
||
)
|
||
).rows[0];
|
||
res.status(201).json({ ok: true, session: toSessionRow(row, { full: true }) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get(
|
||
"/api/cupping/:id",
|
||
requireAuth,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const row = (
|
||
await db.query(
|
||
`SELECT s.*, CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS plan_title
|
||
FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
|
||
WHERE s.id=$1 AND s.user_id=$2`,
|
||
[req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.json({ ok: true, session: toSessionRow(row, { full: true }) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.put(
|
||
"/api/cupping/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
if (!req.body.data || typeof req.body.data !== "object")
|
||
return res.status(400).json({ ok: false, code: "bad_session" });
|
||
let coerced;
|
||
try {
|
||
coerced = coerceSession(req.body.data);
|
||
} catch (e) {
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "bad_session", error: e.message });
|
||
}
|
||
const total = computeTotalScore(
|
||
coerced.scores,
|
||
coerced.ticks,
|
||
coerced.taint_cups,
|
||
coerced.fault_cups,
|
||
coerced.cup_count,
|
||
);
|
||
const row = (
|
||
await db.query(
|
||
"UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *",
|
||
[coerced, total, req.params.id, req.user.id],
|
||
)
|
||
).rows[0];
|
||
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.json({ ok: true, session: toSessionRow(row, { full: true }) });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete(
|
||
"/api/cupping/:id",
|
||
requireAuth,
|
||
csrf,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
const r = await db.query(
|
||
"DELETE FROM cupping_sessions 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);
|
||
}
|
||
},
|
||
);
|
||
|
||
// ─── Account ───────────────────────────────────────────────────────────
|
||
app.put("/api/account/email", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
const email = emailOf(req.body.email);
|
||
if (!/^\S+@\S+\.\S+$/.test(email))
|
||
return res.status(400).json({ ok: false, code: "invalid_email" });
|
||
// The bootstrap admin's email is fixed: they can't change away from it (which would
|
||
// strip the protection every other guard relies on), and nobody else can claim it
|
||
// (which would falsely grant that same protection to an unrelated account).
|
||
if (email === ADMIN_EMAIL || (await isBootstrapAdmin(req.user.id)))
|
||
return res.status(403).json({ ok: false, code: "forbidden" });
|
||
const user = (
|
||
await db.query("SELECT password_hash FROM users WHERE id=$1", [
|
||
req.user.id,
|
||
])
|
||
).rows[0];
|
||
if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash)))
|
||
return res.status(401).json({ ok: false, code: "invalid_credentials" });
|
||
await db.query("UPDATE users SET email=$1 WHERE id=$2", [
|
||
email,
|
||
req.user.id,
|
||
]);
|
||
await audit(req.user.id, "email_changed", email);
|
||
res.json({ ok: true, user: { email, role: req.user.role } });
|
||
} catch (e) {
|
||
if (e.code === "23505")
|
||
return res.status(409).json({ ok: false, code: "email_exists" });
|
||
next(e);
|
||
}
|
||
});
|
||
app.put(
|
||
"/api/account/password",
|
||
requireAuth,
|
||
csrf,
|
||
async (req, res, next) => {
|
||
try {
|
||
if (!PASSWORD_OK(req.body.newPassword))
|
||
return res.status(400).json({ ok: false, code: "invalid_password" });
|
||
const user = (
|
||
await db.query("SELECT password_hash FROM users WHERE id=$1", [
|
||
req.user.id,
|
||
])
|
||
).rows[0];
|
||
if (
|
||
!(await bcrypt.compare(
|
||
String(req.body.currentPassword || ""),
|
||
user.password_hash,
|
||
))
|
||
)
|
||
return res
|
||
.status(401)
|
||
.json({ ok: false, code: "invalid_credentials" });
|
||
const password_hash = await bcrypt.hash(req.body.newPassword, 12);
|
||
await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [
|
||
password_hash,
|
||
req.user.id,
|
||
]);
|
||
// Keep only the session that just proved the current password.
|
||
await db.query(
|
||
"DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2",
|
||
[req.user.id, hash(cookie(req, "rp_session"))],
|
||
);
|
||
await audit(req.user.id, "password_changed");
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
// Profile picture: a small data URL, resized client-side before upload. Served back as a
|
||
// real image so <img>/background-image can use it without shipping base64 in every page.
|
||
const AVATAR_RE = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/;
|
||
app.put("/api/account/avatar", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
const dataUrl = req.body.dataUrl;
|
||
if (
|
||
typeof dataUrl !== "string" ||
|
||
dataUrl.length > 300_000 ||
|
||
!AVATAR_RE.test(dataUrl)
|
||
)
|
||
return res.status(400).json({ ok: false, code: "bad_avatar" });
|
||
await db.query("UPDATE users SET avatar=$1 WHERE id=$2", [
|
||
dataUrl,
|
||
req.user.id,
|
||
]);
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.delete("/api/account/avatar", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
await db.query("UPDATE users SET avatar=NULL WHERE id=$1", [req.user.id]);
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get("/api/account/avatar", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const avatar = (
|
||
await db.query("SELECT avatar FROM users WHERE id=$1", [req.user.id])
|
||
).rows[0]?.avatar;
|
||
const match = avatar ? avatar.match(AVATAR_RE) : null;
|
||
if (!match) return res.status(404).json({ ok: false, code: "not_found" });
|
||
res.set({
|
||
"Content-Type": `image/${match[1]}`,
|
||
"Cache-Control": "private, max-age=300",
|
||
});
|
||
res.send(Buffer.from(match[2], "base64"));
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get("/api/account/sessions", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const currentHash = hash(cookie(req, "rp_session"));
|
||
const rows = (
|
||
await db.query(
|
||
"SELECT token_hash,user_agent,ip,created_at,last_seen_at,expires_at FROM sessions WHERE user_id=$1 ORDER BY COALESCE(last_seen_at,created_at) DESC",
|
||
[req.user.id],
|
||
)
|
||
).rows;
|
||
res.json({
|
||
ok: true,
|
||
sessions: rows.map((row) => ({
|
||
id: row.token_hash.slice(0, 16),
|
||
userAgent: row.user_agent,
|
||
ip: row.ip,
|
||
createdAt: row.created_at,
|
||
lastSeenAt: row.last_seen_at,
|
||
expiresAt: row.expires_at,
|
||
current: row.token_hash === currentHash,
|
||
})),
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.delete(
|
||
"/api/account/sessions/: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 sessions 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" });
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.post(
|
||
"/api/account/sessions/revoke-others",
|
||
requireAuth,
|
||
csrf,
|
||
async (req, res, next) => {
|
||
try {
|
||
await db.query(
|
||
"DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2",
|
||
[req.user.id, hash(cookie(req, "rp_session"))],
|
||
);
|
||
await audit(req.user.id, "sessions_revoked_others");
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete("/api/account", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
const user = (
|
||
await db.query("SELECT password_hash,role FROM users WHERE id=$1", [
|
||
req.user.id,
|
||
])
|
||
).rows[0];
|
||
if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash)))
|
||
return res.status(401).json({ ok: false, code: "invalid_credentials" });
|
||
// The bootstrap admin can never remove themselves — deleting that account would
|
||
// re-open /setup to anyone still holding the deployment's bootstrap token.
|
||
if (await isBootstrapAdmin(req.user.id))
|
||
return res.status(403).json({ ok: false, code: "forbidden" });
|
||
if (user.role === "admin") {
|
||
const admins = await db.query(
|
||
"SELECT count(*)::int AS count FROM users WHERE role='admin'",
|
||
);
|
||
if (admins.rows[0].count <= 1)
|
||
return res.status(409).json({ ok: false, code: "last_admin" });
|
||
}
|
||
await audit(req.user.id, "account_self_deleted");
|
||
await db.query("DELETE FROM users WHERE id=$1", [req.user.id]);
|
||
setSessionCookie(res, "", 0, "");
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
|
||
// ─── Admin ─────────────────────────────────────────────────────────────
|
||
app.get("/api/admin/users", requireAuth, admin, async (req, res, next) => {
|
||
try {
|
||
res.json({
|
||
ok: true,
|
||
signupEnabled:
|
||
(
|
||
await db.query(
|
||
"SELECT value FROM app_settings WHERE key='signup_enabled'",
|
||
)
|
||
).rows[0]?.value === "true",
|
||
users: (
|
||
await db.query(
|
||
"SELECT u.id,u.email,u.role,u.created_at,u.disabled_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id,u.email,u.role,u.created_at,u.disabled_at ORDER BY u.created_at",
|
||
)
|
||
).rows,
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get("/api/admin/metrics", requireAuth, admin, async (req, res, next) => {
|
||
try {
|
||
const [users, plans, recentPlans, sessions] = await Promise.all([
|
||
db.query("SELECT count(*)::int AS count FROM users"),
|
||
db.query("SELECT count(*)::int AS count FROM roast_plans"),
|
||
db.query(
|
||
"SELECT count(*)::int AS count FROM roast_plans WHERE updated_at > now() - interval '7 days'",
|
||
),
|
||
db.query(
|
||
"SELECT count(*)::int AS count FROM sessions WHERE expires_at > now()",
|
||
),
|
||
]);
|
||
res.json({
|
||
ok: true,
|
||
metrics: {
|
||
totalUsers: users.rows[0].count,
|
||
totalPlans: plans.rows[0].count,
|
||
plansUpdatedLast7Days: recentPlans.rows[0].count,
|
||
activeSessions: sessions.rows[0].count,
|
||
},
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get("/api/admin/plans", requireAuth, admin, async (req, res, next) => {
|
||
try {
|
||
const userFilter =
|
||
typeof req.query.user === "string" && UUID_RE.test(req.query.user)
|
||
? req.query.user
|
||
: null;
|
||
const rows = (
|
||
await db.query(
|
||
`SELECT p.id,CASE WHEN COALESCE(p.name,'')<>'' THEN p.name ELSE p.plan->'fields'->>'0.1' END AS title,p.updated_at,u.id AS user_id,u.email
|
||
FROM roast_plans p JOIN users u ON u.id=p.user_id
|
||
WHERE $1::uuid IS NULL OR p.user_id=$1
|
||
ORDER BY p.updated_at DESC`,
|
||
[userFilter],
|
||
)
|
||
).rows;
|
||
res.json({ ok: true, plans: rows });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get("/api/admin/audit", requireAuth, admin, async (req, res, next) => {
|
||
try {
|
||
const rows = (
|
||
await db.query(
|
||
`SELECT a.id,a.action,a.target,a.created_at,u.email AS actor_email
|
||
FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id
|
||
ORDER BY a.created_at DESC LIMIT 50`,
|
||
)
|
||
).rows;
|
||
res.json({ ok: true, events: rows });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.get(
|
||
"/api/admin/password-resets",
|
||
requireAuth,
|
||
admin,
|
||
async (_req, res) => {
|
||
const now = Date.now();
|
||
const links = [];
|
||
for (const [tokenHash, entry] of pendingResets) {
|
||
if (entry.expiresAt <= now) {
|
||
pendingResets.delete(tokenHash);
|
||
continue;
|
||
}
|
||
links.push({
|
||
email: entry.email,
|
||
url: entry.url,
|
||
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||
});
|
||
}
|
||
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", "avatar"]],
|
||
["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"]],
|
||
["roasters", ["id", "user_id", "name", "model", "notes", "is_default", "overrides", "created_at", "updated_at"]],
|
||
[
|
||
"actual_roasts",
|
||
[
|
||
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
|
||
"after", "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",
|
||
"recipe", "tasting_notes", "notes", "brewed_at", "created_at", "updated_at",
|
||
],
|
||
],
|
||
["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
|
||
["user_gear", ["user_id", "brewers", "grinders", "updated_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.
|
||
app.get("/api/admin/llm", requireAuth, admin, async (req, res, next) => {
|
||
try {
|
||
const current = await llmModelSetting();
|
||
let models = [];
|
||
let modelsError = null;
|
||
try {
|
||
models = await listModels();
|
||
} catch (e) {
|
||
modelsError = "llm_unavailable";
|
||
}
|
||
res.json({ ok: true, current, models, modelsError });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.put(
|
||
"/api/admin/llm",
|
||
requireAuth,
|
||
csrf,
|
||
admin,
|
||
async (req, res, next) => {
|
||
try {
|
||
const model = req.body.model;
|
||
if (typeof model !== "string" || model.length > 200)
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
if (model !== "") {
|
||
let models;
|
||
try {
|
||
models = await listModels();
|
||
} catch (e) {
|
||
return res
|
||
.status(503)
|
||
.json({ ok: false, code: "llm_unavailable" });
|
||
}
|
||
if (!models.some((m) => m.key === model))
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "unknown_model" });
|
||
}
|
||
await db.query(
|
||
"UPDATE app_settings SET value=$1 WHERE key='llm_model'",
|
||
[model],
|
||
);
|
||
await audit(req.user.id, "llm_model_changed", model || "auto");
|
||
res.json({ ok: true, current: model });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.put(
|
||
"/api/admin/signup-enabled",
|
||
requireAuth,
|
||
csrf,
|
||
admin,
|
||
async (req, res, next) => {
|
||
try {
|
||
if (typeof req.body.enabled !== "boolean")
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
await db.query(
|
||
"UPDATE app_settings SET value=$1 WHERE key='signup_enabled'",
|
||
[String(req.body.enabled)],
|
||
);
|
||
await audit(req.user.id, "signup_toggled", String(req.body.enabled));
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.put(
|
||
"/api/admin/users/:id/role",
|
||
requireAuth,
|
||
csrf,
|
||
admin,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
if (!["user", "admin"].includes(req.body.role))
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
|
||
return res.status(403).json({ ok: false, code: "forbidden" });
|
||
const r = await db.query(
|
||
"UPDATE users SET role=$1 WHERE id=$2 RETURNING id,email,role",
|
||
[req.body.role, req.params.id],
|
||
);
|
||
if (!r.rowCount)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
await audit(req.user.id, "role_changed", `${r.rows[0].email}->${req.body.role}`);
|
||
res.json({ ok: true, user: r.rows[0] });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.put(
|
||
"/api/admin/users/:id/disabled",
|
||
requireAuth,
|
||
csrf,
|
||
admin,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
if (typeof req.body.disabled !== "boolean")
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
|
||
return res.status(403).json({ ok: false, code: "forbidden" });
|
||
const r = await db.query(
|
||
"UPDATE users SET disabled_at=$1 WHERE id=$2 RETURNING id,email,disabled_at",
|
||
[req.body.disabled ? new Date() : null, req.params.id],
|
||
);
|
||
if (!r.rowCount)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
if (req.body.disabled)
|
||
await db.query("DELETE FROM sessions WHERE user_id=$1", [
|
||
req.params.id,
|
||
]);
|
||
await audit(
|
||
req.user.id,
|
||
req.body.disabled ? "user_disabled" : "user_enabled",
|
||
r.rows[0].email,
|
||
);
|
||
res.json({ ok: true, user: r.rows[0] });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
app.delete(
|
||
"/api/admin/users/:id",
|
||
requireAuth,
|
||
csrf,
|
||
admin,
|
||
requireUuidParam("id"),
|
||
async (req, res, next) => {
|
||
try {
|
||
if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
|
||
return res.status(403).json({ ok: false, code: "forbidden" });
|
||
const r = await db.query(
|
||
"DELETE FROM users WHERE id=$1 RETURNING email",
|
||
[req.params.id],
|
||
);
|
||
if (!r.rowCount)
|
||
return res.status(404).json({ ok: false, code: "not_found" });
|
||
await audit(req.user.id, "user_deleted", r.rows[0].email);
|
||
res.json({ ok: true });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
},
|
||
);
|
||
|
||
// Existing integrations remain authenticated but CSRF-protected for writes.
|
||
app.post("/api/prefill", requireAuth, csrf, async (req, res) => {
|
||
const url = typeof req.body?.url === "string" ? req.body.url.trim() : "";
|
||
if (!url)
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "bad_url", error: "Missing url." });
|
||
try {
|
||
const result = await runPrefill(
|
||
await fetchPageText(url),
|
||
await llmModelSetting(),
|
||
);
|
||
res.json({ ok: true, ...result });
|
||
} catch (err) {
|
||
const code = err.code ?? "prefill_failed";
|
||
res
|
||
.status(
|
||
code === "fetch_timeout"
|
||
? 504
|
||
: code === "bad_url"
|
||
? 400
|
||
: code === "no_model"
|
||
? 503
|
||
: 422,
|
||
)
|
||
.json({ ok: false, code, error: err.message });
|
||
}
|
||
});
|
||
app.post("/api/alog", requireAuth, csrf, (req, res) => {
|
||
try {
|
||
if (typeof req.body?.content !== "string" || !req.body.content.trim())
|
||
return res.status(400).json({ ok: false, code: "bad_request" });
|
||
res.json({
|
||
ok: true,
|
||
...parseAlog(req.body.content, req.body.filename ?? "upload.alog"),
|
||
});
|
||
} catch (err) {
|
||
res
|
||
.status(422)
|
||
.json({ ok: false, code: "unparseable_alog", error: err.message });
|
||
}
|
||
});
|
||
// ─── Equipment (owned brewers + grinders) ──────────────────────────────
|
||
app.get("/api/gear", requireAuth, async (req, res, next) => {
|
||
try {
|
||
const row = (
|
||
await db.query("SELECT brewers,grinders FROM user_gear WHERE user_id=$1", [
|
||
req.user.id,
|
||
])
|
||
).rows[0];
|
||
res.json({
|
||
ok: true,
|
||
gear: { brewers: row?.brewers ?? [], grinders: row?.grinders ?? [] },
|
||
});
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
app.put("/api/gear", requireAuth, csrf, async (req, res, next) => {
|
||
try {
|
||
const rawBrewers = req.body.brewers;
|
||
const rawGrinders = req.body.grinders;
|
||
if (!Array.isArray(rawBrewers) || !Array.isArray(rawGrinders))
|
||
return res.status(400).json({ ok: false, code: "bad_gear" });
|
||
const brewers = [...new Set(rawBrewers)];
|
||
if (
|
||
brewers.length > BREW_METHODS.length ||
|
||
!brewers.every((k) => BREW_METHOD_KEYS.has(k))
|
||
)
|
||
return res
|
||
.status(400)
|
||
.json({ ok: false, code: "bad_gear", error: "unknown brewer key" });
|
||
const grinders = [
|
||
...new Set(
|
||
rawGrinders
|
||
.filter((g) => typeof g === "string")
|
||
.map((g) => g.trim().slice(0, 100))
|
||
.filter(Boolean),
|
||
),
|
||
].slice(0, 20);
|
||
const row = (
|
||
await db.query(
|
||
`INSERT INTO user_gear(user_id,brewers,grinders,updated_at) VALUES($1,$2,$3,now())
|
||
ON CONFLICT (user_id) DO UPDATE SET brewers=$2, grinders=$3, updated_at=now()
|
||
RETURNING brewers,grinders`,
|
||
[req.user.id, JSON.stringify(brewers), JSON.stringify(grinders)],
|
||
)
|
||
).rows[0];
|
||
res.json({ ok: true, gear: { brewers: row.brewers, grinders: row.grinders } });
|
||
} catch (e) {
|
||
next(e);
|
||
}
|
||
});
|
||
|
||
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() }),
|
||
);
|
||
app.get("/api/alog/library/:filename", requireAuth, async (req, res) => {
|
||
try {
|
||
res.json({
|
||
ok: true,
|
||
...(await readAlogFromLibrary(req.params.filename)),
|
||
});
|
||
} catch (e) {
|
||
res
|
||
.status(e.code === "not_found" ? 404 : 400)
|
||
.json({ ok: false, code: e.code, error: e.message });
|
||
}
|
||
});
|
||
|
||
app.use((req, res, next) => {
|
||
// express.static resolves percent-encoding and repeated slashes before hitting disk, so
|
||
// the blocklist must compare against the same normalized form or a request like
|
||
// /%69ndex.html or //index.html would slip past this check and still be served below.
|
||
let normalized;
|
||
try {
|
||
normalized = path.posix.normalize(decodeURIComponent(req.path));
|
||
} catch {
|
||
normalized = req.path;
|
||
}
|
||
return PUBLIC_SHELL_FILES.has(normalized)
|
||
? res.status(302).location("/").end()
|
||
: next();
|
||
});
|
||
// index:false — otherwise express.static serves index.html for any request path that
|
||
// normalizes to a directory (e.g. "/", "/./", "//"), bypassing the shell-file blocklist
|
||
// 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
|
||
// bad request as a 500 with a logged stack trace.
|
||
const status = err.status || err.statusCode;
|
||
if (status >= 400 && status < 500)
|
||
return res.status(status).json({ ok: false, code: "bad_request" });
|
||
console.error(err);
|
||
res.status(500).json({ ok: false, code: "internal_error" });
|
||
});
|
||
return app;
|
||
}
|