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:
1 parent
cc82f8102a
commit
328e54c68e
4 files changed
+275
-3
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user