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:
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal file
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/app/(dashboard)/animals/page.tsx
Normal file
92
src/app/(dashboard)/animals/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
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([
|
||||
db.location.findMany(),
|
||||
db.plantType.findMany(),
|
||||
@@ -27,13 +27,15 @@ export async function GET() {
|
||||
db.labelOnItem.findMany(),
|
||||
db.itemAttachment.findMany(),
|
||||
db.itemLog.findMany(),
|
||||
db.animal.findMany(),
|
||||
db.animalLog.findMany(),
|
||||
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
]);
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
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({
|
||||
@@ -52,6 +54,8 @@ export async function GET() {
|
||||
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
|
||||
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, 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)),
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ export async function POST(req: Request) {
|
||||
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
|
||||
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.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");
|
||||
|
||||
// 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.itemAttachment.deleteMany();
|
||||
await tx.labelOnItem.deleteMany();
|
||||
await tx.animalLog.deleteMany();
|
||||
await tx.task.deleteMany();
|
||||
await tx.item.deleteMany();
|
||||
await tx.label.deleteMany();
|
||||
await tx.animal.deleteMany();
|
||||
await tx.plant.deleteMany();
|
||||
await tx.plantGroup.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 (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments 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 (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
|
||||
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
|
||||
|
||||
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal file
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/app/api/v1/animals/[id]/route.ts
Normal file
33
src/app/api/v1/animals/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/app/api/v1/animals/route.ts
Normal file
28
src/app/api/v1/animals/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
80
src/components/animals/add-animal-log-button.tsx
Normal file
80
src/components/animals/add-animal-log-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
src/components/animals/animal-dialog.tsx
Normal file
168
src/components/animals/animal-dialog.tsx
Normal 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>} />
|
||||
);
|
||||
}
|
||||
55
src/components/animals/delete-animal-button.tsx
Normal file
55
src/components/animals/delete-animal-button.tsx
Normal 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 “{name}”?</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
const navItems = [
|
||||
@@ -10,6 +10,7 @@ const navItems = [
|
||||
{ label: "Garden", href: "/garden", icon: Leaf },
|
||||
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
|
||||
{ label: "Inventory", href: "/inventory", icon: Package },
|
||||
{ label: "Animals", href: "/animals", icon: PawPrint },
|
||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||
// Home maintenance and Equipment come in future releases
|
||||
// { label: "Home", href: "/home", icon: Home },
|
||||
|
||||
34
src/lib/animals/age.test.ts
Normal file
34
src/lib/animals/age.test.ts
Normal 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
22
src/lib/animals/age.ts
Normal 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`;
|
||||
}
|
||||
31
src/lib/animals/animal-fields.ts
Normal file
31
src/lib/animals/animal-fields.ts
Normal 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",
|
||||
];
|
||||
@@ -59,3 +59,31 @@ export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
||||
export type ItemCreateInput = z.infer<typeof itemCreateInput>;
|
||||
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
|
||||
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>;
|
||||
|
||||
@@ -7,6 +7,7 @@ export const KNOWN_SCOPES = [
|
||||
"locations:read", "locations:write",
|
||||
"tasks:read", "tasks:write",
|
||||
"items:read", "items:write",
|
||||
"animals:read", "animals:write",
|
||||
] as const;
|
||||
|
||||
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: "locations", label: "Locations" },
|
||||
{ key: "tasks", label: "Tasks & reminders" },
|
||||
{ key: "items", label: "Inventory", soon: true },
|
||||
{ key: "items", label: "Inventory" },
|
||||
{ key: "animals", label: "Animals & pets" },
|
||||
];
|
||||
|
||||
@@ -8,6 +8,13 @@ export type 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",
|
||||
date: "2026-06-16",
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
// 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";
|
||||
|
||||
Reference in New Issue
Block a user