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:
112
src/lib/services/animals.ts
Normal file
112
src/lib/services/animals.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user