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(); } }