diff --git a/DEBUG-List.md b/DEBUG-List.md index bea0102..127dd89 100644 --- a/DEBUG-List.md +++ b/DEBUG-List.md @@ -41,5 +41,5 @@ Debug: ~~-Nutzungsbedingungen; 5. Verbotene Inhalte, so erweitern das alle Illegalen Aktivitäten für Deutschland enthalten sind, z.b Pornographie und ähnliches.~~ ✅ ERLEDIGT -* Baue eine Funktion ein, das User benachricht werden, falls sich AGB´s oder Datenschutz Informationen geändert wurden; mit der Benachrichtung soll enthalten sein, das man mittels Klick auf die Account-Löschen Funktion kommt falls man mit den Änderungen nicht einverstanden ist. Sowie Fasse die "neusten" Änderungen automatisch in die Benachrichtigung mit ein. Dieser Prozess sollte automatisiert funktionieren - heisst hier wird ein Abschnitt im Admin-Panel benötigt um auch ein manuelles Senden zu erzeugen. Falls du mehr dazu bauen möchstest feel free. +~~-Baue eine Funktion ein, das User benachricht werden, falls sich AGB´s oder Datenschutz Informationen geändert wurden; mit der Benachrichtung soll enthalten sein, das man mittels Klick auf die Account-Löschen Funktion kommt falls man mit den Änderungen nicht einverstanden ist. Sowie Fasse die "neusten" Änderungen automatisch in die Benachrichtigung mit ein. Dieser Prozess sollte automatisiert funktionieren - heisst hier wird ein Abschnitt im Admin-Panel benötigt um auch ein manuelles Senden zu erzeugen. Falls du mehr dazu bauen möchstest feel free.~~ ✅ ERLEDIGT (Admin-Panel `/legal` zum Veröffentlichen + App-weiter Banner mit "Verstanden"/"Konto löschen"-CTA, pull-basiert statt Notification-Fanout) diff --git a/messages/de.json b/messages/de.json index a4c7781..45748f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -84,6 +84,12 @@ "selectDate": "Datum auswählen", "apply": "Übernehmen" }, + "LegalUpdateBanner": { + "title": "Unsere Nutzungsbedingungen oder Datenschutzerklärung haben sich geändert", + "readFull": "Vollständigen Text lesen", + "acknowledge": "Verstanden", + "disagree": "Nicht einverstanden — Konto löschen" + }, "AvatarUpload": { "uploadPhoto": "Foto hochladen", "changePhoto": "Foto ändern", diff --git a/messages/en.json b/messages/en.json index 1574fa8..6603212 100644 --- a/messages/en.json +++ b/messages/en.json @@ -84,6 +84,12 @@ "selectDate": "Select date", "apply": "Apply" }, + "LegalUpdateBanner": { + "title": "Our Terms of Service or Privacy Policy have changed", + "readFull": "Read the full text", + "acknowledge": "Got it", + "disagree": "Disagree — delete my account" + }, "AvatarUpload": { "uploadPhoto": "Upload photo", "changePhoto": "Change photo", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b17a58c..09ae0a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,13 +7,14 @@ datasource db { } model Owner { - id String @id // = Clerk userId (e.g. "user_xxx") - createdAt DateTime @default(now()) - invitedById String? // Clerk userId of the person who invited this owner - pets Pet[] - adminRole AdminRole? - ban UserBan? - issuedInvites InviteCode[] @relation("IssuedBy") + id String @id // = Clerk userId (e.g. "user_xxx") + createdAt DateTime @default(now()) + invitedById String? // Clerk userId of the person who invited this owner + pets Pet[] + adminRole AdminRole? + ban UserBan? + issuedInvites InviteCode[] @relation("IssuedBy") + legalAcknowledgedAt DateTime? // last time the owner dismissed the legal-update banner; null = never seen one @@index([createdAt(sort: Desc)]) } @@ -614,3 +615,24 @@ model ModerationLog { @@index([moderatorId, createdAt(sort: Desc)]) @@index([targetType, targetId]) } + +// ─── Legal Document Versioning ───────────────────────────────────────────── +// Pull-based, not push-based: publishing a version doesn't fan out a +// Notification row per pet (Notification is petId-scoped; ToS/Datenschutz +// acceptance is an Owner-level concern). Instead, each owner's session +// compares Owner.legalAcknowledgedAt against the latest publishedAt. + +enum LegalDocType { + NUTZUNGSBEDINGUNGEN + DATENSCHUTZ +} + +model LegalDocumentVersion { + id String @id @default(cuid()) + docType LegalDocType + changeSummary String @db.VarChar(1000) + publishedAt DateTime @default(now()) + publishedBy String // ownerId of the publishing admin + + @@index([publishedAt(sort: Desc)]) +} diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index f069775..32dc70a 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -27,6 +27,12 @@ interface MockOwnerClient { upsert: MockFn; findUnique: MockFn; create: MockFn; + update: MockFn; +} + +interface MockLegalDocumentVersionClient { + create: MockFn; + findMany: MockFn; } interface MockSpeciesClient { @@ -214,6 +220,7 @@ interface MockPrismaClient { moderationLog: MockModerationLogClient; adminRole: MockAdminRoleClient; notification: MockNotificationClient; + legalDocumentVersion: MockLegalDocumentVersionClient; $transaction: MockFn; $disconnect: MockFn; } @@ -232,6 +239,11 @@ export function createMockPrisma(): MockPrismaClient { upsert: vi.fn(), findUnique: vi.fn(), create: vi.fn(), + update: vi.fn(), + }, + legalDocumentVersion: { + create: vi.fn(), + findMany: vi.fn(), }, species: { findMany: vi.fn(), diff --git a/src/__tests__/legal.test.ts b/src/__tests__/legal.test.ts new file mode 100644 index 0000000..7dc56dc --- /dev/null +++ b/src/__tests__/legal.test.ts @@ -0,0 +1,125 @@ +/** + * Legal Router Unit Tests + * + * Covers the pull-based legal-update flow: admins publish a + * LegalDocumentVersion (change summary), owners see it via getPendingUpdate + * until they acknowledge it. No fan-out Notification rows are created — + * Notification is petId-scoped and ToS/Datenschutz acceptance is an + * Owner-level concern. + */ + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +async function createLegalCaller(overrides?: { + userId?: string | null; + prisma?: ReturnType; +}) { + const { legalRouter } = await import("@/trpc/routers/legal"); + const { initTRPC } = await import("@trpc/server"); + + const mockPrisma = overrides?.prisma ?? createMockPrisma(); + const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1"; + + const t = initTRPC + .context<{ userId: string | null; prisma: typeof mockPrisma }>() + .create(); + + const testRouter = t.router({ legal: legalRouter }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any }); + return { caller, mockPrisma }; +} + +describe("legal.publish", () => { + it("throws FORBIDDEN for a non-admin caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); + const { caller, mockPrisma } = await createLegalCaller(); + mockPrisma.adminRole.findUnique.mockResolvedValue(null); + + await expect( + caller.legal.publish({ docType: "NUTZUNGSBEDINGUNGEN", changeSummary: "Test" }) + ).rejects.toThrow(); + vi.unstubAllEnvs(); + }); + + it("creates a new version for an admin caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "owner-1"); + const { caller, mockPrisma } = await createLegalCaller(); + mockPrisma.legalDocumentVersion.create.mockResolvedValue({ + id: "v1", + docType: "DATENSCHUTZ", + changeSummary: "Abschnitt 3 präzisiert", + publishedAt: new Date(), + publishedBy: "owner-1", + }); + + const result = await caller.legal.publish({ + docType: "DATENSCHUTZ", + changeSummary: "Abschnitt 3 präzisiert", + }); + + expect(mockPrisma.legalDocumentVersion.create).toHaveBeenCalledWith({ + data: { + docType: "DATENSCHUTZ", + changeSummary: "Abschnitt 3 präzisiert", + publishedBy: "owner-1", + }, + }); + expect(result.id).toBe("v1"); + vi.unstubAllEnvs(); + }); +}); + +describe("legal.getPendingUpdate", () => { + it("returns all versions when the owner has never acknowledged one", async () => { + const { caller, mockPrisma } = await createLegalCaller(); + mockPrisma.owner.findUnique.mockResolvedValue({ legalAcknowledgedAt: null }); + mockPrisma.legalDocumentVersion.findMany.mockResolvedValue([ + { id: "v1", docType: "NUTZUNGSBEDINGUNGEN", changeSummary: "x", publishedAt: new Date(), publishedBy: "admin-1" }, + ]); + + const result = await caller.legal.getPendingUpdate(); + + expect(mockPrisma.legalDocumentVersion.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: {} }) + ); + expect(result).toHaveLength(1); + }); + + it("returns only versions newer than the owner's last acknowledgement", async () => { + const ackAt = new Date("2026-01-01"); + const { caller, mockPrisma } = await createLegalCaller(); + mockPrisma.owner.findUnique.mockResolvedValue({ legalAcknowledgedAt: ackAt }); + mockPrisma.legalDocumentVersion.findMany.mockResolvedValue([]); + + const result = await caller.legal.getPendingUpdate(); + + expect(mockPrisma.legalDocumentVersion.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { publishedAt: { gt: ackAt } } }) + ); + expect(result).toBeNull(); + }); +}); + +describe("legal.acknowledge", () => { + it("sets legalAcknowledgedAt on the calling owner", async () => { + const { caller, mockPrisma } = await createLegalCaller(); + mockPrisma.owner.update.mockResolvedValue({ id: "owner-1" }); + + const result = await caller.legal.acknowledge(); + + expect(mockPrisma.owner.update).toHaveBeenCalledWith({ + where: { id: "owner-1" }, + data: { legalAcknowledgedAt: expect.any(Date) }, + }); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index a6465fd..7ffa93f 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -5,6 +5,7 @@ import { createTRPCCaller } from "@/trpc/server"; import { Sidebar } from "@/components/layout/Sidebar"; import { MobileNav } from "@/components/layout/MobileNav"; import { ActivePetInitializer } from "@/components/layout/ActivePetInitializer"; +import { LegalUpdateBanner } from "@/components/legal/LegalUpdateBanner"; /** * Protected app shell layout — applied to all routes under /(app)/. @@ -56,6 +57,7 @@ export default async function AppLayout({ return (
+ {/* Desktop sidebar — hidden on mobile */} diff --git a/src/app/(app)/pets/page.tsx b/src/app/(app)/pets/page.tsx index 3bcfa35..7b2d9e2 100644 --- a/src/app/(app)/pets/page.tsx +++ b/src/app/(app)/pets/page.tsx @@ -10,9 +10,14 @@ import { DeleteAccountDialog } from "@/components/account/DeleteAccountDialog"; * Server component: prefetches pets.list, renders heading + Add Pet button. * PetGrid is a client island for active pet selection via useActivePet(). */ -export default async function PetsPage() { +export default async function PetsPage({ + searchParams, +}: { + searchParams: Promise<{ deleteAccount?: string }>; +}) { const trpc = await createTRPCCaller(); const pets = await trpc.pets.list(); + const { deleteAccount } = await searchParams; return (
@@ -33,7 +38,10 @@ export default async function PetsPage() { AGB - p.name)} /> + p.name)} + defaultOpen={deleteAccount === "1"} + />
); diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index bc3c4b7..c98f6a9 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -2,7 +2,7 @@ import { notFound, redirect } from "next/navigation"; import { auth } from "@clerk/nextjs/server"; import Link from "next/link"; import { createTRPCCaller } from "@/trpc/server"; -import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag, Ticket } from "lucide-react"; +import { LayoutDashboard, Users, Megaphone, ShieldCheck, FileText, Flag, Ticket, Scale } from "lucide-react"; interface AdminLayoutProps { children: React.ReactNode; @@ -44,6 +44,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps { href: `${base}/ads`, label: "Ads", Icon: Megaphone }, { href: `${base}/invites`, label: "Invites", Icon: Ticket }, { href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck }, + { href: `${base}/legal`, label: "Legal", Icon: Scale }, ]; return ( diff --git a/src/app/p/[secret]/legal/page.tsx b/src/app/p/[secret]/legal/page.tsx new file mode 100644 index 0000000..c0aa1a6 --- /dev/null +++ b/src/app/p/[secret]/legal/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { Send, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +const DOC_TYPE_LABELS: Record = { + NUTZUNGSBEDINGUNGEN: "Nutzungsbedingungen", + DATENSCHUTZ: "Datenschutzerklärung", +}; + +function formatDate(d: string | Date) { + return new Date(d).toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function AdminLegalPage() { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [docType, setDocType] = useState<"NUTZUNGSBEDINGUNGEN" | "DATENSCHUTZ">("NUTZUNGSBEDINGUNGEN"); + const [changeSummary, setChangeSummary] = useState(""); + + const { data: versions = [], isLoading } = useQuery(trpc.legal.listVersions.queryOptions()); + + const publishMutation = useMutation( + trpc.legal.publish.mutationOptions({ + onSuccess: () => { + void qc.invalidateQueries({ queryKey: trpc.legal.listVersions.queryKey() }); + toast.success("Veröffentlicht — Owner sehen die Meldung beim nächsten Laden der App"); + setChangeSummary(""); + }, + onError: () => toast.error("Veröffentlichung fehlgeschlagen"), + }) + ); + + const handlePublish = () => { + if (!changeSummary.trim()) return; + publishMutation.mutate({ docType, changeSummary: changeSummary.trim() }); + }; + + return ( +
+
+

Legal-Updates

+

+ Veröffentlichen ist gleichzeitig das Senden — jeder Owner sieht die Zusammenfassung + als Banner, bis er sie bestätigt. Ein erneutes Veröffentlichen (auch mit gleichem Text) + zeigt das Banner allen Owner erneut. +

+
+ + {/* Publish form */} +
+ +