Files
api/apps/api/src/routes/pets.ts
T
Barcode Betty de33edd7c6 fix: address CTO review — visitCount bug + upcomingAppointment date filter
- Replace .select({ count: appointments.id }).limit(1) + .length with
  sql<number>`count(*)::int` pattern per project standard (references invoices.ts:86)
- Add gte(appointments.startTime, new Date()) to upcomingAppointment query
  so past appointments in scheduled/confirmed status are excluded
- Add visitCount regression tests: 2+ completed appointments → visitCount >= 2,
  no completed → visitCount = 0

Updated UAT_PLAYBOOK.md §profile-summary (visitCount regression + date filter)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:34:11 +00:00

416 lines
13 KiB
TypeScript

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
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,
getPresignedGetUrl,
deleteObject,
} from "../lib/s3.js";
export const petsRouter = new Hono<AppEnv>();
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(),
sizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(),
coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(),
temperamentScore: z.number().int().min(1).max(5).optional(),
temperamentFlags: z.array(z.string().max(100)).max(20).optional(),
medicalAlerts: z.array(z.object({
type: z.string().max(100),
description: z.string().max(1000),
severity: z.enum(["low", "medium", "high"]),
})).max(50).optional(),
preferredCuts: z.array(z.string().max(200)).max(20).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);
});
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 });
});
// ─── Profile Summary ───────────────────────────────────────────────────────────
async function groomerLinkageCheck(
db: ReturnType<typeof getDb>,
clientId: string,
staffRow: NonNullable<AppEnv["Variables"]["staff"]>
): Promise<boolean> {
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<number>`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,
});
});