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 ; + return ( + + + + ); } diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx index f2a20fb..ccb4ad5 100644 --- a/src/app/datenschutz/page.tsx +++ b/src/app/datenschutz/page.tsx @@ -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.

+

+ 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. +

diff --git a/src/components/health/HealthConsentGate.tsx b/src/components/health/HealthConsentGate.tsx new file mode 100644 index 0000000..576d5cd --- /dev/null +++ b/src/components/health/HealthConsentGate.tsx @@ -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 ( +
+ +
+ ); + } + + if (!status?.hasConsented) { + return ( +
+
+
+

{t("subtitle")}

+ + + +

+ {t("linksIntro")}{" "} + + {t("privacyLink")} + +

+ +
+ + +
+
+ ); + } + + return ( + <> + {children} + +
+
+
+
+ + + + + {t("revokeDialogTitle")} + {t("revokeDialogDesc")} + + + + + + + + + ); +} diff --git a/src/trpc/routers/health.ts b/src/trpc/routers/health.ts index 49a7f81..437ade6 100644 --- a/src/trpc/routers/health.ts +++ b/src/trpc/routers/health.ts @@ -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) { + 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 },