feat(api): add extended pet profile fields — schema, migration, CRUD, Zod validation #10

Merged
groombook-engineer[bot] merged 2 commits from flea-flicker/pet-profile-extended-fields into dev 2026-05-19 23:42:33 +00:00
8 changed files with 527 additions and 1 deletions
+1 -1
View File
@@ -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: {
@@ -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;
@@ -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": {} }
}
+14
View File
@@ -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
}
]
}
@@ -0,0 +1,414 @@
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<string, unknown>[] = [];
let appointmentRows: Record<string, unknown>[] = [];
let insertedValues: Record<string, unknown>[] = [];
let updatedValues: Record<string, unknown>[] = [];
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<string, unknown> = {};
const chain = new Proxy({}, {
get(target, prop) {
if (prop === "values") {
return (v: Record<string, unknown>) => { 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<string, unknown> = {};
let whereId: string | null = null;
const chain = new Proxy({}, {
get(target, prop) {
if (prop === "set") {
return (v: Record<string, unknown>) => { 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(),
}),
pets,
appointments,
and,
eq,
exists,
or,
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeApp(staff: StaffRow = MANAGER) {
const app = new Hono<AppEnv>();
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 15", 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"]);
});
});
+16
View File
@@ -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<string[]>().default([]),
medicalAlerts: jsonb("medical_alerts").$type<MedicalAlert[]>().default([]),
preferredCuts: jsonb("preferred_cuts").$type<string[]>().default([]),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
+9
View File
@@ -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 });
+13
View File
@@ -42,10 +42,23 @@ export interface Pet {
customFields: Record<string, string>;
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;