refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped

- Consolidate all DB schema definitions into packages/shared as single source of truth
- Migrate backend from raw SQL to Drizzle ORM across all modules
- Extract frontend inline UI into separate component files
- Refactor discord-gateway circuitBreaker into conversationState + moderationState
- Convert messageStore to Proxy singleton pattern
- Add validateBody/validateQuery middleware + Zod schemas for API endpoints
- Modernize Docker builds with multi-stage + pnpm deploy
- Migrate CI/CD from deployment to image-based pipeline
- Remove 60+ unused/dead files (~15K lines)
- Update color scheme from sky-blue to teal-cyan
- Move DB connection management to @bete/shared/database

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-07-27 21:54:31 +07:00
co-authored by Claude Opus 4.8
parent 63f21513bd
commit 5802d02e29
223 changed files with 11499 additions and 13350 deletions
@@ -1,24 +1,15 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
ExternalLink,
Flag,
Hash,
ImageIcon,
Loader2,
MessageSquare,
RefreshCw,
Search,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
import { Flag, Loader2, MessageSquare, RefreshCw, Search } from "lucide-react";
import { useCallback, useState } from "react";
import { ImagesGrid } from "@/components/messages/images-grid";
import { MessageCard } from "@/components/messages/message-card";
import { MessageDetailView } from "@/components/messages/message-detail-view";
import { ReviewList } from "@/components/messages/review-list";
import { ErrorState, LoadingSkeleton } from "@/components/shared";
import { GuildSelector } from "@/components/shared/guild-selector";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -26,7 +17,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
@@ -49,9 +39,7 @@ import {
useTextChannels,
} from "@/hooks";
import { messagesApi } from "@/lib/api";
import { formatBytes, safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export default function MessagesPage() {
@@ -69,7 +57,7 @@ export default function MessagesPage() {
error,
refetch,
} = useMessages(guildId, selectedChannel || undefined);
const { data: cursorData, refetch: refetchCursor } = useMessagesHasMore(
const { data: cursorData } = useMessagesHasMore(
guildId,
selectedChannel || undefined,
);
@@ -89,12 +77,9 @@ export default function MessagesPage() {
loading: detailLoading,
} = useMessageDetail(detailId);
// Images fetch is managed by the query hook (enabled when guildId is set)
// Review fetch is managed by the query hook
// Search query (manual trigger)
const [searchEnabled, setSearchEnabled] = useState(false);
const { data: searchResults, isFetching: searching } = useQuery<
const { data: searchResults } = useQuery<
MessageRecord[]
>({
queryKey: ["messages-search", guildId, searchQuery],
@@ -150,13 +135,13 @@ export default function MessagesPage() {
{channels.length > 0 && (
<Select
value={selectedChannel}
onValueChange={(v) => v && setSelectedChannel(v)}
onValueChange={(v) => setSelectedChannel(v ?? "")}
>
<SelectTrigger className="h-9 w-full sm:w-44">
<SelectValue placeholder="All channels" />
</SelectTrigger>
<SelectContent>
<SelectItem value=" ">All channels</SelectItem>
<SelectItem value="">All channels</SelectItem>
{channels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
# {ch.name}
@@ -244,72 +229,16 @@ export default function MessagesPage() {
{/* ── IMAGES tab ── */}
{viewTab === "images" && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
{!images || images.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<ImageIcon
className="size-10 text-muted-foreground/40 mb-3"
aria-label="No images"
/>
<p className="text-sm text-muted-foreground">No images yet.</p>
</div>
) : (
images.map((msg) => {
const imgUrl = extractImage(msg.metadata);
return (
<Card
key={msg.id}
className="group relative overflow-hidden cursor-pointer"
onClick={() => setDetailId(msg.id)}
>
<div className="aspect-square relative bg-muted">
{imgUrl ? (
<img
src={imgUrl}
alt={msg.content || "Image"}
className="absolute inset-0 size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
No image
</div>
)}
{msg.content && (
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
<p className="text-xs text-white/90 line-clamp-2">
{msg.username}: {msg.content}
</p>
</div>
)}
</div>
</Card>
);
})
)}
</div>
<ImagesGrid images={images ?? []} onSelect={setDetailId} />
)}
{/* ── REVIEW tab ── */}
{viewTab === "review" && (
<div className="space-y-2 animate-fade-in-up">
{!reviews || reviews.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Flag className="size-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
No flagged messages to review.
</p>
</div>
) : (
reviews.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={setDetailId}
onReanalyze={(id) => reanalyzeMut.mutate(id)}
/>
))
)}
</div>
<ReviewList
reviews={reviews ?? []}
onSelect={setDetailId}
onReanalyze={(id) => reanalyzeMut.mutate(id)}
/>
)}
{/* Detail dialog */}
@@ -329,7 +258,7 @@ export default function MessagesPage() {
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : detailMessage ? (
<DetailView
<MessageDetailView
message={detailMessage}
attachments={detailAttachments}
/>
@@ -340,349 +269,3 @@ export default function MessagesPage() {
</div>
);
}
// ── Message Card ────────────────────────────────────────────────
function MessageCard({
message: msg,
onClick,
onReanalyze,
}: {
message: MessageRecord;
onClick: (id: string) => void;
onReanalyze: (id: string) => void;
}) {
const severity = (
{
low: "border-l-sky-400",
medium: "border-l-yellow-400",
high: "border-l-orange-400",
critical: "border-l-red-500",
} as Record<string, string>
)[msg.ai_severity ?? ""];
return (
<Card
className={cn(
"cursor-pointer transition-all duration-200 hover:bg-accent/5 hover:shadow-sm",
severity && "border-l-2",
severity,
)}
onClick={() => onClick(msg.id)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">
<Hash className="size-3 inline mr-0.5" />
{msg.channel_id.slice(0, 8)}
</span>
<AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{msg.ai_severity}
</Badge>
)}
{msg.type === "deleted" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
deleted
</Badge>
)}
{msg.type === "edited" && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
>
edited
</Badge>
)}
</div>
<p
className={cn(
"text-sm leading-relaxed",
msg.type === "deleted" &&
"italic text-muted-foreground line-through",
)}
>
{msg.content}
</p>
{(() => {
const u = extractFirstImage(msg.metadata);
if (!u) return null;
return (
<img
src={u}
alt=""
className="mt-2 max-h-48 rounded-lg border border-border/50 object-cover"
/>
);
})()}
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
<Badge
key={f}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
<Button
variant="ghost"
size="xs"
onClick={(e) => {
e.stopPropagation();
onReanalyze(msg.id);
}}
>
<RefreshCw className="size-3 mr-1" /> Reanalyze
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function AiStatusBadge({ status }: { status?: string | null }) {
const c = (
{
clean:
"bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/20",
warn: "bg-yellow-500/15 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
flagged: "bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/20",
error:
"bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-500/20",
pending:
"bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
processing:
"bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/20",
} as Record<string, string>
)[status ?? ""];
if (!c) return null;
return (
<Badge
variant="outline"
className={cn("text-[10px] px-1.5 py-0 h-4 font-medium", c)}
>
{status}
</Badge>
);
}
// ── Detail View ──────────────────────────────────────────────────
function DetailView({
message,
attachments,
}: {
message: MessageRecord;
attachments: {
id: string;
filename: string;
type: string;
size: number;
uploaded_url?: string | null;
discord_url?: string | null;
}[];
}) {
return (
<div className="space-y-5">
<div className="flex items-start gap-3">
<Avatar className="size-10">
<AvatarImage src={message.avatar_url ?? undefined} />
<AvatarFallback>
{message.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{message.username}</span>
<span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleString()}
</span>
{message.type === "deleted" && (
<Badge variant="destructive" className="text-[10px]">
deleted
</Badge>
)}
{message.type === "edited" && (
<Badge variant="outline" className="text-[10px]">
edited
</Badge>
)}
</div>
<p className="text-sm mt-2 whitespace-pre-wrap break-words leading-relaxed">
{message.content}
</p>
</div>
</div>
{message.ai_analysis && (
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-4 text-primary" />
<p className="text-xs text-muted-foreground font-medium">
AI Analysis
</p>
</div>
<p className="text-sm leading-relaxed">{message.ai_analysis}</p>
</div>
)}
{message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Moderation Flags
</p>
<div className="flex flex-wrap gap-1.5">
{safeParseJsonArray(message.ai_moderation_flags).map((f) => (
<Badge key={f} variant="destructive" className="text-[11px]">
{f}
</Badge>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{message.ai_status && (
<MiniStat label="Status" value={message.ai_status} capitalize />
)}
{message.ai_severity && message.ai_severity !== "none" && (
<MiniStat
label="Severity"
value={message.ai_severity}
destructive
capitalize
/>
)}
{message.ai_confidence != null && (
<MiniStat
label="Confidence"
value={`${(message.ai_confidence * 100).toFixed(0)}%`}
/>
)}
{message.ai_recommended_action &&
message.ai_recommended_action !== "none" && (
<MiniStat
label="Action"
value={message.ai_recommended_action}
capitalize
/>
)}
</div>
{attachments.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground font-medium">
Attachments ({attachments.length})
</p>
<div className="grid grid-cols-2 gap-2">
{attachments.map((a) => (
<a
key={a.id}
href={a.uploaded_url ?? a.discord_url ?? "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-border/50 p-2 hover:bg-muted transition-colors group"
>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{a.filename}</p>
<p className="text-[11px] text-muted-foreground">
{a.type} · {formatBytes(a.size)}
</p>
</div>
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
);
}
function MiniStat({
label,
value,
destructive,
capitalize,
}: {
label: string;
value: string;
destructive?: boolean;
capitalize?: boolean;
}) {
return (
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={cn(
"text-sm font-medium mt-0.5",
capitalize && "capitalize",
destructive && "text-destructive",
)}
>
{value}
</p>
</CardContent>
</Card>
);
}
// ── Helpers ──────────────────────────────────────────────────────
function extractFirstImage(metadata: string | null | undefined): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> =
m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
function extractImage(metadata: string | null | undefined): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> =
m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}