diff --git a/db/migrations/009_roasters.sql b/db/migrations/009_roasters.sql
new file mode 100644
index 0000000..110e045
--- /dev/null
+++ b/db/migrations/009_roasters.sql
@@ -0,0 +1,17 @@
+-- Additive only. Per-user roasting machines: uploaded .alogs attach to one, learning is
+-- computed per roaster, and `overrides` stores the user's manual tweaks to what the learned
+-- profile would otherwise apply to their plans (milestone temps, TP time, pace factor).
+CREATE TABLE roasters (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ model text NOT NULL DEFAULT '',
+ notes text NOT NULL DEFAULT '',
+ is_default boolean NOT NULL DEFAULT false,
+ overrides jsonb NOT NULL DEFAULT '{}',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX roasters_user ON roasters(user_id, created_at);
+ALTER TABLE actual_roasts ADD COLUMN roaster_id uuid REFERENCES roasters(id) ON DELETE SET NULL;
+CREATE INDEX actual_roasts_roaster ON actual_roasts(roaster_id);
diff --git a/db/migrations/010_user_avatar.sql b/db/migrations/010_user_avatar.sql
new file mode 100644
index 0000000..dd48260
--- /dev/null
+++ b/db/migrations/010_user_avatar.sql
@@ -0,0 +1,2 @@
+-- Additive only. Account profile picture, stored as a small client-side-resized data URL.
+ALTER TABLE users ADD COLUMN avatar text;
diff --git a/public/account.html b/public/account.html
index 7a1b139..cb81713 100644
--- a/public/account.html
+++ b/public/account.html
@@ -65,6 +65,29 @@
Profile
+
+
+
+
+
+
+
Not saved yet
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/account.js b/public/js/account.js
index 045a66c..19da5a5 100644
--- a/public/js/account.js
+++ b/public/js/account.js
@@ -1,5 +1,5 @@
import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
-import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
+import { applyAvatar, initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";
function fmtDate(value) {
@@ -331,6 +331,62 @@ function wirePwaButtons() {
});
}
+// Profile picture: resized client-side to a small square JPEG data URL before upload, so the
+// server only ever stores a few tens of KB per user.
+function wireAvatar(user) {
+ const refresh = (hasAvatar) => {
+ const shownUser = { ...user, hasAvatar };
+ applyAvatar(document.getElementById("profile-avatar"), shownUser, true);
+ applyAvatar(document.getElementById("nav-user-avatar"), shownUser, true);
+ document.getElementById("avatar-remove").classList.toggle("hidden", !hasAvatar);
+ };
+ refresh(user.hasAvatar);
+ document.getElementById("avatar-file").addEventListener("change", async (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ try {
+ const bitmap = await createImageBitmap(file);
+ const size = 128;
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const context = canvas.getContext("2d");
+ // Cover-crop the centre square so faces aren't squished.
+ const side = Math.min(bitmap.width, bitmap.height);
+ context.drawImage(
+ bitmap,
+ (bitmap.width - side) / 2,
+ (bitmap.height - side) / 2,
+ side,
+ side,
+ 0,
+ 0,
+ size,
+ size,
+ );
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
+ await api("/api/account/avatar", {
+ method: "PUT",
+ body: JSON.stringify({ dataUrl }),
+ });
+ showToast("Profile picture updated.");
+ refresh(true);
+ } catch (error) {
+ showToast(error.message || "Could not read that image.", "fail");
+ }
+ });
+ document.getElementById("avatar-remove").addEventListener("click", async () => {
+ try {
+ await api("/api/account/avatar", { method: "DELETE" });
+ showToast("Profile picture removed.");
+ refresh(false);
+ } catch (error) {
+ showToast(error.message, "fail");
+ }
+ });
+}
+
async function loadTokens() {
const body = document.getElementById("tokens-body");
try {
@@ -413,6 +469,7 @@ async function init() {
wireForms(user);
wirePwaButtons();
wireTokenForm();
+ wireAvatar(user);
await Promise.all([loadSessions(), loadPlans(), loadTokens()]);
}
diff --git a/public/js/main.js b/public/js/main.js
index c7f519d..f61359c 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -17,7 +17,7 @@ import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
-import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
+import { applyAvatar, initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
@@ -47,6 +47,17 @@ async function loadRoasterProfile() {
try {
const response = await fetch("/api/roaster-profile");
if (response.ok) roasterProfile = (await response.json()).profile;
+ // A manual pace tweak on the default machine (Machines page) beats the plan-log
+ // learned pace in the ledger.
+ if (Number.isFinite(roasterProfile?.paceFactorOverride))
+ machineProfile = {
+ ...(machineProfile ?? {}),
+ pace: {
+ value: roasterProfile.paceFactorOverride,
+ source: "override",
+ roasterName: roasterProfile.roaster?.name ?? null,
+ },
+ };
} catch {
/* offline — curve falls back to reference machine temps */
}
@@ -76,6 +87,10 @@ function renderMachineProfileNote() {
const el = document.getElementById("machine-profile-note");
if (!el) return;
const pace = machineProfile?.pace;
+ if (pace?.source === "override") {
+ el.textContent = `Pace ×${pace.value.toFixed(2)} — your manual tweak${pace.roasterName ? ` for ${pace.roasterName}` : ""} (Machines page).`;
+ return;
+ }
if (!pace || pace.source !== "learned") {
el.textContent =
"Using reference timing (one operator's Hottop KN-8828B-2K+) — recalibrates to your own machine as you log roasts with an actual first-crack time.";
@@ -926,6 +941,33 @@ async function deletePlan(plan) {
await loadPlans();
}
+// Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders
+// inline (display: contents). Any action inside closes it — every tool opens a drawer anyway.
+function wireToolsMenu() {
+ const toggle = document.getElementById("btn-tools-menu");
+ const group = document.getElementById("header-tools-group");
+ if (!toggle || !group) return;
+ const close = () => {
+ group.classList.remove("open");
+ toggle.setAttribute("aria-expanded", "false");
+ };
+ toggle.addEventListener("click", (event) => {
+ event.stopPropagation();
+ const open = group.classList.toggle("open");
+ toggle.setAttribute("aria-expanded", String(open));
+ });
+ group.addEventListener("click", (event) => {
+ if (event.target.closest("button")) close();
+ });
+ document.addEventListener("click", (event) => {
+ if (!group.classList.contains("open")) return;
+ if (!group.contains(event.target) && event.target !== toggle) close();
+ });
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") close();
+ });
+}
+
function wireToolbar() {
document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
const on = document.body.classList.toggle("show-fids");
@@ -1094,16 +1136,17 @@ async function init() {
const { user } = await meResponse.json();
document.getElementById("account-email").textContent = user.email;
document.getElementById("settings-email").textContent = user.email;
- document.getElementById("nav-user-avatar").textContent = user.email
- .charAt(0)
- .toUpperCase();
+ applyAvatar(document.getElementById("nav-user-avatar"), user);
if (user.role === "admin")
document.getElementById("nav-admin").classList.remove("hidden");
const localDraft = loadFromStorage(user.id);
state.plan = localDraft ?? blankPlan();
const draftRemoteId = remotePlanId;
const draftSyncedAtAtLoad = draftSyncedAt;
- await Promise.all([loadPlans(), loadMachineProfile(user.id), loadRoasterProfile()]);
+ // Roaster profile loads after the machine profile on purpose: its pace override (if
+ // any) must be applied on top of the freshly loaded machine profile, not raced with it.
+ await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
+ await loadRoasterProfile();
const requestedId = new URLSearchParams(location.search).get("plan");
const selected = plans.find((plan) => plan.id === requestedId);
// Prefer the local draft only when it targets this exact plan AND was last confirmed
@@ -1153,6 +1196,7 @@ async function init() {
initSideNav();
wireDrawers();
wireToolbar();
+ wireToolsMenu();
wireTempUnitToggle();
updateTempUnitUI();
wirePwa();
diff --git a/public/js/nav.js b/public/js/nav.js
index 1334c1d..f1db881 100644
--- a/public/js/nav.js
+++ b/public/js/nav.js
@@ -10,6 +10,7 @@ const NAV_GROUPS = [
items: [
{ href: "/app", icon: "◐", label: "Planner" },
{ href: "/roasts", icon: "∿", label: "Roasts" },
+ { href: "/roasters", icon: "♨", label: "Machines" },
{ href: "/inventory", icon: "▥", label: "Green lots" },
{ href: "/cupping", icon: "◒", label: "Cupping" },
],
@@ -112,6 +113,19 @@ export function initSideNav() {
return { closeMobileNav };
}
+/** Renders a user's avatar into an element: their profile picture when set, else initial. */
+export function applyAvatar(el, user, cacheBust = false) {
+ if (user.hasAvatar) {
+ el.textContent = "";
+ el.style.backgroundImage = `url("/api/account/avatar${cacheBust ? `?t=${Date.now()}` : ""}")`;
+ el.style.backgroundSize = "cover";
+ el.style.backgroundPosition = "center";
+ } else {
+ el.style.backgroundImage = "";
+ el.textContent = user.email.charAt(0).toUpperCase();
+ }
+}
+
/** Populates the nav's user chip and admin link; redirects to /login when signed out. */
export async function loadNavUser() {
const response = await fetch("/api/auth/me");
@@ -123,7 +137,7 @@ export async function loadNavUser() {
const emailEl = document.getElementById("account-email");
if (emailEl) emailEl.textContent = user.email;
const avatarEl = document.getElementById("nav-user-avatar");
- if (avatarEl) avatarEl.textContent = user.email.charAt(0).toUpperCase();
+ if (avatarEl) applyAvatar(avatarEl, user);
if (user.role === "admin")
document.getElementById("nav-admin")?.classList.remove("hidden");
return user;
diff --git a/public/js/roasters.js b/public/js/roasters.js
new file mode 100644
index 0000000..4d9f92a
--- /dev/null
+++ b/public/js/roasters.js
@@ -0,0 +1,288 @@
+import { api, protectedFetch } from "./api.js?v=__ASSET_VERSION__";
+import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
+import { showToast } from "./toast.js?v=__ASSET_VERSION__";
+
+let roasters = [];
+let selectedId = null;
+let profile = null;
+
+const fmtTime = (s) =>
+ s == null
+ ? "—"
+ : `${Math.floor(Math.round(s) / 60)}:${String(Math.round(s) % 60).padStart(2, "0")}`;
+
+// Metric rows of the report: what the planner actually consumes from a machine's profile.
+// `fmt` renders read-only values; overrides are edited in the raw unit (°C / seconds / ×).
+const METRICS = [
+ { key: "chargeTempC", label: "Charge temp (curve fallback)", unit: "°C" },
+ { key: "turningPointS", label: "Turning point time (thermal lag)", unit: "s", fmt: fmtTime },
+ { key: "turningPointTempC", label: "Turning point temp", unit: "°C" },
+ { key: "yellowTempC", label: "Yellow temp (curve fallback)", unit: "°C" },
+ { key: "firstCrackTempC", label: "First crack temp (curve fallback)", unit: "°C" },
+ { key: "dropTempC", label: "Drop temp (curve fallback)", unit: "°C" },
+ { key: "paceFactor", label: "Pace factor (multiplies first-crack timing)", unit: "×" },
+];
+
+function renderRoasters() {
+ const body = document.getElementById("roasters-body");
+ if (!roasters.length) {
+ body.innerHTML = `
No roasters yet — add your machine below. Your first one becomes the default.
+ Your roasters — what the app has learned from each, and your
+ tweaks to it
+
+
+
+
+
+
+
+
+
+
Your roasters
+
+
+
+
+
+
Roaster
+
Default
+
Logged roasts
+
+
+
+
+
+
+
+
+ Uploaded .alogs attach to the default roaster (you can reassign
+ any roast from its detail view on the Roasts page). Learning,
+ the planner's suggested curve, and roast reviews all use the
+ selected machine's own history.
+
+
+
+
+
+
+
Analysis
+
+
+
+
+ No roasts logged against this machine yet — upload finished
+ .alogs on the Roasts page and the analysis fills in.
+
+
+
+
+
+
+
What the plans use
+
Learned from your roasts
+
Your tweak
+
Applied
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/app.js b/server/app.js
index 5e74fb3..e8cabef 100644
--- a/server/app.js
+++ b/server/app.js
@@ -49,6 +49,7 @@ const PUBLIC_SHELL_FILES = new Set([
"/beans.html",
"/brews.html",
"/gear.html",
+ "/roasters.html",
"/api-docs.html",
]);
@@ -144,6 +145,7 @@ export function createApp({
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/gear" ||
+ req.path === "/roasters" ||
req.path === "/api-docs"
)
res.set("Cache-Control", "no-store, private");
@@ -227,7 +229,7 @@ export function createApp({
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 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",
+ "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];
@@ -425,6 +427,7 @@ export function createApp({
["/beans", "beans.html"],
["/brews", "brews.html"],
["/gear", "gear.html"],
+ ["/roasters", "roasters.html"],
["/api-docs", "api-docs.html"],
]) {
app.get(route, async (req, res, next) => {
@@ -611,6 +614,7 @@ export function createApp({
email: req.user.email,
role: req.user.role,
createdAt: req.user.created_at,
+ hasAvatar: Boolean(req.user.has_avatar),
},
}),
);
@@ -1183,17 +1187,234 @@ export function createApp({
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.
- const userRoasterProfile = async (userId) =>
- computeRoasterProfile(
- (
- await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId])
- ).rows.map((r) => r.parsed),
- );
+ // 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 {
- res.json({ ok: true, profile: await userRoasterProfile(req.user.id) });
+ 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);
}
@@ -1239,6 +1460,8 @@ export function createApp({
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,
@@ -1258,7 +1481,7 @@ export function createApp({
const run = (async () => {
const row = (
await db.query(
- `SELECT a.parsed, p.plan FROM actual_roasts a
+ `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],
@@ -1270,7 +1493,7 @@ export function createApp({
row.parsed,
row.plan ?? null,
await llmModelSetting(),
- await userRoasterProfile(userId),
+ 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",
@@ -1313,11 +1536,33 @@ export function createApp({
.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,filename,original_content,parsed)
- VALUES($1,$2,$3,$4,$5) RETURNING *`,
- [req.user.id, roastPlanId, filename, content, parsed],
+ `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);
@@ -1334,9 +1579,11 @@ export function createApp({
: null;
const rows = (
await db.query(
- `SELECT a.id,a.roast_plan_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
- p.plan->'fields'->>'0.1' AS plan_title
- FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
+ `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,
+ p.plan->'fields'->>'0.1' 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],
@@ -1355,8 +1602,10 @@ export function createApp({
try {
const row = (
await db.query(
- `SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, 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
+ `SELECT a.*, p.plan->'fields'->>'0.1' 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],
)
@@ -1393,6 +1642,41 @@ export function createApp({
}
},
);
+ // Reassign a roast to another of the user's machines (the only mutable field on an upload —
+ // the file and its parse are immutable history).
+ app.put(
+ "/api/roasts/:id",
+ requireAuth,
+ csrf,
+ requireUuidParam("id"),
+ async (req, res, next) => {
+ try {
+ let roasterId = null;
+ if (req.body.roasterId !== undefined && 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;
+ }
+ const r = await db.query(
+ "UPDATE actual_roasts SET roaster_id=$1, updated_at=now() WHERE id=$2 AND user_id=$3",
+ [roasterId, req.params.id, req.user.id],
+ );
+ if (!r.rowCount)
+ return res.status(404).json({ ok: false, code: "not_found" });
+ res.json({ ok: true, roasterId });
+ } catch (e) {
+ next(e);
+ }
+ },
+ );
app.post(
"/api/roasts/:id/evaluate",
requireAuth,
@@ -2157,6 +2441,51 @@ export function createApp({
}
},
);
+ // Profile picture: a small data URL, resized client-side before upload. Served back as a
+ // real image so /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"));
@@ -2356,7 +2685,7 @@ export function createApp({
// 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"]],
+ ["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"]],
[
@@ -2369,10 +2698,11 @@ export function createApp({
],
["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", "filename", "original_content", "parsed",
+ "id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
"evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
diff --git a/server/openapi.js b/server/openapi.js
index 3458078..e42f5c1 100644
--- a/server/openapi.js
+++ b/server/openapi.js
@@ -108,6 +108,7 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
},
"/api/roasts/{id}": {
get: { tags: ["actual roasts"], summary: "Roast detail incl. curve, LLM review, linked plan", parameters: [idParam], responses: { 200: ok("Detail"), 404: err("Not found") } },
+ put: { tags: ["actual roasts"], summary: "Reassign the roast to another of your machines", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true } })), responses: { 200: ok("Reassigned"), 404: err("Not found") } },
delete: { tags: ["actual roasts"], summary: "Delete an uploaded roast", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
"/api/roasts/{id}/download": { get: { tags: ["actual roasts"], summary: "Download the original .alog", parameters: [idParam], responses: { 200: { description: "Original file as attachment" }, 404: err("Not found") } } },
@@ -164,7 +165,15 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
// ── LLM helpers ──
"/api/plan-chat": { post: { tags: ["llm"], summary: "Chat with the LLM about a roast plan (stateless; send the full visible conversation)", requestBody: jsonBody(obj({ plan: { type: "object" }, messages: arr(obj({ role: { ...str, enum: ["user", "assistant"] }, content: str }, ["role", "content"])) }, ["plan", "messages"])), responses: { 200: ok("Reply", obj({ ok: bool, reply: str })), 400: err("Bad plan/messages"), 503: err("No LLM model configured") } } },
- "/api/roaster-profile": { get: { tags: ["llm"], summary: "Learned roaster behavior aggregated from your uploaded .alogs", responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
+ "/api/roaster-profile": { get: { tags: ["llm"], summary: "Learned behavior for a machine (?roaster=id; default roaster otherwise), with user overrides applied", parameters: [{ name: "roaster", in: "query", schema: { type: "string", format: "uuid" } }], responses: { 200: ok("Profile", obj({ ok: bool, profile: { type: "object" } })) } } },
+ "/api/roasters": {
+ get: { tags: ["roasters"], summary: "List your roasting machines", responses: { 200: ok("Roasters") } },
+ post: { tags: ["roasters"], summary: "Add a machine (first one becomes default)", requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool }, ["name"])), responses: { 201: ok("Created") } },
+ },
+ "/api/roasters/{id}": {
+ put: { tags: ["roasters"], summary: "Update a machine (name/model/notes/default/override tweaks)", parameters: [idParam], requestBody: jsonBody(obj({ name: str, model: str, notes: str, isDefault: bool, overrides: { type: "object", description: "chargeTempC/turningPointS/turningPointTempC/yellowTempC/firstCrackTempC/dropTempC/paceFactor" } }), false), responses: { 200: ok("Updated"), 400: err("Bad override") } },
+ delete: { tags: ["roasters"], summary: "Delete a machine (its roasts detach)", parameters: [idParam], responses: { 200: ok("Deleted") } },
+ },
"/api/prefill": { post: { tags: ["llm"], summary: "Extract coffee facts from a product URL (used by roast planner and bean form)", requestBody: jsonBody(obj({ url: str }, ["url"])), responses: { 200: ok("Extraction + derived worksheet fields"), 422: err("Extraction failed"), 503: err("No LLM model configured") } } },
"/api/alog": { post: { tags: ["llm"], summary: "Parse an Artisan .alog for the reference-curve overlay (no storage)", requestBody: jsonBody(obj({ filename: str, content: str }, ["content"])), responses: { 200: ok("Parsed"), 422: err("Unparseable") } } },
@@ -175,6 +184,11 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
"/api/account/sessions/{id}": { delete: { tags: ["account"], summary: "Revoke a session", parameters: [{ ...idParam, schema: str }], responses: { 200: ok("Revoked") } } },
"/api/account/sessions/revoke-others": { post: { tags: ["account"], summary: "Revoke all other sessions", responses: { 200: ok("Revoked") } } },
"/api/account/export": { get: { tags: ["account"], summary: "Download all of your own data as JSON", responses: { 200: ok("Personal data export") } } },
+ "/api/account/avatar": {
+ get: { tags: ["account"], summary: "Your profile picture (image response)", responses: { 200: { description: "Image" }, 404: err("No picture set") } },
+ put: { tags: ["account"], summary: "Set your profile picture (small data URL)", requestBody: jsonBody(obj({ dataUrl: { ...str, description: "data:image/png|jpeg|webp;base64,… (≤300KB)" } }, ["dataUrl"])), responses: { 200: ok("Saved"), 400: err("Bad image") } },
+ delete: { tags: ["account"], summary: "Remove your profile picture", responses: { 200: ok("Removed") } },
+ },
"/api/account": { delete: { tags: ["account"], summary: "Delete your account", requestBody: jsonBody(obj({ password: str }, ["password"])), responses: { 200: ok("Deleted") } } },
// ── Admin ──
@@ -205,7 +219,7 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
},
servers: [{ url: origin || "/" }],
tags: [
- { name: "auth" }, { name: "tokens" }, { name: "roast plans" }, { name: "actual roasts" },
+ { name: "auth" }, { name: "tokens" }, { name: "roast plans" }, { name: "actual roasts" }, { name: "roasters" },
{ name: "green inventory" }, { name: "cupping" }, { name: "beans" }, { name: "brews" },
{ name: "llm" }, { name: "account" }, { name: "admin" },
],
diff --git a/test/helpers.js b/test/helpers.js
index ccd2a20..4be047a 100644
--- a/test/helpers.js
+++ b/test/helpers.js
@@ -22,7 +22,7 @@ export async function setup(env = {}, appOptions = {}) {
const pg = mem.adapters.createPg();
const db = new pg.Pool();
await db.query(
- `CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz);
+ `CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz,avatar text);
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
@@ -34,7 +34,8 @@ 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 roasters(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,name text NOT NULL,model text NOT NULL DEFAULT '',notes text NOT NULL DEFAULT '',is_default boolean NOT NULL DEFAULT false,overrides jsonb NOT NULL DEFAULT '{}',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,roaster_id uuid REFERENCES roasters(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,recipe text NOT NULL DEFAULT '',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);
diff --git a/test/roasters.test.js b/test/roasters.test.js
new file mode 100644
index 0000000..d80b750
--- /dev/null
+++ b/test/roasters.test.js
@@ -0,0 +1,173 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import request from "supertest";
+import { setup, signup } from "./helpers.js";
+
+function makeAlog(title, fcIdx = 14) {
+ 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, fcIdx, 0, 0, 0, 20, 0] });
+}
+
+const stubEval = async () => ({ summary: "ok", grade: "good", highlights: [], concerns: [], suggestions: [], planComparison: null });
+
+test("profile picture: set, serve, remove, validate", async () => {
+ const { agent } = await setup();
+ const { csrf } = await signup(agent, "avatar@example.com");
+ assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, false);
+ assert.equal((await agent.get("/api/account/avatar")).status, 404);
+
+ // 1x1 PNG
+ const png =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
+ assert.equal(
+ (await agent.put("/api/account/avatar").set("x-csrf-token", csrf).send({ dataUrl: png })).status,
+ 200,
+ );
+ assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, true);
+ const served = await agent.get("/api/account/avatar");
+ assert.equal(served.status, 200);
+ assert.equal(served.headers["content-type"], "image/png");
+
+ // Junk rejected
+ for (const dataUrl of ["not-an-image", "data:image/svg+xml;base64,PHN2Zz4=", 5])
+ assert.equal(
+ (await agent.put("/api/account/avatar").set("x-csrf-token", csrf).send({ dataUrl })).status,
+ 400,
+ );
+
+ assert.equal(
+ (await agent.delete("/api/account/avatar").set("x-csrf-token", csrf)).status,
+ 200,
+ );
+ assert.equal((await agent.get("/api/auth/me")).body.user.hasAvatar, false);
+ assert.equal((await agent.get("/api/account/avatar")).status, 404);
+});
+
+test("roasters: CRUD, default handling, uploads attach and reassign, per-roaster profiles", async () => {
+ const { app, agent } = await setup({}, { evaluateRoast: stubEval });
+ const { csrf } = await signup(agent, "machines@example.com");
+
+ // First roaster auto-defaults; second doesn't
+ const hottop = (
+ await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "Hottop", model: "KN-8828B-2K+" })
+ ).body.roaster;
+ assert.equal(hottop.isDefault, true);
+ const aillio = (
+ await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "Aillio" })
+ ).body.roaster;
+ assert.equal(aillio.isDefault, false);
+ assert.equal(
+ (await agent.post("/api/roasters").set("x-csrf-token", csrf).send({ name: "" })).status,
+ 400,
+ );
+
+ // Uploads attach to the default roaster unless told otherwise
+ const upDefault = await agent
+ .post("/api/roasts")
+ .set("x-csrf-token", csrf)
+ .send({ filename: "d.alog", content: makeAlog("On default") });
+ assert.equal(upDefault.body.roast.roasterId, hottop.id);
+ const upExplicit = await agent
+ .post("/api/roasts")
+ .set("x-csrf-token", csrf)
+ .send({ filename: "e.alog", content: makeAlog("On aillio", 15), roasterId: aillio.id });
+ assert.equal(upExplicit.body.roast.roasterId, aillio.id);
+ const list = await agent.get("/api/roasts");
+ assert.equal(list.body.roasts.find((r) => r.filename === "d.alog").roasterName, "Hottop");
+
+ // Roaster list counts its roasts
+ const roasters = (await agent.get("/api/roasters")).body.roasters;
+ assert.equal(roasters.find((r) => r.id === hottop.id).roastCount, 1);
+ assert.equal(roasters.find((r) => r.id === aillio.id).roastCount, 1);
+
+ // Per-roaster profile only sees that machine's roasts; default profile = default roaster
+ const hottopProfile = (
+ await agent.get(`/api/roaster-profile?roaster=${hottop.id}`)
+ ).body.profile;
+ assert.equal(hottopProfile.n, 1);
+ assert.equal(hottopProfile.roaster.name, "Hottop");
+ const defaultProfile = (await agent.get("/api/roaster-profile")).body.profile;
+ assert.equal(defaultProfile.roaster.id, hottop.id);
+ assert.equal(defaultProfile.n, 1);
+
+ // Overrides: applied on top of learned medians; bad values rejected
+ const put = await agent
+ .put(`/api/roasters/${hottop.id}`)
+ .set("x-csrf-token", csrf)
+ .send({ overrides: { firstCrackTempC: 196, paceFactor: 1.1 } });
+ assert.equal(put.status, 200);
+ const tweaked = (
+ await agent.get(`/api/roaster-profile?roaster=${hottop.id}`)
+ ).body.profile;
+ assert.equal(tweaked.medians.firstCrackTempC, 196);
+ assert.notEqual(tweaked.learnedMedians.firstCrackTempC, 196);
+ assert.equal(tweaked.paceFactorOverride, 1.1);
+ assert.equal(
+ (
+ await agent
+ .put(`/api/roasters/${hottop.id}`)
+ .set("x-csrf-token", csrf)
+ .send({ overrides: { paceFactor: 9 } })
+ ).status,
+ 400,
+ );
+
+ // Reassign a roast between machines
+ const reassign = await agent
+ .put(`/api/roasts/${upDefault.body.roast.id}`)
+ .set("x-csrf-token", csrf)
+ .send({ roasterId: aillio.id });
+ assert.equal(reassign.status, 200);
+ assert.equal(
+ (await agent.get(`/api/roaster-profile?roaster=${aillio.id}`)).body.profile.n,
+ 2,
+ );
+
+ // Default transfer + delete keeps roasts (detached)
+ await agent.put(`/api/roasters/${aillio.id}`).set("x-csrf-token", csrf).send({ isDefault: true });
+ const after = (await agent.get("/api/roasters")).body.roasters;
+ assert.equal(after.find((r) => r.id === hottop.id).isDefault, false);
+ assert.equal(after.find((r) => r.id === aillio.id).isDefault, true);
+ assert.equal(
+ (await agent.delete(`/api/roasters/${aillio.id}`).set("x-csrf-token", csrf)).status,
+ 200,
+ );
+ const survivors = await agent.get("/api/roasts");
+ assert.equal(survivors.body.roasts.length, 2);
+ assert.equal(
+ survivors.body.roasts.every((r) => r.roasterId === null || r.roasterId === hottop.id),
+ true,
+ );
+
+ // Ownership: a stranger sees nothing and can't touch anything
+ const stranger = request.agent(app);
+ const { csrf: strangerCsrf } = await signup(stranger, "other@example.com");
+ assert.equal((await stranger.get("/api/roasters")).body.roasters.length, 0);
+ assert.equal(
+ (await stranger.get(`/api/roaster-profile?roaster=${hottop.id}`)).status,
+ 404,
+ );
+ assert.equal(
+ (
+ await stranger
+ .put(`/api/roasters/${hottop.id}`)
+ .set("x-csrf-token", strangerCsrf)
+ .send({ name: "hijack" })
+ ).status,
+ 404,
+ );
+ assert.equal(
+ (
+ await stranger
+ .post("/api/roasts")
+ .set("x-csrf-token", strangerCsrf)
+ .send({ filename: "x.alog", content: makeAlog("x"), roasterId: hottop.id })
+ ).status,
+ 404,
+ );
+});