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,92 @@
|
||||
import "dotenv/config";
|
||||
import { z } from "zod";
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
// Server
|
||||
WEBSERVER_PORT: z.coerce.number().positive().default(3001),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
LOG_LEVEL: z
|
||||
.enum(["error", "warn", "info", "http", "verbose", "debug", "silly"])
|
||||
.default("info"),
|
||||
VERBOSE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url().optional(),
|
||||
DATABASE_HOST: z.string().default("localhost"),
|
||||
DATABASE_PORT: z.coerce.number().default(5432),
|
||||
DATABASE_NAME: z.string().default("discord_moderation"),
|
||||
DATABASE_USER: z.string().default("postgres"),
|
||||
DATABASE_PASSWORD: z.string().optional(),
|
||||
|
||||
// Redis (optional, for pub/sub)
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
REDIS_HOST: z.string().default("localhost"),
|
||||
REDIS_PORT: z.coerce.number().default(6379),
|
||||
|
||||
// Discord
|
||||
MONITOR_GUILD_ID: z.string().min(1).optional(),
|
||||
|
||||
// Admin
|
||||
ADMIN_PASSWORD: z.string().optional(),
|
||||
|
||||
// Analytics
|
||||
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
||||
BACKLOG_SYNC_BATCH_SIZE: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100)
|
||||
.default(100),
|
||||
|
||||
// AI Moderation
|
||||
AI_ANALYSIS_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
||||
OPENAI_MODERATION_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.openai.com/v1"),
|
||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||
AI_LLM_API_KEY: z.string().optional(),
|
||||
AI_LLM_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://9router.asepharyana.my.id/v1"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1024),
|
||||
AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20),
|
||||
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(60000),
|
||||
|
||||
// Attachments
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
|
||||
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||
TELE_UPLOAD_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://upload.asepharyana.tech/api/upload"),
|
||||
})
|
||||
.parse(process.env);
|
||||
|
||||
export const config = configSchema;
|
||||
export type Config = typeof config;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../config/index.js";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
|
||||
const logger = createChildLogger("database");
|
||||
|
||||
let pool: Pool | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export async function initializeDatabase() {
|
||||
if (db) {
|
||||
logger.warn("Database already initialized");
|
||||
return db;
|
||||
}
|
||||
|
||||
const databaseUrl =
|
||||
config.DATABASE_URL ||
|
||||
`postgresql://${config.DATABASE_USER}${config.DATABASE_PASSWORD ? `:${config.DATABASE_PASSWORD}` : ""}@${config.DATABASE_HOST}:${config.DATABASE_PORT}/${config.DATABASE_NAME}`;
|
||||
|
||||
pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
pool.on("error", (err) => {
|
||||
logger.error({ err }, "Unexpected error on idle client");
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
logger.info("Database connection successful");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to connect to database");
|
||||
throw err;
|
||||
}
|
||||
|
||||
db = drizzle(pool);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
if (!db) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeDatabase() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
db = null;
|
||||
logger.info("Database connection closed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode: number = 500,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
public details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "NOT_FOUND", 404);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(message, "UNAUTHORIZED", 401);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message: string = "Forbidden") {
|
||||
super(message, "FORBIDDEN", 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CONFLICT", 409);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends AppError {
|
||||
constructor(
|
||||
message: string,
|
||||
public originalError?: Error,
|
||||
) {
|
||||
super(message, "DATABASE_ERROR", 500);
|
||||
this.name = "DatabaseError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CONFIG_ERROR", 500);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import pino from "pino";
|
||||
import { config } from "../config/index.js";
|
||||
|
||||
const isDev = config.NODE_ENV === "development";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.LOG_LEVEL,
|
||||
transport: isDev
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:standard",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export function createChildLogger(context: string) {
|
||||
return logger.child({ context });
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof createChildLogger>;
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { AppError, UnauthorizedError } from "../errors/index.js";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
|
||||
const logger = createChildLogger("middleware");
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
) {
|
||||
if (err instanceof AppError) {
|
||||
logger.warn({ code: err.code, statusCode: err.statusCode }, err.message);
|
||||
return res.status(err.statusCode).json({
|
||||
error: err.code,
|
||||
message: err.message,
|
||||
...(err instanceof ValidationError && { details: err.details }),
|
||||
});
|
||||
}
|
||||
|
||||
logger.error({ err }, "Unhandled error");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
|
||||
export function adminAuth(adminPassword: string) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const password = req.headers["x-admin-password"] as string;
|
||||
|
||||
if (!password || password !== adminPassword) {
|
||||
throw new UnauthorizedError("Invalid admin password");
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function asyncHandler(
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>,
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
// Import ValidationError for type checking
|
||||
import { ValidationError } from "../errors/index.js";
|
||||
Reference in New Issue
Block a user