feat(pets): add pet record schema

This commit is contained in:
ginnoir
2026-07-09 20:50:11 -05:00
parent f3c6137fed
commit 7ffc2f9522
6 changed files with 1692 additions and 1 deletions
+70
View File
@@ -0,0 +1,70 @@
-- Custom SQL migration file, put your code below! --
CREATE TABLE "pets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"name" text NOT NULL,
"species" text NOT NULL,
"breed" text,
"birth_date" date,
"notes" text,
"primary_image_url" text,
"images" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pets_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE TABLE "pet_appointments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"household_id" uuid NOT NULL,
"title" text NOT NULL,
"appointment_at" timestamp with time zone NOT NULL,
"clinic" text,
"notes" text,
"calendar_event_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pet_appointments_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "pet_appointments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE TABLE "pet_vaccinations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"household_id" uuid NOT NULL,
"name" text NOT NULL,
"administered_on" date NOT NULL,
"due_on" date,
"provider" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pet_vaccinations_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "pet_vaccinations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE TABLE "pet_prescriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pet_id" uuid NOT NULL,
"household_id" uuid NOT NULL,
"medication" text NOT NULL,
"dosage" text NOT NULL,
"instructions" text,
"prescribed_on" date,
"expires_on" date,
"refills_remaining" integer,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pet_prescriptions_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action,
CONSTRAINT "pet_prescriptions_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
);
--> statement-breakpoint
CREATE INDEX "pets_household_idx" ON "pets" USING btree ("household_id");
--> statement-breakpoint
CREATE INDEX "pet_appointments_pet_at_idx" ON "pet_appointments" USING btree ("pet_id", "appointment_at");
--> statement-breakpoint
CREATE INDEX "pet_vaccinations_pet_due_idx" ON "pet_vaccinations" USING btree ("pet_id", "due_on");
--> statement-breakpoint
CREATE INDEX "pet_prescriptions_pet_active_idx" ON "pet_prescriptions" USING btree ("pet_id", "active");
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -190,6 +190,13 @@
"when": 1783561000000,
"tag": "0026_assistant_model_route",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1783648174004,
"tag": "0027_pets_module",
"breakpoints": true
}
]
}
}
+102
View File
@@ -0,0 +1,102 @@
import {
boolean,
date,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { households } from "../_core/schema";
export const pets = pgTable(
"pets",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
species: text("species").notNull(),
breed: text("breed"),
birthDate: date("birth_date"),
notes: text("notes"),
primaryImageUrl: text("primary_image_url"),
images: jsonb("images").notNull().default([]).$type<string[]>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("pets_household_idx").on(table.householdId)],
);
export const petAppointments = pgTable(
"pet_appointments",
{
id: uuid("id").primaryKey().defaultRandom(),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
title: text("title").notNull(),
appointmentAt: timestamp("appointment_at", { withTimezone: true }).notNull(),
clinic: text("clinic"),
notes: text("notes"),
calendarEventId: uuid("calendar_event_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("pet_appointments_pet_at_idx").on(table.petId, table.appointmentAt)],
);
export const petVaccinations = pgTable(
"pet_vaccinations",
{
id: uuid("id").primaryKey().defaultRandom(),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
administeredOn: date("administered_on").notNull(),
dueOn: date("due_on"),
provider: text("provider"),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("pet_vaccinations_pet_due_idx").on(table.petId, table.dueOn)],
);
export const petPrescriptions = pgTable(
"pet_prescriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
petId: uuid("pet_id")
.notNull()
.references(() => pets.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
medication: text("medication").notNull(),
dosage: text("dosage").notNull(),
instructions: text("instructions"),
prescribedOn: date("prescribed_on"),
expiresOn: date("expires_on"),
refillsRemaining: integer("refills_remaining"),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("pet_prescriptions_pet_active_idx").on(table.petId, table.active)],
);
export type Pet = typeof pets.$inferSelect;
export type PetAppointment = typeof petAppointments.$inferSelect;
export type PetVaccination = typeof petVaccinations.$inferSelect;
export type PetPrescription = typeof petPrescriptions.$inferSelect;
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
export const petInput = z.object({
name: z.string().trim().min(1).max(120),
species: z.string().trim().min(1).max(80),
breed: optionalText(120),
birthDate: isoDate.nullable().optional(),
notes: optionalText(4000),
images: z.array(z.string().min(1)).max(10).default([]),
primaryImageUrl: z.string().min(1).nullable().optional(),
});
export const petUpdateInput = petInput.partial();
export const appointmentInput = z.object({
petId: z.string().uuid(),
title: z.string().trim().min(1).max(160),
appointmentAt: z.coerce.date(),
clinic: optionalText(160),
notes: optionalText(4000),
reminderOffsets: z.array(z.number().int().min(0).max(43_200)).default([30]),
});
export const appointmentUpdateInput = appointmentInput.omit({ petId: true }).partial();
export const vaccinationInput = z.object({
petId: z.string().uuid(),
name: z.string().trim().min(1).max(160),
administeredOn: isoDate,
dueOn: isoDate.nullable().optional(),
provider: optionalText(160),
notes: optionalText(4000),
});
export const vaccinationUpdateInput = vaccinationInput.omit({ petId: true }).partial();
export const prescriptionInput = z.object({
petId: z.string().uuid(),
medication: z.string().trim().min(1).max(160),
dosage: z.string().trim().min(1).max(160),
instructions: optionalText(2000),
prescribedOn: isoDate.nullable().optional(),
expiresOn: isoDate.nullable().optional(),
refillsRemaining: z.number().int().min(0).max(99).nullable().optional(),
active: z.boolean().default(true),
});
export const prescriptionUpdateInput = prescriptionInput.omit({ petId: true }).partial();
export const scheduleAppointmentInput = z.object({
appointmentId: z.string().uuid(),
calendarId: z.string().uuid(),
});
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
appointmentInput,
petInput,
prescriptionInput,
scheduleAppointmentInput,
vaccinationInput,
} from "../../src/modules/pets/server/schemas";
const PET_ID = "f5a50e32-a217-4d65-ae10-98a1f441d171";
const CALENDAR_ID = "bd8e10c2-58f4-4db8-9326-0b50fdda47bb";
test("pet and medical record schemas accept bounded valid input", () => {
assert.equal(petInput.parse({ name: "Mochi", species: "cat", images: [] }).name, "Mochi");
assert.equal(
appointmentInput.parse({
petId: PET_ID,
title: "Annual exam",
appointmentAt: "2026-08-01T15:00:00.000Z",
}).title,
"Annual exam",
);
assert.equal(
vaccinationInput.parse({ petId: PET_ID, name: "Rabies", administeredOn: "2026-07-01" }).name,
"Rabies",
);
assert.equal(
prescriptionInput.parse({ petId: PET_ID, medication: "Apoquel", dosage: "5 mg daily" }).active,
true,
);
assert.deepEqual(
scheduleAppointmentInput.parse({ appointmentId: PET_ID, calendarId: CALENDAR_ID }),
{
appointmentId: PET_ID,
calendarId: CALENDAR_ID,
},
);
});
test("pet and medical record schemas reject invalid identifiers, dates, and galleries", () => {
assert.throws(() => petInput.parse({ name: "", species: "cat" }));
assert.throws(() =>
petInput.parse({
name: "Mochi",
species: "cat",
images: Array(11).fill("/api/uploads/pets/a.jpg"),
}),
);
assert.throws(() =>
appointmentInput.parse({ petId: "not-a-uuid", title: "Exam", appointmentAt: "not-a-date" }),
);
assert.throws(() =>
vaccinationInput.parse({ petId: PET_ID, name: "Rabies", administeredOn: "07/01/2026" }),
);
assert.throws(() =>
scheduleAppointmentInput.parse({ appointmentId: "bad", calendarId: "also-bad" }),
);
});