refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)

- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 21:44:29 +07:00
co-authored by Claude Opus 4.8
parent bda8304bb9
commit c48a0c5e3b
193 changed files with 16879 additions and 1158 deletions
@@ -0,0 +1,360 @@
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Image as ImageIcon,
Pencil,
RotateCw,
Smile,
Trash2,
} from "lucide-react";
import { Fragment, useMemo, useState } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import { Badge, Button, Skeleton } from "../../../shared/ui";
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
/**
* Renders message content with Discord custom emojis displayed as images
* instead of raw text like `<:name:id>`.
*/
function renderContentWithCustomEmojis(content: string): React.ReactNode {
const parts: React.ReactNode[] = [];
const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g");
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
// Text before the emoji
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
const [, animated, name, id] = match;
const ext = animated ? "gif" : "png";
const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`;
parts.push(
<img
key={`${id}-${match.index}`}
src={url}
alt={name}
className="inline-block h-[22px] w-[22px] align-middle object-contain"
loading="lazy"
draggable={false}
title={`:${name}:`}
/>,
);
lastIndex = regex.lastIndex;
}
// Remaining text after last emoji
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
// If no emojis were found, just return the raw content
if (parts.length === 0) {
return content;
}
return <Fragment>{parts}</Fragment>;
}
interface MessageCardProps {
message: MessageRecord;
onReanalyze: (id: string) => Promise<void>;
}
interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
}
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
function parseStringList(value?: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
function aiVariant(status: string) {
if (status === "clean") return "success";
if (status === "warn") return "warning";
if (status === "flagged" || status === "error") return "destructive";
return "secondary";
}
function severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-red-500/20 text-red-300 border-red-500/30";
case "high":
return "bg-orange-500/20 text-orange-300 border-orange-500/30";
case "medium":
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
case "low":
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
default:
return "bg-muted text-muted-foreground border-border";
}
}
function formatTimeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return new Date(ts).toLocaleDateString();
}
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const metadata = useMemo(
() => parseMetadata(message.metadata),
[message.metadata],
);
const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending";
const categories = useMemo(() => {
const list = parseStringList(
message.ai_categories ?? message.ai_moderation_flags,
);
return list.filter((c) => c !== "analysis_incomplete");
}, [message.ai_categories, message.ai_moderation_flags]);
const confidence =
message.ai_confidence ?? message.ai_moderation_score ?? null;
const [isReanalyzing, setIsReanalyzing] = useState(false);
const stickers = metadata.stickers ?? [];
const attachments = metadata.attachments ?? [];
const imageAttachments = attachments.filter(
(a) =>
a.contentType?.startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(a.name),
);
const hasImages = imageAttachments.length > 0;
const handleReanalyze = async () => {
setIsReanalyzing(true);
try {
await onReanalyze(message.id);
} finally {
setIsReanalyzing(false);
}
};
return (
<article
className={`group rounded-2xl border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
>
<div className="flex gap-3">
<img
src={
message.avatar_url ??
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
/>
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-foreground">
{message.username || message.user_id}
</span>
<span
className="text-xs text-muted-foreground"
title={new Date(message.created_at).toLocaleString()}
>
{formatTimeAgo(message.created_at)}
</span>
{message.edited_at && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Pencil className="h-3 w-3" /> edited
</span>
)}
{message.deleted_at && (
<span className="flex items-center gap-1 text-xs text-destructive">
<Trash2 className="h-3 w-3" /> deleted
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
<Badge
variant={aiVariant(aiStatus)}
className="flex items-center gap-1 text-xs"
>
{aiStatus === "clean" && (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{aiStatus === "warn" && (
<AlertTriangle className="h-3.5 w-3.5" />
)}
{aiStatus === "flagged" && (
<AlertCircle className="h-3.5 w-3.5" />
)}
{aiStatus === "error" && (
<AlertCircle className="h-3.5 w-3.5" />
)}
{aiStatus}
</Badge>
{message.ai_severity && message.ai_severity !== "none" && (
<Badge
className={`text-xs ${severityColor(message.ai_severity)}`}
>
{message.ai_severity}
</Badge>
)}
{confidence != null && (
<Badge variant="outline" className="text-xs tabular-nums">
{Math.round(confidence * 100)}%
</Badge>
)}
</div>
</div>
{displayContent ? (
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{renderContentWithCustomEmojis(displayContent)}
</p>
) : null}
{stickers.length > 0 && (
<div className="flex flex-wrap gap-3">
{stickers.map((sticker) => (
<div
key={sticker.name || sticker.url}
className="flex items-center gap-2"
>
{sticker.url ? (
<img
src={sticker.url}
alt={sticker.name || "sticker"}
className="h-16 w-16 rounded-xl border border-border object-contain bg-muted/50"
loading="lazy"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-xl border border-border bg-muted/50">
<Smile className="h-8 w-8 text-muted-foreground" />
</div>
)}
<span
className="text-xs text-muted-foreground max-w-[120px] truncate"
title={sticker.name}
>
{sticker.name}
</span>
</div>
))}
</div>
)}
{hasImages && (
<div className="flex gap-2 overflow-x-auto">
{imageAttachments.slice(0, 4).map((img) => (
<a
key={img.url}
href={img.url}
target="_blank"
rel="noreferrer"
className="shrink-0 overflow-hidden rounded-xl border border-border"
>
<img
src={img.url}
alt={img.name}
className="h-20 w-20 object-cover transition-transform hover:scale-105"
loading="lazy"
/>
</a>
))}
{imageAttachments.length > 4 && (
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-border bg-muted text-xs text-muted-foreground">
+{imageAttachments.length - 4}{" "}
<ImageIcon className="ml-1 h-3 w-3" />
</div>
)}
</div>
)}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{categories.map((category) => (
<Badge key={category} variant="secondary" className="text-xs">
{category}
</Badge>
))}
</div>
)}
{message.ai_analysis ? (
<div className="rounded-xl bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
{message.ai_analysis}
</div>
) : null}
{message.ai_error ? (
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
AI error: {message.ai_error}
</div>
) : null}
<div className="flex items-center gap-2 pt-1">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
className="text-xs"
>
<RotateCw
className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`}
/>
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-xs text-destructive/80">
Click to retry analysis
</span>
)}
</div>
</div>
</div>
</article>
);
}
export function MessageCardSkeleton() {
return (
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
</div>
</article>
);
}