1f888ac716
Adds a defense-in-depth audit row to impersonationAuditLogs when the staff-side owner-bypass path fires. Mirrors the failure-isolation pattern in src/middleware/portalAudit.ts: insert failures are logged and swallowed so a working read can never turn into a 500. - New writeOwnerBypassAudit helper called only when isOwner === true. - No DB migration; petId + actorStaffId go inside metadata jsonb. - resolveImpersonationClientId stays pure (no audit side effects). - Positive + negative tests + a cross-tenant regression test. - UAT_PLAYBOOK.md §3.19d: TC-API-3.19d documents the audit assertion. Parent tracking: GRO-2062 (Paperclip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
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<AppEnv>();
|
|
|
|
// 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<typeof getDb>,
|
|
c: { req: { header: (name: string) => string | undefined } }
|
|
): Promise<string | null> {
|
|
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<typeof getDb>,
|
|
args: {
|
|
sessionId: string;
|
|
petId: string;
|
|
actorStaffId: string;
|
|
pageVisited: string;
|
|
}
|
|
): Promise<void> {
|
|
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<number>`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 });
|
|
});
|