feat(pets): add pet record schema
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user