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
This commit is contained in:
asepharyana
2026-09-02 00:01:12 +07:00
parent 7c21629404
commit 30d640ca5b
8 changed files with 456 additions and 3 deletions
@@ -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<string, unknown> | 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<string, unknown>[]).map((r) => ({
id: String(r.id),
content: String(r.content),