diff --git a/prisma/migrations/20260616051637_v0_14_animals/migration.sql b/prisma/migrations/20260616051637_v0_14_animals/migration.sql new file mode 100644 index 0000000..9d53ce5 --- /dev/null +++ b/prisma/migrations/20260616051637_v0_14_animals/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be5eb86..cce618d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,6 +101,7 @@ model Location { updatedAt DateTime @updatedAt plants Plant[] items Item[] + animals Animal[] @@index([parentId]) @@index([kind]) @@ -239,6 +240,8 @@ model Task { plant Plant? @relation(fields: [plantId], references: [id]) itemId String? item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull) + animalId String? + animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull) recurrence RecurrenceType @default(YEARLY) intervalDays Int? // for CUSTOM: every N days month Int? // 1–12: for YEARLY, which month @@ -252,6 +255,7 @@ model Task { @@index([nextDue]) @@index([plantId]) @@index([itemId]) + @@index([animalId]) @@index([active]) } @@ -395,3 +399,58 @@ model ItemLog { @@index([itemId]) @@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]) +} diff --git a/src/app/(dashboard)/animals/[id]/page.tsx b/src/app/(dashboard)/animals/[id]/page.tsx new file mode 100644 index 0000000..a7a985a --- /dev/null +++ b/src/app/(dashboard)/animals/[id]/page.tsx @@ -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: , label: "Location", value: animal.location.name }); + if (animal.sex) facts.push({ icon: , label: "Sex", value: animal.sex }); + if (animal.birthdate) facts.push({ icon: , label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` }); + if (animal.acquiredAt) facts.push({ icon: , label: "Acquired", value: formatDate(animal.acquiredAt) }); + if (animal.source) facts.push({ icon: , label: "From", value: animal.source }); + if (latestWeight?.weight != null) facts.push({ icon: , label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` }); + + return ( +
+
+ + + +
+

{animal.name}

+

{[animal.species, animal.breed].filter(Boolean).join(" · ")}

+
+ {age && {age}} +
+ +
+ + + +
+ + {animal.imageUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {animal.name} +
+ )} + + {facts.length > 0 && ( + + + {facts.map((f, i) => ( +
+ {f.icon} +
+

{f.label}

+

{f.value}

+
+
+ ))} +
+
+ )} + + {animal.notes && ( + {animal.notes} + )} + +
+

Care log

+ {animal.logs.length === 0 ? ( +
+ No entries yet — log a feeding, vet visit, or weigh-in above. +
+ ) : ( +
+ {animal.logs.map((log) => ( +
+
+
+ + {ANIMAL_LOG_LABELS[log.type]} + + {log.weight != null && · {Number(log.weight)} lbs} + {formatDate(log.date)} +
+ {log.notes &&

{log.notes}

} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/animals/page.tsx b/src/app/(dashboard)/animals/page.tsx new file mode 100644 index 0000000..bf0d52f --- /dev/null +++ b/src/app/(dashboard)/animals/page.tsx @@ -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(); + 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 ( +
+
+
+

Animals & pets

+

+ {animals.length} {animals.length === 1 ? "animal" : "animals"} +

+
+ +
+ + {animals.length === 0 ? ( +
+ +

No animals yet

+

Add a pet or some livestock to start a care log.

+
+ ) : ( +
+ {sorted.map(([species, list]) => ( +
+

{species}

+
+ {list.map((a) => { + const age = ageString(a.birthdate, now); + return ( + + {a.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + + + )} +
+

{a.name}

+

+ {[a.breed, age].filter(Boolean).join(" · ") || a.species} +

+
+ {a.location?.name && ( + + {a.location.name} + + )} + + + ); + })} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts index 0ddf5a0..62c221d 100644 --- a/src/app/api/admin/backup/route.ts +++ b/src/app/api/admin/backup/route.ts @@ -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)), }); diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts index 89c9f63..43f5765 100644 --- a/src/app/api/admin/restore/route.ts +++ b/src/app/api/admin/restore/route.ts @@ -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 }); diff --git a/src/app/api/v1/animals/[id]/logs/route.ts b/src/app/api/v1/animals/[id]/logs/route.ts new file mode 100644 index 0000000..de13483 --- /dev/null +++ b/src/app/api/v1/animals/[id]/logs/route.ts @@ -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); + } +} diff --git a/src/app/api/v1/animals/[id]/route.ts b/src/app/api/v1/animals/[id]/route.ts new file mode 100644 index 0000000..73830cd --- /dev/null +++ b/src/app/api/v1/animals/[id]/route.ts @@ -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); + } +} diff --git a/src/app/api/v1/animals/route.ts b/src/app/api/v1/animals/route.ts new file mode 100644 index 0000000..2b908bd --- /dev/null +++ b/src/app/api/v1/animals/route.ts @@ -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); + } +} diff --git a/src/components/animals/add-animal-log-button.tsx b/src/components/animals/add-animal-log-button.tsx new file mode 100644 index 0000000..3f7b2c0 --- /dev/null +++ b/src/components/animals/add-animal-log-button.tsx @@ -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 ( + <> + + + + Log for this animal +
+
+ + +
+ {type === "WEIGHT" && ( +
+ + setWeight(e.target.value)} autoFocus /> +
+ )} +
+ +