From a610ef9d39ec270fbd59e8e48fd6d193c3217b1a Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Tue, 26 May 2026 00:08:02 +0000 Subject: [PATCH 1/4] chore: trigger CI for GRO-1757 + GRO-1764 Co-Authored-By: Paperclip --- .ci-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ci-trigger diff --git a/.ci-trigger b/.ci-trigger new file mode 100644 index 0000000..bc10555 --- /dev/null +++ b/.ci-trigger @@ -0,0 +1 @@ +GRO-1757+GRO-1764 CI trigger 2026-05-26 \ No newline at end of file -- 2.52.0 From b83a793de4d768b36cb1e342d2150ef6d218a291 Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Tue, 26 May 2026 00:36:04 +0000 Subject: [PATCH 2/4] chore: PR CI build trigger for GRO-1757 image (do not merge) (#87) Co-authored-by: Lint Roller Co-committed-by: Lint Roller --- .ci-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci-trigger b/.ci-trigger index bc10555..9af0df7 100644 --- a/.ci-trigger +++ b/.ci-trigger @@ -1 +1 @@ -GRO-1757+GRO-1764 CI trigger 2026-05-26 \ No newline at end of file +GRO-1757 PR-based CI build trigger - 2026-05-26T00:15:41Z \ No newline at end of file -- 2.52.0 From d9ba6045adb36cd71156ad4077b78a96f45900ec Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 26 May 2026 00:45:05 +0000 Subject: [PATCH 3/4] chore: direct push CI trigger for GRO-1757 (b61d899f) to include in dev image Co-Authored-By: Paperclip --- .ci-trigger | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci-trigger b/.ci-trigger index 9af0df7..1a34787 100644 --- a/.ci-trigger +++ b/.ci-trigger @@ -1 +1 @@ -GRO-1757 PR-based CI build trigger - 2026-05-26T00:15:41Z \ No newline at end of file +GRO-1757 direct push CI trigger - 2026-05-26T00:15:41Z -- 2.52.0 From 32156e9a451075369a81c7e17c9f792c2db56cdc Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 26 May 2026 12:30:10 +0000 Subject: [PATCH 4/4] fix: restore pet profile summary endpoint from dev (GRO-1177) --- apps/api/src/routes/pets.ts | 132 +++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index dbc5418..f8b6440 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js"; +import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js"; import type { AppEnv } from "../middleware/rbac.js"; import { getPresignedUploadUrl, @@ -283,3 +283,133 @@ petsRouter.get("/:petId/photo", async (c) => { const url = await getPresignedGetUrl(pet.photoKey); return c.json({ url, photoKey: pet.photoKey, photoUploadedAt: pet.photoUploadedAt }); }); + +// ─── Profile Summary ─────────────────────────────────────────────────────────── + +async function groomerLinkageCheck( + db: ReturnType, + clientId: string, + staffRow: NonNullable +): Promise { + const [linkage] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.clientId, clientId), + or( + eq(appointments.staffId, staffRow.id), + eq(appointments.batherStaffId, staffRow.id) + ) + ) + ) + .limit(1); + return !!linkage; +} + +/** + * GET /:id/profile-summary + * Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment. + * Groomer RBAC: same visibility rules as GET /:id. + */ +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"; + + const [row] = await db.select().from(pets).where(eq(pets.id, petId)); + if (!row) return c.json({ error: "Not found" }, 404); + + if (isGroomer) { + const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow); + if (!hasLinkage) return c.json({ error: "Forbidden" }, 403); + } + + // Recent grooming history: last 10, with staff name join + const historyRows = await db + .select({ + id: groomingVisitLogs.id, + petId: groomingVisitLogs.petId, + appointmentId: groomingVisitLogs.appointmentId, + staffId: groomingVisitLogs.staffId, + staffName: staff.name, + cutStyle: groomingVisitLogs.cutStyle, + productsUsed: groomingVisitLogs.productsUsed, + notes: groomingVisitLogs.notes, + groomedAt: groomingVisitLogs.groomedAt, + createdAt: groomingVisitLogs.createdAt, + }) + .from(groomingVisitLogs) + .leftJoin(staff, eq(staff.id, groomingVisitLogs.staffId)) + .where(eq(groomingVisitLogs.petId, petId)) + .orderBy(desc(groomingVisitLogs.groomedAt)) + .limit(10); + + const recentGroomingHistory = historyRows.map((r) => ({ + id: r.id, + petId: r.petId, + appointmentId: r.appointmentId, + staffId: r.staffId, + staffName: r.staffName, + cutStyle: r.cutStyle, + productsUsed: r.productsUsed, + notes: r.notes, + groomedAt: r.groomedAt?.toISOString() ?? null, + createdAt: r.createdAt?.toISOString() ?? null, + })); + + const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null; + + // Completed appointment count for this pet + const [{ count: visitCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(appointments) + .where(and(eq(appointments.petId, petId), eq(appointments.status, "completed"))); + + // Upcoming appointment: next scheduled or confirmed + const [nextAppt] = await db + .select({ + id: appointments.id, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + startTime: appointments.startTime, + endTime: appointments.endTime, + status: appointments.status, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .leftJoin(services, eq(services.id, appointments.serviceId)) + .leftJoin(staff, eq(staff.id, appointments.staffId)) + .where( + and( + eq(appointments.petId, petId), + or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")), + gte(appointments.startTime, new Date()) + ) + ) + .orderBy(appointments.startTime) + .limit(1); + + const upcomingAppointment = nextAppt + ? { + id: nextAppt.id, + serviceId: nextAppt.serviceId, + serviceName: nextAppt.serviceName, + staffId: nextAppt.staffId, + staffName: nextAppt.staffName, + startTime: nextAppt.startTime?.toISOString() ?? null, + endTime: nextAppt.endTime?.toISOString() ?? null, + status: nextAppt.status, + } + : null; + + return c.json({ + ...row, + recentGroomingHistory, + lastVisitDate, + visitCount, + upcomingAppointment, + }); +}); -- 2.52.0