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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user