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:
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user