Fix report pagination and rebuild saved plan navigation
Test and deploy / test-and-deploy (push) Successful in 1m43s

This commit is contained in:
snowspeeder committed 2026-09-05 15:47:44 -04:00
1 parent 824a964f9d
commit 5f3a015ea2
20 files changed
+1480 -809

No files matched your search

+38 -4
View File
@@ -1,3 +1,4 @@
import { sanitizeObservations } from "../shared/roast-comparison.js";
import express from "express";
import path from "node:path";
import crypto from "node:crypto";
@@ -46,6 +47,7 @@ const PUBLIC_SHELL_FILES = new Set([
"/inventory.html",
"/cupping.html",
"/roasts.html",
"/plans.html",
"/beans.html",
"/brews.html",
"/gear.html",
@@ -143,6 +145,7 @@ export function createApp({
req.path === "/inventory" ||
req.path === "/cupping" ||
req.path === "/roasts" ||
req.path === "/plans" ||
req.path === "/beans" ||
req.path === "/brews" ||
req.path === "/gear" ||
@@ -427,6 +430,7 @@ export function createApp({
}
});
for (const [route, file] of [
["/plans", "plans.html"],
["/beans", "beans.html"],
["/brews", "brews.html"],
["/gear", "gear.html"],
@@ -749,6 +753,23 @@ export function createApp({
// ─── Plans ─────────────────────────────────────────────────────────────
app.get("/api/plans", requireAuth, async (req, res, next) => {
try {
if (req.query.view === "summary") {
const page = Number(req.query.page ?? 1), limit = Number(req.query.limit ?? 25);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger((page - 1) * limit) || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
return res.status(400).json({ok:false, code:"bad_pagination"});
const params = [req.user.id], where = ["user_id=$1"];
const add = (value, clause) => { params.push(value); where.push(clause.replaceAll("?", "$"+params.length)); };
const query = typeof req.query.q === "string" ? req.query.q.trim().slice(0,200) : "";
if (query) add("%"+query.replace(/[\\%_]/g, "\\$&")+"%", "(name ILIKE ? OR plan->'fields'->>'0.1' ILIKE ? OR plan->'fields'->>'1.1' ILIKE ? OR plan->'fields'->>'1.3' ILIKE ?)");
for (const [key,field] of [["process","3.1"],["level","4.1"]]) {
if (typeof req.query[key] === "string" && req.query[key]) add(req.query[key].slice(0,80), `plan->'fields'->>'${field}' = ?`);
}
const predicate = where.join(" AND ");
const total = Number((await db.query(`SELECT COUNT(*) AS total FROM roast_plans WHERE ${predicate}`, params)).rows[0].total);
const sort = new Map([["updated","updated_at DESC, id DESC"],["oldest","updated_at ASC, id ASC"],["name","name ASC, id ASC"]]).get(req.query.sort) ?? "updated_at DESC, id DESC";
const rows = (await db.query(`SELECT id,name,created_at,updated_at,plan->'fields' AS fields FROM roast_plans WHERE ${predicate} ORDER BY ${sort} LIMIT $${params.length+1} OFFSET $${params.length+2}`, [...params,limit,(page-1)*limit])).rows;
return res.json({ok:true, plans:rows.map(row=>({id:row.id,name:row.name,title:row.name||row.fields?.["0.1"]||"Untitled plan",coffee:row.fields?.["0.1"]||"",origin:row.fields?.["1.3"]||"",cultivar:row.fields?.["1.1"]||"",process:row.fields?.["3.1"]||"",level:row.fields?.["4.1"]||"",batchG:row.fields?.["0.4"]||"",updated_at:row.updated_at,created_at:row.created_at})),total,page,limit});
}
res.json({
ok: true,
plans: (
@@ -762,6 +783,13 @@ export function createApp({
next(e);
}
});
app.get("/api/plans/:id", requireAuth, requireUuidParam("id"), async (req,res,next)=>{
try {
const row=(await db.query("SELECT id,plan,name,created_at,updated_at FROM roast_plans WHERE id=$1 AND user_id=$2",[req.params.id,req.user.id])).rows[0];
if(!row) return res.status(404).json({ok:false,code:"not_found"});
res.json({ok:true,plan:row});
} catch(error) {next(error);}
});
app.post("/api/plans", requireAuth, csrf, async (req, res, next) => {
try {
if (!req.body.plan || typeof req.body.plan !== "object")
@@ -1497,7 +1525,7 @@ export function createApp({
createdAt: row.created_at,
};
return full
? { ...base, parsed, evaluation, after: row.after ?? {}, plan: row.plan ?? null }
? { ...base, parsed, evaluation, observations: row.observations ?? {}, after: row.after ?? {}, plan: row.plan ?? null }
: base;
};
// "After the roast" observations recorded against an actual roast (moved here from the
@@ -1782,6 +1810,11 @@ export function createApp({
params.push(roastPlanId);
sets.push(`roast_plan_id=$${params.length}`);
}
if (req.body.observations !== undefined) {
const observations = sanitizeObservations(req.body.observations);
if (!observations) return res.status(400).json({ok:false,code:"bad_observations"});
params.push(observations);sets.push(`observations=$${params.length}`);
}
if (req.body.after !== undefined) {
const after = sanitizeAfter(req.body.after);
if (!after)
@@ -1793,7 +1826,7 @@ export function createApp({
return res.status(400).json({ ok: false, code: "bad_request" });
params.push(req.params.id, req.user.id);
const r = await db.query(
`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after`,
`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after, observations`,
params,
);
if (!r.rowCount)
@@ -1803,6 +1836,7 @@ export function createApp({
roasterId: r.rows[0].roaster_id,
roastPlanId: r.rows[0].roast_plan_id,
after: r.rows[0].after,
observations: r.rows[0].observations,
});
} catch (e) {
next(e);
@@ -2855,7 +2889,7 @@ export function createApp({
"actual_roasts",
[
"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
"after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
"observations", "after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
],
],
[
@@ -2936,7 +2970,7 @@ export function createApp({
for (const row of rows) {
const params = columns.map((column) => {
const value = row[column];
if (value === undefined) return null;
if (value === undefined) return table === "actual_roasts" && column === "observations" ? "{}" : null;
// jsonb columns arrive as objects; everything else is scalar.
return value !== null && typeof value === "object"
? JSON.stringify(value)
+3 -2
View File
@@ -92,10 +92,11 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
// ── Roast plans ──
"/api/plans": {
get: { tags: ["roast plans"], summary: "List your roast plans", responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
get: { tags: ["roast plans"], summary: "List your roast plans; view=summary returns paginated lightweight rows", parameters: ["view","q","process","level","sort","page","limit"].map(name=>({name,in:"query",schema:{type:"string"}})), responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } },
},
"/api/plans/{id}": {
get: {tags:["roast plans"],summary:"Read one of your plans",parameters:[idParam],responses:{200:ok("Plan"),404:err("Not found")}},
put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } },
delete: { tags: ["roast plans"], summary: "Delete a roast plan", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
},
@@ -108,7 +109,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: "Update the roast's machine and/or after-the-roast observations", parameters: [idParam], requestBody: jsonBody(obj({ roasterId: { ...str, nullable: true }, after: { type: "object", description: "greenIn/out/weightLossPct/vsTarget/actualDtrPct/colour/restedDays/brewRatio/method/cupNotes/oneChange/disproof (strings)" } })), responses: { 200: ok("Updated"), 404: err("Not found") } },
put: { tags: ["actual roasts"], summary: "Update the roast’s plan, machine, milestone annotations or after-roast notes", parameters: [idParam], requestBody: jsonBody(obj({ roastPlanId:{...str,nullable:true}, observations:{type:"object",description:"Milestone keys charge/tp/yellow/fc/drop, each containing timeS, tempC (nullable numbers), and note"}, roasterId: { ...str, nullable: true }, after: { type: "object", description: "greenIn/out/weightLossPct/vsTarget/actualDtrPct/colour/restedDays/brewRatio/method/cupNotes/oneChange/disproof (strings)" } })), responses: { 200: ok("Updated"), 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 .alog — original, or ?variant=updated for a supplemental copy with the app's data (weights, notes, cupping) written back as Artisan fields, importable into Artisan", parameters: [idParam, { name: "variant", in: "query", schema: { type: "string", enum: ["updated"] } }], responses: { 200: { description: "File as attachment" }, 404: err("Not found") } } },