import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; import { and, desc, eq, exists, getDb, impersonationAuditLogs, impersonationSessions, or, pets, appointments, staff, services, sql, } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, getPresignedGetUrl, deleteObject, } from "../lib/s3.js"; export const petsRouter = new Hono(); // Convert Zod validation errors from 422 to 400 and ensure any thrown error // returns a structured JSON body rather than Hono's default empty-body 500. // GRO-2014: profile-summary previously bubbled unhandled errors and produced // an empty-body 500. Mirror the onError pattern already used in invoices.ts // and reports.ts so every error has a JSON envelope. petsRouter.onError((err, c) => { if (err instanceof z.ZodError) { return c.json({ error: "Validation failed", issues: err.issues }, 400); } console.error("[pets] unhandled error", err); return c.json({ error: "Internal Server Error" }, 500); }); // UUID format used by all pet routes — guards path params against malformed // values before they hit Drizzle / Postgres uuid columns (which would throw). const uuidSchema = z.string().uuid(); const createPetSchema = z.object({ clientId: z.string().uuid(), name: z.string().min(1).max(200), species: z.string().min(1).max(100), breed: z.string().max(200).optional(), weightKg: z.number().positive().optional(), dateOfBirth: z.string().datetime().optional(), healthAlerts: z.string().max(2000).optional(), groomingNotes: z.string().max(2000).optional(), cutStyle: z.string().max(500).optional(), shampooPreference: z.string().max(500).optional(), specialCareNotes: z.string().max(2000).optional(), customFields: z.record(z.string(), z.string()).optional(), petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(), coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(), }); const updatePetSchema = createPetSchema.partial().omit({ clientId: true }); // List pets, optionally filtered by clientId. // Groomers see only pets owned by clients with ≥1 appointment for this groomer. petsRouter.get("/", async (c) => { const db = getDb(); const clientId = c.req.query("clientId"); const staffRow = c.get("staff"); const isGroomer = staffRow?.role === "groomer"; // Groomer: filter to pets whose client has an appointment for this groomer const groomerClientFilter = isGroomer ? exists( db .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.clientId, pets.clientId), or( eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id) ) ) ) ) : undefined; const conditions = []; if (clientId) conditions.push(eq(pets.clientId, clientId)); if (groomerClientFilter) conditions.push(groomerClientFilter); const rows = await db .select() .from(pets) .where(conditions.length > 0 ? and(...conditions) : undefined); return c.json(rows); }); petsRouter.get("/:id", async (c) => { const db = getDb(); const petId = c.req.param("id"); const staffRow = c.get("staff"); const isGroomer = staffRow?.role === "groomer"; const [row] = await db .select() .from(pets) .where(eq(pets.id, petId)); if (!row) return c.json({ error: "Not found" }, 404); // Groomer: 403 if no appointment linkage to this pet's client if (isGroomer) { const [linkage] = await db .select({ id: appointments.id }) .from(appointments) .where( and( eq(appointments.clientId, row.clientId), or( eq(appointments.staffId, staffRow.id), eq(appointments.batherStaffId, staffRow.id) ) ) ) .limit(1); if (!linkage) return c.json({ error: "Forbidden" }, 403); } return c.json(row); }); /** * Resolves the clientId from the X-Impersonation-Session-Id header, if present and active. * Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row * by rbac.ts) to access their own pet's data when they are the rightful owner. * * Returns null when the header is missing, the session is unknown/expired/ended, or the * session exists but has no clientId — callers should treat null as "no owner-bypass". */ async function resolveImpersonationClientId( db: ReturnType, c: { req: { header: (name: string) => string | undefined } } ): Promise { const sessionId = c.req.header("X-Impersonation-Session-Id"); if (!sessionId) return null; const [session] = await db .select({ clientId: impersonationSessions.clientId, status: impersonationSessions.status, expiresAt: impersonationSessions.expiresAt, }) .from(impersonationSessions) .where(eq(impersonationSessions.id, sessionId)) .limit(1); if (!session) return null; if (session.status !== "active") return null; if (session.expiresAt <= new Date()) return null; return session.clientId; } /** * Defense-in-depth audit write for the staff-side owner-bypass path in * GET /pets/:id/profile-summary. Mirrors the failure-isolation pattern in * src/middleware/portalAudit.ts: errors are logged but never thrown, so a * misbehaving audit insert cannot turn a working read into a 500. * * Called only when the owner-bypass actually fires (i.e. the requester is a * groomer-role staff row with no appointment linkage, but supplies a valid * X-Impersonation-Session-Id whose clientId matches the pet's owner). The * `petId` and `actorStaffId` are written inside `metadata` because the * impersonation_audit_logs schema has no first-class columns for them and * adding a migration is out of scope. */ async function writeOwnerBypassAudit( db: ReturnType, args: { sessionId: string; petId: string; actorStaffId: string; pageVisited: string; } ): Promise { try { await db.insert(impersonationAuditLogs).values({ sessionId: args.sessionId, action: "read_profile_summary", pageVisited: args.pageVisited, metadata: { petId: args.petId, actorStaffId: args.actorStaffId }, }); } catch (err) { console.error("[pets] failed to write owner-bypass audit log:", err); } } petsRouter.get("/:id/profile-summary", async (c) => { const db = getDb(); const petId = c.req.param("id"); // GRO-2014: validate UUID format before hitting Postgres. Passing a non-UUID // string to a uuid column makes the driver throw, which previously surfaced // as an empty-body 500 to clients. const parsedId = uuidSchema.safeParse(petId); if (!parsedId.success) { return c.json({ error: "Not found" }, 404); } // Defense in depth: resolveStaffMiddleware should always populate `staff` // for protected routes (or short-circuit with 401/403 of its own). Guard // anyway so a misconfigured route mount can't trigger a TypeError on // staffRow.id when the linkage check runs. const staffRow = c.get("staff"); if (!staffRow) { return c.json({ error: "Unauthorized" }, 401); } 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); // Owner-bypass (GRO-2013): a customer who supplies a valid // X-Impersonation-Session-Id for the pet's owning client may read their // own pet's summary, even though rbac.ts auto-provisions them as a // `groomer` staff row with no appointment linkage. let isOwner = false; if (isGroomer) { const headerSessionId = c.req.header("X-Impersonation-Session-Id"); const ownerClientId = await resolveImpersonationClientId(db, c); isOwner = !!ownerClientId && ownerClientId === pet.clientId; if (isOwner && headerSessionId) { // GRO-2063: defense-in-depth audit row. Only fires when the bypass // is actually granted; never on the normal groomer-linkage path, // 403/404/401, or when the header is absent. Failure is swallowed // (try/catch inside writeOwnerBypassAudit) so this can never turn a // working read into a 500. await writeOwnerBypassAudit(db, { sessionId: headerSessionId, petId: pet.id, actorStaffId: staffRow.id, pageVisited: c.req.path, }); } } // Groomer RBAC: check appointment linkage to this pet's client if (isGroomer && !isOwner) { 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 [countRow] = await db .select({ count: sql`count(*)::int` }) .from(appointments) .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))); const visitCount = countRow?.count ?? 0; // 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, 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"); const [row] = await db .insert(pets) .values({ ...rest, weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, customFields: customFields ?? {}, }) .returning(); return c.json(row, 201); }); petsRouter.patch( "/:id", zValidator("json", updatePetSchema), async (c) => { const db = getDb(); const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json"); const [row] = await db .update(pets) .set({ ...rest, weightKg: weightKg?.toString(), dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, ...(customFields !== undefined ? { customFields } : {}), updatedAt: new Date(), }) .where(eq(pets.id, c.req.param("id"))) .returning(); if (!row) return c.json({ error: "Not found" }, 404); return c.json(row); } ); petsRouter.delete("/:id", async (c) => { const db = getDb(); const [row] = await db .delete(pets) .where(eq(pets.id, c.req.param("id"))) .returning(); if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); }); // ─── Photo routes ────────────────────────────────────────────────────────────── const ALLOWED_CONTENT_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", "image/gif", ]); const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB const uploadUrlSchema = z.object({ contentType: z.string().refine((v) => ALLOWED_CONTENT_TYPES.has(v), { message: "contentType must be one of: image/jpeg, image/png, image/webp, image/gif", }), fileSizeBytes: z.number().int().positive().max(MAX_PHOTO_SIZE, { message: "File must not exceed 5 MB", }), }); const confirmSchema = z.object({ key: z.string().min(1), }); /** * POST /:petId/photo/upload-url * Returns a presigned S3 PUT URL and the object key for the upload. * All staff roles (manager, receptionist, groomer) may call this. */ petsRouter.post( "/:petId/photo/upload-url", zValidator("json", uploadUrlSchema), async (c) => { const db = getDb(); const petId = c.req.param("petId"); const { contentType, fileSizeBytes } = c.req.valid("json"); const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); if (!pet) return c.json({ error: "Pet not found" }, 404); const ext = contentType.split("/")[1] ?? "jpg"; const key = `pets/${petId}/${Date.now()}.${ext}`; const uploadUrl = await getPresignedUploadUrl(key, contentType, fileSizeBytes); return c.json({ uploadUrl, key }); } ); /** * POST /:petId/photo/confirm * Called after the client has successfully uploaded to the presigned URL. * Records the object key in the DB. */ petsRouter.post( "/:petId/photo/confirm", zValidator("json", confirmSchema), async (c) => { const db = getDb(); const petId = c.req.param("petId"); const { key } = c.req.valid("json"); // Validate that the key belongs to this pet to prevent key hijacking if (!key.startsWith(`pets/${petId}/`)) { return c.json({ error: "Invalid key" }, 400); } const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); if (!pet) return c.json({ error: "Pet not found" }, 404); // Delete the previous photo from storage to avoid orphaned objects if (pet.photoKey) { try { await deleteObject(pet.photoKey); } catch (err) { console.warn(`Failed to delete previous photo ${pet.photoKey}, orphaned object may remain:`, err); } } const [row] = await db .update(pets) .set({ photoKey: key, photoUploadedAt: new Date(), updatedAt: new Date() }) .where(eq(pets.id, petId)) .returning(); if (!row) return c.json({ error: "Pet not found" }, 404); return c.json({ ok: true, photoKey: row.photoKey }); } ); /** * DELETE /:petId/photo * Removes the photo from object storage and clears the DB record. * All staff roles (manager, receptionist, groomer) may call this. */ petsRouter.delete("/:petId/photo", async (c) => { const db = getDb(); const petId = c.req.param("petId"); const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); if (!pet) return c.json({ error: "Pet not found" }, 404); if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); try { await deleteObject(pet.photoKey); } catch (err) { console.warn(`Failed to delete photo ${pet.photoKey} from S3, orphaned object may remain:`, err); } await db .update(pets) .set({ photoKey: null, photoUploadedAt: null, updatedAt: new Date() }) .where(eq(pets.id, petId)); return c.json({ ok: true }); }); /** * GET /:petId/photo * Returns a presigned GET URL for the pet's photo. * All authenticated staff may access (read). */ petsRouter.get("/:petId/photo", async (c) => { const db = getDb(); const petId = c.req.param("petId"); const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); if (!pet) return c.json({ error: "Pet not found" }, 404); if (!pet.photoKey) return c.json({ error: "No photo on file" }, 404); const url = await getPresignedGetUrl(pet.photoKey); return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt }); });