51 KiB
Pets Module Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a household-scoped Pets module where family members can manage pet profiles, photos, vet appointments, vaccinations, prescriptions, and one-click calendar entries for vet visits.
Architecture: Follow the Garden module's bounded-module structure: schema, validated server actions and queries, client components, a registered manifest, and App Router pages. The module owns all pet medical data and accesses Calendar only through createEventForScope; it stores the resulting calendar event ID to prevent duplicate calendar creation. Pet records are searchable and appear in activity/dashboard registries, but are intentionally not public-shareable because they contain medical information.
Tech Stack: Next.js 15 App Router, React 19, TypeScript strict, Drizzle ORM/Postgres, Zod 4, Tailwind 4, shadcn Base UI, MinIO upload route, Playwright.
Global Constraints
- Follow
AGENTS.md: household-scope every read and mutation; use server actions for mutations; do not import from sibling modules except the established calendar bridge action. - Keep a new Drizzle migration immutable once generated; run
pnpm db:generateand commit the SQL plusdrizzle/metasnapshot/journal updates. - Use the existing authenticated
/api/uploadsendpoint with a newpetskey prefix; accept images only and retain the existing 10-photo per entity limit. - Use
datecolumns for birth, vaccination, and prescription dates; use a timezone-aware timestamp for appointments. - Keep pet records private to the household: register
pets.petfor search/activity/dashboard/quick-add, but do not add a share capability,ShareButton, or public loader. - Every module addition must include its authenticated v1 API surface, OpenAPI documentation, assistant tool definitions/execution/labels, and regression tests; the API and assistant must use the same scoped service functions as the UI.
- Every user-visible due date needs a reminder lifecycle. Pet appointments use selected reminder offsets (default
[30]); vaccinations usedueOn; active prescriptions useexpiresOn. Creates/updates replace unfired reminders and deletes cancel them. - Use a shared timezone helper to schedule date-only vaccination and prescription reminders at 09:00 in
HOUSEHOLD_TIMEZONE(falling back toTZ, thenAmerica/Chicago), including daylight-saving transitions. Do not schedule at UTC midnight. - Create a calendar event only when the user explicitly chooses a calendar. Record its ID in the appointment and reject a second creation for that appointment.
- Preserve the existing untracked
.codex-remote-attachments/directory; it is not part of this work.
File Structure
| Path | Responsibility |
|---|---|
src/modules/pets/schema.ts |
Drizzle tables and inferred row types for pets and their medical records. |
src/modules/pets/server/schemas.ts |
Shared Zod boundary validation for every mutation. |
src/modules/pets/server/actions.ts |
Household-gated CRUD, image-gallery mutations, activity logging, and calendar bridge. |
src/modules/pets/server/queries.ts |
DTO-producing scoped reads, search adapter, and dashboard statistics. |
src/modules/pets/server/reminders.ts |
Pet reminder scheduling and cancellation helpers. |
src/modules/pets/components/* |
Focused profile, gallery, and medical-record UI components. |
src/modules/pets/manifest.tsx |
Nav, entity search/activity registration, overview widget, and quick-add contribution. |
src/app/pets/** |
Thin authenticated route pages for listing, creating, editing, and viewing pets. |
src/app/api/v1/pets/** |
Token/session-authenticated REST routes over the scoped pet service layer. |
docs/api/openapi.yaml |
Documented pets REST paths and request/response schemas. |
src/modules/agent/{tools,tool-executor,tool-labels}.ts |
Assistant-visible Pets contracts, API delegation, and progress copy. |
src/modules/agent/server/loop-guards.ts |
Mutating Pets tools are treated as agent write operations. |
src/lib/household-timezone.ts |
Shared DST-safe local date/time conversion for pet reminders and agent runtime. |
src/components/quick-add/dialogs/pet-create-dialog.tsx |
Minimal quick-add profile dialog. |
drizzle/0027_pets_module.sql |
Generated immutable database migration. |
tests/unit/pets-schemas.test.ts |
Boundary validation regression coverage. |
tests/e2e/pets.spec.ts |
Browser happy path for the profile, medical records, gallery, and calendar bridge. |
Task 1: Pet data model and validation contract
Files:
- Create:
src/modules/pets/schema.ts - Create:
src/modules/pets/server/schemas.ts - Create:
tests/unit/pets-schemas.test.ts - Create: generated
drizzle/0027_pets_module.sql - Modify: generated
drizzle/meta/0027_snapshot.json - Modify: generated
drizzle/meta/_journal.json
Interfaces:
-
Produces
pets,petAppointments,petVaccinations, andpetPrescriptionstables, all scoped byhouseholdId. -
Produces
petInput,petUpdateInput,appointmentInput,vaccinationInput,prescriptionInput, andscheduleAppointmentInputZod schemas. -
Consumed by server actions, queries, pages, and the migration runner in later tasks.
-
Step 1: Write the failing validation tests
// tests/unit/pets-schemas.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import {
appointmentInput,
petInput,
prescriptionInput,
vaccinationInput,
} from "@/modules/pets/server/schemas";
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: "f5a50e32-a217-4d65-ae10-98a1f441d171",
title: "Annual exam",
appointmentAt: "2026-08-01T15:00:00.000Z",
}).title,
"Annual exam",
);
assert.equal(
vaccinationInput.parse({
petId: "f5a50e32-a217-4d65-ae10-98a1f441d171",
name: "Rabies",
administeredOn: "2026-07-01",
}).name,
"Rabies",
);
assert.equal(
prescriptionInput.parse({
petId: "f5a50e32-a217-4d65-ae10-98a1f441d171",
medication: "Apoquel",
dosage: "5 mg daily",
active: true,
}).active,
true,
);
});
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: "f5a50e32-a217-4d65-ae10-98a1f441d171",
name: "Rabies",
administeredOn: "07/01/2026",
}),
);
});
- Step 2: Run the test to verify it fails
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts
Expected: FAIL because the pets schema module does not exist.
- Step 3: Define the Drizzle tables and Zod inputs
// src/modules/pets/schema.ts (table shape)
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(),
},
(t) => [index("pets_household_idx").on(t.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(),
},
(t) => [index("pet_appointments_pet_at_idx").on(t.petId, t.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(),
},
(t) => [index("pet_vaccinations_pet_due_idx").on(t.petId, t.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(),
},
(t) => [index("pet_prescriptions_pet_active_idx").on(t.petId, t.active)],
);
// src/modules/pets/server/schemas.ts (selected contracts)
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 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 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 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 scheduleAppointmentInput = z.object({
appointmentId: z.string().uuid(),
calendarId: z.string().uuid(),
});
- Step 4: Generate and inspect the migration
Run: pnpm db:generate
Expected: a new 0027_pets_module.sql creates exactly four tables, indexes listed above, and cascading foreign keys only to households/pets; no existing migration is modified.
- Step 5: Run the test to verify it passes
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts && pnpm typecheck
Expected: PASS; TypeScript reports no errors.
- Step 6: Commit
git add src/modules/pets/schema.ts src/modules/pets/server/schemas.ts tests/unit/pets-schemas.test.ts drizzle
git commit -m "feat(pets): add pet record schema"
Task 2: Scoped pet queries and mutations
Files:
- Create:
src/modules/pets/server/actions.ts - Create:
src/modules/pets/server/queries.ts - Create:
src/modules/pets/server/reminders.ts - Create:
src/lib/household-timezone.ts - Create:
tests/unit/household-timezone.test.ts - Modify:
tests/unit/pets-schemas.test.ts
Interfaces:
-
Consumes the tables and inputs from Task 1 plus
ApiAuthContext,getCurrentSession, andlogActivityForScope. -
Produces
createPetForScope,updatePetForScope,deletePetForScope, record CRUD functions, image-gallery functions,listPetsForScope,getPetForScope,searchPets, andgetPetsOverviewStats. -
UI, v1 routes, and assistant tools call these
ForScopeexports; session wrappers only revalidate UI paths and never duplicate authorization or business rules. -
Produces
src/modules/pets/server/reminders.ts, which synchronizes appointment, vaccination, and prescription reminders through_core/reminders. -
Step 1: Extend validation coverage for update payloads
test("partial update schemas do not erase omitted fields", () => {
assert.deepEqual(petUpdateInput.parse({ notes: null }), { notes: null });
assert.deepEqual(prescriptionUpdateInput.parse({ active: false }), { active: false });
});
- Step 2: Run the test to verify it fails
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts
Expected: FAIL because the update schemas are not exported.
- Step 3: Implement scope-safe data access and actions
async function assertCanAccessPet(id: string, householdId: string) {
const [pet] = await db
.select({ id: pets.id })
.from(pets)
.where(and(eq(pets.id, id), eq(pets.householdId, householdId)))
.limit(1);
if (!pet) throw new Error("Forbidden");
}
export async function createPetForScope(scope: ApiAuthContext, input: z.input<typeof petInput>) {
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 createAppointmentForScope(
scope: ApiAuthContext,
input: z.input<typeof appointmentInput>,
) {
const parsed = appointmentInput.parse(input);
await assertCanAccessPet(parsed.petId, scope.householdId);
const [appointment] = await db
.insert(petAppointments)
.values({
...parsed,
householdId: scope.householdId,
clinic: parsed.clinic ?? null,
notes: parsed.notes ?? null,
})
.returning();
if (!appointment) throw new Error("Appointment was not created");
return appointment;
}
Implement matching update/delete functions for pets, appointments, vaccinations, and prescriptions. Scope every record mutation by both its record ID and householdId; check the parent pet before inserting a child. Use undefined for omitted update fields and null only for explicit clearing. Add addPetImage, removePetImage, and setPetPrimaryImage with the Garden behavior: require ownership, reject galleries of 11 images, and choose the first remaining image when the removed image was primary. Session wrappers revalidate /pets, /pets/[id], and /pets/[id]/edit as appropriate.
// src/modules/pets/server/reminders.ts
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) return cancelReminder(input.entityType, input.entityId);
return scheduleReminder({
...input,
fireAt: householdDateAt(input.dueOn, 9),
offsetMinutes: null,
});
}
Create src/lib/household-timezone.ts with resolveHouseholdTimezone() and householdDateAt(isoDate: string, hour: number, timeZone = resolveHouseholdTimezone()): Date. Implement householdDateAt by formatting a UTC candidate with Intl.DateTimeFormat(..., { timeZone, hourCycle: "h23" }).formatToParts, converting the formatted local parts to an offset, then subtracting that offset from the candidate. Task 5 replaces the Agent-local timezone resolver with this shared export. Add a unit test for America/Chicago covering both 2026-01-15 and 2026-07-15, asserting the result formats to 09:00 in that timezone.
// tests/unit/household-timezone.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { householdDateAt } from "@/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");
}
});
On appointment create/update, call syncPetAppointmentReminders with reminderOffsets ?? [30]; on an update with omitted offsets, retain listReminderOffsets("pets.appointment", appointment.id). On a caller-supplied empty array, cancel all unfired appointment reminders. On vaccination/prescription create/update, call syncPetDueDateReminder with dueOn/expiresOn; on deletion call cancelReminder for the matching entity. Appointment, vaccination, and prescription DTOs must expose current reminder offsets or reminderScheduled so the UI/API/agent can accurately present the state.
export type PetDetailDto = {
id: string;
name: string;
species: string;
breed: string | null;
birthDate: string | null;
notes: string | null;
primaryImageUrl: string | null;
images: string[];
appointments: PetAppointmentDto[];
vaccinations: PetVaccinationDto[];
prescriptions: PetPrescriptionDto[];
};
export async function getPetsOverviewStats(
householdId: string,
): Promise<{ petCount: number; dueSoonCount: number }> {
// Count pets, then count vaccinations due within 30 days and active prescriptions expiring within 30 days.
// A record due today or earlier is included in dueSoonCount.
}
Have listPetsForScope order by name, getPetForScope fetch child records by descending appointment/administered/prescribed date, and searchPets search name, species, and breed with a 10-row limit. Convert database dates/timestamps to ISO strings in all DTOs.
- Step 4: Run the focused tests and static gate
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts tests/unit/household-timezone.test.ts && pnpm typecheck && pnpm lint
Expected: PASS; lint may retain only the repository's pre-existing warnings.
- Step 5: Commit
git add src/modules/pets/server src/lib/household-timezone.ts tests/unit/pets-schemas.test.ts tests/unit/household-timezone.test.ts
git commit -m "feat(pets): add scoped records and queries"
Task 3: Calendar bridge for vet appointments
Files:
- Create:
src/modules/pets/server/calendar-bridge.ts - Modify:
src/modules/pets/server/schemas.ts - Modify:
src/modules/pets/server/actions.ts - Modify:
tests/unit/pets-schemas.test.ts
Interfaces:
-
Consumes
createEventForScopeandlistCalendarsfrom the Calendar module, the scoped appointment actions from Task 2, and ascheduleAppointmentInputpayload. -
Produces
scheduleAppointmentOnCalendarForScope(scope, input)and its session wrapper, returning the createdCalendarEventDto. -
On success, cancels the appointment's
pets.appointmentreminders because the newcalendar.eventreminders become the single notification source. -
The detail page in Task 7 consumes
listCalendarsand this action. -
Step 1: Add a failing calendar-bridge input test
test("calendar scheduling requires appointment and calendar identifiers", () => {
assert.deepEqual(
scheduleAppointmentInput.parse({
appointmentId: "f5a50e32-a217-4d65-ae10-98a1f441d171",
calendarId: "bd8e10c2-58f4-4db8-9326-0b50fdda47bb",
}),
{
appointmentId: "f5a50e32-a217-4d65-ae10-98a1f441d171",
calendarId: "bd8e10c2-58f4-4db8-9326-0b50fdda47bb",
},
);
assert.throws(() =>
scheduleAppointmentInput.parse({ appointmentId: "bad", calendarId: "also-bad" }),
);
});
- Step 2: Run the test to verify it fails
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts
Expected: FAIL because scheduleAppointmentInput is absent.
- Step 3: Implement single-create calendar behavior
export async function scheduleAppointmentOnCalendarForScope(
scope: ApiAuthContext,
input: z.input<typeof scheduleAppointmentInput>,
) {
const parsed = scheduleAppointmentInput.parse(input);
const [row] = await db
.select({ appointment: petAppointments, petName: pets.name })
.from(petAppointments)
.innerJoin(pets, eq(petAppointments.petId, pets.id))
.where(
and(
eq(petAppointments.id, parsed.appointmentId),
eq(petAppointments.householdId, scope.householdId),
),
)
.limit(1);
if (!row) throw new Error("Appointment not found");
if (row.appointment.calendarEventId) throw new Error("Appointment is already on the calendar");
const startAt = row.appointment.appointmentAt;
const reminderOffsets = await listReminderOffsets("pets.appointment", row.appointment.id);
const event = await createEventForScope(
{ householdId: scope.householdId, userId: scope.userId },
{
calendarId: parsed.calendarId,
title: `Vet: ${row.petName} — ${row.appointment.title}`,
startAt,
endAt: new Date(startAt.getTime() + 60 * 60 * 1000),
allDay: false,
location: row.appointment.clinic ?? undefined,
notes: row.appointment.notes ?? undefined,
reminderOffsets: reminderOffsets.length > 0 ? reminderOffsets : [30],
},
);
await db
.update(petAppointments)
.set({ calendarEventId: event.id, updatedAt: new Date() })
.where(eq(petAppointments.id, row.appointment.id));
await cancelReminder("pets.appointment", row.appointment.id);
await logActivityForScope(scope, {
entityType: "pets.pet",
entityId: row.appointment.petId,
action: "update",
payload: { appointment: row.appointment.title, calendarEventId: event.id },
});
return event;
}
Export listCalendars and CalendarDto from the bridge file exactly as Garden does. Revalidate both the pet detail page and /calendar in the session wrapper. Do not update or delete linked calendar events in this issue; calendar remains the owner after creation.
- Step 4: Run the focused tests and static gate
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts && pnpm typecheck
Expected: PASS.
- Step 5: Commit
git add src/modules/pets/server tests/unit/pets-schemas.test.ts
git commit -m "feat(pets): add appointment calendar bridge"
Task 4: Authenticated Pets v1 API and OpenAPI contract
Files:
- Create:
src/app/api/v1/pets/route.ts - Create:
src/app/api/v1/pets/[id]/route.ts - Create:
src/app/api/v1/pets/[id]/appointments/route.ts - Create:
src/app/api/v1/pets/appointments/[id]/route.ts - Create:
src/app/api/v1/pets/appointments/[id]/calendar/route.ts - Create:
src/app/api/v1/pets/[id]/vaccinations/route.ts - Create:
src/app/api/v1/pets/vaccinations/[id]/route.ts - Create:
src/app/api/v1/pets/[id]/prescriptions/route.ts - Create:
src/app/api/v1/pets/prescriptions/[id]/route.ts - Modify:
docs/api/openapi.yaml - Create:
tests/unit/api-v1-pets.test.ts
Interfaces:
-
Consumes only Task 2/3
ForScopeactions and queries plusapiJson/withApiHandler; routes must never call session-only wrappers. -
Produces the REST collection/detail paths used by external clients and the assistant's HTTP tool executor.
-
Defines API schemas for
PetSummary,PetDetail,PetAppointment,PetVaccination,PetPrescription, their create/update payloads, and the calendarization request. -
Step 1: Write failing route authentication and payload tests
// tests/unit/api-v1-pets.test.ts
import assert from "node:assert/strict";
import { describe, it } from "node:test";
describe("Pets v1 API auth gates", () => {
it("rejects unauthenticated collection reads", async () => {
const { GET } = await import("../../src/app/api/v1/pets/route");
const response = await GET(new Request("http://localhost/api/v1/pets"));
assert.equal(response.status, 401);
assert.deepEqual(await response.json(), { error: "Unauthorized" });
});
it("rejects malformed pet identifiers before touching services", async () => {
const { GET } = await import("../../src/app/api/v1/pets/[id]/route");
const response = await GET(new Request("http://localhost/api/v1/pets/not-a-uuid"), {
params: Promise.resolve({ id: "not-a-uuid" }),
});
assert.equal(response.status, 401);
});
});
- Step 2: Run the test to verify it fails
Run: pnpm exec tsx --test tests/unit/api-v1-pets.test.ts
Expected: FAIL because the Pets v1 route modules do not exist.
- Step 3: Implement the route families through scoped services
// src/app/api/v1/pets/[id]/appointments/route.ts
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const appointment = await createAppointmentForScope(
scope,
appointmentInput.parse({ ...(body as object), petId: id }),
);
return apiJson(toAppointmentDto(appointment), 201);
});
}
Implement all routes with the following exact surface:
GET, POST /api/v1/pets
GET, PATCH, DELETE /api/v1/pets/{id}
GET, POST /api/v1/pets/{id}/appointments
PATCH, DELETE /api/v1/pets/appointments/{id}
POST /api/v1/pets/appointments/{id}/calendar
GET, POST /api/v1/pets/{id}/vaccinations
PATCH, DELETE /api/v1/pets/vaccinations/{id}
GET, POST /api/v1/pets/{id}/prescriptions
PATCH, DELETE /api/v1/pets/prescriptions/{id}
For nested POST, overwrite petId with the URL parameter so callers cannot create a record under another household's pet. For update/delete/calendar routes, validate the record ID with Zod, call the corresponding ForScope function, and return { ok: true } for a successful delete/calendar action. GET /api/v1/pets/{id} returns the aggregate PetDetailDto; nested GET routes return their record arrays. Preserve withApiHandler error mapping so malformed payloads are 400, ownership failures are 403, and missing scoped records are 404.
Add each path and component schema to docs/api/openapi.yaml, tag them Pets, and document reminderOffsets as an array of non-negative integers on appointment create/update. Document dueOn and expiresOn as format: date, and state that those dates schedule a 09:00 household-local reminder when supplied.
- Step 4: Run unit API checks and inspect the OpenAPI endpoint
Run: pnpm exec tsx --test tests/unit/api-v1-pets.test.ts tests/unit/api-v1-calendar.test.ts && pnpm typecheck
Expected: PASS; the existing OpenAPI route continues to serve the updated YAML behind authentication.
- Step 5: Commit
git add src/app/api/v1/pets docs/api/openapi.yaml tests/unit/api-v1-pets.test.ts
git commit -m "feat(pets): add authenticated v1 API"
Task 5: Assistant Pets tools and write safety
Files:
- Modify:
src/modules/agent/tools.ts - Modify:
src/modules/agent/tool-executor.ts - Modify:
src/modules/agent/tool-labels.ts - Modify:
src/modules/agent/server/loop-guards.ts - Modify:
tests/unit/agent-tools-runtime.test.ts
Interfaces:
-
Consumes Task 4's documented API paths through
createApiToolExecutor; the agent never imports the Pets database module or bypasses API auth. -
Produces read/write tool definitions that exactly map one public action to one API call, human progress labels, write-tool loop guards, and Pets guidance in
AGENT_SYSTEM_PROMPT. -
Step 1: Add failing executor routing tests
it("routes pet medical tools through the loopback v1 API", async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : undefined,
});
return Response.json({ id: "appointment-1" }, { status: 201 });
}) as typeof fetch;
const execute = createApiToolExecutor(new Request("https://fam.ginnoir.com/api/agent/chat"));
await execute(
"create_pet_appointment",
JSON.stringify({
petId: "pet-1",
title: "Annual exam",
appointmentAt: "2026-08-01T15:00:00.000Z",
reminderOffsets: [30],
}),
);
await execute(
"schedule_pet_appointment_on_calendar",
JSON.stringify({ appointmentId: "appointment-1", calendarId: "calendar-1" }),
);
assert.deepEqual(
calls.map((call) => [call.method, call.url.replace("http://127.0.0.1:3000", "")]),
[
["POST", "/api/v1/pets/pet-1/appointments"],
["POST", "/api/v1/pets/appointments/appointment-1/calendar"],
],
);
globalThis.fetch = originalFetch;
});
- Step 2: Run the test to verify it fails
Run: pnpm exec tsx --test tests/unit/agent-tools-runtime.test.ts
Expected: FAIL because Pets tool names have no executor cases.
- Step 3: Add complete tool declarations, execution, labels, and guards
Declare and implement these exact tools in tools.ts and tool-executor.ts:
list_pets, get_pet, create_pet, update_pet, delete_pet
create_pet_appointment, update_pet_appointment, delete_pet_appointment, schedule_pet_appointment_on_calendar
create_pet_vaccination, update_pet_vaccination, delete_pet_vaccination
create_pet_prescription, update_pet_prescription, delete_pet_prescription
Each executor case must validate required IDs with requireString, construct a body from only supplied optional fields, and call the corresponding Task 4 path through callApi. Mutating tool names all go in WRITE_TOOL_NAMES; list_pets and get_pet do not. Add one clear tool-labels.ts message per name, for example create_pet_vaccination → Recording a vaccination… and schedule_pet_appointment_on_calendar → Adding vet visit to calendar….
Update AGENT_SYSTEM_PROMPT with: Pets: use list_pets to resolve a pet id before changing records. Appointments accept ISO 8601 datetimes and reminderOffsets in minutes; vaccination dueOn and prescription expiresOn are YYYY-MM-DD and schedule household-local reminders. Pet medical records are household-private and cannot be shared. Move resolveHouseholdTimezone into src/lib/household-timezone.ts and import it from tools.ts; this prevents the new Pets reminder service from depending on the Agent module.
- Step 4: Run agent and type checks
Run: pnpm exec tsx --test tests/unit/agent-tools-runtime.test.ts tests/unit/agent-loop-guards.test.ts && pnpm typecheck
Expected: PASS; the executor uses http://127.0.0.1:$PORT or INTERNAL_API_BASE_URL, never the public fam.ginnoir.com origin.
- Step 5: Commit
git add src/modules/agent src/lib/household-timezone.ts tests/unit/agent-tools-runtime.test.ts
git commit -m "feat(agent): add pets tools"
Task 6: Module registration, navigation, upload scope, dashboard, and quick add
Files:
- Create:
src/modules/pets/manifest.tsx - Create:
src/modules/pets/components/pets-overview-widget.tsx - Create:
src/components/quick-add/dialogs/pet-create-dialog.tsx - Modify:
src/modules/index.ts - Modify:
src/app/api/uploads/route.ts - Modify:
src/components/nav-icon.tsx - Modify:
src/components/quick-add-sheet.tsx - Modify:
src/components/command-palette.tsx - Modify:
src/components/quick-add/create-host.tsx
Interfaces:
-
Consumes
searchPets,getPetsOverviewStats, andcreatePetfrom earlier tasks. -
Produces a
petsmodule manifest, apets.overviewwidget, nav/quick-add icon support, and thepetsMinIO prefix. -
Pages in Task 7 are reachable via
/pets; quick add opensPetCreateDialogwith create keypets.pet. -
Step 1: Add the failing registration assertion to the browser test
// tests/e2e/pets.spec.ts (create this file now)
import { expect, test } from "@playwright/test";
test("pets module is reachable from navigation and quick add", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Pets" }).click();
await expect(page).toHaveURL(/\/pets$/);
await page.getByRole("button", { name: /quick add|new/i }).click();
await expect(page.getByText("Add pet")).toBeVisible();
});
- Step 2: Run it to verify it fails
Run: pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "reachable"
Expected: FAIL because the Pets nav item and dialog do not exist.
- Step 3: Register the module and supporting UI
// src/modules/pets/manifest.tsx
const petsManifest: ModuleManifest = {
id: "pets",
name: "Pets",
nav: { href: "/pets", label: "Pets", icon: "paw-print" },
entities: [
{
type: "pets.pet",
label: { singular: "Pet", plural: "Pets" },
search: { search: searchPets },
reminder: { canRemind: true },
resolveUrl: (id) => `/pets/${id}`,
renderActivity: (entry) =>
entry.action === "create"
? `Added pet \"${String(entry.payload?.name ?? "")}\"`
: `Updated pet \"${String(entry.payload?.name ?? "")}\"`,
},
],
dashboardWidgets: [
{
id: "pets.overview",
title: "Pets overview",
description: "Pet count and medical items due within 30 days.",
category: "Pets",
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
defaultPriority: 60,
configSchema: z.object({}),
defaultConfig: {},
render: (props) => <PetsOverviewWidgetServer {...props} />,
},
],
quickAdds: [
{
id: "pets.add",
label: "Add pet",
icon: "paw-print",
url: "/pets/new",
createKey: "pets.pet",
},
],
};
Add PawPrint to ICONS in nav-icon.tsx, map paw-print to paw-print in QUICK_ADD_ICON, and map it to 🐾 in iconEmoji. Register the manifest after Garden in src/modules/index.ts. Expand the upload scope allowlist to scopeParam === "pets" and retain the existing default-to-garden behavior. PetCreateDialog should expose required Name and Species fields, call createPet, show an error toast on failure, then close and navigate to /pets/[id].
function PetsOverviewWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) {
return <PetsOverviewWidget stats={await getPetsOverviewStats(ctx.householdId)} />;
}
Render the widget's stable empty state as No medical items due soon. so it is useful on a newly created household and inspectable in dashboard edit mode.
- Step 4: Run the registration test and typecheck
Run: pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "reachable" && pnpm typecheck
Expected: PASS.
- Step 5: Commit
git add src/modules/pets/manifest.tsx src/modules/pets/components/pets-overview-widget.tsx src/modules/index.ts src/app/api/uploads/route.ts src/components/nav-icon.tsx src/components/quick-add src/components/command-palette.tsx src/components/quick-add-sheet.tsx tests/e2e/pets.spec.ts
git commit -m "feat(pets): register module and quick add"
Task 7: Pet profile pages, gallery, and medical record UI
Files:
- Create:
src/app/pets/page.tsx - Create:
src/app/pets/new/page.tsx - Create:
src/app/pets/[id]/page.tsx - Create:
src/app/pets/[id]/edit/page.tsx - Create:
src/modules/pets/components/pet-list.tsx - Create:
src/modules/pets/components/pet-form.tsx - Create:
src/modules/pets/components/pet-detail.tsx - Create:
src/modules/pets/components/medical-records.tsx - Modify:
tests/e2e/pets.spec.ts
Interfaces:
-
Consumes DTOs/actions from Tasks 2–3,
DetailBackLink, Base UI tabs/badges/separators, andCalendarDto. -
Produces an accessible CRUD UI at
/petsand a tabbed profile detail with Info, Gallery, and Medical tabs. -
The calendar bridge action is only shown for appointment records that have no
calendarEventId. -
Step 1: Extend the E2E happy path
test("pets profile and medical records happy path", async ({ page }) => {
const suffix = Date.now().toString();
const name = `E2E Pet ${suffix}`;
await page.goto("/pets");
await page.getByRole("link", { name: /add pet/i }).click();
await page.getByLabel("Name").fill(name);
await page.getByLabel("Species").fill("cat");
await page.getByRole("button", { name: /create/i }).click();
await expect(page.getByRole("heading", { name })).toBeVisible();
await page.getByRole("tab", { name: "Medical" }).click();
await page.getByRole("button", { name: /add appointment/i }).click();
await page.getByLabel("Title").fill("Annual exam");
await page.getByLabel("Appointment date and time").fill("2026-08-01T10:00");
await page.getByRole("button", { name: /save appointment/i }).click();
await expect(page.getByText("Annual exam")).toBeVisible();
await page.getByRole("button", { name: /add vaccination/i }).click();
await page.getByLabel("Vaccination name").fill("Rabies");
await page.getByLabel("Administered on").fill("2026-07-01");
await page.getByRole("button", { name: /save vaccination/i }).click();
await expect(page.getByText("Rabies")).toBeVisible();
await page.getByRole("button", { name: /add prescription/i }).click();
await page.getByLabel("Medication").fill("Apoquel");
await page.getByLabel("Dosage").fill("5 mg daily");
await page.getByRole("button", { name: /save prescription/i }).click();
await expect(page.getByText("Apoquel")).toBeVisible();
});
- Step 2: Run the test to verify it fails
Run: pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "profile and medical"
Expected: FAIL because /pets and the accessible controls do not exist.
- Step 3: Implement thin pages and focused components
// src/app/pets/[id]/page.tsx
export default async function PetPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [pet, calendars] = await Promise.all([getPet(id), listCalendars()]);
if (!pet) notFound();
return (
<div className="page-content">
<DetailBackLink href="/pets" label="Pets" className="mb-4" />
<PetDetail pet={pet} calendars={calendars} />
</div>
);
}
PetForm must use createPet/updatePet, preserve nullable fields as null, and navigate to the returned/edited profile after success. PetDetail must use the same gallery lifecycle as PlantDetail, but upload to /api/uploads?scope=pets. It must not display ShareButton or ShareLinkList. Use Tabs for Info, Gallery, and Medical; keep medical editors in medical-records.tsx so profile/gallery state does not grow into one oversized component. Appointment editors expose validated reminder offsets (default one 30-minute reminder, allow an empty array to disable); vaccination and active-prescription rows display Reminder: 9:00 AM on YYYY-MM-DD when dueOn/expiresOn is set, otherwise No reminder scheduled.
// schedule button contract inside medical-records.tsx
{
!appointment.calendarEventId && (
<button
onClick={() =>
startTransition(async () => {
await scheduleAppointmentOnCalendar({
appointmentId: appointment.id,
calendarId,
reminderMinutesBefore: 30,
});
router.refresh();
})
}
disabled={isPending || !calendarId}
>
Add to calendar
</button>
);
}
Use a Calendar select populated from CalendarDto[]; do not default a calendar or create events on medical-record save. A completed calendar bridge shows Added to calendar, hides the button, and replaces the pet appointment reminders with Calendar's same saved offsets. Each record editor must provide edit and delete controls, a local confirmation for delete, visible server errors, and labels matching the E2E names above. In particular, use aria-label={\Edit ${vaccination.name}`}andaria-label={`Edit ${prescription.medication}`}for row edit buttons, and exposeDue on, Expires on, Calendar, and Upload photo` as labels.
- Step 4: Run the complete pet E2E and static checks
Run: pnpm test:e2e -- tests/e2e/pets.spec.ts && pnpm typecheck && pnpm lint
Expected: PASS; lint has no new warnings.
- Step 5: Commit
git add src/app/pets src/modules/pets/components tests/e2e/pets.spec.ts
git commit -m "feat(pets): add profile and medical records UI"
Task 8: Calendar bridge, reminder, and gallery browser coverage
Files:
- Modify:
tests/e2e/pets.spec.ts
Interfaces:
-
Consumes the finished pet UI, upload route, and Task 3 calendar bridge.
-
Produces regression coverage that a visit is calendarized only once and a gallery stays under the ten-photo boundary.
-
Step 1: Add failure-first browser assertions
async function createPet(page: import("@playwright/test").Page, name: string) {
await page.goto("/pets");
await page.getByRole("link", { name: /add pet/i }).click();
await page.getByLabel("Name").fill(name);
await page.getByLabel("Species").fill("cat");
await page.getByRole("button", { name: /create/i }).click();
await expect(page.getByRole("heading", { name })).toBeVisible();
}
test("pet appointment is calendarized once", async ({ page }) => {
await createPet(page, `Calendar Pet ${Date.now()}`);
await page.getByRole("tab", { name: "Medical" }).click();
await page.getByRole("button", { name: /add appointment/i }).click();
await page.getByLabel("Title").fill("Annual exam");
await page.getByLabel("Appointment date and time").fill("2026-08-01T10:00");
await page.getByRole("button", { name: /save appointment/i }).click();
await page.getByLabel("Calendar").selectOption({ index: 0 });
await page.getByRole("button", { name: /add to calendar/i }).click();
await expect(page.getByText("Added to calendar")).toBeVisible();
await expect(page.getByRole("button", { name: /add to calendar/i })).toHaveCount(0);
await page.goto("/calendar");
await expect(page.getByText(/Vet: .*Annual exam/)).toBeVisible();
});
test("pet medical due dates show their scheduled reminders", async ({ page }) => {
await createPet(page, `Reminder Pet ${Date.now()}`);
await page.getByRole("tab", { name: "Medical" }).click();
await page.getByRole("button", { name: /add vaccination/i }).click();
await page.getByLabel("Vaccination name").fill("Rabies");
await page.getByLabel("Administered on").fill("2026-07-01");
await page.getByLabel("Due on").fill("2026-08-01");
await page.getByRole("button", { name: /save vaccination/i }).click();
await page.getByRole("button", { name: /add prescription/i }).click();
await page.getByLabel("Medication").fill("Apoquel");
await page.getByLabel("Dosage").fill("5 mg daily");
await page.getByLabel("Expires on").fill("2026-08-15");
await page.getByRole("button", { name: /save prescription/i }).click();
await expect(page.getByText("Reminder: 9:00 AM on 2026-08-01")).toBeVisible();
await expect(page.getByText("Reminder: 9:00 AM on 2026-08-15")).toBeVisible();
await page.getByRole("button", { name: "Edit Rabies" }).click();
await page.getByLabel("Due on").fill("");
await page.getByRole("button", { name: /save vaccination/i }).click();
await page.getByRole("button", { name: "Edit Apoquel" }).click();
await page.getByLabel("Expires on").fill("");
await page.getByRole("button", { name: /save prescription/i }).click();
await expect(page.getByText("No reminder scheduled")).toHaveCount(2);
});
test("pet gallery only offers uploads below ten photos", async ({ page }) => {
await createPet(page, `Gallery Pet ${Date.now()}`);
await page.getByRole("tab", { name: "Gallery" }).click();
for (let index = 1; index <= 10; index++) {
await page.getByLabel("Upload photo").setInputFiles({
name: `pet-${index}.png`,
mimeType: "image/png",
buffer: Buffer.from([137, 80, 78, 71]),
});
await expect(page.getByText(`${index}/10 photos`)).toBeVisible();
}
await expect(page.getByText("10/10 photos")).toBeVisible();
await expect(page.getByLabel("Upload photo")).toHaveCount(0);
});
- Step 2: Run the tests to verify the relevant behavior fails before fixes
Run: pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "calendarized once|medical due dates|gallery only"
Expected: initially FAIL until the UI correctly records calendarEventId, surfaces synced reminders, and mirrors the Garden gallery limit.
- Step 3: Fix only the observed UI/action gaps
For a repeated calendar button, persist calendarEventId immediately after createEventForScope and render the button only when it is null. For a missing/uncancelled date reminder, call syncPetDueDateReminder after each create/update and cancelReminder in each delete branch. For an eleventh gallery control, render the upload label only when pet.images.length < 10. Do not introduce a pet-specific upload endpoint, direct MinIO client use in the browser, duplicate calendar event creation, UTC-midnight date reminders, or Calendar-module schema changes.
- Step 4: Run the full verification matrix
Run: pnpm exec tsx --test tests/unit/pets-schemas.test.ts tests/unit/household-timezone.test.ts tests/unit/api-v1-pets.test.ts tests/unit/agent-tools-runtime.test.ts tests/unit/agent-loop-guards.test.ts; pnpm typecheck; pnpm lint; pnpm test:e2e -- tests/e2e/pets.spec.ts; pnpm build
Expected: all commands exit 0. Stop any local dev server started for the E2E run after verification.
- Step 5: Commit
git add tests/e2e/pets.spec.ts src/modules/pets
git commit -m "test(pets): cover calendar and gallery limits"
Task 9: Documentation, issue closeout, and release handoff
Files:
- Modify:
STATUS.md - Modify:
docs/issues-map.md - Modify:
AGENTS.md - Modify:
CLAUDE.md
Interfaces:
-
Consumes validated behavior and commits from Tasks 1–8.
-
Produces durable project status and a Gitea issue closeout linked to the implementing commits/PR.
-
Step 1: Write the documentation delta
Keep the matching AGENTS.md and CLAUDE.md completion rule: every module addition or behavior change must include authenticated v1 API/OpenAPI coverage, API-backed assistant tools, and the reminder lifecycle for user-visible due dates. Add a concise STATUS.md Done entry stating: household-scoped Pets module, profile/gallery, appointments/vaccinations/prescriptions, automatic appointment/vaccination/prescription reminder lifecycle, explicit one-time calendar bridge, private-by-default data, authenticated v1 API/OpenAPI contract, assistant tools, manifest/quick-add/dashboard registration, migration number, and validation commands. Change the docs/issues-map.md #32 row from a bare epic title to Pets module — complete with the Gitea URL unchanged.
- Step 2: Verify documentation and repository state
Run: git diff --check && git status --short && pnpm format:check
Expected: no whitespace errors, only intended tracked files, and formatting passes. Preserve .codex-remote-attachments/ as untracked.
- Step 3: Commit documentation
git add AGENTS.md CLAUDE.md STATUS.md docs/issues-map.md
git commit -m "docs(pets): record module completion"
- Step 4: Push and close Gitea issue #32 only after live verification
Push the feature branch, open/merge the appropriate Gitea PR, verify the deployed /pets page with an authenticated browser session, then close ginnoir/famapp#32 with the merged commit or PR reference. Do not close #33–#38.
Plan Self-Review
- Spec coverage: profile CRUD/photos are Tasks 1, 2, and 7; appointments, shots, prescriptions and their reminder lifecycles are Tasks 1–3 and 7–8; the explicit calendar bridge is Task 3; authenticated REST/API documentation is Task 4; assistant tools and write safety are Task 5; manifest/nav/widget/quick-add are Task 6; the requested E2E happy path is Tasks 6–8.
- Intentional scope limits: no public sharing, external vet/geocoding integrations, automated Calendar event synchronization after creation, or list bridge. Those are not required by #32 and each needs a separately designed issue.
- Consistency: every child record carries
petIdandhouseholdId; UI, v1 API, and the assistant shareForScopeservices; appointment reminders transfer to the linked Calendar event exactly once; vaccination/prescription reminders use DST-safe household-local time; dates are serialized as ISO values in DTOs.