Compare commits

...

2 Commits

Author SHA1 Message Date
cffa739056 docs: add Animals module to README + CLAUDE.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:28:51 -05:00
39be24c2fc 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>
2026-06-16 00:23:53 -05:00
23 changed files with 985 additions and 7 deletions

View File

@@ -11,7 +11,9 @@ user-facing overview.
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree. **PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
- **Inventory** — durable **Item**s that nest (`parentItemId`), with **Label**s - **Inventory** — durable **Item**s that nest (`parentItemId`), with **Label**s
(M:N via `LabelOnItem`), `ItemAttachment`, and `ItemLog`. Shares the Location spine. (M:N via `LabelOnItem`), `ItemAttachment`, and `ItemLog`. Shares the Location spine.
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants + items. - **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`. - **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`.
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`). - **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo. - **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.

View File

@@ -15,8 +15,10 @@ Live at **http://192.168.0.5:8053**.
- **Inventory** — durable **items** that nest (boxes within rooms within - **Inventory** — durable **items** that nest (boxes within rooms within
buildings), with labels, attachments, and logs. Shares the same location spine buildings), with labels, attachments, and logs. Shares the same location spine
as the garden. as the garden.
- **Animals** — pets & livestock with their own logs, placed on the shared
location tree; reminders can be linked to an animal.
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly), - **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
optionally linked to a plant. optionally linked to a plant or animal.
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope - **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant, permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
and automation. and automation.

View File

@@ -0,0 +1,66 @@
-- CreateEnum
CREATE TYPE "AnimalLogType" AS ENUM ('NOTE', 'FEEDING', 'VET', 'WEIGHT', 'MEDICATION', 'BREEDING', 'ACQUIRED', 'DIED');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "animalId" TEXT;
-- CreateTable
CREATE TABLE "Animal" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"species" TEXT,
"breed" TEXT,
"sex" TEXT,
"locationId" TEXT,
"birthdate" TIMESTAMP(3),
"acquiredAt" TIMESTAMP(3),
"source" TEXT,
"notes" TEXT,
"imageUrl" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Animal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnimalLog" (
"id" TEXT NOT NULL,
"animalId" TEXT NOT NULL,
"type" "AnimalLogType" NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
"weight" DECIMAL(10,2),
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnimalLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Animal_locationId_idx" ON "Animal"("locationId");
-- CreateIndex
CREATE INDEX "Animal_species_idx" ON "Animal"("species");
-- CreateIndex
CREATE INDEX "Animal_active_idx" ON "Animal"("active");
-- CreateIndex
CREATE INDEX "AnimalLog_animalId_idx" ON "AnimalLog"("animalId");
-- CreateIndex
CREATE INDEX "AnimalLog_date_idx" ON "AnimalLog"("date");
-- CreateIndex
CREATE INDEX "Task_animalId_idx" ON "Task"("animalId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Animal" ADD CONSTRAINT "Animal_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnimalLog" ADD CONSTRAINT "AnimalLog_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -101,6 +101,7 @@ model Location {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
plants Plant[] plants Plant[]
items Item[] items Item[]
animals Animal[]
@@index([parentId]) @@index([parentId])
@@index([kind]) @@index([kind])
@@ -239,6 +240,8 @@ model Task {
plant Plant? @relation(fields: [plantId], references: [id]) plant Plant? @relation(fields: [plantId], references: [id])
itemId String? itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull) item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
animalId String?
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY) recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days intervalDays Int? // for CUSTOM: every N days
month Int? // 112: for YEARLY, which month month Int? // 112: for YEARLY, which month
@@ -252,6 +255,7 @@ model Task {
@@index([nextDue]) @@index([nextDue])
@@index([plantId]) @@index([plantId])
@@index([itemId]) @@index([itemId])
@@index([animalId])
@@index([active]) @@index([active])
} }
@@ -395,3 +399,58 @@ model ItemLog {
@@index([itemId]) @@index([itemId])
@@index([date]) @@index([date])
} }
// ---------------------------------------------------------------------------
// Animals & pets — livestock and household pets, leaves in a Location with
// their own care log (vet, feeding, weight, meds) and recurring reminders.
// ---------------------------------------------------------------------------
enum AnimalLogType {
NOTE
FEEDING
VET
WEIGHT
MEDICATION
BREEDING
ACQUIRED
DIED
}
model Animal {
id String @id @default(cuid())
name String
species String? // "Dog", "Cat", "Chicken", "Goat"
breed String?
sex String? // freeform: "Female", "Male", "Wether"
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
birthdate DateTime?
acquiredAt DateTime?
source String?
notes String?
imageUrl String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs AnimalLog[]
tasks Task[]
@@index([locationId])
@@index([species])
@@index([active])
}
model AnimalLog {
id String @id @default(cuid())
animalId String
animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade)
type AnimalLogType
date DateTime @default(now())
notes String?
weight Decimal? @db.Decimal(10, 2) // for WEIGHT entries
actorId String?
createdAt DateTime @default(now())
@@index([animalId])
@@index([date])
}

View File

@@ -0,0 +1,131 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { ChevronLeft, MapPin, CalendarDays, Cake, Package, Scale, PawPrint } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { ageString } from "@/lib/animals/age";
import { ANIMAL_LOG_LABELS, ANIMAL_LOG_COLORS } from "@/lib/animals/animal-fields";
import { EditAnimalButton, type AnimalFields } from "@/components/animals/animal-dialog";
import { DeleteAnimalButton } from "@/components/animals/delete-animal-button";
import { AddAnimalLogButton } from "@/components/animals/add-animal-log-button";
function toDateInput(d: Date | null): string {
return d ? new Date(d).toISOString().split("T")[0] : "";
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const a = await db.animal.findUnique({ where: { id: params.id }, select: { name: true } });
return { title: a?.name ?? "Animal" };
}
export default async function AnimalDetailPage({ params }: { params: { id: string } }) {
const [animal, locations] = await Promise.all([
db.animal.findUnique({
where: { id: params.id },
include: { location: { select: { id: true, name: true } }, logs: { orderBy: { date: "desc" } } },
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
if (!animal || !animal.active) notFound();
const age = ageString(animal.birthdate, new Date());
const latestWeight = animal.logs.find((l) => l.type === "WEIGHT" && l.weight != null);
const editFields: AnimalFields = {
id: animal.id,
name: animal.name,
species: animal.species ?? "",
breed: animal.breed ?? "",
sex: animal.sex ?? "",
locationId: animal.locationId ?? "",
birthdate: toDateInput(animal.birthdate),
acquiredAt: toDateInput(animal.acquiredAt),
source: animal.source ?? "",
notes: animal.notes ?? "",
};
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
if (animal.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: animal.location.name });
if (animal.sex) facts.push({ icon: <PawPrint className="h-4 w-4" />, label: "Sex", value: animal.sex });
if (animal.birthdate) facts.push({ icon: <Cake className="h-4 w-4" />, label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` });
if (animal.acquiredAt) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Acquired", value: formatDate(animal.acquiredAt) });
if (animal.source) facts.push({ icon: <Package className="h-4 w-4" />, label: "From", value: animal.source });
if (latestWeight?.weight != null) facts.push({ icon: <Scale className="h-4 w-4" />, label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/animals" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{animal.name}</h1>
<p className="text-sm text-muted-foreground">{[animal.species, animal.breed].filter(Boolean).join(" · ")}</p>
</div>
{age && <Badge variant="secondary" className="ml-auto shrink-0">{age}</Badge>}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditAnimalButton animal={editFields} locations={locations} />
<AddAnimalLogButton animalId={animal.id} />
<DeleteAnimalButton id={animal.id} name={animal.name} />
</div>
{animal.imageUrl && (
<div className="rounded-lg overflow-hidden h-56 bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={animal.imageUrl} alt={animal.name} className="w-full h-full object-cover" />
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{facts.map((f, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="truncate">{f.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{animal.notes && (
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{animal.notes}</CardContent></Card>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">Care log</h2>
{animal.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No entries yet log a feeding, vet visit, or weigh-in above.
</div>
) : (
<div className="space-y-2">
{animal.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${ANIMAL_LOG_COLORS[log.type]}`}>
{ANIMAL_LOG_LABELS[log.type]}
</span>
{log.weight != null && <span className="text-xs text-muted-foreground">· {Number(log.weight)} lbs</span>}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { PawPrint, MapPin, ChevronRight } from "lucide-react";
import { ageString } from "@/lib/animals/age";
import { AddAnimalButton } from "@/components/animals/animal-dialog";
export const metadata = { title: "Animals" };
const now = new Date();
export default async function AnimalsPage() {
const [animals, locations] = await Promise.all([
db.animal.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Group by species ("Other" bucket last).
const groups = new Map<string, typeof animals>();
for (const a of animals) {
const key = a.species?.trim() || "Other";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(a);
}
const sorted = Array.from(groups.entries()).sort((a, b) =>
a[0] === "Other" ? 1 : b[0] === "Other" ? -1 : a[0].localeCompare(b[0]),
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Animals &amp; pets</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{animals.length} {animals.length === 1 ? "animal" : "animals"}
</p>
</div>
<AddAnimalButton locations={locations} />
</div>
{animals.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<PawPrint className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No animals yet</p>
<p className="text-sm mt-1">Add a pet or some livestock to start a care log.</p>
</div>
) : (
<div className="space-y-6">
{sorted.map(([species, list]) => (
<div key={species} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{species}</p>
<div className="divide-y border rounded-lg overflow-hidden">
{list.map((a) => {
const age = ageString(a.birthdate, now);
return (
<Link key={a.id} href={`/animals/${a.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
{a.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.imageUrl} alt="" className="h-9 w-9 rounded-full object-cover shrink-0" />
) : (
<span className="h-9 w-9 rounded-full bg-[hsl(var(--leaf)/.1)] flex items-center justify-center shrink-0">
<PawPrint className="h-4 w-4 text-[hsl(var(--leaf)/.6)]" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="font-medium">{a.name}</p>
<p className="text-xs text-muted-foreground">
{[a.breed, age].filter(Boolean).join(" · ") || a.species}
</p>
</div>
{a.location?.name && (
<Badge variant="outline" className="gap-1 text-xs">
<MapPin className="h-2.5 w-2.5" />{a.location.name}
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -11,7 +11,7 @@ export async function GET() {
} }
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions, const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
labels, items, labelOnItems, itemAttachments, itemLogs, auditEvents] = labels, items, labelOnItems, itemAttachments, itemLogs, animals, animalLogs, auditEvents] =
await Promise.all([ await Promise.all([
db.location.findMany(), db.location.findMany(),
db.plantType.findMany(), db.plantType.findMany(),
@@ -27,13 +27,15 @@ export async function GET() {
db.labelOnItem.findMany(), db.labelOnItem.findMany(),
db.itemAttachment.findMany(), db.itemAttachment.findMany(),
db.itemLog.findMany(), db.itemLog.findMany(),
db.animal.findMany(),
db.animalLog.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }), db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]); ]);
const manifest = { const manifest = {
version: 1, version: 1,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "auditEvents"], tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "animals", "animalLogs", "auditEvents"],
}; };
const zip = zipSync({ const zip = zipSync({
@@ -52,6 +54,8 @@ export async function GET() {
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)), "labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)), "itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)), "itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
"animals.json": strToU8(JSON.stringify(animals, null, 2)),
"animalLogs.json": strToU8(JSON.stringify(animalLogs, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)), "auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
}); });

View File

@@ -85,6 +85,8 @@ export async function POST(req: Request) {
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : []; const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : []; const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : []; const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
const animals = files["animals.json"] ? asArray("animals.json") : [];
const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : [];
const auditEvents = asArray("auditEvents.json"); const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order. // Restore inside a transaction — wipe then reload in FK order.
@@ -94,9 +96,11 @@ export async function POST(req: Request) {
await tx.itemLog.deleteMany(); await tx.itemLog.deleteMany();
await tx.itemAttachment.deleteMany(); await tx.itemAttachment.deleteMany();
await tx.labelOnItem.deleteMany(); await tx.labelOnItem.deleteMany();
await tx.animalLog.deleteMany();
await tx.task.deleteMany(); await tx.task.deleteMany();
await tx.item.deleteMany(); await tx.item.deleteMany();
await tx.label.deleteMany(); await tx.label.deleteMany();
await tx.animal.deleteMany();
await tx.plant.deleteMany(); await tx.plant.deleteMany();
await tx.plantGroup.deleteMany(); await tx.plantGroup.deleteMany();
await tx.plantType.deleteMany(); await tx.plantType.deleteMany();
@@ -121,6 +125,8 @@ export async function POST(req: Request) {
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never }); if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never }); if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never }); if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
if (animals.length) await tx.animal.createMany({ data: animals as never });
if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never });
if (tasks.length) await tx.task.createMany({ data: tasks as never }); if (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never }); if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never }); if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalLogInput } from "@/lib/api/schemas";
import { addAnimalLog } from "@/lib/services/animals";
// POST /api/v1/animals/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalLogInput.parse(await req.json());
return NextResponse.json(await addAnimalLog(ctx, params.id, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalUpdateInput } from "@/lib/api/schemas";
import { getAnimal, updateAnimal, deleteAnimal } from "@/lib/services/animals";
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:read");
return NextResponse.json(await getAnimal(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalUpdateInput.parse(await req.json());
return NextResponse.json(await updateAnimal(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
await deleteAnimal(ctx, params.id);
return NextResponse.json({ ok: true });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalCreateInput } from "@/lib/api/schemas";
import { listAnimals, createAnimal } from "@/lib/services/animals";
// GET /api/v1/animals?q=&species=
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "animals:read");
const sp = new URL(req.url).searchParams;
return NextResponse.json(
await listAnimals(ctx, { q: sp.get("q") ?? undefined, species: sp.get("species") ?? undefined }),
);
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/animals
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalCreateInput.parse(await req.json());
return NextResponse.json(await createAnimal(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { ClipboardList } from "lucide-react";
import { ANIMAL_LOG_TYPES, ANIMAL_LOG_LABELS } from "@/lib/animals/animal-fields";
export function AddAnimalLogButton({ animalId }: { animalId: string }) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("NOTE");
const [notes, setNotes] = useState("");
const [weight, setWeight] = useState("");
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await fetch(`/api/v1/animals/${animalId}/logs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, notes: notes.trim() || undefined, weight: weight ? Number(weight) : null }),
});
setBusy(false);
if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
toast({ title: "Logged!" });
setNotes(""); setWeight(""); setOpen(false);
router.refresh();
}
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<ClipboardList className="h-4 w-4 mr-1" /> Log
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Log for this animal</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ANIMAL_LOG_TYPES.map((t) => <SelectItem key={t} value={t}>{ANIMAL_LOG_LABELS[t]}</SelectItem>)}
</SelectContent>
</Select>
</div>
{type === "WEIGHT" && (
<div className="space-y-1.5">
<Label>Weight (lbs)</Label>
<Input type="number" min="0" step="0.01" value={weight} onChange={(e) => setWeight(e.target.value)} autoFocus />
</div>
)}
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What happened?" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save log"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil } from "lucide-react";
import { COMMON_SPECIES } from "@/lib/animals/animal-fields";
export type LocationOpt = { id: string; name: string };
export type AnimalFields = {
id?: string;
name: string;
species: string;
breed: string;
sex: string;
locationId: string;
birthdate: string;
acquiredAt: string;
source: string;
notes: string;
};
export function blankAnimal(): AnimalFields {
return { name: "", species: "", breed: "", sex: "", locationId: "", birthdate: "", acquiredAt: "", source: "", notes: "" };
}
const NONE = "__none__";
function AnimalDialog({ trigger, title, initial, locations }: {
trigger: React.ReactNode; title: string; initial: AnimalFields; locations: LocationOpt[];
}) {
const [open, setOpen] = useState(false);
const [f, setF] = useState<AnimalFields>(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const { toast } = useToast();
function set<K extends keyof AnimalFields>(k: K, v: AnimalFields[K]) {
setF((p) => ({ ...p, [k]: v }));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!f.name.trim()) { setError("Please give the animal a name."); return; }
setError("");
setBusy(true);
const payload = {
name: f.name.trim(),
species: f.species.trim() || undefined,
breed: f.breed.trim() || undefined,
sex: f.sex.trim() || undefined,
locationId: f.locationId || null,
birthdate: f.birthdate || null,
acquiredAt: f.acquiredAt || null,
source: f.source.trim() || undefined,
notes: f.notes.trim() || undefined,
};
const res = await fetch(f.id ? `/api/v1/animals/${f.id}` : "/api/v1/animals", {
method: f.id ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
return;
}
toast({ title: f.id ? "Saved!" : "Added!" });
setOpen(false);
router.refresh();
}
return (
<>
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Name *</Label>
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Bella" />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="space-y-1.5">
<Label>Species</Label>
<Input list="species-list" value={f.species} onChange={(e) => set("species", e.target.value)} placeholder="Dog" />
<datalist id="species-list">
{COMMON_SPECIES.map((s) => <option key={s} value={s} />)}
</datalist>
</div>
<div className="space-y-1.5">
<Label>Breed</Label>
<Input value={f.breed} onChange={(e) => set("breed", e.target.value)} placeholder="Border collie" />
</div>
<div className="space-y-1.5">
<Label>Sex</Label>
<Input value={f.sex} onChange={(e) => set("sex", e.target.value)} placeholder="Female" />
</div>
<div className="space-y-1.5">
<Label>Location</Label>
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Birthdate</Label>
<Input type="date" value={f.birthdate} onChange={(e) => set("birthdate", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Acquired</Label>
<Input type="date" value={f.acquiredAt} onChange={(e) => set("acquiredAt", e.target.value)} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Where from?</Label>
<Input value={f.source} onChange={(e) => set("source", e.target.value)} placeholder="Breeder, shelter, hatchery…" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Notes</Label>
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function AddAnimalButton({ locations }: { locations: LocationOpt[] }) {
return (
<AnimalDialog title="Add an animal" initial={blankAnimal()} locations={locations}
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add animal</Button>} />
);
}
export function EditAnimalButton({ animal, locations }: { animal: AnimalFields; locations: LocationOpt[] }) {
return (
<AnimalDialog title="Edit animal" initial={animal} locations={locations}
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Trash2 } from "lucide-react";
export function DeleteAnimalButton({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleDelete() {
setBusy(true);
const res = await fetch(`/api/v1/animals/${id}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not remove.", variant: "destructive" });
return;
}
toast({ title: "Removed", description: `"${name}" removed.` });
setOpen(false);
router.push("/animals");
router.refresh();
}
return (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" /> Remove
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>The animal is hidden but its full history is kept.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Removing…" : "Remove"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,7 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package } from "lucide-react"; import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package, PawPrint } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
@@ -10,6 +10,7 @@ const navItems = [
{ label: "Garden", href: "/garden", icon: Leaf }, { label: "Garden", href: "/garden", icon: Leaf },
{ label: "Plant types", href: "/garden/types", icon: BookOpen }, { label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package }, { label: "Inventory", href: "/inventory", icon: Package },
{ label: "Animals", href: "/animals", icon: PawPrint },
{ label: "Reminders", href: "/tasks", icon: Bell }, { label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases // Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home }, // { label: "Home", href: "/home", icon: Home },

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { ageString } from "./age";
const now = new Date("2026-06-16T00:00:00Z");
describe("ageString", () => {
it("returns null without a birthdate", () => {
expect(ageString(null, now)).toBeNull();
expect(ageString(undefined, now)).toBeNull();
});
it("returns null for a future birthdate", () => {
expect(ageString(new Date("2026-07-01"), now)).toBeNull();
});
it("counts days under a month", () => {
expect(ageString(new Date("2026-06-11T00:00:00Z"), now)).toBe("5 days");
expect(ageString(new Date("2026-06-15T00:00:00Z"), now)).toBe("1 day");
});
it("counts months under two years", () => {
expect(ageString(new Date("2026-03-16T00:00:00Z"), now)).toBe("3 months");
expect(ageString(new Date("2025-06-16T00:00:00Z"), now)).toBe("12 months");
});
it("counts whole years from two years on", () => {
expect(ageString(new Date("2024-06-16T00:00:00Z"), now)).toBe("2 years");
expect(ageString(new Date("2019-01-16T00:00:00Z"), now)).toBe("7 years");
});
it("says newborn on the birth day", () => {
expect(ageString(new Date("2026-06-16T00:00:00Z"), now)).toBe("newborn");
});
});

22
src/lib/animals/age.ts Normal file
View File

@@ -0,0 +1,22 @@
// Pure: a friendly age string from a birthdate. Days under a month, months
// under two years, then whole years.
export function ageString(
birthdate: Date | string | null | undefined,
now: Date,
): string | null {
if (!birthdate) return null;
const b = new Date(birthdate);
if (Number.isNaN(b.getTime()) || b > now) return null;
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (now.getDate() < b.getDate()) months--;
if (months < 1) {
const days = Math.floor((now.getTime() - b.getTime()) / 86_400_000);
return days <= 0 ? "newborn" : `${days} day${days === 1 ? "" : "s"}`;
}
if (months < 24) return `${months} month${months === 1 ? "" : "s"}`;
const years = Math.floor(months / 12);
return `${years} years`;
}

View File

@@ -0,0 +1,31 @@
import { AnimalLogType } from "@prisma/client";
export const ANIMAL_LOG_LABELS: Record<AnimalLogType, string> = {
NOTE: "Note",
FEEDING: "Feeding",
VET: "Vet visit",
WEIGHT: "Weight",
MEDICATION: "Medication",
BREEDING: "Breeding",
ACQUIRED: "Acquired",
DIED: "Died / removed",
};
export const ANIMAL_LOG_COLORS: Record<AnimalLogType, string> = {
NOTE: "text-slate-600 dark:text-slate-400",
FEEDING: "text-green-600 dark:text-green-400",
VET: "text-blue-600 dark:text-blue-400",
WEIGHT: "text-violet-600 dark:text-violet-400",
MEDICATION: "text-amber-600 dark:text-amber-400",
BREEDING: "text-pink-600 dark:text-pink-400",
ACQUIRED: "text-teal-600 dark:text-teal-400",
DIED: "text-red-600 dark:text-red-400",
};
export const ANIMAL_LOG_TYPES: AnimalLogType[] = [
"NOTE", "FEEDING", "VET", "WEIGHT", "MEDICATION", "BREEDING", "ACQUIRED", "DIED",
];
export const COMMON_SPECIES = [
"Dog", "Cat", "Chicken", "Duck", "Goat", "Sheep", "Pig", "Rabbit", "Horse", "Cow", "Bee", "Other",
];

View File

@@ -59,3 +59,31 @@ export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
export type ItemCreateInput = z.infer<typeof itemCreateInput>; export type ItemCreateInput = z.infer<typeof itemCreateInput>;
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>; export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
export type ItemLogInput = z.infer<typeof itemLogInput>; export type ItemLogInput = z.infer<typeof itemLogInput>;
// --- Animals & pets ---
export const animalCreateInput = z.object({
name: z.string().min(1),
species: z.string().optional(),
breed: z.string().optional(),
sex: z.string().optional(),
locationId: z.string().nullable().optional(),
birthdate: z.string().nullable().optional(),
acquiredAt: z.string().nullable().optional(),
source: z.string().optional(),
notes: z.string().optional(),
imageUrl: z.string().nullable().optional(),
});
export const animalUpdateInput = animalCreateInput.partial();
export const animalLogInput = z.object({
type: z.enum(["NOTE", "FEEDING", "VET", "WEIGHT", "MEDICATION", "BREEDING", "ACQUIRED", "DIED"]),
notes: z.string().optional(),
weight: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(),
});
export type AnimalCreateInput = z.infer<typeof animalCreateInput>;
export type AnimalUpdateInput = z.infer<typeof animalUpdateInput>;
export type AnimalLogInput = z.infer<typeof animalLogInput>;

View File

@@ -7,6 +7,7 @@ export const KNOWN_SCOPES = [
"locations:read", "locations:write", "locations:read", "locations:write",
"tasks:read", "tasks:write", "tasks:read", "tasks:write",
"items:read", "items:write", "items:read", "items:write",
"animals:read", "animals:write",
] as const; ] as const;
export type KnownScope = (typeof KNOWN_SCOPES)[number]; export type KnownScope = (typeof KNOWN_SCOPES)[number];
@@ -18,5 +19,6 @@ export const SCOPE_RESOURCES: { key: string; label: string; soon?: boolean }[] =
{ key: "plant-types", label: "Plant types" }, { key: "plant-types", label: "Plant types" },
{ key: "locations", label: "Locations" }, { key: "locations", label: "Locations" },
{ key: "tasks", label: "Tasks & reminders" }, { key: "tasks", label: "Tasks & reminders" },
{ key: "items", label: "Inventory", soon: true }, { key: "items", label: "Inventory" },
{ key: "animals", label: "Animals & pets" },
]; ];

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.14.0",
date: "2026-06-16",
changes: [
"New Animals & Pets section — keep a record for each pet and animal on the homestead: species, breed, birthday (with age shown automatically), and where they live. Log feedings, vet visits, weigh-ins, and medications, and set recurring reminders like vaccinations. Works for the dog and cat as well as chickens or goats.",
],
},
{ {
version: "0.13.0", version: "0.13.0",
date: "2026-06-16", date: "2026-06-16",

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;
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.13.0"; export const APP_VERSION = "0.14.0";