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,
|
"when": 1783561000000,
|
||||||
"tag": "0026_assistant_model_route",
|
"tag": "0026_assistant_model_route",
|
||||||
"breakpoints": true
|
"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