diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 917a605..dbda604 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -69,6 +69,12 @@ model Pet {
conversationsAsB Conversation[] @relation("ConvPetB")
sentMessages Message[] @relation("MessageSender")
+ // Phase 7 back-relations
+ weightLogs WeightLog[]
+ vetVisits VetVisit[]
+ vaccines Vaccine[]
+ healthCard HealthCard?
+
@@index([ownerId])
}
@@ -341,6 +347,55 @@ model Message {
@@index([senderPetId])
}
+// ─── Phase 7 Models ──────────────────────────────────────────────────────────
+
+model WeightLog {
+ id String @id @default(cuid())
+ pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ date DateTime @db.Date
+ weightGrams Int // stored in grams; display as kg
+ notes String? @db.VarChar(200)
+ createdAt DateTime @default(now())
+
+ @@index([petId, date(sort: Desc)])
+}
+
+model VetVisit {
+ id String @id @default(cuid())
+ pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ date DateTime @db.Date
+ reason String @db.VarChar(100)
+ notes String? @db.VarChar(500)
+ vetName String? @db.VarChar(100)
+ createdAt DateTime @default(now())
+
+ @@index([petId, date(sort: Desc)])
+}
+
+model Vaccine {
+ id String @id @default(cuid())
+ pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
+ petId String
+ name String @db.VarChar(100)
+ dateGiven DateTime @db.Date
+ nextDueDate DateTime? @db.Date
+ notes String? @db.VarChar(200)
+ createdAt DateTime @default(now())
+
+ @@index([petId])
+}
+
+model HealthCard {
+ id String @id @default(cuid())
+ pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
+ petId String @unique
+ token String @unique @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
enum NotificationType {
diff --git a/src/app/(app)/pets/[petId]/health/page.tsx b/src/app/(app)/pets/[petId]/health/page.tsx
new file mode 100644
index 0000000..40edd1f
--- /dev/null
+++ b/src/app/(app)/pets/[petId]/health/page.tsx
@@ -0,0 +1,27 @@
+import { notFound, redirect } from "next/navigation";
+import { currentUser } from "@clerk/nextjs/server";
+import { createTRPCCaller } from "@/trpc/server";
+import { HealthDashboard } from "@/components/health/HealthDashboard";
+
+interface PageProps {
+ params: Promise<{ petId: string }>;
+}
+
+export default async function HealthPage({ params }: PageProps) {
+ const { petId } = await params;
+
+ const [user, trpc] = await Promise.all([currentUser(), createTRPCCaller()]);
+
+ let pet;
+ try {
+ pet = await trpc.pets.getProfile({ petId });
+ } catch {
+ notFound();
+ }
+
+ if (!user || pet.ownerId !== user.id) {
+ redirect(`/pets/${petId}`);
+ }
+
+ return ;
+}
diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx
index 983d17d..74d1e48 100644
--- a/src/app/(app)/pets/[petId]/page.tsx
+++ b/src/app/(app)/pets/[petId]/page.tsx
@@ -78,14 +78,22 @@ export default async function PetProfilePage({ params }: PageProps) {
)}
- {/* Edit link — only own pet */}
+ {/* Owner actions — edit + health */}
{isOwnPet && (
-
- Edit profile
-
+
+
+ Edit profile
+
+
+ Health
+
+
)}
diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx
new file mode 100644
index 0000000..caf9033
--- /dev/null
+++ b/src/app/health-card/[token]/page.tsx
@@ -0,0 +1,165 @@
+import { notFound } from "next/navigation";
+import { PawPrint, AlertTriangle } from "lucide-react";
+import { prisma } from "@/lib/prisma";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { getAvatarUrl } from "@/lib/avatar-url";
+
+interface PageProps {
+ params: Promise<{ token: string }>;
+}
+
+function formatDate(d: Date | string) {
+ return new Date(d).toLocaleDateString("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+}
+
+function formatWeight(grams: number): string {
+ if (grams < 1000) return `${grams} g`;
+ return `${parseFloat((grams / 1000).toFixed(3))} kg`;
+}
+
+function isOverdue(date: Date | string): boolean {
+ return new Date(date) < new Date();
+}
+
+export default async function HealthCardPage({ params }: PageProps) {
+ const { token } = await params;
+
+ const card = await prisma.healthCard.findUnique({
+ where: { token },
+ include: {
+ pet: {
+ include: {
+ species: true,
+ breed: true,
+ weightLogs: { orderBy: { date: "desc" }, take: 20 },
+ vetVisits: { orderBy: { date: "desc" }, take: 10 },
+ vaccines: { orderBy: { dateGiven: "desc" } },
+ },
+ },
+ },
+ });
+
+ if (!card) notFound();
+
+ const { pet } = card;
+ const avatarUrl = getAvatarUrl(pet.avatarKey);
+
+ return (
+
+ {/* Header bar */}
+
+
+
PawFeed
+
Health Card
+
+
+
+ {/* Pet identity */}
+
+
+ {avatarUrl && }
+
+ {pet.name.slice(0, 2).toUpperCase()}
+
+
+
+
{pet.name}
+
+ {pet.species.name}
+ {pet.breed && {pet.breed.name}}
+
+
+
+
+ {/* Vaccines */}
+ {pet.vaccines.length > 0 && (
+
+
Vaccines
+
+ {pet.vaccines.map((vac) => {
+ const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate);
+ return (
+
+
+
{vac.name}
+
Given {formatDate(vac.dateGiven)}
+
+
+ {vac.nextDueDate && (
+
+ {overdue && }
+ {overdue ? "Overdue" : `Due ${formatDate(vac.nextDueDate)}`}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Weight history */}
+ {pet.weightLogs.length > 0 && (
+
+
Weight History
+
+ {pet.weightLogs.map((log) => (
+
+
{formatDate(log.date)}
+
+ {log.notes &&
{log.notes}
}
+
{formatWeight(log.weightGrams)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Vet visits */}
+ {pet.vetVisits.length > 0 && (
+
+
Vet Visits
+
+ {pet.vetVisits.map((visit, i) => (
+
+ {i > 0 &&
}
+
+
+
{visit.reason}
+
{formatDate(visit.date)}
+
+ {visit.vetName && (
+
{visit.vetName}
+ )}
+ {visit.notes && (
+
{visit.notes}
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {pet.vaccines.length === 0 && pet.weightLogs.length === 0 && pet.vetVisits.length === 0 && (
+
+
No health records have been added yet.
+
+ )}
+
+ {/* Footer */}
+
+ Generated via PawFeed · {new Date().toLocaleDateString("de-DE")}
+
+
+
+ );
+}
diff --git a/src/components/health/HealthDashboard.tsx b/src/components/health/HealthDashboard.tsx
new file mode 100644
index 0000000..6e7198e
--- /dev/null
+++ b/src/components/health/HealthDashboard.tsx
@@ -0,0 +1,558 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle } from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Separator } from "@/components/ui/separator";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+
+interface HealthDashboardProps {
+ petId: string;
+ petName: string;
+}
+
+function formatDate(d: Date | string) {
+ return new Date(d).toLocaleDateString("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+}
+
+function formatWeight(grams: number): string {
+ if (grams < 1000) return `${grams} g`;
+ const kg = grams / 1000;
+ return `${parseFloat(kg.toFixed(3))} kg`;
+}
+
+function isOverdue(date: Date | string): boolean {
+ return new Date(date) < new Date();
+}
+
+// ── Weight Tab ────────────────────────────────────────────────────────────────
+
+function WeightTab({ petId }: { petId: string }) {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const [showForm, setShowForm] = useState(false);
+ const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
+ const [weightKg, setWeightKg] = useState("");
+ const [notes, setNotes] = useState("");
+
+ const { data = [], isLoading } = useQuery(trpc.health.listWeightLogs.queryOptions({ petId }));
+
+ const addMutation = useMutation({
+ ...trpc.health.addWeightLog.mutationOptions(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
+ toast.success("Weight entry added");
+ setShowForm(false);
+ setWeightKg("");
+ setNotes("");
+ },
+ onError: () => toast.error("Failed to save weight entry"),
+ });
+
+ const deleteMutation = useMutation({
+ ...trpc.health.deleteWeightLog.mutationOptions(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
+ toast.success("Entry deleted");
+ },
+ onError: () => toast.error("Failed to delete entry"),
+ });
+
+ const handleAdd = () => {
+ const kg = parseFloat(weightKg);
+ if (!date || isNaN(kg) || kg <= 0) {
+ toast.error("Please fill in date and a valid weight");
+ return;
+ }
+ addMutation.mutate({ petId, date, weightKg: kg, notes: notes.trim() || undefined });
+ };
+
+ return (
+
+
+
{data.length} {data.length === 1 ? "entry" : "entries"}
+
+
+
+ {showForm && (
+
+
+
+
+ setNotes(e.target.value)} />
+
+
+
+
+
+
+ )}
+
+ {isLoading &&
}
+
+ {!isLoading && data.length === 0 && (
+
No weight entries yet
+ )}
+
+ {data.length > 0 && (
+
+ {data.map((entry) => (
+
+
+
{formatWeight(entry.weightGrams)}
+
+ {formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ── Vet Visits Tab ────────────────────────────────────────────────────────────
+
+function VetVisitsTab({ petId }: { petId: string }) {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const [showForm, setShowForm] = useState(false);
+ const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
+ const [reason, setReason] = useState("");
+ const [vetName, setVetName] = useState("");
+ const [notes, setNotes] = useState("");
+
+ const { data = [], isLoading } = useQuery(trpc.health.listVetVisits.queryOptions({ petId }));
+
+ const addMutation = useMutation({
+ ...trpc.health.addVetVisit.mutationOptions(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
+ toast.success("Vet visit added");
+ setShowForm(false);
+ setReason("");
+ setVetName("");
+ setNotes("");
+ },
+ onError: () => toast.error("Failed to save vet visit"),
+ });
+
+ const deleteMutation = useMutation({
+ ...trpc.health.deleteVetVisit.mutationOptions(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
+ toast.success("Entry deleted");
+ },
+ onError: () => toast.error("Failed to delete entry"),
+ });
+
+ const handleAdd = () => {
+ if (!date || !reason.trim()) {
+ toast.error("Please fill in date and reason");
+ return;
+ }
+ addMutation.mutate({
+ petId,
+ date,
+ reason: reason.trim(),
+ vetName: vetName.trim() || undefined,
+ notes: notes.trim() || undefined,
+ });
+ };
+
+ return (
+
+
+
{data.length} {data.length === 1 ? "visit" : "visits"}
+
+
+
+ {showForm && (
+
+
+
+
+ setReason(e.target.value)} />
+
+
+
+
+
+
+
+
+
+ )}
+
+ {isLoading &&
}
+
+ {!isLoading && data.length === 0 && (
+
No vet visits yet
+ )}
+
+ {data.length > 0 && (
+
+ {data.map((visit) => (
+
+
+
{visit.reason}
+
+ {formatDate(visit.date)}{visit.vetName ? ` · ${visit.vetName}` : ""}
+
+ {visit.notes && (
+
{visit.notes}
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ── Vaccines Tab ──────────────────────────────────────────────────────────────
+
+function VaccinesTab({ petId }: { petId: string }) {
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+ const [showForm, setShowForm] = useState(false);
+ const [name, setName] = useState("");
+ const [dateGiven, setDateGiven] = useState(new Date().toISOString().slice(0, 10));
+ const [nextDueDate, setNextDueDate] = useState("");
+ const [notes, setNotes] = useState("");
+
+ const { data = [], isLoading } = useQuery(trpc.health.listVaccines.queryOptions({ petId }));
+
+ const addMutation = useMutation({
+ ...trpc.health.addVaccine.mutationOptions(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
+ toast.success("Vaccine added");
+ setShowForm(false);
+ setName("");
+ setNextDueDate("");
+ setNotes("");
+ },
+ onError: () => toast.error("Failed to save vaccine"),
+ });
+
+ const deleteMutation = useMutation({
+ ...trpc.health.deleteVaccine.mutationOptions(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
+ toast.success("Entry deleted");
+ },
+ onError: () => toast.error("Failed to delete entry"),
+ });
+
+ const handleAdd = () => {
+ if (!name.trim() || !dateGiven) {
+ toast.error("Please fill in vaccine name and date given");
+ return;
+ }
+ addMutation.mutate({
+ petId,
+ name: name.trim(),
+ dateGiven,
+ nextDueDate: nextDueDate || undefined,
+ notes: notes.trim() || undefined,
+ });
+ };
+
+ return (
+
+
+
{data.length} {data.length === 1 ? "vaccine" : "vaccines"}
+
+
+
+ {showForm && (
+
+
+
+ setName(e.target.value)} />
+
+
+
+
+ setNotes(e.target.value)} />
+
+
+
+
+
+
+ )}
+
+ {isLoading &&
}
+
+ {!isLoading && data.length === 0 && (
+
No vaccines recorded yet
+ )}
+
+ {data.length > 0 && (
+
+ {data.map((vac) => {
+ const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate);
+ return (
+
+
+
+
{vac.name}
+ {overdue && (
+
+ Overdue
+
+ )}
+ {vac.nextDueDate && !overdue && (
+
+ Due {formatDate(vac.nextDueDate)}
+
+ )}
+
+
+ Given {formatDate(vac.dateGiven)}{vac.notes ? ` · ${vac.notes}` : ""}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+// ── Share Dialog ──────────────────────────────────────────────────────────────
+
+function ShareDialog({ petId, petName, open, onOpenChange }: {
+ petId: string;
+ petName: string;
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+}) {
+ const trpc = useTRPC();
+ const [token, setToken] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const getOrCreateMutation = useMutation({
+ ...trpc.health.getOrCreateCard.mutationOptions(),
+ onSuccess: (data) => setToken(data.token),
+ onError: () => toast.error("Failed to generate health card link"),
+ });
+
+ const regenerateMutation = useMutation({
+ ...trpc.health.regenerateCard.mutationOptions(),
+ onSuccess: (data) => {
+ setToken(data.token);
+ toast.success("Link regenerated — old link is now invalid");
+ },
+ onError: () => toast.error("Failed to regenerate link"),
+ });
+
+ const handleOpen = (v: boolean) => {
+ onOpenChange(v);
+ if (v && !token) {
+ getOrCreateMutation.mutate({ petId });
+ }
+ };
+
+ const cardUrl = token ? `${window.location.origin}/health-card/${token}` : null;
+
+ const handleCopy = async () => {
+ if (!cardUrl) return;
+ await navigator.clipboard.writeText(cardUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+ );
+}
+
+// ── Main Dashboard ────────────────────────────────────────────────────────────
+
+export function HealthDashboard({ petId, petName }: HealthDashboardProps) {
+ const [shareOpen, setShareOpen] = useState(false);
+
+ return (
+
+
+
Health — {petName}
+
+
+
+
+
+ Weight
+ Vet Visits
+ Vaccines
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts
index 132e85b..9fe8eac 100644
--- a/src/trpc/routers/_app.ts
+++ b/src/trpc/routers/_app.ts
@@ -16,6 +16,7 @@ import { searchRouter } from "@/trpc/routers/search";
import { notificationsRouter } from "@/trpc/routers/notifications";
import { videosRouter } from "@/trpc/routers/videos";
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
+import { healthRouter } from "@/trpc/routers/health";
export const appRouter = router({
pets: petsRouter,
@@ -36,6 +37,7 @@ export const appRouter = router({
videos: videosRouter,
conversations: conversationsRouter,
messages: messagesRouter,
+ health: healthRouter,
});
// Export the type for use in the client
diff --git a/src/trpc/routers/health.ts b/src/trpc/routers/health.ts
new file mode 100644
index 0000000..ed9df59
--- /dev/null
+++ b/src/trpc/routers/health.ts
@@ -0,0 +1,161 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure } from "@/trpc/init";
+import type { PrismaClient } from "@prisma/client";
+
+async function assertOwner(prisma: PrismaClient, petId: string, userId: string) {
+ const pet = await prisma.pet.findUnique({ where: { id: petId }, select: { ownerId: true } });
+ if (!pet || pet.ownerId !== userId) {
+ throw new TRPCError({ code: "FORBIDDEN" });
+ }
+}
+
+export const healthRouter = router({
+ // ── Weight Logs ─────────────────────────────────────────────────────────────
+
+ listWeightLogs: protectedProcedure
+ .input(z.object({ petId: z.string().min(1) }))
+ .query(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.weightLog.findMany({
+ where: { petId: input.petId },
+ orderBy: { date: "desc" },
+ });
+ }),
+
+ addWeightLog: protectedProcedure
+ .input(z.object({
+ petId: z.string().min(1),
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ weightKg: z.number().positive().max(500),
+ notes: z.string().max(200).optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.weightLog.create({
+ data: {
+ petId: input.petId,
+ date: new Date(input.date),
+ weightGrams: Math.round(input.weightKg * 1000),
+ notes: input.notes ?? null,
+ },
+ });
+ }),
+
+ deleteWeightLog: protectedProcedure
+ .input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ await ctx.prisma.weightLog.delete({ where: { id: input.id } });
+ return { success: true };
+ }),
+
+ // ── Vet Visits ───────────────────────────────────────────────────────────────
+
+ listVetVisits: protectedProcedure
+ .input(z.object({ petId: z.string().min(1) }))
+ .query(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.vetVisit.findMany({
+ where: { petId: input.petId },
+ orderBy: { date: "desc" },
+ });
+ }),
+
+ addVetVisit: protectedProcedure
+ .input(z.object({
+ petId: z.string().min(1),
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ reason: z.string().min(1).max(100),
+ vetName: z.string().max(100).optional(),
+ notes: z.string().max(500).optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.vetVisit.create({
+ data: {
+ petId: input.petId,
+ date: new Date(input.date),
+ reason: input.reason,
+ vetName: input.vetName ?? null,
+ notes: input.notes ?? null,
+ },
+ });
+ }),
+
+ deleteVetVisit: protectedProcedure
+ .input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ await ctx.prisma.vetVisit.delete({ where: { id: input.id } });
+ return { success: true };
+ }),
+
+ // ── Vaccines ─────────────────────────────────────────────────────────────────
+
+ listVaccines: protectedProcedure
+ .input(z.object({ petId: z.string().min(1) }))
+ .query(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.vaccine.findMany({
+ where: { petId: input.petId },
+ orderBy: { dateGiven: "desc" },
+ });
+ }),
+
+ addVaccine: protectedProcedure
+ .input(z.object({
+ petId: z.string().min(1),
+ name: z.string().min(1).max(100),
+ dateGiven: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ nextDueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ notes: z.string().max(200).optional(),
+ }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ return ctx.prisma.vaccine.create({
+ data: {
+ petId: input.petId,
+ name: input.name,
+ dateGiven: new Date(input.dateGiven),
+ nextDueDate: input.nextDueDate ? new Date(input.nextDueDate) : null,
+ notes: input.notes ?? null,
+ },
+ });
+ }),
+
+ deleteVaccine: protectedProcedure
+ .input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ await ctx.prisma.vaccine.delete({ where: { id: input.id } });
+ return { success: true };
+ }),
+
+ // ── Health Card ──────────────────────────────────────────────────────────────
+
+ getOrCreateCard: protectedProcedure
+ .input(z.object({ petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ const card = await ctx.prisma.healthCard.upsert({
+ where: { petId: input.petId },
+ update: {},
+ create: { petId: input.petId },
+ });
+ return { token: card.token };
+ }),
+
+ regenerateCard: protectedProcedure
+ .input(z.object({ petId: z.string().min(1) }))
+ .mutation(async ({ ctx, input }) => {
+ await assertOwner(ctx.prisma, input.petId, ctx.userId);
+ const newToken = crypto.randomUUID().replace(/-/g, "");
+ const card = await ctx.prisma.healthCard.upsert({
+ where: { petId: input.petId },
+ update: { token: newToken },
+ create: { petId: input.petId, token: newToken },
+ });
+ return { token: card.token };
+ }),
+});