Compare commits

...

3 Commits

Author SHA1 Message Date
Test User a9be160c1b 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 <noreply@paperclip.ing>
2026-04-19 00:35:38 +00:00
Test User dcb929be5b 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 <noreply@paperclip.ing>
2026-04-17 17:22:41 +00:00
Test User 0ace23de53 fix(GRO-765): include service name in portal appointments response
- 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 <noreply@paperclip.ing>
2026-04-17 12:50:07 +00:00
5 changed files with 69 additions and 12 deletions
+5 -2
View File
@@ -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
+5 -6
View File
@@ -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,
@@ -60,12 +59,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 +78,11 @@ 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) => {
+22 -1
View File
@@ -123,7 +123,28 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
const data = await response.json();
const fetchedAppointments: Appointment[] = data.appointments || data || [];
const rawAppointments: Record<string, unknown>[] = 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));
+18 -1
View File
@@ -116,7 +116,24 @@ export function Dashboard({
const invoicesData = await invoicesRes.json();
const brandingData = await brandingRes.json();
setAppointments(appointmentsData.appointments || []);
const rawAppointments: Record<string, unknown>[] = 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")
+19 -2
View File
@@ -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<string, unknown>[] = 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);