From 87b038d4fd04f44210cead3ea2c562b272b4ea0a Mon Sep 17 00:00:00 2001 From: Groom Book CEO Date: Wed, 18 Mar 2026 13:23:31 +0000 Subject: [PATCH 1/3] Fix reports crash: serialize Date as ISO string in churn risk query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/reports/clients endpoint crashes with a 500 because Drizzle's sql template literal in a HAVING clause cannot serialize a JavaScript Date object — the postgres driver expects a string. Convert the Date to an ISO string and add an explicit ::timestamptz cast so PostgreSQL handles the comparison correctly. Closes groombook/groombook#49 Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- apps/api/src/routes/reports.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 7a56a31..3d53bda 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -279,6 +279,7 @@ reportsRouter.get("/clients", async (c) => { // Clients with no appointment in last 90 days (churn risk) const ninetyDaysAgo = new Date(); ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); const churnRisk = await db .select({ @@ -290,7 +291,7 @@ reportsRouter.get("/clients", async (c) => { .leftJoin(appointments, eq(appointments.clientId, clients.id)) .groupBy(clients.id, clients.name) .having( - sql`MAX(${appointments.startTime}) < ${ninetyDaysAgo} OR MAX(${appointments.startTime}) IS NULL` + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` ) .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`); -- 2.52.0 From 639429d73dff1b2ed55ca2fdd3e1be4bdaec8c9b Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:26:25 +0000 Subject: [PATCH 2/3] Fix reports crash: serialize Date as ISO string in churn risk query (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/reports/clients endpoint crashes with a 500 because Drizzle's sql template literal in a HAVING clause cannot serialize a JavaScript Date object — the postgres driver expects a string. Convert the Date to an ISO string and add an explicit ::timestamptz cast so PostgreSQL handles the comparison correctly. Closes groombook/groombook#49 Co-authored-by: Groom Book CEO Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.6 --- apps/api/src/routes/reports.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 7a56a31..3d53bda 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -279,6 +279,7 @@ reportsRouter.get("/clients", async (c) => { // Clients with no appointment in last 90 days (churn risk) const ninetyDaysAgo = new Date(); ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + const ninetyDaysAgoISO = ninetyDaysAgo.toISOString(); const churnRisk = await db .select({ @@ -290,7 +291,7 @@ reportsRouter.get("/clients", async (c) => { .leftJoin(appointments, eq(appointments.clientId, clients.id)) .groupBy(clients.id, clients.name) .having( - sql`MAX(${appointments.startTime}) < ${ninetyDaysAgo} OR MAX(${appointments.startTime}) IS NULL` + sql`MAX(${appointments.startTime}) < ${ninetyDaysAgoISO}::timestamptz OR MAX(${appointments.startTime}) IS NULL` ) .orderBy(sql`MAX(${appointments.startTime}) ASC NULLS FIRST`); -- 2.52.0 From 5e185f0c378cc610940f24ad40e9cf9b55b15eb1 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Wed, 18 Mar 2026 13:28:47 +0000 Subject: [PATCH 3/3] fix(reports): add error handler and improve error messages for diagnosis - Add reportsRouter.onError() to catch unhandled Drizzle/DB exceptions and return a JSON 500 with the error message instead of an empty response. Previously, any thrown error caused a non-OK response that the frontend showed only as a generic "Failed to load report data". - Improve the frontend error to identify which specific report endpoint failed (name + HTTP status + first 120 chars of response body), making the root cause visible without needing to open the browser network tab. Closes #49 Co-Authored-By: Paperclip --- apps/api/src/routes/reports.ts | 5 +++++ apps/web/src/pages/Reports.tsx | 19 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index 3d53bda..8be162b 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -16,6 +16,11 @@ import { export const reportsRouter = new Hono(); +reportsRouter.onError((err, c) => { + console.error("[reports] unhandled error:", err); + return c.json({ error: "Internal server error", message: err.message }, 500); +}); + // ─── Helpers ────────────────────────────────────────────────────────────────── function parseDate(value: string | undefined, fallback: Date): Date { diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index 40d0087..fabb159 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -176,8 +176,23 @@ export function ReportsPage() { fetch(`/api/reports/clients?${qs}`), ]); - if (!summRes.ok || !revRes.ok || !apptRes.ok || !svcRes.ok || !clientRes.ok) { - throw new Error("Failed to load report data"); + const failures = [ + ["summary", summRes], + ["revenue", revRes], + ["appointments", apptRes], + ["services", svcRes], + ["clients", clientRes], + ].filter(([, r]) => !(r as Response).ok); + if (failures.length > 0) { + const details = await Promise.all( + failures.map(async ([name, r]) => { + const res = r as Response; + let body = ""; + try { body = await res.text(); } catch { /* ignore */ } + return `${name} (HTTP ${res.status}${body ? `: ${body.slice(0, 120)}` : ""})`; + }) + ); + throw new Error(`Failed to load report data — ${details.join(", ")}`); } const [summData, revData, apptData, svcData, clientData] = await Promise.all([ -- 2.52.0