From 30d640ca5bd5f8bbb88292f4c7c7bc1e65bd97e7 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 2 Sep 2026 00:01:12 +0700 Subject: [PATCH] feat(fe): add Member Intelligence (/users) page wired to full reputation data - New /users route: member roster (trust score, clean/flagged counts, message volume) + inspector detail (trust breakdown, clean streak, infractions, AI profile, recent messages) with live search - Backend dashboard.listUsers/getUserDetail now JOIN user_reputations to expose trust_score, clean_message_streak, total_infractions, last_infraction_at + warn_count/clean_count breakdowns - Frontend types updated to match; useUsers refactored to PaginatedUsers shape, added useUserSearch for the old search behavior - Nav rail + mobile nav: add Users entry --- .../modules/dashboard/dashboard.repository.ts | 44 ++- .../src/app/(dashboard)/users/page.tsx | 14 + .../src/app/(dashboard)/users/view.tsx | 372 ++++++++++++++++++ services/frontend/src/hooks/index.ts | 1 + services/frontend/src/hooks/use-dashboard.ts | 11 +- services/frontend/src/lib/api/server.ts | 5 + services/frontend/src/lib/navigation.ts | 7 + services/frontend/src/lib/types/dashboard.ts | 5 + 8 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 services/frontend/src/app/(dashboard)/users/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/users/view.tsx diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts index f9ad4e31..41907860 100644 --- a/services/backend/src/modules/dashboard/dashboard.repository.ts +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -5,6 +5,7 @@ import { pgChannelCulturesTable, pgMessagesTable, pgUserProfilesTable, + pgUserReputationsTable, pgVoiceRecordingsTable, } from "../../shared/index.js"; import type { ListUsersQuery } from "./dashboard.service.js"; @@ -153,8 +154,14 @@ export class DashboardRepository { m.username, m.avatar_url, p.profile_summary, + r.trust_score, + r.clean_message_streak, + r.total_infractions, + r.last_infraction_at, m.total_messages, m.flagged_count, + m.clean_count, + m.warn_count, m.last_message_at FROM ( SELECT @@ -163,11 +170,14 @@ export class DashboardRepository { avatar_url, COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, + COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count, + COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warn_count, MAX(created_at) AS last_message_at FROM ${pgMessagesTable} GROUP BY user_id, username, avatar_url ) m LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id + LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id ${whereClause} ORDER BY m.last_message_at DESC NULLS LAST LIMIT ${limit + 1} @@ -180,8 +190,20 @@ export class DashboardRepository { username: r.username as string | null, avatar_url: r.avatar_url as string | null, profile_summary: r.profile_summary as string | null, + trust_score: r.trust_score != null ? Number(r.trust_score) : null, + clean_message_streak: + r.clean_message_streak != null + ? Number(r.clean_message_streak) + : null, + total_infractions: + r.total_infractions != null ? Number(r.total_infractions) : null, + last_infraction_at: r.last_infraction_at + ? Number(r.last_infraction_at) + : null, total_messages: Number(r.total_messages), flagged_count: Number(r.flagged_count), + clean_count: Number(r.clean_count), + warn_count: Number(r.warn_count), last_message_at: r.last_message_at ? Number(r.last_message_at) : null, })); @@ -429,8 +451,13 @@ export class DashboardRepository { m.total_messages, m.flagged_count, m.clean_count, + m.warn_count, p.profile_summary, - p.last_analyzed_at + p.last_analyzed_at, + r.trust_score, + r.clean_message_streak, + r.total_infractions, + r.last_infraction_at FROM ( SELECT user_id, @@ -438,12 +465,14 @@ export class DashboardRepository { avatar_url, COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, - COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count + COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count, + COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warn_count FROM ${pgMessagesTable} WHERE user_id = ${userId} GROUP BY user_id, username, avatar_url ) m LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id + LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id `); const row = userResult.rows[0] as Record | undefined; @@ -466,10 +495,21 @@ export class DashboardRepository { total_messages: Number(row.total_messages), flagged_count: Number(row.flagged_count), clean_count: Number(row.clean_count), + warn_count: Number(row.warn_count), profile_summary: row.profile_summary as string | null, last_analyzed_at: row.last_analyzed_at ? Number(row.last_analyzed_at) : null, + trust_score: row.trust_score != null ? Number(row.trust_score) : null, + clean_message_streak: + row.clean_message_streak != null + ? Number(row.clean_message_streak) + : null, + total_infractions: + row.total_infractions != null ? Number(row.total_infractions) : null, + last_infraction_at: row.last_infraction_at + ? Number(row.last_infraction_at) + : null, recent_messages: (recent.rows as Record[]).map((r) => ({ id: String(r.id), content: String(r.content), diff --git a/services/frontend/src/app/(dashboard)/users/page.tsx b/services/frontend/src/app/(dashboard)/users/page.tsx new file mode 100644 index 00000000..20466ded --- /dev/null +++ b/services/frontend/src/app/(dashboard)/users/page.tsx @@ -0,0 +1,14 @@ +import { getUsers } from "@/lib/api/server"; +import { UsersView } from "./view"; + +export const dynamic = "force-dynamic"; + +export default async function UsersPage() { + let users: import("@/lib/types").PaginatedUsers | undefined; + try { + users = await getUsers(50); + } catch { + /* client hooks surface errors */ + } + return ; +} diff --git a/services/frontend/src/app/(dashboard)/users/view.tsx b/services/frontend/src/app/(dashboard)/users/view.tsx new file mode 100644 index 00000000..585d1e69 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/users/view.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { + AlertTriangle, + CheckCircle2, + Clock, + Hash, + Search, + Shield, + ShieldAlert, + Sparkles, + Trophy, + Users as UsersIcon, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useAmbient } from "@/components/ambient/ambient-context"; +import { + Avatar, + Badge, + GlassPanel, + Input, + Skeleton, +} from "@/components/primitives"; +import { + EmptyState, + ErrorState, + PageTransition, + SectionHeader, + SkeletonRows, +} from "@/components/shared"; +import { useUserDetail, useUsers } from "@/hooks"; +import { useStaggerReveal } from "@/hooks/use-gsap-animation"; +import { formatNumber, formatRelativeTime } from "@/lib/format"; +import type { + DashboardUser, + DashboardUserDetail, + PaginatedUsers, +} from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; + +function trustTone(score?: number | null) { + if (score == null) return "neutral"; + if (score >= 70) return "signal"; + if (score >= 45) return "amber"; + return "vermilion"; +} + +function trustLabel(score?: number | null) { + if (score == null) return "UNKNOWN"; + if (score >= 80) return "TRUSTED"; + if (score >= 60) return "LOW RISK"; + if (score >= 45) return "MONITOR"; + if (score >= 30) return "WATCH"; + return "HIGH RISK"; +} + +export function UsersView({ initialUsers }: { initialUsers?: PaginatedUsers }) { + const { data: usersData, isLoading, error } = useUsers(initialUsers); + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const detail = useUserDetail(selectedId); + const ambient = useAmbient(); + + const users = usersData?.data; + + useEffect(() => { + ambient.set("signal", 0.25, "members"); + }, [ambient]); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return users ?? []; + return (users ?? []).filter( + (u) => + u.username?.toLowerCase().includes(q) || + u.user_id.toLowerCase().includes(q), + ); + }, [users, search]); + + const containerRef = useStaggerReveal(".user-tile", { + stagger: 0.03, + y: 8, + dependencies: [users?.length], + }); + + if (error && !users) return {}} />; + if (isLoading && !users) return ; + + return ( + +
+ {/* Tactical HUD Header Bar */} +
+
+ +

+ Member Intelligence · Persona Registry +

+
+
+ NODES: + + {filtered.length} REGISTERED + +
+
+ + {/* Search Bar */} +
+ + setSearch(e.target.value)} + placeholder="Search members by name or ID..." + className="pl-9 text-xs" + /> +
+ +
+ {/* Member List */} + + + {filtered.length} members + + } + /> + {filtered.length === 0 ? ( + } + title="No members found" + description="Members appear once messages are captured." + /> + ) : ( +
+ {filtered.map((u) => ( + + setSelectedId((prev) => + prev === u.user_id ? null : u.user_id, + ) + } + /> + ))} +
+ )} +
+ + {/* Detail Panel */} + + + {!selectedId ? ( + + ) : detail.isLoading ? ( +
+ + + +
+ ) : detail.data ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +function UserRow({ + user, + selected, + onSelect, +}: { + user: DashboardUser; + selected: boolean; + onSelect: () => void; +}) { + const flagPct = + user.total_messages > 0 + ? (user.flagged_count / user.total_messages) * 100 + : 0; + const tone = trustTone(user.trust_score); + return ( + + ); +} + +function MemberDetail({ user }: { user: DashboardUserDetail }) { + const flagPct = + user.total_messages > 0 + ? (user.flagged_count / user.total_messages) * 100 + : 0; + const cleanPct = + user.total_messages > 0 + ? (user.clean_count / user.total_messages) * 100 + : 0; + + return ( +
+ {/* Identity */} +
+ +
+
+ {user.username ?? "unknown"} +
+
+ #{user.user_id} +
+
+ + {trustLabel(user.trust_score)} + +
+ + {/* Trust Metrics */} +
+
+
Trust
+
+ {user.trust_score ?? "—"} +
+
+
+
Clean Streak
+
+ {user.clean_message_streak ?? 0} +
+
+
+
Infractions
+
+ {user.total_infractions ?? 0} +
+
+
+ + {/* Activity Breakdown */} +
+ {[ + { + label: "Clean", + count: user.clean_count, + pct: cleanPct, + tone: "bg-success", + }, + { + label: "Flagged", + count: user.flagged_count, + pct: flagPct, + tone: "bg-vermilion", + }, + ].map((row) => ( +
+ + {row.label} + +
+
+
+ + {formatNumber(row.count)} + +
+ ))} +
+ Total: {formatNumber(user.total_messages)} messages + {user.last_message_at && ( + + last {formatRelativeTime(user.last_message_at)} + + )} +
+
+ + {/* Profile summary */} + {user.profile_summary && ( +
+
+ AI profile +
+
+ {user.profile_summary} +
+
+ )} + + {/* Recent messages */} + {user.recent_messages.length > 0 && ( +
+
Recent Messages
+
+ {user.recent_messages.slice(0, 8).map((m) => ( +
+
+ + #{m.channel_id.slice(0, 12)} + + {formatRelativeTime(m.created_at)} + +
+
+ {m.content || "(attachment/embed)"} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 59ded156..29adc1fd 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -7,6 +7,7 @@ export { useTopReactions, useTopReactors, useUserDetail, + useUserSearch, useUsers, } from "./use-dashboard"; export { useGuilds } from "./use-guilds"; diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index 6414e96c..4bbd87e2 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -6,6 +6,7 @@ import type { DashboardChannelDetail, DashboardStats, DashboardUserDetail, + PaginatedUsers, TopReactedMessage, TopReactor, } from "@/lib/types"; @@ -33,7 +34,15 @@ export function useActivity(days = 14, initialData?: DashboardActivity) { ); } -export function useUsers(search?: string) { +export function useUsers(initialData?: PaginatedUsers) { + return useSWR( + ["dashboard-users"], + () => dashboardApi.listUsers(50), + { fallbackData: initialData }, + ); +} + +export function useUserSearch(search?: string) { return useSWR( ["dashboard-users", search ?? ""], async () => { diff --git a/services/frontend/src/lib/api/server.ts b/services/frontend/src/lib/api/server.ts index d63b941a..6240c773 100644 --- a/services/frontend/src/lib/api/server.ts +++ b/services/frontend/src/lib/api/server.ts @@ -71,6 +71,11 @@ export async function getActivity(days = 14): Promise { days, }) as unknown as Promise; } +export async function getUsers(limit = 20) { + return serverOrpc().dashboard.users({ + limit, + }) as unknown as Promise; +} // ---- Media ---- export async function getMediaStatus(): Promise { diff --git a/services/frontend/src/lib/navigation.ts b/services/frontend/src/lib/navigation.ts index 80abaa96..14b34544 100644 --- a/services/frontend/src/lib/navigation.ts +++ b/services/frontend/src/lib/navigation.ts @@ -7,6 +7,7 @@ import { Music, Search, Shield, + Users, } from "lucide-react"; export interface NavItem { @@ -33,6 +34,12 @@ export const navItems: NavItem[] = [ icon: MessageSquare, matchPrefix: "/messages", }, + { + href: "/users", + label: "Users", + icon: Users, + matchPrefix: "/users", + }, { href: "/voice", label: "Voice", diff --git a/services/frontend/src/lib/types/dashboard.ts b/services/frontend/src/lib/types/dashboard.ts index 1b9fd690..9190d50b 100644 --- a/services/frontend/src/lib/types/dashboard.ts +++ b/services/frontend/src/lib/types/dashboard.ts @@ -54,9 +54,13 @@ export interface DashboardUser { profile_summary?: string | null; total_messages: number; flagged_count: number; + clean_count?: number; + warn_count?: number; last_message_at?: number | null; trust_score?: number | null; clean_message_streak?: number | null; + total_infractions?: number | null; + last_infraction_at?: number | null; } export interface DashboardUserDetail extends DashboardUser { @@ -64,6 +68,7 @@ export interface DashboardUserDetail extends DashboardUser { clean_message_streak: number | null; total_infractions: number | null; clean_count: number; + warn_count?: number; recent_messages: MessageRecord[]; }