diff --git a/messages/de.json b/messages/de.json
index c614bd1..cbae96e 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index c5eb917..0d3642a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 282e1f5..6045305 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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[]
diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts
index 7c3b915..477b533 100644
--- a/src/__tests__/health.test.ts
+++ b/src/__tests__/health.test.ts
@@ -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 });
+ });
+});
diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts
index 040139a..727d5ba 100644
--- a/src/__tests__/helpers/prisma-mock.ts
+++ b/src/__tests__/helpers/prisma-mock.ts
@@ -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(),
diff --git a/src/app/(app)/pets/[petId]/health/page.tsx b/src/app/(app)/pets/[petId]/health/page.tsx
index 40edd1f..0dd3012 100644
--- a/src/app/(app)/pets/[petId]/health/page.tsx
+++ b/src/app/(app)/pets/[petId]/health/page.tsx
@@ -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
+ 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. +
{t("subtitle")}
+ + + ++ {t("linksIntro")}{" "} + + {t("privacyLink")} + +
+ +{t("manageTitle")}
+{t("manageDesc")}
+