fix(users): drop dead user_reputations JOIN that broke Members page + audit

Root cause: gateway migration 0016 removed the per-user reputation feature
(DROP TABLE user_reputations), but the backend still LEFT JOINed
pgUserReputationsTable in dashboard listUsers/getUserDetail and the chatbot
get_user_reputation tool. Against the live DB these queries raised 42P01
(undefined_table) -> the new /users page could never load (oRPC WS error).

Fix:
- dashboard.repository.ts: remove reputation columns/join from listUsers and
  getUserDetail (data sourced only from messages + user_profiles).
- chatbot.tools.ts: get_user_reputation now returns honest 'unavailable'
  (feature removed) instead of querying the dropped table.
- chatbot.toolDefs.ts: update tool description so the LLM doesn't advertise
  a dead feature.
- frontend types/users view: drop trust_score/clean_message_streak/
  total_infractions/last_infraction_at; derive risk label from real flagged%
  and add warn_count to the inspector (real available data).

Verified: backend+frontend tsc clean, biome clean, 37 unit tests pass,
query tested against live DB (returns real members), build green.
This commit is contained in:
asepharyana
2026-09-02 00:12:52 +07:00
parent 7122258d7c
commit d0453881b8
5 changed files with 56 additions and 94 deletions
@@ -152,7 +152,7 @@ export const tools: ToolDef[] = [
function: { function: {
name: "get_user_reputation", name: "get_user_reputation",
description: description:
"Ambil skor trust, jumlah infraction, dan streak pesan bersih seorang user dari user_reputations. Untuk 'berapa trust score si A?' / riwayat pelanggaran. Butuh user_id.", "Cek status skor reputasi per-user. CATATAN: fitur trust score per-user telah dihapus dari sistem — tool ini menjawab 'unavailable' dan menyarankan rasio flag vs total sebagai pengganti.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -6,7 +6,6 @@ import {
pgMessageReviewsTable, pgMessageReviewsTable,
pgMessagesTable, pgMessagesTable,
pgUserProfilesTable, pgUserProfilesTable,
pgUserReputationsTable,
pgVoiceRecordingsTable, pgVoiceRecordingsTable,
} from "../../shared/index.js"; } from "../../shared/index.js";
@@ -282,26 +281,18 @@ async function userProfile(userId?: string, guildId?: string): Promise<string> {
} }
async function userReputation( async function userReputation(
userId?: string, _userId?: string,
guildId?: string, _guildId?: string,
): Promise<string> { ): Promise<string> {
const db = getDatabase(); // The per-user reputation feature (trust scores, streaks, infractions) was
if (!userId) return JSON.stringify({ error: "userId wajib" }); // removed from the gateway (migration 0016 drops user_reputations). Return
const conds = [eq(pgUserReputationsTable.user_id, userId)]; // an honest "unavailable" answer from the derived moderation signals instead
if (guildId) conds.push(eq(pgUserReputationsTable.guild_id, guildId)); // of querying the now-dropped table.
const rows = await db return JSON.stringify({
.select({ available: false,
user_id: pgUserReputationsTable.user_id, message:
guild_id: pgUserReputationsTable.guild_id, "Skor trust/skala reputasi per-user telah dihapus dari sistem. Gunakan rasio pesan ter-flag vs total untuk menilai risiko (lihat dashboard Users).",
trust_score: pgUserReputationsTable.trust_score, });
clean_message_streak: pgUserReputationsTable.clean_message_streak,
total_infractions: pgUserReputationsTable.total_infractions,
last_infraction_at: pgUserReputationsTable.last_infraction_at,
})
.from(pgUserReputationsTable)
.where(and(...conds))
.limit(1);
return JSON.stringify(rows[0] ?? { error: "reputasi tidak ditemukan" });
} }
async function channelCulture(channelId?: string): Promise<string> { async function channelCulture(channelId?: string): Promise<string> {
@@ -5,7 +5,6 @@ import {
pgChannelCulturesTable, pgChannelCulturesTable,
pgMessagesTable, pgMessagesTable,
pgUserProfilesTable, pgUserProfilesTable,
pgUserReputationsTable,
pgVoiceRecordingsTable, pgVoiceRecordingsTable,
} from "../../shared/index.js"; } from "../../shared/index.js";
import type { ListUsersQuery } from "./dashboard.service.js"; import type { ListUsersQuery } from "./dashboard.service.js";
@@ -154,10 +153,6 @@ export class DashboardRepository {
m.username, m.username,
m.avatar_url, m.avatar_url,
p.profile_summary, p.profile_summary,
r.trust_score,
r.clean_message_streak,
r.total_infractions,
r.last_infraction_at,
m.total_messages, m.total_messages,
m.flagged_count, m.flagged_count,
m.clean_count, m.clean_count,
@@ -177,7 +172,6 @@ export class DashboardRepository {
GROUP BY user_id, username, avatar_url GROUP BY user_id, username, avatar_url
) m ) m
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
${whereClause} ${whereClause}
ORDER BY m.last_message_at DESC NULLS LAST ORDER BY m.last_message_at DESC NULLS LAST
LIMIT ${limit + 1} LIMIT ${limit + 1}
@@ -190,16 +184,6 @@ export class DashboardRepository {
username: r.username as string | null, username: r.username as string | null,
avatar_url: r.avatar_url as string | null, avatar_url: r.avatar_url as string | null,
profile_summary: r.profile_summary 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), total_messages: Number(r.total_messages),
flagged_count: Number(r.flagged_count), flagged_count: Number(r.flagged_count),
clean_count: Number(r.clean_count), clean_count: Number(r.clean_count),
@@ -453,11 +437,7 @@ export class DashboardRepository {
m.clean_count, m.clean_count,
m.warn_count, m.warn_count,
p.profile_summary, 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 ( FROM (
SELECT SELECT
user_id, user_id,
@@ -472,7 +452,6 @@ export class DashboardRepository {
GROUP BY user_id, username, avatar_url GROUP BY user_id, username, avatar_url
) m ) m
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id 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; const row = userResult.rows[0] as Record<string, unknown> | undefined;
@@ -500,16 +479,6 @@ export class DashboardRepository {
last_analyzed_at: row.last_analyzed_at last_analyzed_at: row.last_analyzed_at
? Number(row.last_analyzed_at) ? Number(row.last_analyzed_at)
: null, : 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) => ({ recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
id: String(r.id), id: String(r.id),
content: String(r.content), content: String(r.content),
@@ -38,20 +38,18 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
function trustTone(score?: number | null) { function trustTone(flagPct: number) {
if (score == null) return "neutral"; if (flagPct >= 20) return "vermilion";
if (score >= 70) return "signal"; if (flagPct >= 8) return "amber";
if (score >= 45) return "amber"; if (flagPct >= 3) return "signal";
return "vermilion"; return "neutral";
} }
function trustLabel(score?: number | null) { function riskLabel(flagPct: number) {
if (score == null) return "UNKNOWN"; if (flagPct >= 20) return "HIGH RISK";
if (score >= 80) return "TRUSTED"; if (flagPct >= 8) return "WATCH";
if (score >= 60) return "LOW RISK"; if (flagPct >= 3) return "MONITOR";
if (score >= 45) return "MONITOR"; return "LOW RISK";
if (score >= 30) return "WATCH";
return "HIGH RISK";
} }
export function UsersView({ initialUsers }: { initialUsers?: PaginatedUsers }) { export function UsersView({ initialUsers }: { initialUsers?: PaginatedUsers }) {
@@ -191,7 +189,7 @@ function UserRow({
user.total_messages > 0 user.total_messages > 0
? (user.flagged_count / user.total_messages) * 100 ? (user.flagged_count / user.total_messages) * 100
: 0; : 0;
const tone = trustTone(user.trust_score); const tone = trustTone(flagPct);
return ( return (
<button <button
type="button" type="button"
@@ -208,11 +206,9 @@ function UserRow({
<span className="truncate text-xs font-semibold text-ink"> <span className="truncate text-xs font-semibold text-ink">
{user.username ?? "unknown"} {user.username ?? "unknown"}
</span> </span>
{user.trust_score != null && ( <Badge tone={tone} className="font-mono text-[9px]">
<Badge tone={tone} className="font-mono text-[9px]"> {riskLabel(flagPct)}
{Math.round(user.trust_score)} TRUST </Badge>
</Badge>
)}
</div> </div>
<div className="mt-0.5 flex items-center gap-2 font-mono text-[9px] text-ink-faint"> <div className="mt-0.5 flex items-center gap-2 font-mono text-[9px] text-ink-faint">
<span className="truncate">#{user.user_id.slice(0, 10)}</span> <span className="truncate">#{user.user_id.slice(0, 10)}</span>
@@ -225,6 +221,11 @@ function UserRow({
{formatNumber(user.flagged_count)} flagged {formatNumber(user.flagged_count)} flagged
</span> </span>
)} )}
{!!user.warn_count && user.warn_count > 0 && (
<span className="text-amber">
{formatNumber(user.warn_count)} warn
</span>
)}
</div> </div>
</div> </div>
{flagPct > 5 && ( {flagPct > 5 && (
@@ -259,32 +260,29 @@ function MemberDetail({ user }: { user: DashboardUserDetail }) {
#{user.user_id} #{user.user_id}
</div> </div>
</div> </div>
<Badge <Badge tone={trustTone(flagPct)} className="font-mono text-[9px]">
tone={trustTone(user.trust_score)} {riskLabel(flagPct)}
className="font-mono text-[9px]"
>
{trustLabel(user.trust_score)}
</Badge> </Badge>
</div> </div>
{/* Trust Metrics */} {/* Risk & Activity Metrics */}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="hud-card px-2.5 py-2 text-center"> <div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Trust</div> <div className="eyebrow">Messages</div>
<div className="font-mono text-sm font-semibold text-signal"> <div className="font-mono text-sm font-semibold text-ink">
{user.trust_score ?? "—"} {formatNumber(user.total_messages)}
</div> </div>
</div> </div>
<div className="hud-card px-2.5 py-2 text-center"> <div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Clean Streak</div> <div className="eyebrow">Flag %</div>
<div className="font-mono text-sm font-semibold text-success">
{user.clean_message_streak ?? 0}
</div>
</div>
<div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Infractions</div>
<div className="font-mono text-sm font-semibold text-vermilion"> <div className="font-mono text-sm font-semibold text-vermilion">
{user.total_infractions ?? 0} {flagPct.toFixed(1)}%
</div>
</div>
<div className="hud-card px-2.5 py-2 text-center">
<div className="eyebrow">Clean %</div>
<div className="font-mono text-sm font-semibold text-success">
{cleanPct.toFixed(1)}%
</div> </div>
</div> </div>
</div> </div>
@@ -304,6 +302,16 @@ function MemberDetail({ user }: { user: DashboardUserDetail }) {
pct: flagPct, pct: flagPct,
tone: "bg-vermilion", tone: "bg-vermilion",
}, },
...(user.warn_count
? [
{
label: "Warned",
count: user.warn_count,
pct: (user.warn_count / user.total_messages) * 100,
tone: "bg-amber",
},
]
: []),
].map((row) => ( ].map((row) => (
<div key={row.label} className="flex items-center gap-2 text-[10px]"> <div key={row.label} className="flex items-center gap-2 text-[10px]">
<span className="w-14 shrink-0 font-mono text-ink-muted"> <span className="w-14 shrink-0 font-mono text-ink-muted">
@@ -57,16 +57,10 @@ export interface DashboardUser {
clean_count?: number; clean_count?: number;
warn_count?: number; warn_count?: number;
last_message_at?: number | null; 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 { export interface DashboardUserDetail extends DashboardUser {
last_analyzed_at?: number | null; last_analyzed_at?: number | null;
clean_message_streak: number | null;
total_infractions: number | null;
clean_count: number; clean_count: number;
warn_count?: number; warn_count?: number;
recent_messages: MessageRecord[]; recent_messages: MessageRecord[];