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 01/15] 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`); From 21c0a7b59c74467c0e9c5ad20e5f768168d0b37d 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:36:31 +0000 Subject: [PATCH 02/15] fix(reports): fix churn query crash and improve error reporting (#51) The /api/reports/clients endpoint was crashing with a 500 on every request because a raw JavaScript Date passed into a sql template literal in .having() cannot be serialized by postgres-js. The fix serializes it as an ISO string with an explicit ::timestamptz cast. Also adds reportsRouter.onError() and improves the frontend error message to surface which specific endpoint failed and why. Fixes #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([ From 9ab05022a6ef674185c29c22769c161e94239a66 Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:47:32 +0000 Subject: [PATCH 03/15] fix(packages): reorder exports conditions to prevent Node.js .ts resolution (#52) Node.js v20.20.1 is matching the `types` export condition before `default`, causing ERR_UNKNOWN_FILE_EXTENSION when it tries to load .ts source files at runtime. Moving `default` before `types` ensures Node.js resolves to the compiled .js output first. TypeScript explicitly seeks the `types` condition regardless of key order, so TS resolution is unaffected. Fixes the API container CrashLoopBackOff in the groombook namespace. Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- packages/db/package.json | 4 ++-- packages/types/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index 3c8a012..dadfe80 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -7,8 +7,8 @@ "types": "./src/index.ts", "exports": { ".": { - "types": "./src/index.ts", - "default": "./dist/index.js" + "default": "./dist/index.js", + "types": "./src/index.ts" } }, "scripts": { diff --git a/packages/types/package.json b/packages/types/package.json index 9265ada..9d2d7ac 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -7,8 +7,8 @@ "types": "./src/index.ts", "exports": { ".": { - "types": "./src/index.ts", - "default": "./dist/index.js" + "default": "./dist/index.js", + "types": "./src/index.ts" } }, "scripts": { From 5757cd0631a4ed751609ec4ee640bf907d994f4c Mon Sep 17 00:00:00 2001 From: "groombook-paperclip[bot]" <268890960+groombook-paperclip[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:23:49 +0000 Subject: [PATCH 04/15] feat: customer portal with 7 sections and staff impersonation (#54) * feat(web): add customer portal with 7 sections and staff impersonation Implements the customer-facing portal for pet parents with: - Dashboard showing upcoming appointments, pet cards, loyalty rewards - Multi-step appointment booking flow with recurring scheduling - Pet profiles with medical/behavioral notes and vaccination tracking - Grooming report cards with before/after, behavior assessment, sharing - Billing & payments with invoices, saved methods, autopay, tips, packages - Communication with chat-style messaging and notification preferences - Account settings with personal info, password, pet management, agreements - Staff impersonation mode with required reason, 30-min session timer, non-dismissable banner, viewport border, watermark, read-only enforcement, and full audit trail viewer Also adds Tailwind CSS, lucide-react, and recharts as dependencies. Closes #53 Co-Authored-By: Paperclip * fix(web): remove unused imports to pass lint Co-Authored-By: Paperclip --------- Co-authored-by: Groom Book CTO Co-authored-by: Paperclip --- apps/web/package.json | 6 +- apps/web/src/App.tsx | 3 + apps/web/src/index.css | 2 + apps/web/src/portal/AuditLogViewer.tsx | 62 ++ apps/web/src/portal/CustomerPortal.tsx | 344 ++++++++ apps/web/src/portal/ImpersonationBanner.tsx | 83 ++ apps/web/src/portal/mockData.ts | 348 ++++++++ .../src/portal/sections/AccountSettings.tsx | 177 +++++ apps/web/src/portal/sections/Appointments.tsx | 442 +++++++++++ .../src/portal/sections/BillingPayments.tsx | 252 ++++++ .../web/src/portal/sections/Communication.tsx | 196 +++++ apps/web/src/portal/sections/Dashboard.tsx | 195 +++++ apps/web/src/portal/sections/PetProfiles.tsx | 236 ++++++ apps/web/src/portal/sections/ReportCards.tsx | 172 ++++ apps/web/vite.config.ts | 2 + pnpm-lock.yaml | 740 ++++++++++++++++-- 16 files changed, 3211 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/portal/AuditLogViewer.tsx create mode 100644 apps/web/src/portal/CustomerPortal.tsx create mode 100644 apps/web/src/portal/ImpersonationBanner.tsx create mode 100644 apps/web/src/portal/mockData.ts create mode 100644 apps/web/src/portal/sections/AccountSettings.tsx create mode 100644 apps/web/src/portal/sections/Appointments.tsx create mode 100644 apps/web/src/portal/sections/BillingPayments.tsx create mode 100644 apps/web/src/portal/sections/Communication.tsx create mode 100644 apps/web/src/portal/sections/Dashboard.tsx create mode 100644 apps/web/src/portal/sections/PetProfiles.tsx create mode 100644 apps/web/src/portal/sections/ReportCards.tsx diff --git a/apps/web/package.json b/apps/web/package.json index c3a339b..34bc32a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,9 +13,13 @@ }, "dependencies": { "@groombook/types": "workspace:*", + "@tailwindcss/vite": "^4.2.2", + "lucide-react": "^0.577.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.2" + "react-router-dom": "^7.1.2", + "recharts": "^3.8.0", + "tailwindcss": "^4.2.2" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ba6a85..14a1e61 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; +import { CustomerPortal } from "./portal/CustomerPortal.js"; const NAV_LINKS = [ { to: "/", label: "Appointments" }, @@ -16,6 +17,7 @@ const NAV_LINKS = [ { to: "/invoices", label: "Invoices" }, { to: "/group-bookings", label: "Group Bookings" }, { to: "/reports", label: "Reports" }, + { to: "/portal", label: "Customer Portal" }, ]; export function App() { @@ -80,6 +82,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 83e7286..67a2b22 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,3 +1,5 @@ +@import "tailwindcss"; + *, *::before, *::after { box-sizing: border-box; } diff --git a/apps/web/src/portal/AuditLogViewer.tsx b/apps/web/src/portal/AuditLogViewer.tsx new file mode 100644 index 0000000..7510c1a --- /dev/null +++ b/apps/web/src/portal/AuditLogViewer.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { X, Filter } from "lucide-react"; +import type { AuditEntry } from "./mockData.js"; + +interface Props { + auditLog: AuditEntry[]; + onClose: () => void; +} + +export function AuditLogViewer({ auditLog, onClose }: Props) { + const [filterAction, setFilterAction] = useState("all"); + + const actionTypes = ["all", ...new Set(auditLog.map(e => e.action))]; + const filtered = filterAction === "all" ? auditLog : auditLog.filter(e => e.action === filterAction); + + return ( +
+
+
+

Impersonation Audit Log

+ +
+
+ + + {filtered.length} entries +
+
+ {filtered.length === 0 ? ( +

No audit entries

+ ) : ( +
+ {filtered.map(entry => ( +
+
+ {new Date(entry.timestamp).toLocaleTimeString()} +
+
+ + {entry.action.replace(/_/g, " ")} + +

{entry.detail}

+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx new file mode 100644 index 0000000..22a4d1c --- /dev/null +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -0,0 +1,344 @@ +import { useState, useReducer, useCallback } from "react"; +import { + Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare, + Settings, Eye, LogOut, Clock, Shield, +} from "lucide-react"; +import { Dashboard } from "./sections/Dashboard.js"; +import { AppointmentsSection } from "./sections/Appointments.js"; +import { PetProfiles } from "./sections/PetProfiles.js"; +import { ReportCards } from "./sections/ReportCards.js"; +import { BillingPayments } from "./sections/BillingPayments.js"; +import { Communication } from "./sections/Communication.js"; +import { AccountSettings } from "./sections/AccountSettings.js"; +import { ImpersonationBanner } from "./ImpersonationBanner.js"; +import { AuditLogViewer } from "./AuditLogViewer.js"; +import type { ImpersonationSession, AuditEntry } from "./mockData.js"; +import { CUSTOMER } from "./mockData.js"; + +type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; + +const NAV_ITEMS: { id: Section; label: string; icon: typeof Home }[] = [ + { id: "dashboard", label: "Home", icon: Home }, + { id: "appointments", label: "Appointments", icon: Calendar }, + { id: "pets", label: "My Pets", icon: PawPrint }, + { id: "reports", label: "Report Cards", icon: FileText }, + { id: "billing", label: "Billing", icon: CreditCard }, + { id: "messages", label: "Messages", icon: MessageSquare }, + { id: "settings", label: "Settings", icon: Settings }, +]; + +type ImpersonationAction = + | { type: "START"; staffName: string; staffRole: string; reason: string } + | { type: "END" } + | { type: "EXTEND" } + | { type: "LOG"; entry: AuditEntry }; + +function impersonationReducer( + state: ImpersonationSession | null, + action: ImpersonationAction +): ImpersonationSession | null { + switch (action.type) { + case "START": { + const now = new Date(); + const expires = new Date(now.getTime() + 30 * 60 * 1000); + return { + active: true, + staffName: action.staffName, + staffRole: action.staffRole, + customerName: CUSTOMER.name, + reason: action.reason, + startedAt: now.toISOString(), + expiresAt: expires.toISOString(), + extended: false, + readOnly: true, + auditLog: [{ + id: "audit-0", + timestamp: now.toISOString(), + action: "session_start", + detail: `Impersonation started by ${action.staffName} (${action.staffRole}). Reason: ${action.reason}`, + }], + }; + } + case "END": + if (!state) return null; + return { + ...state, + active: false, + auditLog: [...state.auditLog, { + id: `audit-${state.auditLog.length}`, + timestamp: new Date().toISOString(), + action: "session_end", + detail: "Impersonation session ended", + }], + }; + case "EXTEND": + if (!state) return null; + return { + ...state, + expiresAt: new Date(new Date(state.expiresAt).getTime() + 30 * 60 * 1000).toISOString(), + extended: true, + auditLog: [...state.auditLog, { + id: `audit-${state.auditLog.length}`, + timestamp: new Date().toISOString(), + action: "session_extended", + detail: "Session extended by 30 minutes", + }], + }; + case "LOG": + if (!state) return null; + return { ...state, auditLog: [...state.auditLog, action.entry] }; + default: + return state; + } +} + +export function CustomerPortal() { + const [activeSection, setActiveSection] = useState
("dashboard"); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [showAuditLog, setShowAuditLog] = useState(false); + const [showImpersonationSetup, setShowImpersonationSetup] = useState(false); + const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null); + + const logPageView = useCallback((page: string) => { + if (impersonation?.active) { + dispatchImpersonation({ + type: "LOG", + entry: { + id: `audit-${Date.now()}`, + timestamp: new Date().toISOString(), + action: "page_view", + detail: `Viewed: ${page}`, + }, + }); + } + }, [impersonation?.active]); + + const handleNavClick = (section: Section) => { + setActiveSection(section); + setMobileNavOpen(false); + logPageView(section); + }; + + const isReadOnly = impersonation?.active && impersonation.readOnly; + + const renderSection = () => { + switch (activeSection) { + case "dashboard": + return ; + case "appointments": + return ; + case "pets": + return ; + case "reports": + return ; + case "billing": + return ; + case "messages": + return ; + case "settings": + return ; + } + }; + + return ( +
+ {impersonation?.active && ( + <> + dispatchImpersonation({ type: "END" })} + onExtend={() => dispatchImpersonation({ type: "EXTEND" })} + onShowAudit={() => setShowAuditLog(true)} + /> + {/* Watermark */} +
+
+ STAFF VIEW +
+
+ + )} + + {showAuditLog && impersonation && ( + setShowAuditLog(false)} + /> + )} + + {/* Mobile Header */} +
+ + Paws & Reflect +
+ SM +
+
+ +
+ {/* Sidebar Navigation */} + + + {/* Mobile nav overlay */} + {mobileNavOpen && ( +
setMobileNavOpen(false)} + /> + )} + + {/* Main Content */} +
+
+
+

+ {NAV_ITEMS.find(n => n.id === activeSection)?.label} +

+
+
+ Hi, {CUSTOMER.name.split(" ")[0]} +
+ SM +
+
+
+
+ {renderSection()} +
+
+
+ + {/* Impersonation Setup Modal */} + {showImpersonationSetup && { + dispatchImpersonation({ type: "START", staffName: "Chris", staffRole: "Admin", reason }); + setShowImpersonationSetup(false); + }} + onCancel={() => setShowImpersonationSetup(false)} + />} +
+ ); +} + +function ImpersonationSetupModal({ onStart, onCancel }: { onStart: (reason: string) => void; onCancel: () => void }) { + const [reason, setReason] = useState(""); + return ( +
+
+
+
+ +
+
+

Start Staff Impersonation

+

View portal as {CUSTOMER.name}

+
+
+
+ +