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 <noreply@anthropic.com>
This commit is contained in:
+112
-1
@@ -1,7 +1,18 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
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 type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -97,6 +108,106 @@ petsRouter.get("/:id", async (c) => {
|
|||||||
return c.json(row);
|
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) => {
|
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
|
||||||
|
|||||||
Reference in New Issue
Block a user