diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 0f95961..6653db5 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -261,6 +261,10 @@ This means: | TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized | | TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" | | TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned | +| TC-API-8.12 | Portal GET pets returns extended fields (GRO-2187) | Establish a portal session (TC-API-8.8), then `GET /api/portal/pets` with `X-Impersonation-Session-Id` | 200 OK; each pet includes `coatType`, `petSizeCategory`, `healthAlerts`, `preferredCuts`, `medicalAlerts` (in addition to id/name/breed/weight/birthDate/photoUrl/notes) | +| TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values | +| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted | +| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged | ### 4.9 Waitlist diff --git a/packages/db/migrations/0041_route_optimization.sql b/packages/db/migrations/0041_route_optimization.sql new file mode 100644 index 0000000..634bfa5 --- /dev/null +++ b/packages/db/migrations/0041_route_optimization.sql @@ -0,0 +1,66 @@ +-- Migration: 0041_route_optimization.sql +-- Route optimization schema: geocoding columns on clients, groomerRoutes + +-- routeStops tables, and route settings on business_settings. +-- Written idempotently so it is safe to re-run. + +-- ─── Enums ──────────────────────────────────────────────────────────────────── + +DO $$ BEGIN + CREATE TYPE "route_status" AS ENUM ('draft', 'optimized', 'in_progress', 'completed'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ─── Clients: geocoding columns ─────────────────────────────────────────────── + +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "latitude" double precision; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "longitude" double precision; +ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "geocoded_at" timestamp; + +-- ─── Business settings: route optimization config ───────────────────────────── + +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "default_travel_buffer_mins" integer NOT NULL DEFAULT 15; +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "route_optimization_provider" text DEFAULT 'nominatim'; +-- Encrypted at rest at the application layer (AES-256-GCM). +ALTER TABLE "business_settings" + ADD COLUMN IF NOT EXISTS "google_maps_api_key" text; + +-- ─── Groomer routes table ───────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "groomer_routes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "staff_id" uuid NOT NULL REFERENCES "staff"("id") ON DELETE CASCADE, + "route_date" date NOT NULL, + "status" "route_status" NOT NULL DEFAULT 'draft', + "total_travel_mins" integer, + "total_distance_km" numeric(8, 2), + "optimized_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_groomer_routes_staff_date" UNIQUE ("staff_id", "route_date") +); + +CREATE INDEX IF NOT EXISTS "idx_groomer_routes_staff_id" + ON "groomer_routes"("staff_id"); + +-- ─── Route stops table ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "route_stops" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "route_id" uuid NOT NULL REFERENCES "groomer_routes"("id") ON DELETE CASCADE, + "appointment_id" uuid NOT NULL REFERENCES "appointments"("id") ON DELETE CASCADE, + "stop_order" integer NOT NULL, + "latitude" double precision NOT NULL, + "longitude" double precision NOT NULL, + "travel_mins_from_prev" integer, + "travel_distance_km_from_prev" numeric(8, 2), + "buffer_mins" integer NOT NULL DEFAULT 15, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "uq_route_stops_route_appointment" UNIQUE ("route_id", "appointment_id"), + CONSTRAINT "uq_route_stops_route_order" UNIQUE ("route_id", "stop_order") +); + +CREATE INDEX IF NOT EXISTS "idx_route_stops_route_id" + ON "route_stops"("route_id"); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 47e54de..1e0c785 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1780000000002, "tag": "0040_register_missing_coat_type_values", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1780000000003, + "tag": "0041_route_optimization", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index c15d42e..866e9b5 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -78,6 +78,9 @@ export function buildClient(overrides: Partial = {}): ClientRow { stripeCustomerId: null, status: "active", disabledAt: null, + latitude: null, + longitude: null, + geocodedAt: null, createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: new Date("2025-01-01T00:00:00Z"), ...overrides, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3a12d96..292fe1c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,5 +1,7 @@ import { boolean, + date, + doublePrecision, index, integer, jsonb, @@ -140,6 +142,10 @@ export const clients = pgTable( stripeCustomerId: text("stripe_customer_id"), status: clientStatusEnum("status").notNull().default("active"), disabledAt: timestamp("disabled_at"), + // Geocoded coordinates for route optimization; null until geocoded. + latitude: doublePrecision("latitude"), + longitude: doublePrecision("longitude"), + geocodedAt: timestamp("geocoded_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, @@ -555,6 +561,16 @@ export const businessSettings = pgTable("business_settings", { accentColor: text("accent_color").notNull().default("#8b7355"), messagingPhoneNumber: text("messaging_phone_number"), telnyxMessagingProfileId: text("telnyx_messaging_profile_id"), + // Route optimization settings. + defaultTravelBufferMins: integer("default_travel_buffer_mins") + .notNull() + .default(15), + routeOptimizationProvider: text("route_optimization_provider").default( + "nominatim" + ), + // Encrypted at rest at the application layer (AES-256-GCM), mirroring + // the handling of authProviderConfigs.clientSecret. + googleMapsApiKey: text("google_maps_api_key"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); @@ -658,3 +674,69 @@ export const bufferRules = pgTable( index("idx_buffer_rules_service_id").on(t.serviceId), ] ); + +// ─── Route Optimization ─────────────────────────────────────────────────────── + +export const routeStatusEnum = pgEnum("route_status", [ + "draft", + "optimized", + "in_progress", + "completed", +]); + +// A groomer's optimized route for a single day. One row per (staff, date). +export const groomerRoutes = pgTable( + "groomer_routes", + { + id: uuid("id").primaryKey().defaultRandom(), + staffId: uuid("staff_id") + .notNull() + .references(() => staff.id, { onDelete: "cascade" }), + routeDate: date("route_date", { mode: "string" }).notNull(), + status: routeStatusEnum("status").notNull().default("draft"), + // Populated once the route is optimized. + totalTravelMins: integer("total_travel_mins"), + totalDistanceKm: numeric("total_distance_km", { precision: 8, scale: 2 }), + optimizedAt: timestamp("optimized_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // One route per groomer per day. + unique("uq_groomer_routes_staff_date").on(t.staffId, t.routeDate), + index("idx_groomer_routes_staff_id").on(t.staffId), + ] +); + +// An ordered stop within a groomer's route, tied to an appointment. +export const routeStops = pgTable( + "route_stops", + { + id: uuid("id").primaryKey().defaultRandom(), + routeId: uuid("route_id") + .notNull() + .references(() => groomerRoutes.id, { onDelete: "cascade" }), + appointmentId: uuid("appointment_id") + .notNull() + .references(() => appointments.id, { onDelete: "cascade" }), + stopOrder: integer("stop_order").notNull(), + latitude: doublePrecision("latitude").notNull(), + longitude: doublePrecision("longitude").notNull(), + // Null for the first stop in the route. + travelMinsFromPrev: integer("travel_mins_from_prev"), + travelDistanceKmFromPrev: numeric("travel_distance_km_from_prev", { + precision: 8, + scale: 2, + }), + bufferMins: integer("buffer_mins").notNull().default(15), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [ + // An appointment appears at most once per route. + unique("uq_route_stops_route_appointment").on(t.routeId, t.appointmentId), + // Stop order is unique within a route. + unique("uq_route_stops_route_order").on(t.routeId, t.stopOrder), + index("idx_route_stops_route_id").on(t.routeId), + ] +); diff --git a/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts new file mode 100644 index 0000000..a95cb23 --- /dev/null +++ b/src/__tests__/portalPets.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const OTHER_CLIENT_ID = "550e8400-e29b-41d4-a716-446655440099"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: futureDate(), + createdAt: new Date(), +}; + +// A persisted pet owned by CLIENT_ID. weightKg is a string because the column is +// numeric (Drizzle serialises numeric to string). +const PET = { + id: PET_ID, + clientId: CLIENT_ID, + name: "Rex", + species: "dog", + breed: "Labrador", + weightKg: "12.50", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + coatType: null, + petSizeCategory: null, + preferredCuts: [], + medicalAlerts: [], + photoKey: null, +}; + +let selectSessionRow: Record | null = null; +let selectPetRow: Record | null = null; +let updatedValues: Record[] = []; + +function resetMock() { + selectSessionRow = null; + selectPetRow = null; + updatedValues = []; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + function tableProxy(name: string) { + return new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + } + + const impersonationSessions = tableProxy("impersonationSessions"); + const pets = tableProxy("pets"); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "pets") { + return makeChainable(selectPetRow ? [selectPetRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + if (selectPetRow) { + updatedValues.push(vals); + return [{ ...selectPetRow, ...vals }]; + } + return []; + }, + }), + }), + }), + // portalAudit inserts an audit row after the handler; make it a no-op so + // the middleware does not log a swallowed error during tests. + insert: () => ({ values: () => ({ returning: () => [] }) }), + }), + impersonationSessions, + pets, + // Other tables imported by the portal router but unused in these tests. + appointments: tableProxy("appointments"), + waitlistEntries: tableProxy("waitlistEntries"), + clients: tableProxy("clients"), + services: tableProxy("services"), + staff: tableProxy("staff"), + invoices: tableProxy("invoices"), + invoiceLineItems: tableProxy("invoiceLineItems"), + impersonationAuditLogs: tableProxy("impersonationAuditLogs"), + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function jsonPatch(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => resetMock()); + +describe("PATCH /portal/pets/:petId", () => { + it("updates an owned pet and persists the mapped columns (200)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + // Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet + // (weight, notes, birthDate, photoUrl) and adds the form's edited keys + // (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large". + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { + id: PET_ID, + name: "Rex Updated", + breed: "Golden Retriever", + weight: "12.50", + weightKg: 18.25, + notes: "old grooming notes", + healthAlerts: "Allergic to oatmeal shampoo", + photoUrl: "pets/rex.jpg", + coatType: "double", + petSizeCategory: "xlarge", + preferredCuts: ["teddy bear", "puppy cut"], + medicalAlerts: [ + { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, + ], + }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Rex Updated"); + expect(body.petSizeCategory).toBe("extra_large"); + expect(body.coatType).toBe("double"); + + const persisted = updatedValues[0]!; + expect(persisted.name).toBe("Rex Updated"); + expect(persisted.breed).toBe("Golden Retriever"); + // weightKg (form key) wins over weight (GET key) and is stored as a string. + expect(persisted.weightKg).toBe("18.25"); + expect(persisted.groomingNotes).toBe("old grooming notes"); + expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo"); + expect(persisted.photoKey).toBe("pets/rex.jpg"); + expect(persisted.coatType).toBe("double"); + expect(persisted.petSizeCategory).toBe("extra_large"); + expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]); + expect(persisted.medicalAlerts).toEqual([ + { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, + ]); + expect(persisted.updatedAt).toBeInstanceOf(Date); + }); + + it("falls back to the weight key when weightKg is absent", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { weight: "9.75" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + expect(updatedValues[0]!.weightKg).toBe("9.75"); + }); + + it("returns 403 when the pet belongs to a different client", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID }; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { name: "Hacker" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(403); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 404 when the pet does not exist", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = null; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { name: "Ghost" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(404); + }); + + it("returns 422 for an invalid coatType", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { coatType: "fluffy" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(422); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 422 for an invalid petSizeCategory", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { petSizeCategory: "gigantic" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(422); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 401 without an impersonation session header", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch(`/portal/pets/${PET_ID}`, { name: "NoAuth" }); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 7b7b160..3f15745 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -225,9 +225,153 @@ portalRouter.get("/pets", async (c) => { const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); - return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes }))); + return c.json(clientPets.map(p => ({ + id: p.id, + name: p.name, + breed: p.breed, + weight: p.weightKg, + birthDate: p.dateOfBirth, + photoUrl: p.photoKey, + notes: p.groomingNotes, + coatType: p.coatType, + petSizeCategory: p.petSizeCategory, + healthAlerts: p.healthAlerts, + preferredCuts: p.preferredCuts, + medicalAlerts: p.medicalAlerts, + }))); }); +// ─── Customer-facing pet update ─────────────────────────────────────────────── +// +// The customer portal pet-profile form (groombook/web) saves edits via +// PATCH /api/portal/pets/:petId. The web payload mixes the keys returned by +// GET /portal/pets (weight, birthDate, photoUrl, notes) with the form's own +// edited keys (weightKg, healthAlerts, coatType, …), so we accept both spellings +// and map each to its `pets` column. Ownership is enforced exactly like the +// appointment-notes handler: 404 if the pet does not exist, 403 if it belongs to +// another client. + +// Allowed enum values mirror packages/db/src/schema.ts coatTypeEnum / +// petSizeCategoryEnum. Kept as plain string lists so an invalid value can be +// rejected with 422 in-handler (zValidator failures would surface as 400). +const PORTAL_COAT_TYPES: readonly string[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]; +const PORTAL_PET_SIZES: readonly string[] = ["small", "medium", "large", "extra_large"]; +// The web size dropdown emits "xlarge"; the DB enum value is "extra_large". +const PORTAL_PET_SIZE_ALIASES: Record = { xlarge: "extra_large" }; + +const portalMedicalAlertSchema = z.object({ + id: z.string().optional(), + type: z.string(), + description: z.string(), + severity: z.enum(["low", "medium", "high"]), +}); + +const portalPetUpdateSchema = z.object({ + name: z.string().min(1).max(200).optional(), + breed: z.string().max(200).nullable().optional(), + // weightKg is the form's edited key; weight is the GET-shaped key. Accept both. + weightKg: z.union([z.number(), z.string()]).nullable().optional(), + weight: z.union([z.number(), z.string()]).nullable().optional(), + birthDate: z.string().nullable().optional(), + notes: z.string().max(2000).nullable().optional(), + healthAlerts: z.string().max(2000).nullable().optional(), + photoUrl: z.string().nullable().optional(), + // coatType / petSizeCategory validated in-handler so bad values return 422. + coatType: z.string().nullable().optional(), + petSizeCategory: z.string().nullable().optional(), + preferredCuts: z.array(z.string()).nullable().optional(), + medicalAlerts: z.array(portalMedicalAlertSchema).nullable().optional(), +}); + +portalRouter.patch( + "/pets/:petId", + zValidator("json", portalPetUpdateSchema), + async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const [pet] = await db + .select() + .from(pets) + .where(eq(pets.id, petId)) + .limit(1); + + if (!pet) { + return c.json({ error: "Not found" }, 404); + } + + if (pet.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + const updateData: Record = { updatedAt: new Date() }; + + if (body.name !== undefined) updateData.name = body.name; + if (body.breed !== undefined) updateData.breed = body.breed; + + if (body.weightKg !== undefined || body.weight !== undefined) { + const w = body.weightKg ?? body.weight; + updateData.weightKg = w === null || w === undefined ? null : String(w); + } + + if (body.birthDate !== undefined) { + updateData.dateOfBirth = body.birthDate ? new Date(body.birthDate) : null; + } + + if (body.notes !== undefined) updateData.groomingNotes = body.notes; + if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts; + if (body.photoUrl !== undefined) updateData.photoKey = body.photoUrl; + + if (body.coatType !== undefined) { + if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) { + return c.json({ error: "Invalid coatType" }, 422); + } + updateData.coatType = body.coatType; + } + + if (body.petSizeCategory !== undefined) { + let size: string | null = body.petSizeCategory; + if (size !== null) { + size = PORTAL_PET_SIZE_ALIASES[size] ?? size; + if (!PORTAL_PET_SIZES.includes(size)) { + return c.json({ error: "Invalid petSizeCategory" }, 422); + } + } + updateData.petSizeCategory = size; + } + + if (body.preferredCuts !== undefined) updateData.preferredCuts = body.preferredCuts ?? []; + if (body.medicalAlerts !== undefined) updateData.medicalAlerts = body.medicalAlerts ?? []; + + const [updated] = await db + .update(pets) + .set(updateData) + .where(eq(pets.id, petId)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + name: updated.name, + breed: updated.breed, + weight: updated.weightKg, + birthDate: updated.dateOfBirth, + photoUrl: updated.photoKey, + notes: updated.groomingNotes, + coatType: updated.coatType, + petSizeCategory: updated.petSizeCategory, + healthAlerts: updated.healthAlerts, + preferredCuts: updated.preferredCuts, + medicalAlerts: updated.medicalAlerts, + }); + } +); + portalRouter.get("/invoices", async (c) => { const db = getDb(); const clientId = c.get("portalClientId");