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

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

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

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

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

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-08 22:37:42 -04:00
co-authored by Claude Fable 5
parent 5efaeb63c9
commit 38c7d01e03
33 changed files with 3498 additions and 205 deletions
+384
View File
@@ -0,0 +1,384 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup, password } from "./helpers.js";
async function bootstrapAdmin(agent) {
const response = await agent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
assert.equal(response.status, 201);
return response.body.csrfToken;
}
test("beans: CRUD, computed remaining weight, ownership", async () => {
const { app, agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const created = await agent.post("/api/beans").set("x-csrf-token", csrf).send({
name: "Kenya AA — own roast",
roaster: "Home",
origin: "Kenya",
process: "washed",
roastLevel: "light",
roastDate: "2026-08-01",
initialWeightG: 210,
tastingNotes: "blackcurrant, tomato",
});
assert.equal(created.status, 201);
const bean = created.body.bean;
assert.equal(bean.remainingWeightG, 210);
// Missing name rejected
assert.equal(
(await agent.post("/api/beans").set("x-csrf-token", csrf).send({ roaster: "X" })).status,
400,
);
// Logging brews reduces computed remaining
for (const dose of [18, 15]) {
const brew = await agent.post("/api/brews").set("x-csrf-token", csrf).send({
beanId: bean.id,
method: "v60",
doseG: dose,
waterG: dose * 16,
});
assert.equal(brew.status, 201);
}
const list = await agent.get("/api/beans");
assert.equal(list.body.beans[0].remainingWeightG, 210 - 33);
assert.equal(list.body.beans[0].brewCount, 2);
// Update
const updated = await agent
.put(`/api/beans/${bean.id}`)
.set("x-csrf-token", csrf)
.send({ name: "Kenya AA (rested)", archived: true });
assert.equal(updated.status, 200);
assert.equal(updated.body.bean.name, "Kenya AA (rested)");
assert.equal(updated.body.bean.archived, true);
assert.equal(updated.body.bean.origin, "Kenya"); // untouched fields survive
// Ownership
const stranger = request.agent(app);
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
assert.equal((await stranger.get("/api/beans")).body.beans.length, 0);
assert.equal(
(
await stranger
.put(`/api/beans/${bean.id}`)
.set("x-csrf-token", strangerCsrf)
.send({ name: "hijack" })
).status,
404,
);
assert.equal(
(
await stranger
.post("/api/brews")
.set("x-csrf-token", strangerCsrf)
.send({ beanId: bean.id, method: "v60" })
).status,
404,
);
// Delete bean: brews keep existing with bean_id nulled
assert.equal(
(await agent.delete(`/api/beans/${bean.id}`).set("x-csrf-token", csrf)).status,
200,
);
const brews = await agent.get("/api/brews");
assert.equal(brews.body.brews.length, 2);
assert.equal(brews.body.brews[0].beanId, null);
});
test("brews: validation, update, filter by bean, method taxonomy", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const methods = await agent.get("/api/brew-methods");
assert.equal(methods.status, 200);
assert.equal(methods.body.categories.length, 3);
assert.equal(methods.body.methods.some((m) => m.key === "moka"), true);
// Unknown method rejected
assert.equal(
(await agent.post("/api/brews").set("x-csrf-token", csrf).send({ method: "teapot" })).status,
400,
);
// Rating out of range rejected
assert.equal(
(
await agent
.post("/api/brews")
.set("x-csrf-token", csrf)
.send({ method: "v60", rating: 11 })
).status,
400,
);
// Non-integer time rejected
assert.equal(
(
await agent
.post("/api/brews")
.set("x-csrf-token", csrf)
.send({ method: "v60", brewTimeS: 2.5 })
).status,
400,
);
const created = await agent.post("/api/brews").set("x-csrf-token", csrf).send({
method: "aeropress",
doseG: 15,
waterG: 230,
waterTempC: 92,
brewTimeS: 150,
grinder: "Comandante",
grindSetting: "22 clicks",
rating: 8,
tastingNotes: "sweet, cocoa, round body",
});
assert.equal(created.status, 201);
assert.equal(created.body.brew.rating, 8);
const updated = await agent
.put(`/api/brews/${created.body.brew.id}`)
.set("x-csrf-token", csrf)
.send({ rating: 6, notes: "slightly over-extracted" });
assert.equal(updated.status, 200);
assert.equal(updated.body.brew.rating, 6);
assert.equal(updated.body.brew.method, "aeropress"); // untouched fields survive
assert.equal(updated.body.brew.tastingNotes, "sweet, cocoa, round body");
const bean = (
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "B" })
).body.bean;
await agent
.post("/api/brews")
.set("x-csrf-token", csrf)
.send({ method: "espresso", beanId: bean.id, doseG: 18, yieldG: 36 });
const filtered = await agent.get(`/api/brews?bean=${bean.id}`);
assert.equal(filtered.body.brews.length, 1);
assert.equal(filtered.body.brews[0].method, "espresso");
assert.equal(filtered.body.brews[0].beanName, "B");
assert.equal(
(
await agent
.delete(`/api/brews/${created.body.brew.id}`)
.set("x-csrf-token", csrf)
).status,
200,
);
assert.equal((await agent.get("/api/brews")).body.brews.length, 1);
});
test("api tokens: bearer auth works, skips CSRF, revocation kills access", async () => {
const { app, agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const created = await agent
.post("/api/tokens")
.set("x-csrf-token", csrf)
.send({ name: "cli" });
assert.equal(created.status, 201);
assert.match(created.body.token, /^rpt_/);
// Bearer client: no cookies, no CSRF header — reads and writes both work
const bearer = created.body.token;
const anonymous = request(app);
const me = await anonymous.get("/api/auth/me").set("authorization", `Bearer ${bearer}`);
assert.equal(me.status, 200);
assert.equal(me.body.user.email, "[email protected]");
const write = await anonymous
.post("/api/beans")
.set("authorization", `Bearer ${bearer}`)
.send({ name: "Token bean" });
assert.equal(write.status, 201);
// Wrong token fails; listing shows metadata only
assert.equal(
(await anonymous.get("/api/auth/me").set("authorization", "Bearer rpt_nope")).status,
401,
);
const list = await agent.get("/api/tokens");
assert.equal(list.body.tokens.length, 1);
assert.equal(list.body.tokens[0].name, "cli");
assert.equal(list.body.tokens[0].id, created.body.id);
assert.equal(String(list.body.tokens[0]).includes("rpt_"), false);
// Revoke → immediate 401
assert.equal(
(await agent.delete(`/api/tokens/${created.body.id}`).set("x-csrf-token", csrf)).status,
200,
);
assert.equal(
(await anonymous.get("/api/auth/me").set("authorization", `Bearer ${bearer}`)).status,
401,
);
});
test("backup: export → import round-trips data and keeps the admin session", async () => {
const { app, agent } = await setup();
const adminCsrf = await bootstrapAdmin(agent);
// Seed data across features as a second user
const user = request.agent(app);
const { csrf: userCsrf } = await signup(user, "[email protected]");
await user.post("/api/plans").set("x-csrf-token", userCsrf).send({ plan: { fields: { "0.1": "Backup plan" } } });
const bean = (
await user.post("/api/beans").set("x-csrf-token", userCsrf).send({ name: "Backup bean", initialWeightG: 200 })
).body.bean;
await user.post("/api/brews").set("x-csrf-token", userCsrf).send({ method: "chemex", beanId: bean.id, doseG: 30, waterG: 500, rating: 9 });
await user.post("/api/inventory").set("x-csrf-token", userCsrf).send({ origin: "Colombia", initialWeightG: 1000 });
const exported = await agent.get("/api/admin/backup");
assert.equal(exported.status, 200);
assert.match(exported.headers["content-disposition"], /attachment/);
const backup = exported.body;
assert.equal(backup.format, "roast-planner-backup");
assert.equal(backup.tables.users.length, 2);
assert.equal(backup.tables.roasted_beans.length, 1);
assert.equal(backup.tables.brews.length, 1);
assert.equal(backup.tables.green_bean_lots.length, 1);
// Non-admin cannot export or import
assert.equal((await user.get("/api/admin/backup")).status, 403);
// Import replaces everything; the importing admin's session survives
const imported = await agent
.post("/api/admin/backup/import")
.set("x-csrf-token", adminCsrf)
.send(backup);
assert.equal(imported.status, 200);
assert.equal(imported.body.sessionKept, true);
assert.equal(imported.body.counts.users, 2);
assert.equal((await agent.get("/api/auth/me")).status, 200);
// Data round-tripped: the user logs back in (their session was not preserved) and finds it
const userAgain = request.agent(app);
const login = await userAgain
.post("/api/auth/login")
.send({ email: "[email protected]", password });
assert.equal(login.status, 200);
assert.equal((await userAgain.get("/api/beans")).body.beans.length, 1);
assert.equal((await userAgain.get("/api/beans")).body.beans[0].remainingWeightG, 170);
assert.equal((await userAgain.get("/api/brews")).body.brews.length, 1);
assert.equal((await userAgain.get("/api/inventory")).body.lots.length, 1);
// A backup with no active admin is refused outright
const noAdmin = structuredClone(backup);
noAdmin.tables.users = noAdmin.tables.users.filter((u) => u.role !== "admin");
assert.equal(
(
await agent
.post("/api/admin/backup/import")
.set("x-csrf-token", adminCsrf)
.send(noAdmin)
).status,
400,
);
// Garbage is refused
assert.equal(
(
await agent
.post("/api/admin/backup/import")
.set("x-csrf-token", adminCsrf)
.send({ format: "nope" })
).status,
400,
);
});
function makeAlog(title = "Roast") {
const timex = [], temp1 = [], temp2 = [];
for (let i = 0; i <= 20; i++) {
timex.push(i * 30);
temp1.push(200 + i);
temp2.push(i < 3 ? 180 - i * 30 : 90 + (i - 3) * 7);
}
return JSON.stringify({ title, mode: "C", weight: [250, 212, "g"], timex, temp1, temp2, timeindex: [1, 8, 14, 0, 0, 0, 20, 0] });
}
test("roaster profile aggregates uploaded roasts; plan chat is grounded in it", async () => {
const chatCalls = [];
const { agent } = await setup(
{},
{
evaluateRoast: async () => ({ summary: "ok", grade: "good", highlights: [], concerns: [], suggestions: [], planComparison: null }),
runPlanChat: async (args) => {
chatCalls.push(args);
return { reply: "Drop 20 seconds earlier.", model: "test" };
},
},
);
const { csrf } = await signup(agent, "[email protected]");
// Empty profile before any uploads
const empty = await agent.get("/api/roaster-profile");
assert.equal(empty.status, 200);
assert.equal(empty.body.profile.n, 0);
// Upload two roasts → profile aggregates them
for (const title of ["r1", "r2"]) {
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: `${title}.alog`, content: makeAlog(title) });
assert.equal(up.status, 201);
}
const profile = (await agent.get("/api/roaster-profile")).body.profile;
assert.equal(profile.n, 2);
assert.equal(Number.isFinite(profile.medians.turningPointS), true);
assert.equal(Number.isFinite(profile.medians.firstCrackTempC), true);
assert.equal(Number.isFinite(profile.rorCPerMin.maillard), true);
// Chat receives the plan, the coerced messages, and both learned profiles
const chat = await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({
plan: { fields: { "0.1": "Chat plan", "1.4": "8:30" } },
messages: [{ role: "user", content: "Why is drop so late?" }],
});
assert.equal(chat.status, 200);
assert.equal(chat.body.reply, "Drop 20 seconds earlier.");
assert.equal(chatCalls[0].plan.fields["0.1"], "Chat plan");
assert.equal(chatCalls[0].roasterProfile.n, 2);
assert.equal(Array.isArray(chatCalls[0].messages), true);
// Bad chat bodies are rejected before any model call
assert.equal(
(await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({ plan: {}, messages: [] })).status,
400,
);
assert.equal(
(
await agent
.post("/api/plan-chat")
.set("x-csrf-token", csrf)
.send({ plan: {}, messages: [{ role: "assistant", content: "hi" }] })
).status,
400,
);
assert.equal(chatCalls.length, 1);
});
test("per-user export and openapi spec", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "Mine" });
const exported = await agent.get("/api/account/export");
assert.equal(exported.status, 200);
assert.equal(exported.body.format, "roast-planner-user-export");
assert.equal(exported.body.tables.roasted_beans.length, 1);
assert.equal(exported.body.tables.users, undefined);
assert.equal(exported.body.tables.api_tokens, undefined);
const spec = await agent.get("/api/openapi.json");
assert.equal(spec.status, 200);
assert.equal(spec.body.openapi, "3.0.3");
assert.equal(!!spec.body.paths["/api/brews"], true);
assert.equal(!!spec.body.paths["/api/admin/backup/import"], true);
assert.equal(!!spec.body.components.securitySchemes.bearerAuth, true);
});
+4 -1
View File
@@ -34,7 +34,10 @@ export async function setup(env = {}, appOptions = {}) {
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now())`,
CREATE TABLE actual_roasts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,filename text NOT NULL,original_content text NOT NULL,parsed jsonb NOT NULL,evaluation jsonb,evaluation_status text NOT NULL DEFAULT 'pending',evaluation_error text,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE roasted_beans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,roaster text NOT NULL DEFAULT '',origin text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',variety text NOT NULL DEFAULT '',roast_level text NOT NULL DEFAULT '',roast_date date,initial_weight_g numeric,url text NOT NULL DEFAULT '',tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE brews(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,bean_id uuid REFERENCES roasted_beans(id) ON DELETE SET NULL,method text NOT NULL,dose_g numeric,water_g numeric,yield_g numeric,grinder text NOT NULL DEFAULT '',grind_setting text NOT NULL DEFAULT '',water_temp_c numeric,brew_time_s integer,bloom_time_s integer,rating numeric,tasting_notes text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',brewed_at timestamptz DEFAULT now(),created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE api_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL DEFAULT '',created_at timestamptz DEFAULT now(),last_used_at timestamptz)`,
);
const app = createApp({
db,