From 70af9da338d5821dee97438e1335d6016dea7f6d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 04:35:51 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20add=20extended=20pet=20profile?= =?UTF-8?q?=20fields=20=E2=80=94=20schema,=20migration,=20CRUD,=20Zod=20va?= =?UTF-8?q?lidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five new nullable columns to the pets table: - coat_type (text) - temperament_score (integer, range 1–5) - temperament_flags (jsonb, string[]) - medical_alerts (jsonb, typed MedicalAlert[]) - preferred_cuts (jsonb, string[]) Also: - Exports MedicalAlert interface and MedicalAlertSeverity type from schema - Updates shared Pet type in packages/types - Adds Zod validators for all fields (ranges, max lengths, enum) - Adds 14 tests covering happy path and validation edge cases - Fixes drizzle.config.ts schema path (was ./src/schema.ts, correct is ./src/db/schema.ts) Refs: GRO-1176 Co-Authored-By: Paperclip --- apps/api/drizzle.config.ts | 2 +- .../migrations/0030_extended_pet_profile.sql | 12 + apps/api/migrations/meta/0030_snapshot.json | 48 +++ apps/api/migrations/meta/_journal.json | 14 + .../src/__tests__/petsExtendedFields.test.ts | 408 ++++++++++++++++++ apps/api/src/db/schema.ts | 16 + apps/api/src/routes/pets.ts | 9 + apps/api/src/types/index.ts | 13 + 8 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 apps/api/migrations/0030_extended_pet_profile.sql create mode 100644 apps/api/migrations/meta/0030_snapshot.json create mode 100644 apps/api/src/__tests__/petsExtendedFields.test.ts diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 16a96b5..ccae727 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/schema.ts", + schema: "./src/db/schema.ts", out: "./migrations", dialect: "postgresql", dbCredentials: { diff --git a/apps/api/migrations/0030_extended_pet_profile.sql b/apps/api/migrations/0030_extended_pet_profile.sql new file mode 100644 index 0000000..a42346d --- /dev/null +++ b/apps/api/migrations/0030_extended_pet_profile.sql @@ -0,0 +1,12 @@ +-- Migration: 0030_extended_pet_profile +-- Adds extended profile fields to the pets table + +BEGIN; + +ALTER TABLE pets ADD COLUMN coat_type text; +ALTER TABLE pets ADD COLUMN temperament_score integer; +ALTER TABLE pets ADD COLUMN temperament_flags jsonb DEFAULT '[]'::jsonb; +ALTER TABLE pets ADD COLUMN medical_alerts jsonb DEFAULT '[]'::jsonb; +ALTER TABLE pets ADD COLUMN preferred_cuts jsonb DEFAULT '[]'::jsonb; + +COMMIT; \ No newline at end of file diff --git a/apps/api/migrations/meta/0030_snapshot.json b/apps/api/migrations/meta/0030_snapshot.json new file mode 100644 index 0000000..b60cb80 --- /dev/null +++ b/apps/api/migrations/meta/0030_snapshot.json @@ -0,0 +1,48 @@ +{ + "id": "0030_extended_pet_profile", + "prevId": "0028_sms_reminders", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pets": { + "name": "pets", + "schema": "", + "columns": { + "id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false }, + "client_id": { "name": "client_id", "type": "uuid", "isNullable": false }, + "name": { "name": "name", "type": "text", "isNullable": false }, + "species": { "name": "species", "type": "text", "isNullable": false }, + "breed": { "name": "breed", "type": "text", "isNullable": true }, + "weight_kg": { "name": "weight_kg", "type": "numeric(5, 2)", "isNullable": true }, + "date_of_birth": { "name": "date_of_birth", "type": "timestamp", "isNullable": true }, + "health_alerts": { "name": "health_alerts", "type": "text", "isNullable": true }, + "grooming_notes": { "name": "grooming_notes", "type": "text", "isNullable": true }, + "cut_style": { "name": "cut_style", "type": "text", "isNullable": true }, + "shampoo_preference": { "name": "shampoo_preference", "type": "text", "isNullable": true }, + "special_care_notes": { "name": "special_care_notes", "type": "text", "isNullable": true }, + "custom_fields": { "name": "custom_fields", "type": "jsonb", "isNullable": false, "default": "'{}'::jsonb" }, + "photo_key": { "name": "photo_key", "type": "text", "isNullable": true }, + "photo_uploaded_at": { "name": "photo_uploaded_at", "type": "timestamp", "isNullable": true }, + "image": { "name": "image", "type": "text", "isNullable": true }, + "coat_type": { "name": "coat_type", "type": "text", "isNullable": true }, + "temperament_score": { "name": "temperament_score", "type": "integer", "isNullable": true }, + "temperament_flags": { "name": "temperament_flags", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" }, + "medical_alerts": { "name": "medical_alerts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" }, + "preferred_cuts": { "name": "preferred_cuts", "type": "jsonb", "isNullable": true, "default": "'[]'::jsonb" }, + "created_at": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" }, + "updated_at": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" } + }, + "indexes": { "idx_pets_client_id": { "name": "idx_pets_client_id", "columns": [{ "expression": "client_id", "isExpression": false, "asc": true, "nulls": "last" }], "isUnique": false } }, + "foreignKeys": { "pets_client_id_clients_id_fk": { "name": "pets_client_id_clients_id_fk", "tableFrom": "pets", "tableTo": "clients", "columnsFrom": ["client_id"], "columnsTo": ["id"], "onDelete": "cascade" } }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { "columns": {}, "schemas": {}, "tables": {} } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index 8db9b8d..782d371 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -204,6 +204,20 @@ "when": 1775741667192, "tag": "0028_sms_reminders", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1775828067192, + "tag": "0029_db_indexes_constraints", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1775914467192, + "tag": "0030_extended_pet_profile", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/__tests__/petsExtendedFields.test.ts b/apps/api/src/__tests__/petsExtendedFields.test.ts new file mode 100644 index 0000000..5106a62 --- /dev/null +++ b/apps/api/src/__tests__/petsExtendedFields.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { AppEnv, StaffRow } from "../middleware/rbac.js"; +import { petsRouter } from "../routes/pets.js"; + +// ─── Mock staff fixtures ────────────────────────────────────────────────────── + +const MANAGER: StaffRow = { + id: "staff-manager-id", + oidcSub: "oidc-manager-sub", + userId: null, + role: "manager", + isSuperUser: true, + name: "Manager McManager", + email: "manager@example.com", + active: true, + icalToken: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +// ─── Mutable mock state ─────────────────────────────────────────────────────── + +const CLIENT_ID = "client-uuid-extended"; +const PET_ID = "pet-uuid-extended"; + +let petRows: Record[] = []; +let appointmentRows: Record[] = []; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; +let deletedId: string | null = null; + +function resetMock() { + petRows = [{ + id: PET_ID, + clientId: CLIENT_ID, + name: "Biscuit", + species: "dog", + breed: "Golden Retriever", + weightKg: "30.00", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + customFields: {}, + photoKey: null, + photoUploadedAt: null, + image: null, + coatType: null, + temperamentScore: null, + temperamentFlags: [], + medicalAlerts: [], + preferredCuts: [], + createdAt: new Date(), + updatedAt: new Date(), + }]; + appointmentRows = []; + insertedValues = []; + updatedValues = []; + deletedId = null; +} + +function makeSelectChainable(rows: unknown[]): unknown { + const chain = new Proxy([...rows], { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; +} + +function makeInsertChainable(): unknown { + let vals: Record = {}; + const chain = new Proxy({}, { + get(target, prop) { + if (prop === "values") { + return (v: Record) => { vals = v; return chain; }; + } + if (prop === "returning") { + return () => { + insertedValues.push(vals); + return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }]; + }; + } + return chain; + }, + }); + return chain; +} + +function makeUpdateChainable(): unknown { + let vals: Record = {}; + let whereId: string | null = null; + const chain = new Proxy({}, { + get(target, prop) { + if (prop === "set") { + return (v: Record) => { vals = v; return chain; }; + } + if (prop === "where") { + return (cond: unknown) => { + // Extract id from condition if it's an eq call + if (whereId) vals = { ...vals }; + return chain; + }; + } + if (prop === "returning") { + return () => { + const merged = { ...petRows[0], ...vals }; + updatedValues.push(vals); + return [merged]; + }; + } + return chain; + }, + }); + return chain; +} + +function makeDeleteChainable(): unknown { + let whereId: string | null = null; + const chain = new Proxy({}, { + get(target, prop) { + if (prop === "where") { + return (cond: unknown) => { + whereId = PET_ID; + return chain; + }; + } + if (prop === "returning") { + return () => { + const row = petRows[0]; + deletedId = row.id as string; + return [row]; + }; + } + return chain; + }, + }); + return chain; +} + +vi.mock("../db", () => { + const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} }); + const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} }); + return { + getDb: () => ({ + select: () => ({ + from: (table: unknown) => { + const name = (table as { _name?: string })._name; + if (name === "appointments") return makeSelectChainable(appointmentRows); + return makeSelectChainable(petRows); + }, + }), + insert: () => makeInsertChainable(), + update: () => makeUpdateChainable(), + delete: () => makeDeleteChainable(), + }), + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeApp(staff: StaffRow = MANAGER) { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("staff", staff); + await next(); + }); + return app.route("/pets", petsRouter); +} + +function createApp() { + const app = makeApp(MANAGER); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("Extended pet profile fields — validation", () => { + beforeEach(resetMock); + + it("rejects temperamentScore of 0 (below min)", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.success).toBe(false); + }); + + it("rejects temperamentScore of 6 (above max)", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.success).toBe(false); + }); + + it("rejects non-integer temperamentScore", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }), + }); + expect(res.status).toBe(400); + }); + + it("rejects invalid medicalAlert severity", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: CLIENT_ID, + name: "Test", + species: "dog", + medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }], + }), + }); + expect(res.status).toBe(400); + }); + + it("accepts valid temperamentScore 1–5", async () => { + const app = createApp(); + for (const score of [1, 2, 3, 4, 5]) { + resetMock(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }), + }); + expect(res.status).toBe(201); + } + }); + + it("accepts all valid medicalAlert severity values", async () => { + const app = createApp(); + for (const severity of ["low", "medium", "high"] as const) { + resetMock(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: CLIENT_ID, + name: "Test", + species: "dog", + medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }], + }), + }); + expect(res.status).toBe(201); + } + }); +}); + +describe("Extended pet profile fields — create", () => { + beforeEach(resetMock); + + it("accepts all extended fields on create", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: CLIENT_ID, + name: "Biscuit", + species: "dog", + breed: "Golden Retriever", + coatType: "double", + temperamentScore: 4, + temperamentFlags: ["anxious_with_dryers", "gentle"], + medicalAlerts: [ + { type: "seizure", description: "Occasional episodes", severity: "medium" }, + ], + preferredCuts: ["puppy cut", "teddy bear"], + }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.coatType).toBe("double"); + expect(body.temperamentScore).toBe(4); + expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]); + expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]); + expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]); + }); + + it("create without extended fields works (all optional)", async () => { + const app = createApp(); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }), + }); + expect(res.status).toBe(201); + }); +}); + +describe("Extended pet profile fields — update", () => { + beforeEach(resetMock); + + it("updates coatType", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ coatType: "smooth" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.coatType).toBe("smooth"); + }); + + it("updates temperamentScore", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ temperamentScore: 2 }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.temperamentScore).toBe(2); + }); + + it("rejects temperamentScore 0 on update", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ temperamentScore: 0 }), + }); + expect(res.status).toBe(400); + }); + + it("rejects invalid severity on update", async () => { + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }], + }), + }); + expect(res.status).toBe(400); + }); + + it("rejects too many temperamentFlags (>20)", async () => { + const app = createApp(); + const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }), + }); + expect(res.status).toBe(400); + }); + + it("rejects too many preferredCuts (>20)", async () => { + const app = createApp(); + const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }), + }); + expect(res.status).toBe(400); + }); + + it("rejects too many medicalAlerts (>50)", async () => { + const app = createApp(); + const alerts = Array.from({ length: 51 }, (_, i) => ({ + type: `type_${i}`, + description: `desc_${i}`, + severity: "low" as const, + })); + const res = await app.request("/pets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }), + }); + expect(res.status).toBe(400); + }); + + it("returns extended fields in GET response", async () => { + petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }]; + const app = createApp(); + const res = await app.request(`/pets/${PET_ID}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.coatType).toBe("wire"); + expect(body.temperamentScore).toBe(3); + expect(body.temperamentFlags).toEqual(["gentle"]); + expect(body.preferredCuts).toEqual(["scissor cut"]); + }); +}); \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 0a5eaef..179ea52 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -12,6 +12,16 @@ import { uuid, } from "drizzle-orm/pg-core"; +// ─── Shared types ─────────────────────────────────────────────────────────────── + +export type MedicalAlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + type: string; + description: string; + severity: MedicalAlertSeverity; +} + // ─── Enums ──────────────────────────────────────────────────────────────────── export const appointmentStatusEnum = pgEnum("appointment_status", [ @@ -146,6 +156,12 @@ export const pets = pgTable( photoKey: text("photo_key"), photoUploadedAt: timestamp("photo_uploaded_at"), image: text("image"), + // Extended profile fields + coatType: text("coat_type"), + temperamentScore: integer("temperament_score"), + temperamentFlags: jsonb("temperament_flags").$type().default([]), + medicalAlerts: jsonb("medical_alerts").$type().default([]), + preferredCuts: jsonb("preferred_cuts").$type().default([]), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, diff --git a/apps/api/src/routes/pets.ts b/apps/api/src/routes/pets.ts index f911d56..a18f85c 100644 --- a/apps/api/src/routes/pets.ts +++ b/apps/api/src/routes/pets.ts @@ -24,6 +24,15 @@ const createPetSchema = z.object({ shampooPreference: z.string().max(500).optional(), specialCareNotes: z.string().max(2000).optional(), customFields: z.record(z.string(), z.string()).optional(), + coatType: z.string().max(100).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 }); diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 90ef116..4f60f42 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -42,10 +42,23 @@ export interface Pet { customFields: Record; photoKey?: string; photoUploadedAt?: string; + coatType?: string | null; + temperamentScore?: number | null; + temperamentFlags?: string[]; + medicalAlerts?: MedicalAlert[]; + preferredCuts?: string[]; createdAt: string; updatedAt: string; } +export type MedicalAlertSeverity = "low" | "medium" | "high"; + +export interface MedicalAlert { + type: string; + description: string; + severity: MedicalAlertSeverity; +} + export interface GroomingVisitLog { id: string; petId: string; From 434c7b94e2c6022679027ab935bf1ba8e37cdea1 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 07:24:52 +0000 Subject: [PATCH 2/2] fix: export named DB utilities in petsExtendedFields test mock pets.ts imports pets, appointments, and, eq, exists, or directly from "../db". The vi.mock factory only returned getDb, causing vitest to throw "No 'pets' export is defined" and 7 tests to get 400 instead of 201/200. Fix adds the missing named exports to the mock return object. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/__tests__/petsExtendedFields.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/api/src/__tests__/petsExtendedFields.test.ts b/apps/api/src/__tests__/petsExtendedFields.test.ts index 5106a62..a013d88 100644 --- a/apps/api/src/__tests__/petsExtendedFields.test.ts +++ b/apps/api/src/__tests__/petsExtendedFields.test.ts @@ -161,6 +161,12 @@ vi.mock("../db", () => { update: () => makeUpdateChainable(), delete: () => makeDeleteChainable(), }), + pets, + appointments, + and, + eq, + exists, + or, }; });