v0.14.0 — Animals & Pets

Livestock and household pets as leaves on the Location tree, mirroring Garden.

- Animal model (species/breed/sex/birthdate/location, soft-delete) + AnimalLog
  (feeding, vet, weight, medication, breeding, acquired, died); Task.animalId for
  recurring care reminders
- API-first /api/v1/animals (CRUD) + logs, animals:read/write scopes; animals
  service shared with the coming MCP
- Animals pages: list grouped by species with auto-computed age, detail with
  facts + latest weight + care log; add/edit/remove + log entry. Nav entry.
  Backup/restore cover the new tables
- Pure age.ts (newborn/days/months/years) with tests; verified end-to-end against
  a running server (create, vet + weigh-in logs, read-back)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 00:23:53 -05:00
parent 777569a21a
commit 39be24c2fc
21 changed files with 979 additions and 5 deletions

112
src/lib/services/animals.ts Normal file
View File

@@ -0,0 +1,112 @@
// Animal service — DB work + AuditEvent for animals & pets. Shared by /api/v1
// routes (which the UI calls) and, later, the MCP server.
import { db } from "@/lib/db";
import { isAdmin } from "@/lib/auth";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import type { AnimalCreateInput, AnimalUpdateInput, AnimalLogInput } from "@/lib/api/schemas";
function toDate(s?: string | null): Date | null {
return s ? new Date(s) : null;
}
export function listAnimals(_ctx: AuthContext, opts?: { q?: string; species?: string }) {
return db.animal.findMany({
where: {
active: true,
...(opts?.q ? { name: { contains: opts.q, mode: "insensitive" } } : {}),
...(opts?.species ? { species: opts.species } : {}),
},
orderBy: { name: "asc" },
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
});
}
export async function getAnimal(_ctx: AuthContext, id: string) {
const animal = await db.animal.findUnique({
where: { id },
include: {
location: { select: { id: true, name: true } },
logs: { orderBy: { date: "desc" }, take: 100 },
},
});
if (!animal || !animal.active) throw new ApiError(404, "Animal not found");
return animal;
}
export async function createAnimal(ctx: AuthContext, input: AnimalCreateInput) {
const animal = await db.animal.create({
data: {
name: input.name.trim(),
species: input.species?.trim() || null,
breed: input.breed?.trim() || null,
sex: input.sex?.trim() || null,
locationId: input.locationId || null,
birthdate: toDate(input.birthdate),
acquiredAt: toDate(input.acquiredAt),
source: input.source?.trim() || null,
notes: input.notes?.trim() || null,
imageUrl: input.imageUrl || null,
},
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
});
await db.auditEvent.create({
data: { action: "animal.create", entityId: animal.id, actorId: ctx.userId, payload: { name: animal.name, species: animal.species, via: ctx.via } },
});
return animal;
}
export async function updateAnimal(ctx: AuthContext, id: string, input: AnimalUpdateInput) {
const existing = await db.animal.findUnique({ where: { id }, select: { active: true } });
if (!existing || !existing.active) throw new ApiError(404, "Animal not found");
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name.trim();
if (input.species !== undefined) data.species = input.species?.trim() || null;
if (input.breed !== undefined) data.breed = input.breed?.trim() || null;
if (input.sex !== undefined) data.sex = input.sex?.trim() || null;
if (input.locationId !== undefined) data.locationId = input.locationId || null;
if (input.birthdate !== undefined) data.birthdate = toDate(input.birthdate);
if (input.acquiredAt !== undefined) data.acquiredAt = toDate(input.acquiredAt);
if (input.source !== undefined) data.source = input.source?.trim() || null;
if (input.notes !== undefined) data.notes = input.notes?.trim() || null;
if (input.imageUrl !== undefined) data.imageUrl = input.imageUrl || null;
const animal = await db.animal.update({
where: { id },
data,
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
});
await db.auditEvent.create({
data: { action: "animal.update", entityId: id, actorId: ctx.userId, payload: { name: animal.name, via: ctx.via } },
});
return animal;
}
export async function deleteAnimal(ctx: AuthContext, id: string) {
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can remove animals");
const animal = await db.animal.findUnique({ where: { id }, select: { active: true, name: true } });
if (!animal || !animal.active) throw new ApiError(404, "Animal not found");
await db.animal.update({ where: { id }, data: { active: false } });
await db.auditEvent.create({
data: { action: "animal.delete", entityId: id, actorId: ctx.userId, payload: { name: animal.name, via: ctx.via } },
});
}
export async function addAnimalLog(ctx: AuthContext, animalId: string, input: AnimalLogInput) {
const animal = await db.animal.findUnique({ where: { id: animalId }, select: { active: true } });
if (!animal || !animal.active) throw new ApiError(404, "Animal not found");
const log = await db.animalLog.create({
data: {
animalId,
type: input.type,
date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null,
weight: input.weight ?? null,
actorId: ctx.userId,
},
});
await db.auditEvent.create({
data: { action: "animal.log.add", entityId: animalId, actorId: ctx.userId, payload: { type: input.type, via: ctx.via } },
});
return log;
}