309 lines
8.9 KiB
JavaScript
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();
|
|
}
|
|
}
|