Files
famapp/docs/superpowers/plans/2026-07-09-pets-module.md
T
2026-07-09 20:46:34 -05:00

51 KiB
Raw Blame History

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:generate and commit the SQL plus drizzle/meta snapshot/journal updates.
  • Use the existing authenticated /api/uploads endpoint with a new pets key prefix; accept images only and retain the existing 10-photo per entity limit.
  • Use date columns for birth, vaccination, and prescription dates; use a timezone-aware timestamp for appointments.
  • Keep pet records private to the household: register pets.pet for 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 use dueOn; active prescriptions use expiresOn. 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 to TZ, then America/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, and petPrescriptions tables, all scoped by householdId.

  • Produces petInput, petUpdateInput, appointmentInput, vaccinationInput, prescriptionInput, and scheduleAppointmentInput Zod 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, and logActivityForScope.

  • Produces createPetForScope, updatePetForScope, deletePetForScope, record CRUD functions, image-gallery functions, listPetsForScope, getPetForScope, searchPets, and getPetsOverviewStats.

  • UI, v1 routes, and assistant tools call these ForScope exports; 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 createEventForScope and listCalendars from the Calendar module, the scoped appointment actions from Task 2, and a scheduleAppointmentInput payload.

  • Produces scheduleAppointmentOnCalendarForScope(scope, input) and its session wrapper, returning the created CalendarEventDto.

  • On success, cancels the appointment's pets.appointment reminders because the new calendar.event reminders become the single notification source.

  • The detail page in Task 7 consumes listCalendars and 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 ForScope actions and queries plus apiJson/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_vaccinationRecording a vaccination… and schedule_pet_appointment_on_calendarAdding 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, and createPet from earlier tasks.

  • Produces a pets module manifest, a pets.overview widget, nav/quick-add icon support, and the pets MinIO prefix.

  • Pages in Task 7 are reachable via /pets; quick add opens PetCreateDialog with create key pets.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"

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 23, DetailBackLink, Base UI tabs/badges/separators, and CalendarDto.

  • Produces an accessible CRUD UI at /pets and 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"

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 18.

  • 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 13 and 78; 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 68.
  • 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 petId and householdId; UI, v1 API, and the assistant share ForScope services; 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.