feat(admin): show Clerk profile data and add a Team-messaging channel to the users panel

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.
This commit is contained in:
admin committed 2026-08-11 11:06:42 +02:00
1 parent cc82f8102a
commit 328e54c68e
4 files changed
+275 -3

No files matched your search

+116
View File
@@ -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);
});
});
+16
View File
@@ -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(),
+38 -2
View File
@@ -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<string | null>(null);
const [unbanTarget, setUnbanTarget] = useState<string | null>(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 (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-zinc-100">Users</h1>
@@ -75,6 +88,12 @@ export default function AdminUsersPage() {
</Badge>
)}
</div>
{user.clerk && (
<p className="text-xs text-zinc-400 mt-0.5 truncate">
{user.clerk.name ? `${user.clerk.name} · ` : ""}
{user.clerk.email ?? "no email on file"}
</p>
)}
<p className="text-xs text-zinc-500 mt-0.5">
{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() {
<p className="text-xs text-red-400 mt-0.5">Reason: {user.ban.reason}</p>
)}
</div>
<div className="shrink-0">
<div className="shrink-0 flex items-center gap-2">
{user.pets[0] && (
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
onClick={() => startConversationMutation.mutate({ targetPetId: user.pets[0].id })}
disabled={startConversationMutation.isPending}
title={`Message ${user.pets[0].name} as PawFeed Team`}
>
{startConversationMutation.isPending &&
startConversationMutation.variables?.targetPetId === user.pets[0].id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageCircle className="h-4 w-4" />
)}
</Button>
)}
{user.ban ? (
<Button
size="sm"
+105 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { randomUUID } from "crypto";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, type PrismaClient } from "@prisma/client";
import { clerkClient } from "@clerk/nextjs/server";
import { router, protectedProcedure } from "@/trpc/init";
import {
createSignedUploadUrl,
@@ -280,7 +281,33 @@ export const adminRouter = router({
});
const hasNext = owners.length > input.limit;
const items = hasNext ? owners.slice(0, input.limit) : owners;
return { users: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
// Batch-fetch Clerk profile data (email/name) — Owner only stores the
// Clerk userId locally, everything else lives in Clerk (Gitea #6).
const clerk = await clerkClient();
const clerkUsers = items.length > 0
? (await clerk.users.getUserList({ userId: items.map((o) => o.id), limit: items.length })).data
: [];
const clerkById = new Map(clerkUsers.map((u) => [u.id, u]));
const usersWithClerkData = items.map((owner) => {
const clerkUser = clerkById.get(owner.id);
const primaryEmail = clerkUser?.emailAddresses.find(
(e) => e.id === clerkUser.primaryEmailAddressId
)?.emailAddress;
return {
...owner,
clerk: clerkUser
? {
email: primaryEmail ?? null,
name: [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || null,
createdAt: new Date(clerkUser.createdAt),
}
: null,
};
});
return { users: usersWithClerkData, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
banUser: protectedProcedure
@@ -343,6 +370,83 @@ export const adminRouter = router({
return { success: true };
}),
// ─── Team messaging (Gitea #6) ────────────────────────────────────────────────
// Reuses the existing pet-to-pet Conversation/Message model instead of a
// parallel admin-inbox system: a single "PawFeed Team" system Pet, owned by
// the env-bootstrapped ADMIN_OWNER_ID, DMs the target pet through the normal
// conversation flow. The admin panel switches its ActivePet to Team and
// opens the ordinary /messages/[conversationId] thread — no new UI needed on
// the recipient's side, and no changes to messages.ts.
//
// Known limitation: only the literal ADMIN_OWNER_ID account can act as Team
// (assertPetOwnership requires ctx.userId === pet.ownerId), since the Team
// pet needs a single owner. Extending this to every AdminRole holder would
// need either a shared-ownership model or relaxing that check for this pet
// specifically — out of scope for this first version.
startTeamConversation: protectedProcedure
.input(z.object({ targetPetId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const adminOwnerId = process.env.ADMIN_OWNER_ID;
if (!adminOwnerId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "ADMIN_OWNER_ID is not configured.",
});
}
const targetPet = await ctx.prisma.pet.findUnique({
where: { id: input.targetPetId },
select: { id: true },
});
if (!targetPet) throw new TRPCError({ code: "NOT_FOUND" });
let teamPet = await ctx.prisma.pet.findFirst({
where: { ownerId: adminOwnerId, name: "PawFeed Team" },
select: { id: true },
});
if (!teamPet) {
// Ensure the Owner row + at least one Species exist before creating
// the pet — same FK requirements as any normal pet.create.
await ctx.prisma.owner.upsert({
where: { id: adminOwnerId },
update: {},
create: { id: adminOwnerId },
});
const species = await ctx.prisma.species.findFirst({ select: { id: true } });
if (!species) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "No Species rows exist — run the seed script first.",
});
}
teamPet = await ctx.prisma.pet.create({
data: {
ownerId: adminOwnerId,
speciesId: species.id,
name: "PawFeed Team",
bio: "Offizieller Support-Account von PawFeed.",
},
select: { id: true },
});
}
if (teamPet.id === input.targetPetId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot message the Team pet itself." });
}
const [petAId, petBId] = [teamPet.id, input.targetPetId].sort();
const conversation = await ctx.prisma.conversation.upsert({
where: { petAId_petBId: { petAId, petBId } },
update: {},
create: { petAId, petBId },
});
return { conversationId: conversation.id, teamPetId: teamPet.id };
}),
// ─── Reports ─────────────────────────────────────────────────────────────────
listReports: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);