Files
roast_command_center/server/workspace.js
T
snowspeeder 8c21750356
Test and deploy / test-and-deploy (push) Successful in 1m21s
Build coffee history and experiment workflows with Ask Bean Bro
2026-09-06 18:43:33 -04:00

309 lines
8.9 KiB
JavaScript

import { buildWorkspace } from "../shared/workspace.js";
export async function workspaceFor(db, userId) {
const [plans, roasts, lots, cups] = await Promise.all([
db.query(
"SELECT id,name,plan->'fields' AS fields,plan->'inventory' AS inventory,plan->'experiment' AS experiment,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",
[userId],
),
db.query(
"SELECT id,roast_plan_id,filename,parsed->'roast' AS roast,parsed->'derived' AS derived,plan_snapshot->'experiment' AS roast_experiment,after,evaluation_status,created_at FROM actual_roasts WHERE user_id=$1 ORDER BY created_at DESC",
[userId],
),
db.query(
"SELECT id,name,origin,remaining_weight_g,archived FROM green_bean_lots WHERE user_id=$1",
[userId],
),
db.query(
"SELECT id,roast_plan_id,total_score,created_at FROM cupping_sessions WHERE user_id=$1 ORDER BY created_at DESC",
[userId],
),
]);
return buildWorkspace(
plans.rows.map((r) => ({
...r,
plan: {
fields: r.fields,
inventory: r.inventory,
experiment: r.experiment,
},
})),
roasts.rows.map((r) => ({
...r,
parsed: { roast: r.roast, derived: r.derived },
})),
lots.rows,
cups.rows,
);
}
export function registerWorkspace(
app,
{ db, requireAuth, csrf, requireUuidParam },
) {
app.post("/api/roasts/manual", requireAuth, csrf, async (req, res, next) => {
try {
const id = req.body.planId;
if (
typeof id !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
id,
)
)
return res.status(400).json({ ok: false, code: "bad_plan" });
const row = (
await db.query(
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2",
[id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
const day = row.plan.roastDay,
events = day?.events || {};
if (
!Number.isFinite(day?.startedAt) ||
day.startedAt <= 0 ||
day.startedAt > Date.now() + 60000 ||
!events.fc ||
!events.drop
)
return res.status(400).json({
ok: false,
code: "missing_events",
error: "Record first crack and drop before finishing.",
});
const milestones = [];
for (const [key, label] of [
["charge", "Charge"],
["tp", "Turning point"],
["yellow", "Yellow / dry end"],
["fc", "First crack"],
["drop", "Drop"],
]) {
const e = events[key];
if (!e) continue;
const temp = e.tempF == null || e.tempF === "" ? null : Number(e.tempF);
if (
!Number.isFinite(e.timeS) ||
e.timeS < 0 ||
e.timeS > 7200 ||
(temp !== null &&
(!Number.isFinite(temp) || temp < -40 || temp > 662))
)
return res.status(400).json({ ok: false, code: "bad_events" });
milestones.push({
key,
label,
timeS: e.timeS,
tempC: temp === null ? null : (temp - 32) / 1.8,
});
}
if (
milestones.some((m, i) => i && m.timeS < milestones[i - 1].timeS) ||
events.drop.timeS <= events.fc.timeS
)
return res.status(400).json({
ok: false,
code: "bad_events",
error: "Milestones must be in order, with drop after first crack.",
});
const charge = events.charge?.timeS || 0,
fc = events.fc.timeS - charge,
drop = events.drop.timeS - charge,
dev = drop - fc;
const parsed = {
roast: {
manual: true,
title: row.name || row.plan.fields?.["0.1"] || "Manual roast",
roastDate: new Date(day.startedAt).toISOString(),
weightInG: Number(row.plan.fields?.["0.4"]) || null,
},
milestones: milestones
.filter((m) => m.key !== "tp")
.map((m) => ({ ...m, timeS: m.timeS - charge })),
turningPoint: milestones.find((m) => m.key === "tp")
? {
...milestones.find((m) => m.key === "tp"),
timeS: events.tp.timeS - charge,
}
: null,
curve: milestones
.filter((m) => m.tempC !== null)
.map((m) => ({ timeS: m.timeS - charge, bt: m.tempC })),
derived: {
firstCrackS: fc,
dropS: drop,
developmentS: dev,
dtrPct: Math.round((dev / drop) * 1000) / 10,
},
warnings: [
"Manually recorded milestones, not continuous telemetry. No instantaneous RoR can be inferred.",
],
};
const key = `${id}:${day.startedAt}`,
filename = `roast-day-${day.startedAt}.json`,
original = JSON.stringify(
{ format: "roast-planner-manual-record", planId: id, record: day },
null,
2,
);
const made =
(
await db.query(
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,evaluation_status,manual_key,plan_snapshot) VALUES($1,$2,$3,$4,$5,'done',$6,$7) ON CONFLICT (user_id,manual_key) DO NOTHING RETURNING id",
[
req.user.id,
id,
filename,
original,
parsed,
key,
{ ...row.plan, assistant: undefined, roastDay: undefined },
],
)
).rows[0] ||
(
await db.query(
"SELECT id FROM actual_roasts WHERE user_id=$1 AND manual_key=$2",
[req.user.id, key],
)
).rows[0];
res.status(201).json({ ok: true, roast: made });
} catch (e) {
next(e);
}
});
app.get("/api/workspace", requireAuth, async (req, res, next) => {
try {
res.json({ ok: true, ...(await workspaceFor(db, req.user.id)) });
} catch (e) {
next(e);
}
});
app.get(
"/api/plans/:id/revisions",
requireAuth,
requireUuidParam("id"),
async (req, res, next) => {
try {
const owned = await db.query(
"SELECT id FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!owned.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
const result = await db.query(
"SELECT id,name,created_at FROM plan_revisions WHERE plan_id=$1 AND user_id=$2 ORDER BY created_at DESC LIMIT 50",
[req.params.id, req.user.id],
);
res.json({ ok: true, revisions: result.rows });
} catch (e) {
next(e);
}
},
);
app.post(
"/api/plans/:id/restore",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
if (
typeof req.body.revisionId !== "string" ||
!/^[0-9a-f-]{36}$/i.test(req.body.revisionId)
)
return res.status(400).json({ ok: false, code: "bad_revision" });
const client = await db.connect();
try {
await client.query("BEGIN");
const current = (
await client.query(
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2 FOR UPDATE",
[req.params.id, req.user.id],
)
).rows[0];
const old = (
await client.query(
"SELECT plan,name FROM plan_revisions WHERE id=$1 AND plan_id=$2 AND user_id=$3",
[req.body.revisionId, req.params.id, req.user.id],
)
).rows[0];
if (!old || !current) {
await client.query("ROLLBACK");
return res.status(404).json({ ok: false, code: "not_found" });
}
await client.query(
"INSERT INTO plan_revisions(user_id,plan_id,plan,name) VALUES($1,$2,$3,$4)",
[req.user.id, req.params.id, current.plan, current.name],
);
// Restore planning inputs; recorded consumption and conversation remain current.
const plan = {
...old.plan,
inventory: current.plan.inventory,
assistant: current.plan.assistant,
roastDay: current.plan.roastDay,
};
const row = (
await client.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, old.name, req.params.id, req.user.id],
)
).rows[0];
await client.query("COMMIT");
res.json({ ok: true, plan: row });
} catch (e) {
await client.query("ROLLBACK");
next(e);
} finally {
client.release();
}
},
);
}
export async function savePlanRevision(db, userId, id, body) {
const client = await db.connect();
try {
await client.query("BEGIN");
const existing = (
await client.query(
"SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2 FOR UPDATE",
[id, userId],
)
).rows[0];
if (!existing) {
await client.query("ROLLBACK");
return null;
}
const plan = body.plan === undefined ? existing.plan : body.plan,
name =
body.name === undefined
? existing.name
: String(body.name).trim().slice(0, 120);
const planning = (p) => {
const { assistant, roastDay, ...rest } = p;
return rest;
};
const { isDeepStrictEqual } = await import("node:util");
if (
name !== existing.name ||
!isDeepStrictEqual(planning(plan), planning(existing.plan))
)
await client.query(
"INSERT INTO plan_revisions(user_id,plan_id,plan,name) VALUES($1,$2,$3,$4)",
[userId, id, planning(existing.plan), existing.name],
);
const row = (
await client.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, id, userId],
)
).rows[0];
await client.query("COMMIT");
return row;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}