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:
1 parent
122a5ba73b
commit
e9378d9d7a
9 files changed
+426
-13
No files matched your search
@@ -422,6 +422,24 @@
|
|||||||
"toastGenerateError": "Link konnte nicht generiert werden",
|
"toastGenerateError": "Link konnte nicht generiert werden",
|
||||||
"toastRegenerateSuccess": "Neuer Link erstellt — alter Link ist jetzt ungültig",
|
"toastRegenerateSuccess": "Neuer Link erstellt — alter Link ist jetzt ungültig",
|
||||||
"toastRegenerateError": "Link konnte nicht neu generiert werden"
|
"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": {
|
"DeleteAccount": {
|
||||||
|
|||||||
@@ -422,6 +422,24 @@
|
|||||||
"toastGenerateError": "Could not generate link",
|
"toastGenerateError": "Could not generate link",
|
||||||
"toastRegenerateSuccess": "New link created — old link is now invalid",
|
"toastRegenerateSuccess": "New link created — old link is now invalid",
|
||||||
"toastRegenerateError": "Could not regenerate link"
|
"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": {
|
"DeleteAccount": {
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ model Pet {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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
|
// Phase 2 back-relations
|
||||||
posts Post[]
|
posts Post[]
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async function createHealthCaller(overrides?: {
|
|||||||
const { initTRPC } = await import("@trpc/server");
|
const { initTRPC } = await import("@trpc/server");
|
||||||
|
|
||||||
const mockPrisma = overrides?.prisma ?? createMockPrisma();
|
const mockPrisma = overrides?.prisma ?? createMockPrisma();
|
||||||
|
mockPrisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops));
|
||||||
const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
|
const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1";
|
||||||
|
|
||||||
const t = initTRPC
|
const t = initTRPC
|
||||||
@@ -123,3 +124,75 @@ describe("health.deleteVaccine", () => {
|
|||||||
expect(result).toEqual({ success: true });
|
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;
|
findUnique: MockFn;
|
||||||
create: MockFn;
|
create: MockFn;
|
||||||
delete: MockFn;
|
delete: MockFn;
|
||||||
|
deleteMany: MockFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockVetVisitClient {
|
interface MockVetVisitClient {
|
||||||
@@ -268,6 +269,7 @@ interface MockVetVisitClient {
|
|||||||
findUnique: MockFn;
|
findUnique: MockFn;
|
||||||
create: MockFn;
|
create: MockFn;
|
||||||
delete: MockFn;
|
delete: MockFn;
|
||||||
|
deleteMany: MockFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockVaccineClient {
|
interface MockVaccineClient {
|
||||||
@@ -275,6 +277,25 @@ interface MockVaccineClient {
|
|||||||
findUnique: MockFn;
|
findUnique: MockFn;
|
||||||
create: MockFn;
|
create: MockFn;
|
||||||
delete: 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 {
|
interface MockDataExportTokenClient {
|
||||||
@@ -333,6 +354,9 @@ interface MockPrismaClient {
|
|||||||
weightLog: MockWeightLogClient;
|
weightLog: MockWeightLogClient;
|
||||||
vetVisit: MockVetVisitClient;
|
vetVisit: MockVetVisitClient;
|
||||||
vaccine: MockVaccineClient;
|
vaccine: MockVaccineClient;
|
||||||
|
healthCard: MockHealthCardClient;
|
||||||
|
emergencyVet: MockEmergencyVetClient;
|
||||||
|
petFeedingInfo: MockPetFeedingInfoClient;
|
||||||
dataExportToken: MockDataExportTokenClient;
|
dataExportToken: MockDataExportTokenClient;
|
||||||
conversation: MockConversationClient;
|
conversation: MockConversationClient;
|
||||||
broadcast: MockBroadcastClient;
|
broadcast: MockBroadcastClient;
|
||||||
@@ -560,18 +584,36 @@ export function createMockPrisma(): MockPrismaClient {
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
},
|
},
|
||||||
vetVisit: {
|
vetVisit: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
},
|
},
|
||||||
vaccine: {
|
vaccine: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
delete: 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: {
|
dataExportToken: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { currentUser } from "@clerk/nextjs/server";
|
import { currentUser } from "@clerk/nextjs/server";
|
||||||
import { createTRPCCaller } from "@/trpc/server";
|
import { createTRPCCaller } from "@/trpc/server";
|
||||||
import { HealthDashboard } from "@/components/health/HealthDashboard";
|
import { HealthDashboard } from "@/components/health/HealthDashboard";
|
||||||
|
import { HealthConsentGate } from "@/components/health/HealthConsentGate";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ petId: string }>;
|
params: Promise<{ petId: string }>;
|
||||||
@@ -23,5 +24,9 @@ export default async function HealthPage({ params }: PageProps) {
|
|||||||
redirect(`/pets/${petId}`);
|
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
|
Zukunft widerrufen, indem Sie sich per E-Mail an {emailLink} wenden; dies berührt nicht
|
||||||
die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung.
|
die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<LegalSection number="4" title="Drittanbieter, Auftragsverarbeiter und Drittlandübermittlungen">
|
<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 { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type { Pet } from "@prisma/client";
|
||||||
import { router, protectedProcedure } from "@/trpc/init";
|
import { router, protectedProcedure } from "@/trpc/init";
|
||||||
import { assertPetOwnership as assertOwner } from "@/lib/assert-pet-ownership";
|
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({
|
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 ─────────────────────────────────────────────────────────────
|
// ── Weight Logs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
listWeightLogs: protectedProcedure
|
listWeightLogs: protectedProcedure
|
||||||
.input(z.object({ petId: z.string().min(1) }))
|
.input(z.object({ petId: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.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({
|
return ctx.prisma.weightLog.findMany({
|
||||||
where: { petId: input.petId },
|
where: { petId: input.petId },
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
@@ -25,7 +87,8 @@ export const healthRouter = router({
|
|||||||
notes: z.string().max(200).optional(),
|
notes: z.string().max(200).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
return ctx.prisma.weightLog.create({
|
||||||
data: {
|
data: {
|
||||||
petId: input.petId,
|
petId: input.petId,
|
||||||
@@ -54,7 +117,8 @@ export const healthRouter = router({
|
|||||||
listVetVisits: protectedProcedure
|
listVetVisits: protectedProcedure
|
||||||
.input(z.object({ petId: z.string().min(1) }))
|
.input(z.object({ petId: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.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({
|
return ctx.prisma.vetVisit.findMany({
|
||||||
where: { petId: input.petId },
|
where: { petId: input.petId },
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
@@ -71,7 +135,8 @@ export const healthRouter = router({
|
|||||||
notes: z.string().max(500).optional(),
|
notes: z.string().max(500).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
return ctx.prisma.vetVisit.create({
|
||||||
data: {
|
data: {
|
||||||
petId: input.petId,
|
petId: input.petId,
|
||||||
@@ -101,7 +166,8 @@ export const healthRouter = router({
|
|||||||
listVaccines: protectedProcedure
|
listVaccines: protectedProcedure
|
||||||
.input(z.object({ petId: z.string().min(1) }))
|
.input(z.object({ petId: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.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({
|
return ctx.prisma.vaccine.findMany({
|
||||||
where: { petId: input.petId },
|
where: { petId: input.petId },
|
||||||
orderBy: { dateGiven: "desc" },
|
orderBy: { dateGiven: "desc" },
|
||||||
@@ -118,7 +184,8 @@ export const healthRouter = router({
|
|||||||
notes: z.string().max(200).optional(),
|
notes: z.string().max(200).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
return ctx.prisma.vaccine.create({
|
||||||
data: {
|
data: {
|
||||||
petId: input.petId,
|
petId: input.petId,
|
||||||
@@ -148,7 +215,8 @@ export const healthRouter = router({
|
|||||||
getEmergencyVet: protectedProcedure
|
getEmergencyVet: protectedProcedure
|
||||||
.input(z.object({ petId: z.string().min(1) }))
|
.input(z.object({ petId: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.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 } });
|
return ctx.prisma.emergencyVet.findUnique({ where: { petId: input.petId } });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -163,7 +231,8 @@ export const healthRouter = router({
|
|||||||
lng: z.number().optional().nullable(),
|
lng: z.number().optional().nullable(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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;
|
const { petId, ...data } = input;
|
||||||
return ctx.prisma.emergencyVet.upsert({
|
return ctx.prisma.emergencyVet.upsert({
|
||||||
where: { petId },
|
where: { petId },
|
||||||
@@ -191,7 +260,8 @@ export const healthRouter = router({
|
|||||||
]).default(14),
|
]).default(14),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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();
|
const expiresAt = new Date();
|
||||||
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
|
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
|
||||||
const card = await ctx.prisma.healthCard.upsert({
|
const card = await ctx.prisma.healthCard.upsert({
|
||||||
@@ -211,7 +281,8 @@ export const healthRouter = router({
|
|||||||
]).default(14),
|
]).default(14),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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 newToken = crypto.randomUUID().replace(/-/g, "");
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
|
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
|
||||||
@@ -228,7 +299,8 @@ export const healthRouter = router({
|
|||||||
getFeedingInfo: protectedProcedure
|
getFeedingInfo: protectedProcedure
|
||||||
.input(z.object({ petId: z.string().min(1) }))
|
.input(z.object({ petId: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.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 } });
|
return ctx.prisma.petFeedingInfo.findUnique({ where: { petId: input.petId } });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -242,7 +314,8 @@ export const healthRouter = router({
|
|||||||
specialDiet: z.string().max(500).optional().nullable(),
|
specialDiet: z.string().max(500).optional().nullable(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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;
|
const { petId, ...data } = input;
|
||||||
return ctx.prisma.petFeedingInfo.upsert({
|
return ctx.prisma.petFeedingInfo.upsert({
|
||||||
where: { petId },
|
where: { petId },
|
||||||
|
|||||||
Reference in new issue
Block a user