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:
1 parent
d7234f55bd
commit
70115de483
15 files changed
+485
-14
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)])
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,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,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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user