diff --git a/src/__tests__/admin-overview.test.ts b/src/__tests__/admin-overview.test.ts new file mode 100644 index 0000000..093d73e --- /dev/null +++ b/src/__tests__/admin-overview.test.ts @@ -0,0 +1,146 @@ +/** + * Admin-Startseite + Rahmen (admin-overview.ts): Warteschlangen für die + * Sidebar-Zähler, Kennzahlen, Eingangs-Feed und globale Suche. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); +const mockGetUserList = vi.fn(); +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "admin-1" }), + clerkClient: vi.fn().mockResolvedValue({ users: { getUserList: (...a: unknown[]) => mockGetUserList(...a) } }), +})); +const mockAssertAdmin = vi.fn(); +vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) })); +vi.mock("@/lib/team-pet", () => ({ ensureTeamPet: vi.fn().mockResolvedValue({ id: "team-pet" }) })); +vi.mock("@/lib/visit-tracking", () => ({ + getDailyVisitCount: vi.fn().mockResolvedValue(3), + getUniqueVisitorCount: vi.fn().mockResolvedValue(12), + lastNDays: vi.fn().mockReturnValue([]), +})); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +import { adminOverviewRouter } from "@/trpc/routers/admin-overview"; + +type MockPrisma = ReturnType; +const ctx = (prisma: MockPrisma) => ({ userId: "admin-1", prisma: prisma as never, locale: "de", ip: "127.0.0.1" }); +const at = (iso: string) => new Date(iso); + +describe("adminOverview", () => { + let prisma: MockPrisma; + + beforeEach(() => { + vi.clearAllMocks(); + mockAssertAdmin.mockResolvedValue("MODERATOR"); + prisma = createMockPrisma(); + prisma.report.count.mockResolvedValue(0); + prisma.report.findMany.mockResolvedValue([]); + }); + + describe("getQueues", () => { + it("counts every queue and reports how long the oldest item has been waiting", async () => { + prisma.report.count.mockResolvedValueOnce(4).mockResolvedValueOnce(1); + prisma.report.findFirst.mockResolvedValue({ createdAt: at("2026-10-01T10:00:00Z") }); + prisma.message.count.mockResolvedValue(2); + prisma.verificationRequest.count.mockResolvedValue(3); + prisma.lostReport.count.mockResolvedValue(1); + + const result = await adminOverviewRouter.createCaller(ctx(prisma)).getQueues(); + + expect(result.reports).toEqual({ count: 4, oldestAt: at("2026-10-01T10:00:00Z"), new24h: 1 }); + expect(result.messages.count).toBe(2); + expect(result.verifications?.count).toBe(3); + expect(result.lost?.count).toBe(1); + expect(prisma.lostReport.count).toHaveBeenCalledWith({ where: { foundAt: null } }); + expect(prisma.message.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ readAt: null, senderPetId: { not: "team-pet" } }), + }); + }); + + it("hides moderator-only queues from SUPPORT", async () => { + mockAssertAdmin.mockResolvedValue("SUPPORT"); + + const result = await adminOverviewRouter.createCaller(ctx(prisma)).getQueues(); + + expect(result.verifications).toBeNull(); + expect(result.lost).toBeNull(); + expect(prisma.verificationRequest.count).not.toHaveBeenCalled(); + }); + + it("rejects non-admins", async () => { + mockAssertAdmin.mockRejectedValue(new TRPCError({ code: "FORBIDDEN" })); + await expect(adminOverviewRouter.createCaller(ctx(prisma)).getQueues()).rejects.toThrow(TRPCError); + }); + }); + + it("getKpis compares this week with last week", async () => { + prisma.owner.count.mockResolvedValueOnce(28).mockResolvedValueOnce(5); + prisma.pet.count.mockResolvedValueOnce(32).mockResolvedValueOnce(6); + prisma.post.count.mockResolvedValueOnce(9).mockResolvedValueOnce(4); + + const result = await adminOverviewRouter.createCaller(ctx(prisma)).getKpis(); + + expect(result).toEqual({ owners: 28, ownersThisWeek: 5, pets: 32, petsThisWeek: 6, postsThisWeek: 9, postsLastWeek: 4, dau: 3, mau: 12 }); + }); + + describe("getInbox", () => { + it("merges all sources newest first with a readable status", async () => { + prisma.report.findMany.mockResolvedValue([ + { id: "r1", createdAt: at("2026-10-05T09:00:00Z"), reason: "SPAM", targetPostId: "post-1", reporter: { name: "Mia" } }, + ]); + prisma.verificationRequest.findMany.mockResolvedValue([ + { id: "v1", createdAt: at("2026-10-05T08:00:00Z"), status: "APPROVED", pet: { name: "Nala" } }, + ]); + prisma.lostReport.findMany.mockResolvedValue([ + { id: "l1", createdAt: at("2026-10-04T18:00:00Z"), foundAt: null, pet: { name: "Milo" } }, + ]); + prisma.message.findMany.mockResolvedValue([ + { id: "m1", createdAt: at("2026-10-05T10:00:00Z"), readAt: null, body: "x".repeat(100), senderPet: { name: "Lea" } }, + ]); + + const items = await adminOverviewRouter.createCaller(ctx(prisma)).getInbox(); + + expect(items.map((i) => [i.kind, i.status])).toEqual([ + ["MESSAGE", "OPEN"], + ["REPORT", "OPEN"], + ["VERIFICATION", "DONE"], + ["LOST", "ACTIVE"], + ]); + expect(items[0]!.detail).toHaveLength(81); + expect(items[1]!.target).toBe("POST"); + }); + + it("leaves out verification and lost reports for SUPPORT", async () => { + mockAssertAdmin.mockResolvedValue("SUPPORT"); + await adminOverviewRouter.createCaller(ctx(prisma)).getInbox(); + expect(prisma.verificationRequest.findMany).not.toHaveBeenCalled(); + expect(prisma.lostReport.findMany).not.toHaveBeenCalled(); + }); + }); + + describe("search", () => { + it("finds pets by name or nickname and owners via Clerk", async () => { + prisma.pet.findMany.mockResolvedValue([{ id: "p1", name: "Berli", nickname: null, avatarKey: null }]); + mockGetUserList.mockResolvedValue({ + data: [{ id: "user_1", firstName: "Anna", lastName: null, primaryEmailAddress: { emailAddress: "anna@example.com" }, emailAddresses: [] }], + }); + + const result = await adminOverviewRouter.createCaller(ctx(prisma)).search({ query: " berl " }); + + expect(prisma.pet.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { OR: [{ name: { contains: "berl", mode: "insensitive" } }, { nickname: { contains: "berl", mode: "insensitive" } }] } }) + ); + expect(mockGetUserList).toHaveBeenCalledWith({ query: "berl", limit: 6 }); + expect(result).toEqual({ + pets: [{ id: "p1", name: "Berli", nickname: null, avatarUrl: null }], + owners: [{ id: "user_1", name: "Anna", email: "anna@example.com" }], + }); + }); + + it("needs at least two characters", async () => { + await expect(adminOverviewRouter.createCaller(ctx(prisma)).search({ query: "b" })).rejects.toThrow(); + }); + }); +}); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index e34b44f..4629ca9 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -140,6 +140,7 @@ interface MockBlockClient { interface MockReportClient { create: MockFn; + findFirst: MockFn; findMany: MockFn; findUnique: MockFn; delete: MockFn; @@ -376,6 +377,8 @@ interface MockPrismaClient { story: MockStoryClient; storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn }; lostReport: { findFirst: MockFn; findUnique: MockFn; findMany: MockFn; count: MockFn; create: MockFn; update: MockFn; delete: MockFn }; + verificationRequest: { findFirst: MockFn; findMany: MockFn; count: MockFn }; + message: { findFirst: MockFn; findMany: MockFn; count: MockFn }; storyView: MockStoryViewClient; follow: MockFollowClient; block: MockBlockClient; @@ -508,6 +511,16 @@ export function createMockPrisma(): MockPrismaClient { deleteMany: vi.fn(), aggregate: vi.fn(), }, + verificationRequest: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + message: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, lostReport: { findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn(), @@ -552,6 +565,7 @@ export function createMockPrisma(): MockPrismaClient { }, report: { create: vi.fn(), + findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn(), diff --git a/src/app/globals.css b/src/app/globals.css index 79d2e2c..eccac4a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -35,6 +35,9 @@ --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); + --color-success: var(--success); + --color-warning: var(--warning); + --color-info: var(--info); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); @@ -125,6 +128,74 @@ --sidebar-ring: oklch(0.556 0 0); } +/* Admin-Panel (/p/[secret]): eigene, warme Palette mit Orange als Primärfarbe in + beiden Modi (die Website-.dark-Tokens sind neutral grau, Primär = hellgrau). + Hell ist Standard; `dark` kommt zusätzlich zu `admin-theme` dazu (Cookie + pf_admin_theme, siehe AdminThemeToggle). Dazu Statusfarben für Abzeichen. */ +.admin-theme { + --background: oklch(0.982 0.004 70); + --foreground: oklch(0.2 0.012 50); + --card: oklch(1 0 0); + --card-foreground: oklch(0.2 0.012 50); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.2 0.012 50); + --primary: oklch(0.62 0.19 42); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.955 0.006 70); + --secondary-foreground: oklch(0.25 0.012 50); + --muted: oklch(0.955 0.006 70); + --muted-foreground: oklch(0.48 0.014 55); + --accent: oklch(0.95 0.02 60); + --accent-foreground: oklch(0.25 0.012 50); + --destructive: oklch(0.55 0.21 27); + --border: oklch(0.91 0.008 70); + --input: oklch(0.89 0.01 70); + --ring: oklch(0.62 0.19 42); + --success: oklch(0.52 0.12 155); + --warning: oklch(0.58 0.14 70); + --info: oklch(0.52 0.13 245); + --chart-1: oklch(0.62 0.19 42); + --chart-2: oklch(0.52 0.12 155); + --chart-3: oklch(0.52 0.13 245); + --sidebar: oklch(1 0 0); + --sidebar-foreground: oklch(0.3 0.012 50); + --sidebar-accent: oklch(0.96 0.03 60); + --sidebar-accent-foreground: oklch(0.45 0.16 42); + --sidebar-border: oklch(0.91 0.008 70); +} + +.admin-theme.dark { + --background: oklch(0.17 0.008 50); + --foreground: oklch(0.96 0.006 70); + --card: oklch(0.215 0.009 50); + --card-foreground: oklch(0.96 0.006 70); + --popover: oklch(0.24 0.01 50); + --popover-foreground: oklch(0.96 0.006 70); + --primary: oklch(0.72 0.17 45); + --primary-foreground: oklch(0.18 0.02 45); + --secondary: oklch(0.27 0.01 50); + --secondary-foreground: oklch(0.94 0.006 70); + --muted: oklch(0.26 0.01 50); + --muted-foreground: oklch(0.72 0.012 60); + --accent: oklch(0.29 0.025 50); + --accent-foreground: oklch(0.96 0.006 70); + --destructive: oklch(0.7 0.17 25); + --border: oklch(1 0 0 / 9%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.72 0.17 45); + --success: oklch(0.74 0.13 155); + --warning: oklch(0.8 0.13 80); + --info: oklch(0.74 0.11 245); + --chart-1: oklch(0.72 0.17 45); + --chart-2: oklch(0.74 0.13 155); + --chart-3: oklch(0.74 0.11 245); + --sidebar: oklch(0.195 0.009 50); + --sidebar-foreground: oklch(0.85 0.008 70); + --sidebar-accent: oklch(0.3 0.04 45); + --sidebar-accent-foreground: oklch(0.85 0.12 50); + --sidebar-border: oklch(1 0 0 / 8%); +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/src/app/p/[secret]/ads/page.tsx b/src/app/p/[secret]/ads/page.tsx index a8e2cd2..45076f5 100644 --- a/src/app/p/[secret]/ads/page.tsx +++ b/src/app/p/[secret]/ads/page.tsx @@ -203,7 +203,7 @@ export default function AdminAdsPage() { return (
-

Advertisements

+

Werbung

-
@@ -297,7 +297,7 @@ export default function AdminAdsPage() { {/* Create / Edit dialog */} { if (!o) closeDialog(); }}> - + {editingId ? "Ad bearbeiten" : "Ad erstellen"} @@ -348,7 +348,7 @@ export default function AdminAdsPage() { type="button" onClick={() => fileInputRef.current?.click()} disabled={imageUploading} - className="w-full h-28 rounded-xl border-2 border-dashed border-zinc-700 hover:border-zinc-500 flex flex-col items-center justify-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + className="w-full h-28 rounded-xl border-2 border-dashed border-border hover:border-ring/50 flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > {imageUploading ? ( @@ -366,40 +366,40 @@ export default function AdminAdsPage() { placeholder="Titel *" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} - className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground" />