Add roaster (machine) management, mobile header-tools redesign, and profile pictures
Test and deploy / test-and-deploy (push) Successful in 1m0s
Test and deploy / test-and-deploy (push) Successful in 1m0s
Machines: - roasters table + actual_roasts.roaster_id (migrations 009): per-user machines with a default; uploads attach to the default roaster and are reassignable from the roast detail view - Learning is now per machine: /api/roaster-profile scopes to a roaster (default when unspecified) and applies the user's override tweaks (milestone temps, TP time, pace factor) on top of learned medians - /roasters page: machine list + analysis report showing learned vs applied values with editable tweaks, typical RoR, and a plain-language list of the adjustments applied to plans; planner honors a manual pace override; evaluations use the roast's own machine profile Mobile header redesign: - The planner's header tools collapse behind a single 'Tools ▾' disclosure (dropdown card) at ≤720px, keeping the fixed three-row mobile header with no wrapped or scrolling button rows; desktop renders inline via display:contents (same DOM, same handlers) Profile pictures: - users.avatar (migration 010) + PUT/GET/DELETE /api/account/avatar; client-side centre-crop resize to 128px JPEG; avatar shows in the nav chip on every page; included in full backups Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e62b9601a2
commit
a341d67467
+351
-21
@@ -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 <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"));
|
||||
@@ -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",
|
||||
],
|
||||
],
|
||||
|
||||
+16
-2
@@ -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" },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user