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)