From 7704924d880771c1920e738d0f84e33d92703f74 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 9 Jul 2026 20:55:10 -0500 Subject: [PATCH] feat(pets): add scoped records and reminders --- src/lib/household-timezone.ts | 39 ++++ src/modules/pets/server/actions.ts | 322 ++++++++++++++++++++++++++ src/modules/pets/server/queries.ts | 210 +++++++++++++++++ src/modules/pets/server/reminders.ts | 54 +++++ tests/unit/household-timezone.test.ts | 16 ++ 5 files changed, 641 insertions(+) create mode 100644 src/lib/household-timezone.ts create mode 100644 src/modules/pets/server/actions.ts create mode 100644 src/modules/pets/server/queries.ts create mode 100644 src/modules/pets/server/reminders.ts create mode 100644 tests/unit/household-timezone.test.ts diff --git a/src/lib/household-timezone.ts b/src/lib/household-timezone.ts new file mode 100644 index 0000000..4d49541 --- /dev/null +++ b/src/lib/household-timezone.ts @@ -0,0 +1,39 @@ +export function resolveHouseholdTimezone(): string { + return process.env.HOUSEHOLD_TIMEZONE?.trim() || process.env.TZ?.trim() || "America/Chicago"; +} + +function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): number { + const value = parts.find((item) => item.type === type)?.value; + if (!value) throw new Error(`Missing ${type} timezone part`); + return Number(value); +} + +export function householdDateAt( + isoDate: string, + hour: number, + timeZone = resolveHouseholdTimezone(), +): Date { + const [year, month, day] = isoDate.split("-").map(Number); + if (!year || !month || !day || hour < 0 || hour > 23) throw new Error("Invalid household date"); + + const candidate = Date.UTC(year, month - 1, day, hour); + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(candidate)); + const formattedAsUtc = Date.UTC( + part(parts, "year"), + part(parts, "month") - 1, + part(parts, "day"), + part(parts, "hour"), + part(parts, "minute"), + part(parts, "second"), + ); + return new Date(candidate - (formattedAsUtc - candidate)); +} diff --git a/src/modules/pets/server/actions.ts b/src/modules/pets/server/actions.ts new file mode 100644 index 0000000..fd1709e --- /dev/null +++ b/src/modules/pets/server/actions.ts @@ -0,0 +1,322 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import type { ApiAuthContext } from "@/lib/api-auth"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { logActivityForScope } from "@/modules/_core/activity"; +import { cancelReminder, listReminderOffsets } from "@/modules/_core/reminders"; +import { petAppointments, petPrescriptions, pets, petVaccinations } from "../schema"; +import { syncPetAppointmentReminders, syncPetDueDateReminder } from "./reminders"; +import { + appointmentInput, + appointmentUpdateInput, + petInput, + petUpdateInput, + prescriptionInput, + prescriptionUpdateInput, + vaccinationInput, + vaccinationUpdateInput, +} from "./schemas"; + +type Scope = Pick; +const idInput = z.object({ id: z.string().uuid() }); + +async function requirePet(scope: Scope, id: string) { + const [pet] = await db + .select() + .from(pets) + .where(and(eq(pets.id, id), eq(pets.householdId, scope.householdId))) + .limit(1); + if (!pet) throw new Error("Pet not found"); + return pet; +} + +async function requireAppointment(scope: Scope, id: string) { + const [row] = await db + .select() + .from(petAppointments) + .where(and(eq(petAppointments.id, id), eq(petAppointments.householdId, scope.householdId))) + .limit(1); + if (!row) throw new Error("Record not found"); + return row; +} +async function requireVaccination(scope: Scope, id: string) { + const [row] = await db + .select() + .from(petVaccinations) + .where(and(eq(petVaccinations.id, id), eq(petVaccinations.householdId, scope.householdId))) + .limit(1); + if (!row) throw new Error("Record not found"); + return row; +} +async function requirePrescription(scope: Scope, id: string) { + const [row] = await db + .select() + .from(petPrescriptions) + .where(and(eq(petPrescriptions.id, id), eq(petPrescriptions.householdId, scope.householdId))) + .limit(1); + if (!row) throw new Error("Record not found"); + return row; +} + +function revalidatePet(id: string) { + revalidatePath("/pets"); + revalidatePath(`/pets/${id}`); +} + +export async function createPetForScope(scope: Scope, input: z.input) { + const parsed = petInput.parse(input); + const [pet] = await db + .insert(pets) + .values({ + householdId: scope.householdId, + ...parsed, + breed: parsed.breed ?? null, + birthDate: parsed.birthDate ?? null, + notes: parsed.notes ?? null, + primaryImageUrl: parsed.primaryImageUrl ?? parsed.images[0] ?? null, + }) + .returning(); + if (!pet) throw new Error("Pet was not created"); + await logActivityForScope(scope, { + entityType: "pets.pet", + entityId: pet.id, + action: "create", + payload: { name: pet.name }, + }); + return pet; +} +export async function createPet(input: z.input) { + const { household, user } = await getCurrentSession(); + const pet = await createPetForScope({ householdId: household.id, userId: user.id }, input); + revalidatePet(pet.id); + return pet; +} + +export async function updatePetForScope( + scope: Scope, + input: { id: string } & z.input, +) { + const parsed = idInput.and(petUpdateInput).parse(input); + await requirePet(scope, parsed.id); + const { id, ...changes } = parsed; + await db + .update(pets) + .set({ ...changes, updatedAt: new Date() }) + .where(eq(pets.id, id)); + await logActivityForScope(scope, { entityType: "pets.pet", entityId: id, action: "update" }); +} +export async function updatePet(input: { id: string } & z.input) { + const { household, user } = await getCurrentSession(); + await updatePetForScope({ householdId: household.id, userId: user.id }, input); + revalidatePet(input.id); +} + +export async function deletePetForScope(scope: Scope, input: { id: string }) { + const { id } = idInput.parse(input); + const pet = await requirePet(scope, id); + await logActivityForScope(scope, { + entityType: "pets.pet", + entityId: id, + action: "delete", + payload: { name: pet.name }, + }); + await db.delete(pets).where(eq(pets.id, id)); +} +export async function deletePet(input: { id: string }) { + const { household, user } = await getCurrentSession(); + await deletePetForScope({ householdId: household.id, userId: user.id }, input); + revalidatePath("/pets"); +} + +export async function createAppointmentForScope( + scope: Scope, + input: z.input, +) { + const parsed = appointmentInput.parse(input); + const pet = await requirePet(scope, parsed.petId); + const [row] = await db + .insert(petAppointments) + .values({ + householdId: scope.householdId, + petId: parsed.petId, + title: parsed.title, + appointmentAt: parsed.appointmentAt, + clinic: parsed.clinic ?? null, + notes: parsed.notes ?? null, + }) + .returning(); + if (!row) throw new Error("Appointment was not created"); + if (scope.userId) + await syncPetAppointmentReminders({ + householdId: scope.householdId, + appointmentId: row.id, + appointmentAt: row.appointmentAt, + createdBy: scope.userId, + petName: pet.name, + title: row.title, + offsets: parsed.reminderOffsets, + }); + return row; +} +export async function updateAppointmentForScope( + scope: Scope, + input: { id: string } & z.input, +) { + const parsed = idInput.and(appointmentUpdateInput).parse(input); + const row = await requireAppointment(scope, parsed.id); + const pet = await requirePet(scope, row.petId); + const offsets = parsed.reminderOffsets ?? (await listReminderOffsets("pets.appointment", row.id)); + const { id, reminderOffsets: _, ...changes } = parsed; + const [updated] = await db + .update(petAppointments) + .set({ ...changes, updatedAt: new Date() }) + .where(eq(petAppointments.id, id)) + .returning(); + if (!updated) throw new Error("Appointment not found"); + if (scope.userId) + await syncPetAppointmentReminders({ + householdId: scope.householdId, + appointmentId: id, + appointmentAt: updated.appointmentAt, + createdBy: scope.userId, + petName: pet.name, + title: updated.title, + offsets, + }); + return updated; +} +export async function deleteAppointmentForScope(scope: Scope, input: { id: string }) { + const { id } = idInput.parse(input); + await requireAppointment(scope, id); + await cancelReminder("pets.appointment", id); + await db.delete(petAppointments).where(eq(petAppointments.id, id)); +} + +export async function createVaccinationForScope( + scope: Scope, + input: z.input, +) { + const parsed = vaccinationInput.parse(input); + const pet = await requirePet(scope, parsed.petId); + const [row] = await db + .insert(petVaccinations) + .values({ + householdId: scope.householdId, + ...parsed, + dueOn: parsed.dueOn ?? null, + provider: parsed.provider ?? null, + notes: parsed.notes ?? null, + }) + .returning(); + if (!row) throw new Error("Vaccination was not created"); + if (scope.userId) + await syncPetDueDateReminder({ + householdId: scope.householdId, + entityType: "pets.vaccination", + entityId: row.id, + dueOn: row.dueOn, + createdBy: scope.userId, + title: `Vaccination due: ${pet.name}`, + body: row.name, + }); + return row; +} +export async function updateVaccinationForScope( + scope: Scope, + input: { id: string } & z.input, +) { + const parsed = idInput.and(vaccinationUpdateInput).parse(input); + const row = await requireVaccination(scope, parsed.id); + const pet = await requirePet(scope, row.petId); + const { id, ...changes } = parsed; + const [updated] = await db + .update(petVaccinations) + .set({ ...changes, updatedAt: new Date() }) + .where(eq(petVaccinations.id, id)) + .returning(); + if (!updated) throw new Error("Vaccination not found"); + if (scope.userId) + await syncPetDueDateReminder({ + householdId: scope.householdId, + entityType: "pets.vaccination", + entityId: id, + dueOn: updated.dueOn, + createdBy: scope.userId, + title: `Vaccination due: ${pet.name}`, + body: updated.name, + }); + return updated; +} +export async function deleteVaccinationForScope(scope: Scope, input: { id: string }) { + const { id } = idInput.parse(input); + await requireVaccination(scope, id); + await cancelReminder("pets.vaccination", id); + await db.delete(petVaccinations).where(eq(petVaccinations.id, id)); +} + +export async function createPrescriptionForScope( + scope: Scope, + input: z.input, +) { + const parsed = prescriptionInput.parse(input); + const pet = await requirePet(scope, parsed.petId); + const [row] = await db + .insert(petPrescriptions) + .values({ + householdId: scope.householdId, + ...parsed, + instructions: parsed.instructions ?? null, + prescribedOn: parsed.prescribedOn ?? null, + expiresOn: parsed.expiresOn ?? null, + refillsRemaining: parsed.refillsRemaining ?? null, + }) + .returning(); + if (!row) throw new Error("Prescription was not created"); + if (scope.userId) + await syncPetDueDateReminder({ + householdId: scope.householdId, + entityType: "pets.prescription", + entityId: row.id, + dueOn: row.active ? row.expiresOn : null, + createdBy: scope.userId, + title: `Prescription due: ${pet.name}`, + body: row.medication, + }); + return row; +} +export async function updatePrescriptionForScope( + scope: Scope, + input: { id: string } & z.input, +) { + const parsed = idInput.and(prescriptionUpdateInput).parse(input); + const row = await requirePrescription(scope, parsed.id); + const pet = await requirePet(scope, row.petId); + const { id, ...changes } = parsed; + const [updated] = await db + .update(petPrescriptions) + .set({ ...changes, updatedAt: new Date() }) + .where(eq(petPrescriptions.id, id)) + .returning(); + if (!updated) throw new Error("Prescription not found"); + if (scope.userId) + await syncPetDueDateReminder({ + householdId: scope.householdId, + entityType: "pets.prescription", + entityId: id, + dueOn: updated.active ? updated.expiresOn : null, + createdBy: scope.userId, + title: `Prescription due: ${pet.name}`, + body: updated.medication, + }); + return updated; +} +export async function deletePrescriptionForScope(scope: Scope, input: { id: string }) { + const { id } = idInput.parse(input); + await requirePrescription(scope, id); + await cancelReminder("pets.prescription", id); + await db.delete(petPrescriptions).where(eq(petPrescriptions.id, id)); +} diff --git a/src/modules/pets/server/queries.ts b/src/modules/pets/server/queries.ts new file mode 100644 index 0000000..12d9df8 --- /dev/null +++ b/src/modules/pets/server/queries.ts @@ -0,0 +1,210 @@ +import { and, asc, desc, eq, or, sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { getCurrentSession } from "@/lib/session"; +import { petAppointments, petPrescriptions, pets, petVaccinations } from "../schema"; + +export type PetListItemDto = { + id: string; + name: string; + species: string; + breed: string | null; + primaryImageUrl: string | null; +}; + +export type PetAppointmentDto = { + id: string; + petId: string; + title: string; + appointmentAt: string; + clinic: string | null; + notes: string | null; + calendarEventId: string | null; +}; + +export type PetVaccinationDto = { + id: string; + petId: string; + name: string; + administeredOn: string; + dueOn: string | null; + provider: string | null; + notes: string | null; +}; + +export type PetPrescriptionDto = { + id: string; + petId: string; + medication: string; + dosage: string; + instructions: string | null; + prescribedOn: string | null; + expiresOn: string | null; + refillsRemaining: number | null; + active: boolean; +}; + +export type PetDetailDto = PetListItemDto & { + householdId: string; + birthDate: string | null; + notes: string | null; + images: string[]; + appointments: PetAppointmentDto[]; + vaccinations: PetVaccinationDto[]; + prescriptions: PetPrescriptionDto[]; +}; + +const toAppointment = (row: typeof petAppointments.$inferSelect): PetAppointmentDto => ({ + id: row.id, + petId: row.petId, + title: row.title, + appointmentAt: row.appointmentAt.toISOString(), + clinic: row.clinic, + notes: row.notes, + calendarEventId: row.calendarEventId, +}); +const toVaccination = (row: typeof petVaccinations.$inferSelect): PetVaccinationDto => ({ + id: row.id, + petId: row.petId, + name: row.name, + administeredOn: row.administeredOn, + dueOn: row.dueOn, + provider: row.provider, + notes: row.notes, +}); +const toPrescription = (row: typeof petPrescriptions.$inferSelect): PetPrescriptionDto => ({ + id: row.id, + petId: row.petId, + medication: row.medication, + dosage: row.dosage, + instructions: row.instructions, + prescribedOn: row.prescribedOn, + expiresOn: row.expiresOn, + refillsRemaining: row.refillsRemaining, + active: row.active, +}); + +export async function listPetsForScope(householdId: string): Promise { + const rows = await db + .select() + .from(pets) + .where(eq(pets.householdId, householdId)) + .orderBy(asc(pets.name)); + return rows.map((row) => ({ + id: row.id, + name: row.name, + species: row.species, + breed: row.breed, + primaryImageUrl: row.primaryImageUrl, + })); +} + +export async function listPets() { + const { household } = await getCurrentSession(); + return listPetsForScope(household.id); +} + +export async function getPetForScope( + householdId: string, + id: string, +): Promise { + const [pet] = await db + .select() + .from(pets) + .where(and(eq(pets.id, id), eq(pets.householdId, householdId))) + .limit(1); + if (!pet) return null; + const [appointments, vaccinations, prescriptions] = await Promise.all([ + db + .select() + .from(petAppointments) + .where(and(eq(petAppointments.petId, id), eq(petAppointments.householdId, householdId))) + .orderBy(desc(petAppointments.appointmentAt)), + db + .select() + .from(petVaccinations) + .where(and(eq(petVaccinations.petId, id), eq(petVaccinations.householdId, householdId))) + .orderBy(desc(petVaccinations.administeredOn)), + db + .select() + .from(petPrescriptions) + .where(and(eq(petPrescriptions.petId, id), eq(petPrescriptions.householdId, householdId))) + .orderBy(desc(petPrescriptions.createdAt)), + ]); + return { + id: pet.id, + householdId: pet.householdId, + name: pet.name, + species: pet.species, + breed: pet.breed, + birthDate: pet.birthDate, + notes: pet.notes, + primaryImageUrl: pet.primaryImageUrl, + images: pet.images, + appointments: appointments.map(toAppointment), + vaccinations: vaccinations.map(toVaccination), + prescriptions: prescriptions.map(toPrescription), + }; +} + +export async function getPet(id: string) { + const { household } = await getCurrentSession(); + return getPetForScope(household.id, id); +} + +export async function searchPets(query: string, householdId: string) { + const term = `%${query}%`; + const rows = await db + .select({ id: pets.id, name: pets.name, species: pets.species, breed: pets.breed }) + .from(pets) + .where( + and( + eq(pets.householdId, householdId), + or( + sql`${pets.name} ilike ${term}`, + sql`${pets.species} ilike ${term}`, + sql`coalesce(${pets.breed}, '') ilike ${term}`, + ), + ), + ) + .limit(10); + return rows.map((row) => ({ + id: row.id, + title: row.name, + excerpt: [row.species, row.breed].filter(Boolean).join(" ยท "), + url: `/pets/${row.id}`, + })); +} + +export async function getPetsOverviewStats(householdId: string) { + const [petCount] = await db + .select({ count: sql`count(*)::int` }) + .from(pets) + .where(eq(pets.householdId, householdId)); + const [vaccinations, prescriptions] = await Promise.all([ + db + .select({ count: sql`count(*)::int` }) + .from(petVaccinations) + .where( + and( + eq(petVaccinations.householdId, householdId), + sql`${petVaccinations.dueOn} <= current_date + interval '30 days'`, + ), + ), + db + .select({ count: sql`count(*)::int` }) + .from(petPrescriptions) + .where( + and( + eq(petPrescriptions.householdId, householdId), + eq(petPrescriptions.active, true), + sql`${petPrescriptions.expiresOn} <= current_date + interval '30 days'`, + ), + ), + ]); + return { + petCount: Number(petCount?.count ?? 0), + dueSoonCount: Number(vaccinations[0]?.count ?? 0) + Number(prescriptions[0]?.count ?? 0), + }; +} + +export { toAppointment, toPrescription, toVaccination }; diff --git a/src/modules/pets/server/reminders.ts b/src/modules/pets/server/reminders.ts new file mode 100644 index 0000000..392d3b5 --- /dev/null +++ b/src/modules/pets/server/reminders.ts @@ -0,0 +1,54 @@ +import { fireAtForEventStart, normalizeReminderOffsets } from "@/lib/reminder-offsets"; +import { householdDateAt } from "@/lib/household-timezone"; +import { + cancelReminder, + scheduleReminder, + syncRemindersForEntity, +} from "@/modules/_core/reminders"; + +export async function syncPetAppointmentReminders(input: { + householdId: string; + appointmentId: string; + appointmentAt: Date; + createdBy: string; + petName: string; + title: string; + offsets: number[]; +}) { + await syncRemindersForEntity({ + householdId: input.householdId, + entityType: "pets.appointment", + entityId: input.appointmentId, + createdBy: input.createdBy, + reminders: normalizeReminderOffsets(input.offsets).map((offsetMinutes) => ({ + fireAt: fireAtForEventStart(input.appointmentAt, offsetMinutes), + offsetMinutes, + title: `Vet visit: ${input.petName}`, + body: input.title, + })), + }); +} + +export async function syncPetDueDateReminder(input: { + householdId: string; + entityType: "pets.vaccination" | "pets.prescription"; + entityId: string; + dueOn: string | null; + createdBy: string; + title: string; + body: string; +}) { + if (!input.dueOn) { + await cancelReminder(input.entityType, input.entityId); + return; + } + await scheduleReminder({ + householdId: input.householdId, + entityType: input.entityType, + entityId: input.entityId, + fireAt: householdDateAt(input.dueOn, 9), + createdBy: input.createdBy, + title: input.title, + body: input.body, + }); +} diff --git a/tests/unit/household-timezone.test.ts b/tests/unit/household-timezone.test.ts new file mode 100644 index 0000000..34a6348 --- /dev/null +++ b/tests/unit/household-timezone.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { householdDateAt } from "../../src/lib/household-timezone"; + +test("householdDateAt preserves 9 AM across Chicago daylight saving time", () => { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Chicago", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); + + for (const date of ["2026-01-15", "2026-07-15"]) { + assert.equal(formatter.format(householdDateAt(date, 9, "America/Chicago")), "09:00"); + } +});