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:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -0,0 +1,86 @@
|
||||
// Custom error classes for all services
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("VALIDATION_ERROR", 400, message, details);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id?: string) {
|
||||
super("NOT_FOUND", 404, `${resource} not found${id ? `: ${id}` : ""}`);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = "Unauthorized") {
|
||||
super("UNAUTHORIZED", 401, message);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = "Forbidden") {
|
||||
super("FORBIDDEN", 403, message);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super("CONFLICT", 409, message);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends AppError {
|
||||
constructor(
|
||||
message = "Internal server error",
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super("INTERNAL_SERVER_ERROR", 500, message, details);
|
||||
this.name = "InternalServerError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("DATABASE_ERROR", 500, message, details);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super("CONFIG_ERROR", 500, message);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordError extends AppError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super("DISCORD_ERROR", 500, message, details);
|
||||
this.name = "DiscordError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends AppError {
|
||||
constructor(operation: string) {
|
||||
super("TIMEOUT", 504, `${operation} timed out`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./errors/index.js";
|
||||
export * from "./logger/index.js";
|
||||
export * from "./types/index.js";
|
||||
export * from "./utils/index.js";
|
||||
@@ -0,0 +1,25 @@
|
||||
import pino from "pino";
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
export function createLogger(context: string) {
|
||||
return pino({
|
||||
name: context,
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
} as pino.LoggerOptions);
|
||||
}
|
||||
|
||||
export function createChildLogger(context: string) {
|
||||
return createLogger(context);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Shared types for all services
|
||||
export interface AppConfig {
|
||||
NODE_ENV: "development" | "production" | "test";
|
||||
LOG_LEVEL: string;
|
||||
VERBOSE: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
DATABASE_URL: string;
|
||||
AUTO_MIGRATE_ON_STARTUP: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordConfig {
|
||||
DISCORD_TOKEN: string;
|
||||
MONITOR_GUILD_ID: string;
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
AI_LLM_API_KEY: string;
|
||||
}
|
||||
|
||||
export interface RedisConfig {
|
||||
REDIS_URL: string;
|
||||
}
|
||||
|
||||
export interface WebServerConfig {
|
||||
WEBSERVER_PORT: number;
|
||||
ADMIN_PASSWORD: string;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
editedAt?: Date;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AttachmentRecord {
|
||||
id: string;
|
||||
messageId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
discordUrl: string;
|
||||
uploadedUrl?: string;
|
||||
uploadStatus: "pending" | "uploaded" | "failed";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface VoiceSegment {
|
||||
userId: string;
|
||||
sessionStart: number;
|
||||
segmentIndex: number;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
totalMessages: number;
|
||||
totalAttachments: number;
|
||||
totalVoiceSegments: number;
|
||||
activeUsers: number;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Utility functions shared across services
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeString(str: string): string {
|
||||
return str.replace(/[<>]/g, "").trim().substring(0);
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export function calculatePagination(
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResponse<never> {
|
||||
return {
|
||||
data: [],
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
pages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOffset(page: number, limit: number): number {
|
||||
return (page - 1) * limit;
|
||||
}
|
||||
Reference in New Issue
Block a user