feat(health): require explicit, revocable consent for Health-Tracking

Add a per-pet consent gate (Art. 9 Abs. 2 lit. a DSGVO) in front of the
whole Health-Tracking area (weight, vet visits, vaccines, emergency
vet, feeding, Health Card): an un-pre-checked checkbox must be
confirmed before any of it is used, enforced server-side in
health.ts (not just the UI) via a new assertHealthConsent check on
every data-bearing endpoint.

Revoking consent (health.revokeConsent) deletes every health record
for that pet in one transaction and is available at any time from the
Health page. Pet gains healthConsentGivenAt/healthConsentRevokedAt
(nullable, additive — db push already applied against the shared
Supabase instance).

Extends the Datenschutzerklärung (Abschnitt 3) with the Art. 9 legal
basis and the revoke/auto-delete mechanism, per counsel's follow-up.
This commit is contained in:
2026-09-04 16:10:14 +02:00
parent 122a5ba73b
commit e9378d9d7a
9 changed files with 426 additions and 13 deletions
+18
View File
@@ -422,6 +422,24 @@
"toastGenerateError": "Link konnte nicht generiert werden",
"toastRegenerateSuccess": "Neuer Link erstellt — alter Link ist jetzt ungültig",
"toastRegenerateError": "Link konnte nicht neu generiert werden"
},
"consent": {
"title": "Einwilligung für Gesundheitsdaten",
"subtitle": "Bevor Sie den Health-Tracking-Bereich nutzen können, benötigen wir Ihre ausdrückliche Einwilligung zur Verarbeitung der Gesundheitsdaten Ihres Haustiers.",
"checkboxLabel": "Ich willige ausdrücklich ein, dass Gesundheitsdaten meines Haustiers (Gewicht, Tierarztbesuche, Impfungen) gespeichert und verarbeitet werden. Ich kann diese Einwilligung jederzeit widerrufen; danach werden alle Gesundheitsdaten dieses Haustiers automatisch gelöscht.",
"linksIntro": "Weitere Informationen in unserer",
"privacyLink": "Datenschutzerklärung",
"cancelCta": "Abbrechen",
"continueCta": "Zustimmen und fortfahren",
"toastError": "Einwilligung konnte nicht gespeichert werden",
"manageTitle": "Einwilligung verwalten",
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
"revokeCta": "Widerrufen",
"revokeDialogTitle": "Einwilligung wirklich widerrufen?",
"revokeDialogDesc": "Dadurch werden alle Gewichtseinträge, Tierarztbesuche, Impfungen, die Notfall-Tierarzt-Angabe, Fütterungsinfos und die Health Card dieses Haustiers unwiderruflich gelöscht.",
"revokeConfirmCta": "Endgültig widerrufen und löschen",
"toastRevoked": "Einwilligung widerrufen — Gesundheitsdaten wurden gelöscht",
"toastRevokeError": "Einwilligung konnte nicht widerrufen werden"
}
},
"DeleteAccount": {
+18
View File
@@ -422,6 +422,24 @@
"toastGenerateError": "Could not generate link",
"toastRegenerateSuccess": "New link created — old link is now invalid",
"toastRegenerateError": "Could not regenerate link"
},
"consent": {
"title": "Health data consent",
"subtitle": "Before you can use Health Tracking, we need your explicit consent to process your pet's health data.",
"checkboxLabel": "I explicitly consent to my pet's health data (weight, vet visits, vaccines) being stored and processed. I can withdraw this consent at any time; all health data for this pet will then be deleted automatically.",
"linksIntro": "More information in our",
"privacyLink": "Privacy Policy",
"cancelCta": "Cancel",
"continueCta": "Agree and continue",
"toastError": "Could not save consent",
"manageTitle": "Manage consent",
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
"revokeCta": "Revoke",
"revokeDialogTitle": "Really revoke consent?",
"revokeDialogDesc": "This permanently deletes all weight entries, vet visits, vaccines, the emergency vet contact, feeding info, and the Health Card for this pet.",
"revokeConfirmCta": "Revoke and delete permanently",
"toastRevoked": "Consent revoked — health data has been deleted",
"toastRevokeError": "Could not revoke consent"
}
},
"DeleteAccount": {
+9
View File
@@ -159,6 +159,15 @@ model Pet {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Explicit, un-pre-checked opt-in required before the Health-Tracking area
// (weight, vet visits, vaccines, emergency vet, feeding, Health Card) can be
// used for this pet — see datenschutz Abschnitt 3 (Art. 9 Abs. 2 lit. a
// DSGVO). Revoking sets this back to null and healthConsentRevokedAt to
// now(), and health.revokeConsent deletes every health record for this pet
// in the same transaction — there is no soft-delete/undo.
healthConsentGivenAt DateTime?
healthConsentRevokedAt DateTime?
// Phase 2 back-relations
posts Post[]
stories Story[]
+73
View File
@@ -28,6 +28,7 @@ async function createHealthCaller(overrides?: {
const { initTRPC } = await import("@trpc/server");
const mockPrisma = overrides?.prisma ?? createMockPrisma();
mockPrisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops));
const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
const t = initTRPC
@@ -123,3 +124,75 @@ describe("health.deleteVaccine", () => {
expect(result).toEqual({ success: true });
});
});
describe("health consent (Art. 9 Abs. 2 lit. a DSGVO)", () => {
it("getConsentStatus reports false when the pet has never consented", async () => {
const { caller, mockPrisma } = await createHealthCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1", healthConsentGivenAt: null });
const result = await caller.health.getConsentStatus({ petId: "my-pet" });
expect(result).toEqual({ hasConsented: false, consentedAt: null });
});
it("blocks addWeightLog when consent was never given", async () => {
const { caller, mockPrisma } = await createHealthCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1", healthConsentGivenAt: null });
await expect(
caller.health.addWeightLog({ petId: "my-pet", date: "2026-09-04", weightKg: 4.2 })
).rejects.toMatchObject({ code: "PRECONDITION_FAILED" });
expect(mockPrisma.weightLog.create).not.toHaveBeenCalled();
});
it("allows addWeightLog once consent is on file", async () => {
const { caller, mockPrisma } = await createHealthCaller();
const consentedAt = new Date("2026-09-04T12:00:00.000Z");
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1", healthConsentGivenAt: consentedAt });
mockPrisma.weightLog.create.mockResolvedValue({ id: "log-1" });
await caller.health.addWeightLog({ petId: "my-pet", date: "2026-09-04", weightKg: 4.2 });
expect(mockPrisma.weightLog.create).toHaveBeenCalled();
});
it("giveConsent stamps the pet with a fresh consent timestamp and clears any prior revocation", async () => {
const { caller, mockPrisma } = await createHealthCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1", healthConsentGivenAt: null });
mockPrisma.pet.update.mockResolvedValue({ healthConsentGivenAt: new Date("2026-09-04T12:00:00.000Z") });
await caller.health.giveConsent({ petId: "my-pet" });
expect(mockPrisma.pet.update).toHaveBeenCalledWith({
where: { id: "my-pet" },
data: { healthConsentGivenAt: expect.any(Date), healthConsentRevokedAt: null },
});
});
it("revokeConsent deletes every health record for the pet and clears the consent flag", async () => {
const { caller, mockPrisma } = await createHealthCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "my-pet", ownerId: "owner-1", healthConsentGivenAt: new Date() });
mockPrisma.weightLog.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.vetVisit.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.vaccine.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.healthCard.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.emergencyVet.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.petFeedingInfo.deleteMany.mockResolvedValue({ count: 1 });
mockPrisma.pet.update.mockResolvedValue({ healthConsentGivenAt: null });
const result = await caller.health.revokeConsent({ petId: "my-pet" });
expect(mockPrisma.weightLog.deleteMany).toHaveBeenCalledWith({ where: { petId: "my-pet" } });
expect(mockPrisma.vetVisit.deleteMany).toHaveBeenCalledWith({ where: { petId: "my-pet" } });
expect(mockPrisma.vaccine.deleteMany).toHaveBeenCalledWith({ where: { petId: "my-pet" } });
expect(mockPrisma.healthCard.deleteMany).toHaveBeenCalledWith({ where: { petId: "my-pet" } });
expect(mockPrisma.emergencyVet.deleteMany).toHaveBeenCalledWith({ where: { petId: "my-pet" } });
expect(mockPrisma.petFeedingInfo.deleteMany).toHaveBeenCalledWith({ where: { petId: "my-pet" } });
expect(mockPrisma.pet.update).toHaveBeenCalledWith({
where: { id: "my-pet" },
data: { healthConsentGivenAt: null, healthConsentRevokedAt: expect.any(Date) },
});
expect(result).toEqual({ success: true });
});
});
+42
View File
@@ -261,6 +261,7 @@ interface MockWeightLogClient {
findUnique: MockFn;
create: MockFn;
delete: MockFn;
deleteMany: MockFn;
}
interface MockVetVisitClient {
@@ -268,6 +269,7 @@ interface MockVetVisitClient {
findUnique: MockFn;
create: MockFn;
delete: MockFn;
deleteMany: MockFn;
}
interface MockVaccineClient {
@@ -275,6 +277,25 @@ interface MockVaccineClient {
findUnique: MockFn;
create: MockFn;
delete: MockFn;
deleteMany: MockFn;
}
interface MockHealthCardClient {
findUnique: MockFn;
upsert: MockFn;
deleteMany: MockFn;
}
interface MockEmergencyVetClient {
findUnique: MockFn;
upsert: MockFn;
deleteMany: MockFn;
}
interface MockPetFeedingInfoClient {
findUnique: MockFn;
upsert: MockFn;
deleteMany: MockFn;
}
interface MockDataExportTokenClient {
@@ -333,6 +354,9 @@ interface MockPrismaClient {
weightLog: MockWeightLogClient;
vetVisit: MockVetVisitClient;
vaccine: MockVaccineClient;
healthCard: MockHealthCardClient;
emergencyVet: MockEmergencyVetClient;
petFeedingInfo: MockPetFeedingInfoClient;
dataExportToken: MockDataExportTokenClient;
conversation: MockConversationClient;
broadcast: MockBroadcastClient;
@@ -560,18 +584,36 @@ export function createMockPrisma(): MockPrismaClient {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
vetVisit: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
vaccine: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
healthCard: {
findUnique: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
emergencyVet: {
findUnique: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
petFeedingInfo: {
findUnique: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
dataExportToken: {
create: vi.fn(),
+6 -1
View File
@@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation";
import { currentUser } from "@clerk/nextjs/server";
import { createTRPCCaller } from "@/trpc/server";
import { HealthDashboard } from "@/components/health/HealthDashboard";
import { HealthConsentGate } from "@/components/health/HealthConsentGate";
interface PageProps {
params: Promise<{ petId: string }>;
@@ -23,5 +24,9 @@ export default async function HealthPage({ params }: PageProps) {
redirect(`/pets/${petId}`);
}
return <HealthDashboard petId={petId} petName={pet.name} />;
return (
<HealthConsentGate petId={petId}>
<HealthDashboard petId={petId} petName={pet.name} />
</HealthConsentGate>
);
}
+10
View File
@@ -88,6 +88,16 @@ export default function DatenschutzPage() {
Zukunft widerrufen, indem Sie sich per E-Mail an {emailLink} wenden; dies berührt nicht
die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung.
</p>
<p className="mt-3">
Für die Verarbeitung von Gesundheitsdaten Ihres Haustiers stützen wir uns auf Art. 9
Abs. 2 lit. a DSGVO (ausdrückliche Einwilligung), sofern Sie den
Health-Tracking-Bereich nutzen. Diese Einwilligung holen wir vor der ersten Nutzung
über eine gesonderte, nicht vorausgewählte Checkbox ein; Sie können sie jederzeit
direkt im Health-Tracking-Bereich widerrufen. Nach einem Widerruf werden sämtliche
Gesundheitsdaten des betroffenen Haustiers (Gewichtseinträge, Tierarztbesuche,
Impfungen, Notfall-Tierarzt-Angabe, Fütterungsinfos, Health Card) automatisch und
unwiderruflich gelöscht.
</p>
</LegalSection>
<LegalSection number="4" title="Drittanbieter, Auftragsverarbeiter und Drittlandübermittlungen">
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import Link from "next/link";
import { HeartPulse, ShieldOff, Loader2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
interface HealthConsentGateProps {
petId: string;
children: React.ReactNode;
}
/**
* Health-Tracking (weight, vet visits, vaccines, emergency vet, feeding,
* Health Card) processes data we treat as sensitive per Art. 9 Abs. 2 lit. a
* DSGVO (see datenschutz Abschnitt 3). Nothing in this area is shown or
* created for a pet until the owner gives explicit, un-pre-checked consent;
* revoking it deletes every health record for that pet server-side
* (health.revokeConsent) — this component is the UI, health.ts is the actual
* enforcement boundary.
*/
export function HealthConsentGate({ petId, children }: HealthConsentGateProps) {
const t = useTranslations("Health.consent");
const trpc = useTRPC();
const qc = useQueryClient();
const router = useRouter();
const [checked, setChecked] = useState(false);
const [revokeOpen, setRevokeOpen] = useState(false);
const { data: status, isLoading } = useQuery(trpc.health.getConsentStatus.queryOptions({ petId }));
const giveMutation = useMutation(
trpc.health.giveConsent.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.getConsentStatus.queryKey({ petId }) });
},
onError: () => toast.error(t("toastError")),
})
);
const revokeMutation = useMutation(
trpc.health.revokeConsent.mutationOptions({
onSuccess: () => {
setRevokeOpen(false);
toast.success(t("toastRevoked"));
qc.invalidateQueries({ queryKey: trpc.health.getConsentStatus.queryKey({ petId }) });
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
qc.invalidateQueries({ queryKey: trpc.health.getEmergencyVet.queryKey({ petId }) });
qc.invalidateQueries({ queryKey: trpc.health.getFeedingInfo.queryKey({ petId }) });
},
onError: () => toast.error(t("toastRevokeError")),
})
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!status?.hasConsented) {
return (
<div className="w-full max-w-[512px] mx-auto px-4 py-10">
<div className="flex items-center gap-2 mb-2">
<HeartPulse className="h-5 w-5 text-orange-500 shrink-0" aria-hidden="true" />
<h1 className="text-xl font-semibold">{t("title")}</h1>
</div>
<p className="text-sm text-muted-foreground mb-6">{t("subtitle")}</p>
<label className="flex items-start gap-3 rounded-xl border bg-card p-4 cursor-pointer mb-6">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input accent-orange-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-500"
/>
<span className="text-sm text-foreground leading-relaxed">{t("checkboxLabel")}</span>
</label>
<p className="text-xs text-muted-foreground mb-6">
{t("linksIntro")}{" "}
<Link
href="/datenschutz"
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline"
>
{t("privacyLink")}
</Link>
</p>
<div className="flex gap-2">
<Button variant="ghost" className="flex-1" onClick={() => router.back()}>
{t("cancelCta")}
</Button>
<Button
className="flex-1 min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white"
onClick={() => giveMutation.mutate({ petId })}
disabled={!checked || giveMutation.isPending}
>
{giveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("continueCta")}
</Button>
</div>
</div>
);
}
return (
<>
{children}
<div className="max-w-[640px] mx-auto px-4 pb-10">
<div className="mt-4 flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<ShieldOff className="h-5 w-5 text-destructive shrink-0 mt-0.5" aria-hidden="true" />
<div className="flex-1">
<p className="text-sm font-medium">{t("manageTitle")}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t("manageDesc")}</p>
</div>
<Button variant="destructive" size="sm" className="shrink-0" onClick={() => setRevokeOpen(true)}>
{t("revokeCta")}
</Button>
</div>
</div>
<Dialog open={revokeOpen} onOpenChange={setRevokeOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("revokeDialogTitle")}</DialogTitle>
<DialogDescription>{t("revokeDialogDesc")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setRevokeOpen(false)} disabled={revokeMutation.isPending}>
{t("cancelCta")}
</Button>
<Button
variant="destructive"
onClick={() => revokeMutation.mutate({ petId })}
disabled={revokeMutation.isPending}
>
{revokeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("revokeConfirmCta")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+85 -12
View File
@@ -1,15 +1,77 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import type { Pet } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership as assertOwner } from "@/lib/assert-pet-ownership";
// Gates every data-bearing Health-Tracking endpoint behind the explicit,
// un-pre-checked opt-in required by Art. 9 Abs. 2 lit. a DSGVO (see
// getConsentStatus/giveConsent/revokeConsent below). Client-side gating
// (HealthConsentGate.tsx) is convenience only — this is the actual boundary.
function assertHealthConsent(pet: Pick<Pet, "healthConsentGivenAt">) {
if (!pet.healthConsentGivenAt) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Health tracking requires consent first.",
});
}
}
export const healthRouter = router({
// ── Consent (Art. 9 Abs. 2 lit. a DSGVO) ────────────────────────────────────
getConsentStatus: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
return {
hasConsented: Boolean(pet.healthConsentGivenAt),
consentedAt: pet.healthConsentGivenAt,
};
}),
giveConsent: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await ctx.prisma.pet.update({
where: { id: input.petId },
data: { healthConsentGivenAt: new Date(), healthConsentRevokedAt: null },
});
return { consentedAt: pet.healthConsentGivenAt };
}),
// Revocation deletes every health record for this pet in one transaction —
// there is no soft-delete/undo, matching the "automatische Löschung"
// requirement. HealthCard is included so any outstanding share link is
// invalidated too.
revokeConsent: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const { petId } = input;
await ctx.prisma.$transaction([
ctx.prisma.weightLog.deleteMany({ where: { petId } }),
ctx.prisma.vetVisit.deleteMany({ where: { petId } }),
ctx.prisma.vaccine.deleteMany({ where: { petId } }),
ctx.prisma.healthCard.deleteMany({ where: { petId } }),
ctx.prisma.emergencyVet.deleteMany({ where: { petId } }),
ctx.prisma.petFeedingInfo.deleteMany({ where: { petId } }),
ctx.prisma.pet.update({
where: { id: petId },
data: { healthConsentGivenAt: null, healthConsentRevokedAt: new Date() },
}),
]);
return { success: true };
}),
// ── Weight Logs ─────────────────────────────────────────────────────────────
listWeightLogs: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.weightLog.findMany({
where: { petId: input.petId },
orderBy: { date: "desc" },
@@ -25,7 +87,8 @@ export const healthRouter = router({
notes: z.string().max(200).optional(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.weightLog.create({
data: {
petId: input.petId,
@@ -54,7 +117,8 @@ export const healthRouter = router({
listVetVisits: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.vetVisit.findMany({
where: { petId: input.petId },
orderBy: { date: "desc" },
@@ -71,7 +135,8 @@ export const healthRouter = router({
notes: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.vetVisit.create({
data: {
petId: input.petId,
@@ -101,7 +166,8 @@ export const healthRouter = router({
listVaccines: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.vaccine.findMany({
where: { petId: input.petId },
orderBy: { dateGiven: "desc" },
@@ -118,7 +184,8 @@ export const healthRouter = router({
notes: z.string().max(200).optional(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.vaccine.create({
data: {
petId: input.petId,
@@ -148,7 +215,8 @@ export const healthRouter = router({
getEmergencyVet: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.emergencyVet.findUnique({ where: { petId: input.petId } });
}),
@@ -163,7 +231,8 @@ export const healthRouter = router({
lng: z.number().optional().nullable(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
const { petId, ...data } = input;
return ctx.prisma.emergencyVet.upsert({
where: { petId },
@@ -191,7 +260,8 @@ export const healthRouter = router({
]).default(14),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
const card = await ctx.prisma.healthCard.upsert({
@@ -211,7 +281,8 @@ export const healthRouter = router({
]).default(14),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
const newToken = crypto.randomUUID().replace(/-/g, "");
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
@@ -228,7 +299,8 @@ export const healthRouter = router({
getFeedingInfo: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
return ctx.prisma.petFeedingInfo.findUnique({ where: { petId: input.petId } });
}),
@@ -242,7 +314,8 @@ export const healthRouter = router({
specialDiet: z.string().max(500).optional().nullable(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const pet = await assertOwner(ctx.prisma, input.petId, ctx.userId);
assertHealthConsent(pet);
const { petId, ...data } = input;
return ctx.prisma.petFeedingInfo.upsert({
where: { petId },