From 0ace23de531dfdab0ede1af818b85a8751da1efa Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 12:50:07 +0000 Subject: [PATCH 1/3] fix(GRO-765): include service name in portal appointments response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added service JOIN to /api/portal/appointments to include name, duration, and price fields in the service sub-object - Changed API response shape from { upcoming, past } to { appointments: [] } to match client expectations and simplify data flow - Updated Appointments, ReportCards, and Dashboard components to transform the new response format (startTime → date + time) and extract serviceName, petName, and groomerName from nested sub-objects Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 7 ++++-- apps/web/src/portal/sections/Appointments.tsx | 23 ++++++++++++++++++- apps/web/src/portal/sections/Dashboard.tsx | 19 ++++++++++++++- apps/web/src/portal/sections/ReportCards.tsx | 21 +++++++++++++++-- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index d768bc8..2becab5 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -60,12 +60,15 @@ portalRouter.get("/appointments", async (c) => { const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null); const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null); + const serviceIds = allAppts.map(a => a.serviceId).filter((id): id is string => id !== null); const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : []; const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : []; + const serviceRows = serviceIds.length ? await db.select().from(services).where(inArray(services.id, serviceIds)) : []; const petMap = Object.fromEntries(petRows.map(p => [p.id, p])); const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s])); + const serviceMap = Object.fromEntries(serviceRows.map(s => [s.id, s])); const appts = allAppts.map(a => ({ id: a.id, @@ -76,14 +79,14 @@ portalRouter.get("/appointments", async (c) => { customerNotes: a.customerNotes, notes: a.notes, pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null, - service: a.serviceId ? { id: a.serviceId } : null, + service: a.serviceId ? { id: a.serviceId, name: serviceMap[a.serviceId]?.name, duration: serviceMap[a.serviceId]?.durationMinutes, price: serviceMap[a.serviceId]?.basePriceCents } : null, staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled"); const past = appts.filter(a => a.startTime <= now || a.status === "cancelled"); - return c.json({ upcoming, past }); + return c.json({ appointments: appts }); }); portalRouter.get("/pets", async (c) => { diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index f5fad62..dacee0f 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -123,7 +123,28 @@ export const AppointmentsSection: React.FC = ({ sessio if (response.ok) { const data = await response.json(); - const fetchedAppointments: Appointment[] = data.appointments || data || []; + const rawAppointments: Record[] = data.appointments || data || []; + + // Transform API response (startTime) to client format (date + time) + const fetchedAppointments: Appointment[] = rawAppointments.map((a) => { + const start = new Date(a.startTime as string); + const dateStr = start.toISOString().split('T')[0]; + const hours = start.getHours(); + const minutes = start.getMinutes().toString().padStart(2, '0'); + const period = hours >= 12 ? 'PM' : 'AM'; + const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const timeStr = `${hour12}:${minutes} ${period}`; + return { + ...a, + date: dateStr, + time: timeStr, + petName: (a.pet as { name?: string })?.name, + serviceName: (a.service as { name?: string })?.name, + groomerName: (a.staff as { name?: string })?.name, + duration: (a.service as { duration?: number })?.duration, + price: (a.service as { price?: number })?.price, + } as Appointment; + }); const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt)); const past = fetchedAppointments.filter((appt) => !isUpcoming(appt)); diff --git a/apps/web/src/portal/sections/Dashboard.tsx b/apps/web/src/portal/sections/Dashboard.tsx index 136a93a..3b836fd 100644 --- a/apps/web/src/portal/sections/Dashboard.tsx +++ b/apps/web/src/portal/sections/Dashboard.tsx @@ -116,7 +116,24 @@ export function Dashboard({ const invoicesData = await invoicesRes.json(); const brandingData = await brandingRes.json(); - setAppointments(appointmentsData.appointments || []); + const rawAppointments: Record[] = appointmentsData.appointments || []; + const transformedAppointments = rawAppointments.map((a) => { + const start = new Date(a.startTime as string); + const dateStr = start.toISOString().split('T')[0]; + const hours = start.getHours(); + const minutes = start.getMinutes().toString().padStart(2, '0'); + const period = hours >= 12 ? 'PM' : 'AM'; + const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const timeStr = `${hour12}:${minutes} ${period}`; + return { + ...a, + date: dateStr, + time: timeStr, + petName: (a.pet as { name?: string })?.name ?? '', + serviceName: (a.service as { name?: string })?.name ?? '', + }; + }); + setAppointments(transformedAppointments as Appointment[]); setPets(petsData.pets || []); // Filter for pending invoices only (not "outstanding") diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index 018b376..d467e45 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -44,8 +44,25 @@ export function ReportCards({ sessionId }: Props) { if (response.ok) { const data = await response.json(); - const allAppointments: Appointment[] = data.appointments || data || []; - const reportCardAppointments = allAppointments.filter( + const rawAppointments: Record[] = data.appointments || data || []; + const transformed: Appointment[] = rawAppointments.map((a) => { + const start = new Date(a.startTime as string); + const dateStr = start.toISOString().split('T')[0]; + const hours = start.getHours(); + const minutes = start.getMinutes().toString().padStart(2, '0'); + const period = hours >= 12 ? 'PM' : 'AM'; + const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const timeStr = `${hour12}:${minutes} ${period}`; + return { + ...a, + date: dateStr, + time: timeStr, + petName: (a.pet as { name?: string })?.name, + serviceName: (a.service as { name?: string })?.name, + groomerName: (a.staff as { name?: string })?.name, + } as Appointment; + }); + const reportCardAppointments = transformed.filter( (appt) => appt.reportCardId ); setAppointments(reportCardAppointments); -- 2.52.0 From dcb929be5b7b5976ef4b7df9dd01bd0077bbaafb Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 17:22:41 +0000 Subject: [PATCH 2/3] fix(GRO-765): remove dead upcoming/past filter code in portal appointments The now/upcoming/past variables were unused after the response shape change to { appointments: appts }. QA flagged them as dead code. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 2becab5..fd5e475 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -40,7 +40,6 @@ portalRouter.get("/appointments", async (c) => { const db = getDb(); const clientId = c.get("portalClientId"); - const now = new Date(); const allAppts = await db .select({ id: appointments.id, @@ -83,9 +82,6 @@ portalRouter.get("/appointments", async (c) => { staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null, })); - const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled"); - const past = appts.filter(a => a.startTime <= now || a.status === "cancelled"); - return c.json({ appointments: appts }); }); -- 2.52.0 From a9be160c1b822991ee4ccc71a923a620a0672b6b Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 19 Apr 2026 00:35:38 +0000 Subject: [PATCH 3/3] fix(GRO-682): pre-populate corepack cache at build time corepack prepare now runs during Docker build (both builder and runner stages) so the cache directory is populated before readOnlyRootFilesystem is enforced at runtime. Previously the mkdir existed without populating the cache, causing ENOENT errors in migrate/seed jobs. Co-Authored-By: Paperclip --- apps/api/Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 23ab29e..2eec9e1 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,7 +12,8 @@ RUN pnpm install --frozen-lockfile # Build FROM deps AS builder -RUN mkdir -p /home/node/.cache/node/corepack +RUN mkdir -p /home/node/.cache/node/corepack && \ + corepack prepare pnpm@9.15.4 --activate COPY packages/ packages/ COPY apps/api/ apps/api/ RUN pnpm --filter @groombook/types build && \ @@ -21,7 +22,9 @@ RUN pnpm --filter @groombook/types build && \ # Runtime FROM node:20-alpine AS runner -RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN corepack enable && \ + mkdir -p /home/node/.cache/node/corepack && \ + corepack prepare pnpm@9.15.4 --activate WORKDIR /app ENV NODE_ENV=production -- 2.52.0