Build coffee history and experiment workflows with Ask Bean Bro
Test and deploy / test-and-deploy (push) Successful in 1m21s
Test and deploy / test-and-deploy (push) Successful in 1m21s
This commit is contained in:
1 parent
1fd53091ac
commit
8c21750356
28 files changed
+3848
-384
No files matched your search
@@ -0,0 +1,308 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user