From 328e54c68eb0051141dda1ed96e8f6fb5112aad4 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Tue, 11 Aug 2026 11:06:42 +0200 Subject: [PATCH] feat(admin): show Clerk profile data and add a Team-messaging channel to the users panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea #6 (parts 1+2): admin.listUsers now batch-fetches email/name from Clerk (users only stored the Clerk userId locally, everything else lives in Clerk) instead of showing a bare userId. Adds admin.startTeamConversation, which lazily creates a "PawFeed Team" system Pet under ADMIN_OWNER_ID and reuses the existing pet-to-pet Conversation/Message model instead of a parallel admin-inbox system — the admin panel switches ActivePet to Team and opens the ordinary /messages/[conversationId] thread, so no new UI is needed on the recipient's side. Known limitation: only the literal ADMIN_OWNER_ID account can act as Team, since assertPetOwnership requires an exact owner match. Extending this to every AdminRole holder is left for a follow-up. Part 3 (GDPR Art. 15 data export) is deliberately not included — research only per the issue's own "erst prüfen dann bauen" note. --- src/__tests__/admin.test.ts | 116 +++++++++++++++++++++++++++ src/__tests__/helpers/prisma-mock.ts | 16 ++++ src/app/p/[secret]/users/page.tsx | 40 ++++++++- src/trpc/routers/admin.ts | 106 +++++++++++++++++++++++- 4 files changed, 275 insertions(+), 3 deletions(-) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index ea7601c..3974969 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -16,8 +16,10 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { TRPCError } from "@trpc/server"; vi.mock("server-only", () => ({})); +const mockGetUserList = vi.fn(); vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "admin-1" }), + clerkClient: vi.fn().mockResolvedValue({ users: { getUserList: mockGetUserList } }), })); vi.mock("@/lib/supabase-storage", () => ({ createSignedUploadUrl: vi.fn(), @@ -223,3 +225,117 @@ describe("admin dashboard time-series", () => { expect(result[29]!.value).toBe(6); // baseline + 1 new owner today }); }); + +describe("admin.listUsers", () => { + it("merges Clerk email/name onto each owner (Gitea #6)", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.owner.findMany.mockResolvedValue([ + { id: "owner-2", pets: [], ban: null, adminRole: null, _count: { pets: 0 } }, + ]); + mockGetUserList.mockResolvedValue({ + data: [ + { + id: "owner-2", + emailAddresses: [{ id: "email-1", emailAddress: "owner2@example.com" }], + primaryEmailAddressId: "email-1", + firstName: "Jane", + lastName: "Doe", + createdAt: 1700000000000, + }, + ], + }); + + const result = await caller.admin.listUsers({ limit: 20 }); + + expect(result.users[0]!.clerk).toEqual({ + email: "owner2@example.com", + name: "Jane Doe", + createdAt: new Date(1700000000000), + }); + }); + + it("returns clerk: null when the Clerk profile is missing (e.g. deleted account)", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.owner.findMany.mockResolvedValue([ + { id: "owner-3", pets: [], ban: null, adminRole: null, _count: { pets: 0 } }, + ]); + mockGetUserList.mockResolvedValue({ data: [] }); + + const result = await caller.admin.listUsers({ limit: 20 }); + + expect(result.users[0]!.clerk).toBeNull(); + }); +}); + +describe("admin.startTeamConversation", () => { + it("creates the Team pet on first use and starts a conversation", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "target-pet-1" }); + mockPrisma.pet.findFirst.mockResolvedValue(null); // no Team pet yet + mockPrisma.owner.upsert.mockResolvedValue({ id: "admin-1" }); + mockPrisma.species.findFirst.mockResolvedValue({ id: "species-1" }); + mockPrisma.pet.create.mockResolvedValue({ id: "team-pet-1" }); + mockPrisma.conversation.upsert.mockResolvedValue({ id: "conv-1" }); + + const result = await caller.admin.startTeamConversation({ targetPetId: "target-pet-1" }); + + expect(mockPrisma.pet.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ ownerId: "admin-1", name: "PawFeed Team" }), + }) + ); + expect(result).toEqual({ conversationId: "conv-1", teamPetId: "team-pet-1" }); + }); + + it("reuses an existing Team pet instead of creating a duplicate", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "target-pet-1" }); + mockPrisma.pet.findFirst.mockResolvedValue({ id: "team-pet-existing" }); + mockPrisma.conversation.upsert.mockResolvedValue({ id: "conv-2" }); + + const result = await caller.admin.startTeamConversation({ targetPetId: "target-pet-1" }); + + expect(mockPrisma.pet.create).not.toHaveBeenCalled(); + expect(result.teamPetId).toBe("team-pet-existing"); + }); + + it("throws NOT_FOUND when the target pet does not exist", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue(null); + + await expect( + caller.admin.startTeamConversation({ targetPetId: "missing-pet" }) + ).rejects.toThrow(TRPCError); + }); + + it("throws BAD_REQUEST when the target pet is the Team pet itself", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "team-pet-existing" }); + mockPrisma.pet.findFirst.mockResolvedValue({ id: "team-pet-existing" }); + + await expect( + caller.admin.startTeamConversation({ targetPetId: "team-pet-existing" }) + ).rejects.toThrow(TRPCError); + }); + + it("throws FORBIDDEN for a non-admin caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); + const { caller } = await createAdminCaller(); + + await expect( + caller.admin.startTeamConversation({ targetPetId: "target-pet-1" }) + ).rejects.toThrow(TRPCError); + }); +}); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index f569748..f84a235 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -18,6 +18,7 @@ interface MockPetClient { create: MockFn; findMany: MockFn; findUnique: MockFn; + findFirst: MockFn; update: MockFn; delete: MockFn; count: MockFn; @@ -45,9 +46,16 @@ interface MockLegalDocumentVersionClient { interface MockSpeciesClient { findMany: MockFn; findUnique: MockFn; + findFirst: MockFn; upsert: MockFn; } +interface MockConversationClient { + upsert: MockFn; + findUnique: MockFn; + findMany: MockFn; +} + interface MockBreedClient { findMany: MockFn; findUnique: MockFn; @@ -265,6 +273,7 @@ interface MockPrismaClient { weightLog: MockWeightLogClient; vetVisit: MockVetVisitClient; vaccine: MockVaccineClient; + conversation: MockConversationClient; $transaction: MockFn; $disconnect: MockFn; } @@ -275,6 +284,7 @@ export function createMockPrisma(): MockPrismaClient { create: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), + findFirst: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn(), @@ -300,8 +310,14 @@ export function createMockPrisma(): MockPrismaClient { species: { findMany: vi.fn(), findUnique: vi.fn(), + findFirst: vi.fn(), upsert: vi.fn(), }, + conversation: { + upsert: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + }, breed: { findMany: vi.fn(), findUnique: vi.fn(), diff --git a/src/app/p/[secret]/users/page.tsx b/src/app/p/[secret]/users/page.tsx index c65b53b..1891f6d 100644 --- a/src/app/p/[secret]/users/page.tsx +++ b/src/app/p/[secret]/users/page.tsx @@ -1,9 +1,11 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; -import { ShieldOff, ShieldCheck, Loader2, ChevronDown } from "lucide-react"; +import { useActivePet } from "@/context/ActivePetContext"; +import { ShieldOff, ShieldCheck, Loader2, ChevronDown, MessageCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; @@ -12,6 +14,8 @@ import { Badge } from "@/components/ui/badge"; export default function AdminUsersPage() { const trpc = useTRPC(); const qc = useQueryClient(); + const router = useRouter(); + const { setActivePet } = useActivePet(); const [banTarget, setBanTarget] = useState(null); const [unbanTarget, setUnbanTarget] = useState(null); const [banReason, setBanReason] = useState(""); @@ -46,6 +50,15 @@ export default function AdminUsersPage() { }) ); + const startConversationMutation = useMutation( + trpc.admin.startTeamConversation.mutationOptions({ + onSuccess: ({ conversationId, teamPetId }) => { + setActivePet(teamPetId); + router.push(`/messages/${conversationId}`); + }, + }) + ); + return (

Users

@@ -75,6 +88,12 @@ export default function AdminUsersPage() { )}
+ {user.clerk && ( +

+ {user.clerk.name ? `${user.clerk.name} · ` : ""} + {user.clerk.email ?? "no email on file"} +

+ )}

{user._count.pets} pet{user._count.pets !== 1 ? "s" : ""} {user.pets.length > 0 && " · " + user.pets.map((p) => p.name).join(", ")} @@ -83,7 +102,24 @@ export default function AdminUsersPage() {

Reason: {user.ban.reason}

)} -
+
+ {user.pets[0] && ( + + )} {user.ban ? (