feat(legal): admin-published legal updates + owner acknowledgement banner

Adds a pull-based legal-update flow instead of fanning out through the
petId-scoped Notification model (ToS/Datenschutz acceptance is an
Owner-level concern, not a per-pet one):

- Prisma: LegalDocType enum, LegalDocumentVersion model,
  Owner.legalAcknowledgedAt.
- trpc/routers/legal.ts: admin publish/listVersions, owner-facing
  getPendingUpdate/acknowledge. assertAdmin exported from admin.ts
  instead of duplicated.
- Admin panel /legal: publish form (doc type + change summary) + history.
  Publishing IS sending — every owner sees it on next load until
  acknowledged; republishing re-surfaces it for everyone.
- LegalUpdateBanner mounted in (app)/layout.tsx: non-dismissable sheet
  with the stored change summary, "Verstanden" (acknowledge) and
  "Konto löschen" (routes into DeleteAccountDialog via new
  ?deleteAccount=1 auto-open support) actions.

Closes the last DEBUG-List.md item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-07-16 20:08:49 +02:00
1 parent d7234f55bd
commit 70115de483
15 files changed
+485 -14

No files matched your search

+1 -1
View File
@@ -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)
+6
View File
@@ -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",
+6
View File
@@ -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",
+29 -7
View File
@@ -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)])
}
+12
View File
@@ -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(),
+125
View File
@@ -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<typeof createMockPrisma>;
}) {
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 });
});
});
+2
View File
@@ -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 (
<div className="flex min-h-screen">
<ActivePetInitializer firstPetId={pets[0].id} />
<LegalUpdateBanner />
{/* Desktop sidebar — hidden on mobile */}
<Sidebar pets={pets} />
+10 -2
View File
@@ -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 (
<div className="container max-w-3xl mx-auto px-6 py-8">
@@ -33,7 +38,10 @@ export default async function PetsPage() {
<span aria-hidden="true">·</span>
<Link href="/nutzungsbedingungen" className="hover:text-foreground transition-colors">AGB</Link>
<span aria-hidden="true">·</span>
<DeleteAccountDialog petNames={pets.map((p) => p.name)} />
<DeleteAccountDialog
petNames={pets.map((p) => p.name)}
defaultOpen={deleteAccount === "1"}
/>
</div>
</div>
);
+2 -1
View File
@@ -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 (
+131
View File
@@ -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<string, string> = {
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Legal-Updates</h1>
<p className="text-sm text-zinc-500 mt-1">
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.
</p>
</div>
{/* Publish form */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4 space-y-3">
<Select value={docType} onValueChange={(v) => setDocType(v as typeof docType)}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-zinc-100 w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NUTZUNGSBEDINGUNGEN">Nutzungsbedingungen</SelectItem>
<SelectItem value="DATENSCHUTZ">Datenschutzerklärung</SelectItem>
</SelectContent>
</Select>
<Textarea
placeholder="Was hat sich geändert? (wird 1:1 im Owner-Banner angezeigt)"
value={changeSummary}
onChange={(e) => setChangeSummary(e.target.value)}
maxLength={1000}
rows={3}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500 resize-none"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-600">{changeSummary.length}/1000</span>
<Button
onClick={handlePublish}
disabled={publishMutation.isPending || !changeSummary.trim()}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{publishMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
) : (
<Send className="h-4 w-4 mr-1.5" />
)}
Veröffentlichen & Senden
</Button>
</div>
</div>
{/* History */}
<div>
<h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-2">Verlauf</h2>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : versions.length === 0 ? (
<p className="text-sm text-zinc-500 py-4">Noch keine Legal-Updates veröffentlicht.</p>
) : (
<div className="space-y-2">
{versions.map((v) => (
<div key={v.id} className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-orange-400">
{DOC_TYPE_LABELS[v.docType] ?? v.docType}
</span>
<span className="text-xs text-zinc-600">{formatDate(v.publishedAt)}</span>
</div>
<p className="text-sm text-zinc-200">{v.changeSummary}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -15,10 +15,13 @@ type Step = "warning" | "confirm" | "loading" | "done";
type Props = {
petNames: string[];
defaultOpen?: boolean;
};
export function DeleteAccountDialog({ petNames }: Props) {
const [open, setOpen] = useState(false);
export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
// Lazy initial state (not an effect) — lets the legal-update banner
// deep-link straight into this dialog via ?deleteAccount=1.
const [open, setOpen] = useState(() => !!defaultOpen);
const [step, setStep] = useState<Step>("warning");
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -0,0 +1,96 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { ScrollText } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from "@/components/ui/sheet";
const DOC_LINKS: Record<string, string> = {
NUTZUNGSBEDINGUNGEN: "/nutzungsbedingungen",
DATENSCHUTZ: "/datenschutz",
};
/**
* Pull-based legal-update notice: compares Owner.legalAcknowledgedAt against
* the newest LegalDocumentVersion on every app-shell mount, instead of
* fanning out a petId-scoped Notification row per pet. See legal.ts router
* for the full rationale. Not dismissable via backdrop/escape — the owner
* must explicitly acknowledge or go delete their account.
*/
export function LegalUpdateBanner() {
const t = useTranslations("LegalUpdateBanner");
const router = useRouter();
const trpc = useTRPC();
const qc = useQueryClient();
const { data: pending } = useQuery(trpc.legal.getPendingUpdate.queryOptions());
const acknowledgeMutation = useMutation(
trpc.legal.acknowledge.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.legal.getPendingUpdate.queryKey() });
},
})
);
if (!pending || pending.length === 0) return null;
const docTypes = [...new Set(pending.map((v) => v.docType))];
return (
<Sheet open onOpenChange={() => {}}>
<SheetContent side="bottom" showCloseButton={false}>
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<ScrollText className="h-4 w-4 shrink-0" />
{t("title")}
</SheetTitle>
</SheetHeader>
<div className="px-4 space-y-3">
{pending.map((version) => (
<p key={version.id} className="text-sm text-muted-foreground">
{version.changeSummary}
</p>
))}
<div className="flex flex-wrap gap-3 text-xs">
{docTypes.map((docType) => (
<a
key={docType}
href={DOC_LINKS[docType] ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline"
>
{t("readFull")}
</a>
))}
</div>
</div>
<SheetFooter className="flex-row justify-end gap-2">
<Button
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => router.push("/pets?deleteAccount=1")}
>
{t("disagree")}
</Button>
<Button
onClick={() => acknowledgeMutation.mutate()}
disabled={acknowledgeMutation.isPending}
>
{t("acknowledge")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+2
View File
@@ -20,6 +20,7 @@ import { healthRouter } from "@/trpc/routers/health";
import { adminRouter } from "@/trpc/routers/admin";
import { invitesRouter } from "@/trpc/routers/invites";
import { adsRouter } from "@/trpc/routers/ads";
import { legalRouter } from "@/trpc/routers/legal";
export const appRouter = router({
pets: petsRouter,
@@ -44,6 +45,7 @@ export const appRouter = router({
admin: adminRouter,
invites: invitesRouter,
ads: adsRouter,
legal: legalRouter,
});
// Export the type for use in the client
+1 -1
View File
@@ -10,7 +10,7 @@ import {
MAX_FILE_SIZE,
} from "@/lib/supabase-storage";
async function assertAdmin(
export async function assertAdmin(
prisma: PrismaClient,
userId: string,
requireSuperAdmin = false
+57
View File
@@ -0,0 +1,57 @@
import "server-only";
import { z } from "zod";
import { LegalDocType } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import { assertAdmin } from "@/trpc/routers/admin";
export const legalRouter = router({
// ─── Admin: publish + history ─────────────────────────────────────────────
publish: protectedProcedure
.input(
z.object({
docType: z.nativeEnum(LegalDocType),
changeSummary: z.string().min(1).max(1000),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.legalDocumentVersion.create({
data: {
docType: input.docType,
changeSummary: input.changeSummary.trim(),
publishedBy: ctx.userId,
},
});
}),
listVersions: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.legalDocumentVersion.findMany({
orderBy: { publishedAt: "desc" },
take: 50,
});
}),
// ─── Owner-facing: pending-update banner ──────────────────────────────────
getPendingUpdate: protectedProcedure.query(async ({ ctx }) => {
const owner = await ctx.prisma.owner.findUnique({
where: { id: ctx.userId },
select: { legalAcknowledgedAt: true },
});
const versions = await ctx.prisma.legalDocumentVersion.findMany({
where: owner?.legalAcknowledgedAt
? { publishedAt: { gt: owner.legalAcknowledgedAt } }
: {},
orderBy: { publishedAt: "desc" },
});
return versions.length > 0 ? versions : null;
}),
acknowledge: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.prisma.owner.update({
where: { id: ctx.userId },
data: { legalAcknowledgedAt: new Date() },
});
return { ok: true };
}),
});