From 64891bd26097be1522de89cbb6b3f0bc397ada45 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sat, 30 May 2026 02:55:58 +0000 Subject: [PATCH] feat(pets): add GET /:id/profile-summary endpoint Adds profile-summary endpoint for groombook web to display: - Basic pet fields (name, species, breed, coatType, etc.) - Recent grooming history (last 10 completed appointments with staff names) - Visit count (completed appointments) - Upcoming appointment (next scheduled/confirmed) Groomer RBAC: groomers can only see pets they've had appointments with. Non-groomer staff (admin/super) can see all pets. Fixes GRO-1802 (UAT regression: profile-summary route never deployed). Co-Authored-By: Claude Opus 4.8 --- src/routes/pets.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 039e8a5..51cfe23 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -1,7 +1,18 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db"; +import { + and, + desc, + eq, + exists, + getDb, + or, + pets, + appointments, + staff, + services, +} from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, @@ -97,6 +108,106 @@ petsRouter.get("/:id", async (c) => { return c.json(row); }); +petsRouter.get("/:id/profile-summary", async (c) => { + const db = getDb(); + const petId = c.req.param("id"); + const staffRow = c.get("staff"); + const isGroomer = staffRow?.role === "groomer"; + + // Fetch the pet + const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!pet) return c.json({ error: "Not found" }, 404); + + // Groomer RBAC: check appointment linkage to this pet's client + if (isGroomer) { + const [linkage] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, pet.clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + if (!linkage) return c.json({ error: "Forbidden" }, 403); + } + + // Recent grooming history — last 10 completed appointments + const recentHistory = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + notes: appointments.notes, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))) + .orderBy(desc(appointments.startTime)) + .limit(10); + + // Visit count (completed appointments) + const [{ count }] = await db + .select({ count: appointments.id }) + .from(appointments) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))) + .limit(1); + + // Upcoming appointment (next scheduled or confirmed) + const [upcoming] = await db + .select({ + id: appointments.id, + startTime: appointments.startTime, + notes: appointments.notes, + confirmationStatus: appointments.confirmationStatus, + serviceName: services.name, + }) + .from(appointments) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .where( + and( + eq(appointments.petId, petId), + or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")) + ) + ) + .orderBy(appointments.startTime) + .limit(1); + + return c.json({ + id: pet.id, + name: pet.name, + species: pet.species, + breed: pet.breed, + coatType: pet.coatType, + petSizeCategory: pet.petSizeCategory, + weightKg: pet.weightKg, + dateOfBirth: pet.dateOfBirth, + recentGroomingHistory: recentHistory.map((h) => ({ + id: h.id, + startTime: h.startTime, + notes: h.notes, + serviceName: h.serviceName, + staffName: h.staffName, + })), + visitCount: Number(count ?? 0), + upcomingAppointment: upcoming + ? { + id: upcoming.id, + startTime: upcoming.startTime, + notes: upcoming.notes, + confirmationStatus: upcoming.confirmationStatus, + serviceName: upcoming.serviceName, + } + : null, + }); +}); + petsRouter.post("/", zValidator("json", createPetSchema), async (c) => { const db = getDb(); const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");