Commit 0b96ce87 authored by ThinhNC's avatar ThinhNC

feat(system-config): implement dynamic configuration module and feature flags

parent ffc94a49
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
"db:migrate:reset": "node scripts/prisma-run.js migrate reset", "db:migrate:reset": "node scripts/prisma-run.js migrate reset",
"db:migrate:status": "node scripts/prisma-run.js migrate status", "db:migrate:status": "node scripts/prisma-run.js migrate status",
"db:seed": "node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts", "db:seed": "node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts",
"config:sync": "tsx scripts/sync-system-configs.ts",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"test": "jest" "test": "jest"
......
-- CreateTable
CREATE TABLE "system_configs" (
"id" UUID NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"description" TEXT,
"category" TEXT NOT NULL DEFAULT 'GENERAL',
"is_public" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "system_configs_key_key" ON "system_configs"("key");
-- CreateIndex
CREATE INDEX "system_configs_category_idx" ON "system_configs"("category");
-- CreateIndex
CREATE INDEX "system_configs_is_public_idx" ON "system_configs"("is_public");
...@@ -468,3 +468,18 @@ model RolePermission { ...@@ -468,3 +468,18 @@ model RolePermission {
@@map("role_permissions") @@map("role_permissions")
} }
model SystemConfig {
id String @id @default(uuid()) @db.Uuid
key String @unique
value Json
description String?
category String @default("GENERAL")
isPublic Boolean @default(false) @map("is_public")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([category])
@@index([isPublic])
@@map("system_configs")
}
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
SYSTEM_PERMISSIONS_CATALOG, SYSTEM_PERMISSIONS_CATALOG,
SYSTEM_ROLE_DEFAULT_PERMISSIONS, SYSTEM_ROLE_DEFAULT_PERMISSIONS,
} from "../src/common/constants/permission.constant"; } from "../src/common/constants/permission.constant";
import { DEFAULT_SYSTEM_CONFIGS } from "../src/common/constants/system-config.constant";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
...@@ -597,10 +598,32 @@ async function seedCrawlJobsAndPages(userId: string) { ...@@ -597,10 +598,32 @@ async function seedCrawlJobsAndPages(userId: string) {
}); });
} }
async function seedSystemConfigs(): Promise<void> {
console.log("Seeding default system configs...");
for (const item of DEFAULT_SYSTEM_CONFIGS) {
await prisma.systemConfig.upsert({
where: { key: item.key },
update: {
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
create: {
key: item.key,
value: item.value as any,
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
});
}
}
async function main() { async function main() {
const permissionMap = await seedPermissions(); const permissionMap = await seedPermissions();
const roleMap = await seedRoles(permissionMap); const roleMap = await seedRoles(permissionMap);
await seedUsers(roleMap); await seedUsers(roleMap);
await seedSystemConfigs();
const crawlerUser = await prisma.user.findUnique({ const crawlerUser = await prisma.user.findUnique({
where: { email: "crawl@crawl.local" }, where: { email: "crawl@crawl.local" },
......
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { DEFAULT_SYSTEM_CONFIGS } from "../src/common/constants/system-config.constant";
const prisma = new PrismaClient();
async function main() {
console.log(`Synchronizing ${DEFAULT_SYSTEM_CONFIGS.length} system configs to database...`);
let upserted = 0;
for (const item of DEFAULT_SYSTEM_CONFIGS) {
await prisma.systemConfig.upsert({
where: { key: item.key },
update: {
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
create: {
key: item.key,
value: item.value as any,
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
});
upserted++;
console.log(`[✔] Upserted: ${item.key} (${item.category}, public: ${item.isPublic})`);
}
const allowedKeys = new Set(DEFAULT_SYSTEM_CONFIGS.map((c) => c.key));
const deleteResult = await prisma.systemConfig.deleteMany({
where: {
key: {
notIn: Array.from(allowedKeys),
},
},
});
if (deleteResult.count > 0) {
console.log(`[x] Cleaned up ${deleteResult.count} obsolete/sensitive keys from database.`);
}
const allConfigs = await prisma.systemConfig.findMany({
orderBy: [{ category: "asc" }, { key: "asc" }],
select: { key: true, category: true, isPublic: true, value: true, description: true },
});
console.log(`\n============================ SUMMARY ============================`);
console.log(`Total configs in DB: ${allConfigs.length}`);
console.log(`Upserted configs: ${upserted}`);
const categories = Array.from(new Set(allConfigs.map((c) => c.category)));
for (const cat of categories) {
const inCat = allConfigs.filter((c) => c.category === cat);
console.log(`Category [${cat}]: ${inCat.length} configs`);
}
}
main()
.catch((err) => {
console.error("Sync error:", err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
...@@ -12,6 +12,7 @@ import routes from "./routes"; ...@@ -12,6 +12,7 @@ import routes from "./routes";
import swaggerDocument from "./docs/swagger.json"; import swaggerDocument from "./docs/swagger.json";
import healthRoute from "./modules/health/health.route"; import healthRoute from "./modules/health/health.route";
import { rateLimitMiddleware } from "./middlewares/rate-limit.middleware"; import { rateLimitMiddleware } from "./middlewares/rate-limit.middleware";
import { maintenanceMiddleware } from "./middlewares/maintenance.middleware";
import { envConfig } from "./config/env.config"; import { envConfig } from "./config/env.config";
import { parseTrustProxy } from "./common/helpers/proxy.helper"; import { parseTrustProxy } from "./common/helpers/proxy.helper";
...@@ -46,7 +47,7 @@ app.use(express.urlencoded({ extended: true })); ...@@ -46,7 +47,7 @@ app.use(express.urlencoded({ extended: true }));
app.use("/health", healthRoute); app.use("/health", healthRoute);
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use("/api/v1", rateLimitMiddleware, routes); app.use("/api/v1", rateLimitMiddleware, maintenanceMiddleware, routes);
app.use(notFoundMiddleware); app.use(notFoundMiddleware);
app.use(errorMiddleware); app.use(errorMiddleware);
......
...@@ -32,6 +32,10 @@ export const AUDIT_ACTIONS = { ...@@ -32,6 +32,10 @@ export const AUDIT_ACTIONS = {
PERMISSION_REVOKED: "PERMISSION_REVOKED", PERMISSION_REVOKED: "PERMISSION_REVOKED",
SUPER_ADMIN_ASSIGN_ATTEMPT: "SUPER_ADMIN_ASSIGN_ATTEMPT", SUPER_ADMIN_ASSIGN_ATTEMPT: "SUPER_ADMIN_ASSIGN_ATTEMPT",
PRIVILEGE_ESCALATION_BLOCKED: "PRIVILEGE_ESCALATION_BLOCKED", PRIVILEGE_ESCALATION_BLOCKED: "PRIVILEGE_ESCALATION_BLOCKED",
SYSTEM_CONFIG_CREATED: "SYSTEM_CONFIG_CREATED",
SYSTEM_CONFIG_UPDATED: "SYSTEM_CONFIG_UPDATED",
SYSTEM_CONFIG_TOGGLED: "SYSTEM_CONFIG_TOGGLED",
SYSTEM_CONFIG_DELETED: "SYSTEM_CONFIG_DELETED",
} as const; } as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
...@@ -12,3 +12,4 @@ export * from "./crawl-page-status.constant"; ...@@ -12,3 +12,4 @@ export * from "./crawl-page-status.constant";
export * from "./webhook.constant"; export * from "./webhook.constant";
export * from "./system-role.constant"; export * from "./system-role.constant";
export * from "./permission.constant"; export * from "./permission.constant";
export * from "./system-config.constant";
...@@ -68,6 +68,10 @@ export const PERMISSIONS = { ...@@ -68,6 +68,10 @@ export const PERMISSIONS = {
// Dashboard // Dashboard
DASHBOARD_READ: "dashboard.read", DASHBOARD_READ: "dashboard.read",
DASHBOARD_READ_ALL: "dashboard.read_all", DASHBOARD_READ_ALL: "dashboard.read_all",
// System Configs
SYSTEM_CONFIG_READ: "system_configs.read",
SYSTEM_CONFIG_MANAGE: "system_configs.manage",
} as const; } as const;
export type PermissionSlug = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type PermissionSlug = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
...@@ -471,6 +475,24 @@ export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [ ...@@ -471,6 +475,24 @@ export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [
action: "read_all", action: "read_all",
isSystem: true, isSystem: true,
}, },
// System Configs
{
name: "View System Configs",
slug: PERMISSIONS.SYSTEM_CONFIG_READ,
description: "Xem danh sách và chi tiết cấu hình hệ thống & feature flags",
resource: "system_configs",
action: "read",
isSystem: true,
},
{
name: "Manage System Configs",
slug: PERMISSIONS.SYSTEM_CONFIG_MANAGE,
description: "Thêm, cập nhật, bật/tắt hoặc xóa cấu hình hệ thống & feature flags",
resource: "system_configs",
action: "manage",
isSystem: true,
},
]; ];
export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record< export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
...@@ -525,6 +547,8 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record< ...@@ -525,6 +547,8 @@ export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
PERMISSIONS.AUDIT_LOGS_READ, PERMISSIONS.AUDIT_LOGS_READ,
PERMISSIONS.DASHBOARD_READ, PERMISSIONS.DASHBOARD_READ,
PERMISSIONS.DASHBOARD_READ_ALL, PERMISSIONS.DASHBOARD_READ_ALL,
PERMISSIONS.SYSTEM_CONFIG_READ,
PERMISSIONS.SYSTEM_CONFIG_MANAGE,
], ],
[SYSTEM_ROLE_SLUGS.CRAWLER_USER]: [ [SYSTEM_ROLE_SLUGS.CRAWLER_USER]: [
PERMISSIONS.CRAWL_JOBS_CREATE, PERMISSIONS.CRAWL_JOBS_CREATE,
......
export const SYSTEM_CONFIG_CATEGORY = {
GENERAL: "GENERAL",
FEATURE_FLAG: "FEATURE_FLAG",
INTEGRATION: "INTEGRATION",
SECURITY: "SECURITY",
} as const;
export type SystemConfigCategory =
(typeof SYSTEM_CONFIG_CATEGORY)[keyof typeof SYSTEM_CONFIG_CATEGORY];
export const SYSTEM_CONFIG_EVENTS_CHANNEL = "system_config:events";
export const SYSTEM_CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
export interface DefaultSystemConfigItem {
key: string;
value: unknown;
category: SystemConfigCategory;
isPublic: boolean;
description?: string;
}
export const DEFAULT_SYSTEM_CONFIGS: readonly DefaultSystemConfigItem[] = [
// ==========================================
// 1. GENERAL (Cấu hình chung, Ứng dụng & Lưu trữ)
// ==========================================
{
key: "app.name",
value: "Data Crawler Studio",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
description: "Tên ứng dụng hiển thị công khai trên hệ thống và thông báo",
},
{
key: "app.description",
value: "Nền tảng cào dữ liệu web, trích xuất cấu trúc và theo dõi biến động nội dung thông minh",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
description: "Mô tả giới thiệu hệ thống cho người dùng và các công cụ tìm kiếm",
},
{
key: "app.frontend_url",
value: "http://localhost:3000",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
description: "Địa chỉ Web Frontend điều hướng liên kết và callback xác thực (FRONTEND_URL)",
},
{
key: "app.support_email",
value: "support@datacrawler.com",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
description: "Hộp thư hỗ trợ kỹ thuật và chăm sóc người dùng hiển thị công khai",
},
{
key: "app.timezone",
value: "Asia/Ho_Chi_Minh",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
description: "Múi giờ mặc định xử lý tác vụ và lịch biểu thu thập dữ liệu",
},
{
key: "crawler.max_pages_default",
value: 100,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Số lượng trang cào tối đa mặc định cho một tác vụ cào dữ liệu (MAX_CRAWL_PAGES)",
},
{
key: "crawler.max_depth_default",
value: 3,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Độ sâu liên kết tối đa mặc định khi thu thập dữ liệu web (MAX_CRAWL_DEPTH)",
},
{
key: "crawler.timeout_ms",
value: 60000,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Thời gian chờ tối đa cho mỗi yêu cầu HTTP thu thập trang (FIRECRAWL_REQUEST_TIMEOUT_MS)",
},
{
key: "crawler.worker_concurrency",
value: 3,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Số lượng luồng worker chạy đồng thời xử lý hàng đợi cào dữ liệu (WORKER_CONCURRENCY)",
},
{
key: "crawler.job_timeout_ms",
value: 300000,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Thời gian chạy tối đa cho toàn bộ một tiến trình cào trước khi timeout (WORKER_JOB_TIMEOUT_MS)",
},
{
key: "crawler.max_stalled_count",
value: 1,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Số lần tối đa cho phép worker khôi phục tác vụ cào khi bị treo (WORKER_MAX_STALLED_COUNT)",
},
{
key: "storage.default_driver",
value: "s3",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Driver lưu trữ tệp xuất dữ liệu mặc định: 's3' hoặc 'local' (STORAGE_DRIVER)",
},
{
key: "storage.export_dir",
value: "storage/exports",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Thư mục lưu trữ tạm thời các gói nén ZIP trên máy chủ (STORAGE_EXPORT_DIR)",
},
{
key: "storage.s3.bucket",
value: "data-crawler-exports",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Tên bucket lưu trữ các tệp xuất dữ liệu cào dạng nén ZIP (S3_BUCKET)",
},
{
key: "storage.s3.region",
value: "us-east-1",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Vùng máy chủ lưu trữ dữ liệu tệp nén S3 (S3_REGION)",
},
{
key: "storage.s3.endpoint",
value: "http://127.0.0.1:9000",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
description: "Địa chỉ Endpoint kết nối dịch vụ lưu trữ AWS S3 hoặc MinIO (S3_ENDPOINT)",
},
// ==========================================
// 2. FEATURE_FLAG (Cờ bật/tắt tính năng động)
// ==========================================
{
key: "feature.registration.enabled",
value: true,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Cho phép người dùng mới đăng ký tài khoản tự do trên hệ thống",
},
{
key: "feature.ai.enabled",
value: false,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Kích hoạt các công cụ phân tích, trích xuất cấu trúc và làm sạch bằng AI",
},
{
key: "feature.maintenance_mode.enabled",
value: false,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Kích hoạt chế độ bảo trì hệ thống toàn diện, tạm ngừng nhận tác vụ mới",
},
{
key: "feature.change_detection.enabled",
value: true,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Tự động so sánh và tạo báo cáo Diff biến động nội dung khi cào định kỳ",
},
{
key: "feature.export.zip.enabled",
value: true,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Cho phép đóng gói và xuất toàn bộ dữ liệu cào sang tệp nén ZIP",
},
{
key: "feature.webhook.deliveries.enabled",
value: true,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Kích hoạt cơ chế phát webhook tự động đến các endpoint đã đăng ký",
},
{
key: "feature.dark_mode_default.enabled",
value: true,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Gợi ý bật giao diện tối (Dark Mode) mặc định cho người dùng mới",
},
{
key: "feature.social_login.enabled",
value: false,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
description: "Cờ tính năng: Cho phép đăng nhập nhanh qua Google hoặc GitHub OAuth",
},
// ==========================================
// 3. INTEGRATION (Tích hợp dịch vụ, Firecrawl & Webhook)
// ==========================================
{
key: "integration.discord.invite_url",
value: "https://discord.gg/datacrawler",
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: true,
description: "Đường dẫn lời mời tham gia cộng đồng hỗ trợ trên Discord",
},
{
key: "integration.telegram.bot_username",
value: "DataCrawlerBot",
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: true,
description: "Tên người dùng Bot Telegram chính thức để nhận thông báo và điều khiển",
},
{
key: "integration.firecrawl.api_key",
value: "fc-ae80a8acc69a4f42a8bca0e80370077e",
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: false,
description: "Khóa Firecrawl API Key điều khiển engine cào DOM (xoay tua & đổi động mà không cần restart)",
},
{
key: "integration.firecrawl.base_url",
value: "https://api.firecrawl.dev",
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: false,
description: "Địa chỉ API gốc của dịch vụ Firecrawl thu thập DOM (FIRECRAWL_BASE_URL)",
},
{
key: "integration.smtp.from_name",
value: "Data Crawler Support",
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: true,
description: "Tên người gửi hiển thị trong các email hệ thống (SMTP_FROM)",
},
{
key: "integration.smtp.from_email",
value: "no-reply@datacrawler.com",
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: true,
description: "Địa chỉ email gửi thông báo hệ thống và mã kích hoạt (SMTP_FROM)",
},
{
key: "integration.webhook.retry_limit",
value: 3,
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: false,
description: "Số lần tự động thử lại tối đa khi gửi webhook thông báo thất bại",
},
{
key: "integration.webhook.timeout_ms",
value: 10000,
category: SYSTEM_CONFIG_CATEGORY.INTEGRATION,
isPublic: false,
description: "Thời gian chờ tối đa cho mỗi yêu cầu gửi webhook (ms)",
},
// ==========================================
// 4. SECURITY (Bảo mật, Token, Quotas & Rate Limits)
// ==========================================
{
key: "jwt.access_expires_in",
value: "1d",
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Thời gian hết hạn của JWT Access Token cho phiên đăng nhập (JWT_ACCESS_EXPIRES_IN)",
},
{
key: "jwt.refresh_expires_in",
value: "7d",
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Thời gian hết hạn của Refresh Token cho phiên đăng nhập (JWT_REFRESH_EXPIRES_IN)",
},
{
key: "quota.user_max_pages",
value: 100,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Hạn ngạch số trang cào tối đa mặc định cho tài khoản người dùng thông thường (USER_MAX_PAGES)",
},
{
key: "quota.user_max_jobs_per_day",
value: 10,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Hạn ngạch số tác vụ cào tối đa trong một ngày cho mỗi tài khoản (USER_MAX_JOBS_PER_DAY)",
},
{
key: "quota.user_max_concurrent_jobs",
value: 3,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Hạn ngạch số tác vụ cào được phép chạy song song cho mỗi tài khoản (USER_MAX_CONCURRENT_JOBS)",
},
{
key: "rate_limit.window_ms",
value: 900000,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Chu kỳ tính giới hạn yêu cầu API (ms, tương đương 15 phút) (RATE_LIMIT_WINDOW_MS)",
},
{
key: "rate_limit.max_requests",
value: 1000,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Số lượng yêu cầu API tối đa được phép trong một chu kỳ giới hạn (RATE_LIMIT_MAX)",
},
{
key: "rate_limit.max_requests_per_minute",
value: 60,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Số lượng request tối đa cho phép mỗi phút cho mỗi địa chỉ IP",
},
{
key: "security.password_min_length",
value: 8,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: true,
description: "Độ dài tối thiểu của mật khẩu khi đăng ký hoặc đổi mật khẩu",
},
{
key: "security.session_timeout_minutes",
value: 1440,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Thời hạn hết hạn của phiên làm việc Access Token (phút, tương đương 24 giờ)",
},
{
key: "security.max_login_attempts",
value: 5,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
description: "Số lần đăng nhập sai tối đa trước khi tài khoản bị tạm khóa",
},
] as const;
import Redis from "ioredis";
import { envConfig } from "../../config/env.config";
let publisherClient: Redis | null = null;
let subscriberClient: Redis | null = null;
export function getRedisPublisher(): Redis | null {
if (!envConfig.redis.enabled) return null;
if (!publisherClient) {
try {
publisherClient = new Redis({
host: envConfig.redis.host,
port: envConfig.redis.port,
maxRetriesPerRequest: 1,
lazyConnect: true,
connectTimeout: 2000,
retryStrategy: () => null,
enableOfflineQueue: false,
});
publisherClient.on("error", () => {
// Suppress unhandled redis error crashes
});
} catch {
publisherClient = null;
}
}
return publisherClient;
}
export function getRedisSubscriber(): Redis | null {
if (!envConfig.redis.enabled) return null;
if (!subscriberClient) {
try {
subscriberClient = new Redis({
host: envConfig.redis.host,
port: envConfig.redis.port,
maxRetriesPerRequest: 1,
lazyConnect: true,
connectTimeout: 2000,
retryStrategy: () => null,
enableOfflineQueue: false,
});
subscriberClient.on("error", () => {
// Suppress unhandled redis error crashes
});
} catch {
subscriberClient = null;
}
}
return subscriberClient;
}
...@@ -3443,4 +3443,251 @@ export const swaggerPaths: Record<string, any> = { ...@@ -3443,4 +3443,251 @@ export const swaggerPaths: Record<string, any> = {
}, },
}, },
}, },
"/system/public": {
get: {
tags: ["System Config"],
summary: "Lấy cấu hình công khai và Feature Flags",
description: "Cho phép client/frontend đọc toàn bộ cấu hình có isPublic: true mà không cần đăng nhập.",
responses: {
200: {
description: "Lấy cấu hình công khai thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: {
type: "object",
properties: {
configs: {
type: "array",
items: { $ref: "#/components/schemas/SystemConfig" },
},
map: { type: "object" },
},
},
},
},
},
},
},
},
},
},
"/system/configs": {
get: {
tags: ["System Config"],
summary: "Danh sách cấu hình hệ thống",
description: "Lấy danh sách cấu hình và cờ tính năng, hỗ trợ tìm kiếm và lọc theo danh mục (Yêu cầu quyền SYSTEM_CONFIG_READ).",
parameters: [
{
name: "category",
in: "query",
schema: {
type: "string",
enum: ["GENERAL", "FEATURE_FLAG", "INTEGRATION", "SECURITY"],
},
description: "Lọc theo danh mục cấu hình",
},
{
name: "search",
in: "query",
schema: { type: "string" },
description: "Tìm kiếm theo khóa hoặc mô tả",
},
{
name: "page",
in: "query",
schema: { type: "integer", default: 1 },
},
{
name: "limit",
in: "query",
schema: { type: "integer", default: 20 },
},
],
responses: {
200: {
description: "Lấy danh sách thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: {
type: "object",
properties: {
items: {
type: "array",
items: { $ref: "#/components/schemas/SystemConfig" },
},
total: { type: "integer" },
page: { type: "integer" },
limit: { type: "integer" },
},
},
},
},
},
},
},
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền SYSTEM_CONFIG_READ" },
},
},
post: {
tags: ["System Config"],
summary: "Tạo cấu hình mới",
description: "Tạo mới một khóa cấu hình hoặc Feature Flag (Yêu cầu quyền SYSTEM_CONFIG_MANAGE).",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CreateSystemConfigRequest" },
},
},
},
responses: {
201: {
description: "Tạo cấu hình thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: { $ref: "#/components/schemas/SystemConfig" },
message: { type: "string" },
},
},
},
},
},
400: { description: "Dữ liệu không hợp lệ" },
409: { description: "Khóa cấu hình đã tồn tại" },
},
},
},
"/system/configs/{key}": {
get: {
tags: ["System Config"],
summary: "Chi tiết một cấu hình",
parameters: [
{
name: "key",
in: "path",
required: true,
schema: { type: "string" },
description: "Khóa định danh cấu hình",
},
],
responses: {
200: {
description: "Lấy chi tiết thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: { $ref: "#/components/schemas/SystemConfig" },
},
},
},
},
},
404: { description: "Không tìm thấy cấu hình" },
},
},
put: {
tags: ["System Config"],
summary: "Cập nhật cấu hình",
parameters: [
{
name: "key",
in: "path",
required: true,
schema: { type: "string" },
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UpdateSystemConfigRequest" },
},
},
},
responses: {
200: {
description: "Cập nhật thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: { $ref: "#/components/schemas/SystemConfig" },
},
},
},
},
},
404: { description: "Không tìm thấy cấu hình" },
},
},
delete: {
tags: ["System Config"],
summary: "Xóa cấu hình",
parameters: [
{
name: "key",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
200: { description: "Xóa thành công" },
404: { description: "Không tìm thấy cấu hình" },
},
},
},
"/system/features/{key}/toggle": {
patch: {
tags: ["System Config"],
summary: "Bật/tắt nhanh Feature Flag",
description: "Chuyển đổi trạng thái boolean (true <-> false) cho một cờ tính năng (Yêu cầu quyền SYSTEM_CONFIG_MANAGE).",
parameters: [
{
name: "key",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
200: {
description: "Chuyển đổi trạng thái thành công",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", example: true },
data: { $ref: "#/components/schemas/SystemConfig" },
message: { type: "string" },
},
},
},
},
},
400: { description: "Cấu hình không phải boolean Feature Flag" },
404: { description: "Không tìm thấy cấu hình" },
},
},
},
}; };
...@@ -1833,6 +1833,356 @@ ...@@ -1833,6 +1833,356 @@
"summary": "Chi tiết Permission" "summary": "Chi tiết Permission"
} }
}, },
"/system/public": {
"get": {
"description": "Cho phép client/frontend đọc toàn bộ cấu hình có isPublic: true mà không cần đăng nhập.",
"responses": {
"200": {
"description": "Lấy cấu hình công khai thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"configs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfig"
}
},
"map": {
"type": "object"
}
}
}
}
}
}
}
}
},
"tags": [
"System Config"
],
"summary": "Lấy cấu hình công khai và Feature Flags"
}
},
"/system/configs": {
"get": {
"description": "Lấy danh sách cấu hình và cờ tính năng, hỗ trợ tìm kiếm và lọc theo danh mục (Yêu cầu quyền SYSTEM_CONFIG_READ).",
"parameters": [
{
"name": "category",
"in": "query",
"schema": {
"type": "string",
"enum": [
"GENERAL",
"FEATURE_FLAG",
"INTEGRATION",
"SECURITY"
]
},
"description": "Lọc theo danh mục cấu hình"
},
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
"description": "Tìm kiếm theo khóa hoặc mô tả"
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"default": 20
}
}
],
"responses": {
"200": {
"description": "Lấy danh sách thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfig"
}
},
"total": {
"type": "integer"
},
"page": {
"type": "integer"
},
"limit": {
"type": "integer"
}
}
}
}
}
}
}
},
"401": {
"description": "Chưa xác thực"
},
"403": {
"description": "Không có quyền SYSTEM_CONFIG_READ"
}
},
"tags": [
"System Config"
],
"summary": "Danh sách cấu hình hệ thống"
},
"post": {
"description": "Tạo mới một khóa cấu hình hoặc Feature Flag (Yêu cầu quyền SYSTEM_CONFIG_MANAGE).",
"responses": {
"201": {
"description": "Tạo cấu hình thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"$ref": "#/components/schemas/SystemConfig"
},
"message": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Dữ liệu không hợp lệ"
},
"409": {
"description": "Khóa cấu hình đã tồn tại"
}
},
"tags": [
"System Config"
],
"summary": "Tạo cấu hình mới",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateSystemConfigRequest"
}
}
}
}
}
},
"/system/configs/{key}": {
"get": {
"description": "",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Khóa định danh cấu hình"
}
],
"responses": {
"200": {
"description": "Lấy chi tiết thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"$ref": "#/components/schemas/SystemConfig"
}
}
}
}
}
},
"404": {
"description": "Không tìm thấy cấu hình"
}
},
"tags": [
"System Config"
],
"summary": "Chi tiết một cấu hình"
},
"put": {
"description": "",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Cập nhật thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"$ref": "#/components/schemas/SystemConfig"
}
}
}
}
}
},
"404": {
"description": "Không tìm thấy cấu hình"
}
},
"tags": [
"System Config"
],
"summary": "Cập nhật cấu hình",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSystemConfigRequest"
}
}
}
}
},
"delete": {
"description": "",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Xóa thành công"
},
"404": {
"description": "Không tìm thấy cấu hình"
}
},
"tags": [
"System Config"
],
"summary": "Xóa cấu hình"
}
},
"/system/features/{key}/toggle": {
"patch": {
"description": "Chuyển đổi trạng thái boolean (true <-> false) cho một cờ tính năng (Yêu cầu quyền SYSTEM_CONFIG_MANAGE).",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Chuyển đổi trạng thái thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"$ref": "#/components/schemas/SystemConfig"
},
"message": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Cấu hình không phải boolean Feature Flag"
},
"404": {
"description": "Không tìm thấy cấu hình"
}
},
"tags": [
"System Config"
],
"summary": "Bật/tắt nhanh Feature Flag"
}
},
"/dashboard/stats": { "/dashboard/stats": {
"get": { "get": {
"description": "Thống kê tổng hợp số lượng crawl jobs theo trạng thái, số trang đã crawl, số lịch crawl đang chạy và tổng số tệp export.", "description": "Thống kê tổng hợp số lượng crawl jobs theo trạng thái, số trang đã crawl, số lịch crawl đang chạy và tổng số tệp export.",
...@@ -6450,6 +6800,107 @@ ...@@ -6450,6 +6800,107 @@
} }
} }
} }
},
"SystemConfig": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"key": {
"type": "string",
"example": "feature.ai.enabled"
},
"value": {
"example": true
},
"description": {
"type": "string",
"nullable": true,
"example": "Kích hoạt AI"
},
"category": {
"type": "string",
"enum": [
"GENERAL",
"FEATURE_FLAG",
"INTEGRATION",
"SECURITY"
],
"example": "FEATURE_FLAG"
},
"isPublic": {
"type": "boolean",
"example": true
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
}
},
"CreateSystemConfigRequest": {
"type": "object",
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"example": "feature.new_module.enabled"
},
"value": {
"example": true
},
"description": {
"type": "string",
"example": "Bật tắt tính năng mới"
},
"category": {
"type": "string",
"enum": [
"GENERAL",
"FEATURE_FLAG",
"INTEGRATION",
"SECURITY"
],
"example": "FEATURE_FLAG"
},
"isPublic": {
"type": "boolean",
"example": false
}
}
},
"UpdateSystemConfigRequest": {
"type": "object",
"properties": {
"value": {
"example": false
},
"description": {
"type": "string",
"example": "Mô tả mới"
},
"category": {
"type": "string",
"enum": [
"GENERAL",
"FEATURE_FLAG",
"INTEGRATION",
"SECURITY"
]
},
"isPublic": {
"type": "boolean"
}
}
} }
} }
}, },
......
...@@ -811,6 +811,50 @@ const rawSchemas = { ...@@ -811,6 +811,50 @@ const rawSchemas = {
}, },
}, },
}, },
SystemConfig: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
key: { type: "string", example: "feature.ai.enabled" },
value: { example: true },
description: { type: "string", nullable: true, example: "Kích hoạt AI" },
category: {
type: "string",
enum: ["GENERAL", "FEATURE_FLAG", "INTEGRATION", "SECURITY"],
example: "FEATURE_FLAG",
},
isPublic: { type: "boolean", example: true },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
CreateSystemConfigRequest: {
type: "object",
required: ["key", "value"],
properties: {
key: { type: "string", example: "feature.new_module.enabled" },
value: { example: true },
description: { type: "string", example: "Bật tắt tính năng mới" },
category: {
type: "string",
enum: ["GENERAL", "FEATURE_FLAG", "INTEGRATION", "SECURITY"],
example: "FEATURE_FLAG",
},
isPublic: { type: "boolean", example: false },
},
},
UpdateSystemConfigRequest: {
type: "object",
properties: {
value: { example: false },
description: { type: "string", example: "Mô tả mới" },
category: {
type: "string",
enum: ["GENERAL", "FEATURE_FLAG", "INTEGRATION", "SECURITY"],
},
isPublic: { type: "boolean" },
},
},
}; };
const outputFile = "./src/docs/swagger.json"; const outputFile = "./src/docs/swagger.json";
......
import { Request, Response, NextFunction } from "express";
import { systemConfigService } from "../modules/system-config/system-config.service";
import { ERROR_CODE } from "../common/errors/error-code";
/**
* Middleware kiểm tra chế độ bảo trì toàn hệ thống (feature.maintenance_mode.enabled)
* Khi bảo trì được bật, chặn các request từ người dùng thông thường,
* ngoại trừ các endpoint quản trị cấu hình, đăng nhập và health check.
*/
export async function maintenanceMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
// Bỏ qua các endpoint thiết yếu để Admin vẫn có thể đăng nhập và tắt chế độ bảo trì
const publicPaths = [
"/system",
"/auth/login",
"/auth/refresh",
"/api-docs",
"/health",
];
const isPublicOrAdminExempt = publicPaths.some(
(prefix) => req.path === prefix || req.path.startsWith(prefix + "/"),
);
if (isPublicOrAdminExempt) {
return next();
}
const isMaintenanceMode = await systemConfigService.isFeatureEnabled(
"feature.maintenance_mode.enabled",
false,
);
if (isMaintenanceMode) {
// Nếu là Admin thì cho phép qua
const user = (req as any).user;
if (user?.role === "ADMIN") {
return next();
}
return res.status(503).json({
success: false,
message:
"Hệ thống đang trong chế độ bảo trì định kỳ để nâng cấp. Vui lòng quay lại sau ít phút.",
code: ERROR_CODE.INTERNAL_SERVER_ERROR,
});
}
next();
}
import rateLimit, { RateLimitRequestHandler } from "express-rate-limit"; import rateLimit, { RateLimitRequestHandler } from "express-rate-limit";
import { envConfig } from "../config/env.config"; import { envConfig } from "../config/env.config";
import { ERROR_CODE } from "../common/errors/error-code"; import { ERROR_CODE } from "../common/errors/error-code";
import { systemConfigService } from "../modules/system-config/system-config.service";
/** /**
* Global API rate limit per IP, configurable for each environment. * Global API rate limit per IP, configurable for each environment.
...@@ -9,7 +10,11 @@ import { ERROR_CODE } from "../common/errors/error-code"; ...@@ -9,7 +10,11 @@ import { ERROR_CODE } from "../common/errors/error-code";
*/ */
export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({
windowMs: envConfig.rateLimit.windowMs, windowMs: envConfig.rateLimit.windowMs,
max: envConfig.rateLimit.max, max: async () =>
systemConfigService.get<number>(
"rate_limit.max_requests",
envConfig.rateLimit.max,
),
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { message: {
......
...@@ -32,6 +32,7 @@ import { ...@@ -32,6 +32,7 @@ import {
createUtcDateFromZonedParts, createUtcDateFromZonedParts,
} from "../../common/helpers/schedule-calculator.helper"; } from "../../common/helpers/schedule-calculator.helper";
import { PermissionService } from "../permissions/permission.service"; import { PermissionService } from "../permissions/permission.service";
import { systemConfigService } from "../system-config/system-config.service";
interface AuthJwtPayload { interface AuthJwtPayload {
id: string; id: string;
...@@ -279,6 +280,18 @@ export class AuthService { ...@@ -279,6 +280,18 @@ export class AuthService {
} }
async register(data: RegisterDto): Promise<MeDto> { async register(data: RegisterDto): Promise<MeDto> {
const isRegistrationEnabled = await systemConfigService.isFeatureEnabled(
"feature.registration.enabled",
true,
);
if (!isRegistrationEnabled) {
throw new AppError(
"Tính năng đăng ký tài khoản hiện đang tạm khóa bởi Quản trị viên.",
403,
ERROR_CODE.FORBIDDEN,
);
}
const existing = await this.repository.findByEmail(data.email); const existing = await this.repository.findByEmail(data.email);
if (existing) { if (existing) {
......
import FirecrawlApp from "@mendable/firecrawl-js"; import FirecrawlApp from "@mendable/firecrawl-js";
import { firecrawlConfig } from "../../config/firecrawl.config"; import { firecrawlConfig } from "../../config/firecrawl.config";
import { systemConfigService } from "../system-config/system-config.service";
let cachedApiKey: string | null = null;
let cachedBaseUrl: string | null = null;
let firecrawlClient: FirecrawlApp | null = null; let firecrawlClient: FirecrawlApp | null = null;
export async function getDynamicFirecrawlClient(): Promise<FirecrawlApp> {
const apiKey = await systemConfigService.get<string>(
"integration.firecrawl.api_key",
firecrawlConfig.apiKey,
);
const baseUrl = await systemConfigService.get<string>(
"integration.firecrawl.base_url",
firecrawlConfig.baseUrl,
);
const effectiveKey = apiKey || firecrawlConfig.apiKey;
const effectiveUrl = baseUrl || firecrawlConfig.baseUrl;
if (!effectiveKey) {
throw new Error(
"FIRECRAWL_API_KEY is not set. Add it in System Config or .env file before using the crawler.",
);
}
if (
!firecrawlClient ||
cachedApiKey !== effectiveKey ||
cachedBaseUrl !== effectiveUrl
) {
cachedApiKey = effectiveKey;
cachedBaseUrl = effectiveUrl;
firecrawlClient = new FirecrawlApp({
apiKey: effectiveKey,
apiUrl: effectiveUrl,
});
}
return firecrawlClient;
}
export function getFirecrawlClient(): FirecrawlApp { export function getFirecrawlClient(): FirecrawlApp {
if (!firecrawlClient) { if (!firecrawlClient) {
if (!firecrawlConfig.apiKey) { if (!firecrawlConfig.apiKey) {
......
import { SystemConfigService } from "../system-config.service";
import { SystemConfigRepository } from "../system-config.repository";
import { AuditLogService } from "../../audit-logs/audit-log.service";
import {
SYSTEM_CONFIG_CATEGORY,
DEFAULT_SYSTEM_CONFIGS,
} from "../../../common/constants/system-config.constant";
import { AppError } from "../../../common/errors/app-error";
import { ERROR_CODE } from "../../../common/errors/error-code";
describe("SystemConfigService", () => {
let service: SystemConfigService;
let mockRepo: jest.Mocked<SystemConfigRepository>;
let mockAudit: jest.Mocked<AuditLogService>;
beforeEach(() => {
mockRepo = {
findAll: jest.fn(),
findByKey: jest.fn(),
findPublicConfigs: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
ensureDefault: jest.fn(),
} as unknown as jest.Mocked<SystemConfigRepository>;
mockAudit = {
log: jest.fn().mockResolvedValue(undefined),
findAll: jest.fn(),
} as unknown as jest.Mocked<AuditLogService>;
service = new SystemConfigService(mockRepo, mockAudit);
});
describe("get & Level 1 Cache", () => {
it("should fetch from repository on cache miss and cache the result", async () => {
const mockRecord = {
id: "1",
key: "app.name",
value: "Custom App",
description: "Test",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockRepo.findByKey.mockResolvedValueOnce(mockRecord);
// Miss: queries repo
const val1 = await service.get("app.name");
expect(val1).toBe("Custom App");
expect(mockRepo.findByKey).toHaveBeenCalledTimes(1);
// Hit: serves from in-memory cache without calling repo again
const val2 = await service.get("app.name");
expect(val2).toBe("Custom App");
expect(mockRepo.findByKey).toHaveBeenCalledTimes(1);
});
it("should return defaultValue when key does not exist", async () => {
mockRepo.findByKey.mockResolvedValueOnce(null);
const val = await service.get("non_existent_key", "default_val");
expect(val).toBe("default_val");
});
});
describe("isFeatureEnabled", () => {
it("should return true when value is boolean true", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "1",
key: "feature.registration.enabled",
value: true,
description: null,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const enabled = await service.isFeatureEnabled(
"feature.registration.enabled",
);
expect(enabled).toBe(true);
});
it("should return false when value is boolean false", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "2",
key: "feature.ai.enabled",
value: false,
description: null,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const enabled = await service.isFeatureEnabled("feature.ai.enabled");
expect(enabled).toBe(false);
});
it("should handle string boolean values ('true' / 'false')", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "3",
key: "feature.flag.string",
value: "true",
description: null,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const enabled = await service.isFeatureEnabled("feature.flag.string");
expect(enabled).toBe(true);
});
it("should fallback to default value when flag not found", async () => {
mockRepo.findByKey.mockResolvedValueOnce(null);
const enabled = await service.isFeatureEnabled("missing.flag", true);
expect(enabled).toBe(true);
});
});
describe("getPublicConfigs", () => {
it("should return public configs list and dictionary map", async () => {
mockRepo.findPublicConfigs.mockResolvedValueOnce([
{
id: "1",
key: "app.name",
value: "DataCrawler",
description: "App Name",
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "2",
key: "feature.registration.enabled",
value: true,
description: "Registration Flag",
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
const res = await service.getPublicConfigs();
expect(res.configs).toHaveLength(2);
expect(res.map).toEqual({
"app.name": "DataCrawler",
"feature.registration.enabled": true,
});
// Second call should be served from memory cache
const res2 = await service.getPublicConfigs();
expect(res2).toEqual(res);
expect(mockRepo.findPublicConfigs).toHaveBeenCalledTimes(1);
});
});
describe("create", () => {
it("should create new config, clear cache and log audit action", async () => {
mockRepo.findByKey.mockResolvedValueOnce(null);
mockRepo.create.mockResolvedValueOnce({
id: "uuid-1",
key: "feature.new_module",
value: true,
description: "New module flag",
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await service.create(
{
key: "feature.new_module",
value: true,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
},
{ actorId: "admin-id" },
);
expect(result.key).toBe("feature.new_module");
expect(mockRepo.create).toHaveBeenCalled();
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "admin-id",
action: "SYSTEM_CONFIG_CREATED",
}),
);
});
it("should throw DUPLICATE_ENTRY when key already exists", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "uuid-1",
key: "app.name",
value: "My App",
description: null,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(
service.create({
key: "app.name",
value: "Conflict",
}),
).rejects.toThrow(AppError);
});
});
describe("update", () => {
it("should update config, clear cache and log audit action", async () => {
const existing = {
id: "uuid-1",
key: "app.name",
value: "Old App",
description: null,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
};
mockRepo.findByKey.mockResolvedValueOnce(existing);
mockRepo.update.mockResolvedValueOnce({
...existing,
value: "New App",
});
const updated = await service.update(
"app.name",
{ value: "New App" },
{ actorId: "admin-id" },
);
expect(updated.value).toBe("New App");
expect(mockRepo.update).toHaveBeenCalledWith("app.name", {
value: "New App",
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "admin-id",
action: "SYSTEM_CONFIG_UPDATED",
}),
);
});
it("should throw NOT_FOUND when updating non-existent config", async () => {
mockRepo.findByKey.mockResolvedValueOnce(null);
await expect(
service.update("not.found", { value: 123 }),
).rejects.toThrow(AppError);
});
});
describe("toggleFeature", () => {
it("should toggle boolean feature flag from true to false", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "uuid-1",
key: "feature.ai.enabled",
value: true,
description: null,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
mockRepo.update.mockResolvedValueOnce({
id: "uuid-1",
key: "feature.ai.enabled",
value: false,
description: null,
category: SYSTEM_CONFIG_CATEGORY.FEATURE_FLAG,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const toggled = await service.toggleFeature("feature.ai.enabled", {
actorId: "admin-1",
});
expect(toggled.value).toBe(false);
expect(mockRepo.update).toHaveBeenCalledWith("feature.ai.enabled", {
value: false,
});
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "admin-1",
action: "SYSTEM_CONFIG_TOGGLED",
}),
);
});
it("should throw VALIDATION_ERROR when trying to toggle non-boolean config", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "uuid-1",
key: "rate_limit.max_requests_per_minute",
value: 60,
description: null,
category: SYSTEM_CONFIG_CATEGORY.SECURITY,
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(
service.toggleFeature("rate_limit.max_requests_per_minute"),
).rejects.toThrow(
expect.objectContaining({
code: ERROR_CODE.VALIDATION_ERROR,
}),
);
});
});
describe("delete", () => {
it("should delete existing config and log audit action", async () => {
mockRepo.findByKey.mockResolvedValueOnce({
id: "uuid-1",
key: "temp.config",
value: "test",
description: null,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
});
mockRepo.delete.mockResolvedValueOnce({
id: "uuid-1",
key: "temp.config",
value: "test",
description: null,
category: SYSTEM_CONFIG_CATEGORY.GENERAL,
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const res = await service.delete("temp.config", { actorId: "admin-1" });
expect(res.success).toBe(true);
expect(mockRepo.delete).toHaveBeenCalledWith("temp.config");
expect(mockAudit.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "admin-1",
action: "SYSTEM_CONFIG_DELETED",
}),
);
});
});
describe("ensureDefaultConfigs", () => {
it("should ensure all default configs are inserted", async () => {
mockRepo.ensureDefault.mockResolvedValue({} as any);
await service.ensureDefaultConfigs();
expect(mockRepo.ensureDefault).toHaveBeenCalledTimes(
DEFAULT_SYSTEM_CONFIGS.length,
);
});
});
});
import { Request, Response, NextFunction } from "express";
import { systemConfigService } from "./system-config.service";
import {
CreateSystemConfigDto,
UpdateSystemConfigDto,
SystemConfigQueryDto,
} from "./system-config.dto";
export class SystemConfigController {
private readonly service = systemConfigService;
getPublic = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.getPublicConfigs();
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
findAll = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const query = req.query as SystemConfigQueryDto;
const result = await this.service.findAll(query);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
findByKey = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const { key } = req.params;
const result = await this.service.findByKey(key);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
create = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const dto: CreateSystemConfigDto = req.body;
const result = await this.service.create(dto, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.status(201).json({
success: true,
data: result,
message: "Tạo cấu hình mới thành công",
});
} catch (error) {
next(error);
}
};
update = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const { key } = req.params;
const dto: UpdateSystemConfigDto = req.body;
const result = await this.service.update(key, dto, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.json({
success: true,
data: result,
message: "Cập nhật cấu hình thành công",
});
} catch (error) {
next(error);
}
};
toggleFeature = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const { key } = req.params;
const result = await this.service.toggleFeature(key, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.json({
success: true,
data: result,
message: `Đã ${result.value ? "bật" : "tắt"} tính năng thành công`,
});
} catch (error) {
next(error);
}
};
delete = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const { key } = req.params;
await this.service.delete(key, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.json({
success: true,
message: "Xóa cấu hình thành công",
});
} catch (error) {
next(error);
}
};
}
import { SystemConfigCategory } from "../../common/constants/system-config.constant";
export interface CreateSystemConfigDto {
key: string;
value: unknown;
description?: string;
category?: SystemConfigCategory;
isPublic?: boolean;
}
export interface UpdateSystemConfigDto {
value?: unknown;
description?: string;
category?: SystemConfigCategory;
isPublic?: boolean;
}
export interface SystemConfigQueryDto {
category?: SystemConfigCategory;
search?: string;
isPublic?: boolean | string;
page?: number | string;
limit?: number | string;
}
export interface SystemConfigResponseDto {
id: string;
key: string;
value: unknown;
description: string | null;
category: string;
isPublic: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface PublicConfigsResponseDto {
configs: Array<{
key: string;
value: unknown;
category: string;
description: string | null;
}>;
map: Record<string, unknown>;
}
export interface SystemConfigEventPayload {
key?: string;
action: "create" | "update" | "toggle" | "delete" | "invalidate";
timestamp: number;
}
import { prisma } from "../../database/prisma.client";
import { Prisma } from "@prisma/client";
import {
CreateSystemConfigDto,
UpdateSystemConfigDto,
SystemConfigQueryDto,
} from "./system-config.dto";
import { DefaultSystemConfigItem } from "../../common/constants/system-config.constant";
export class SystemConfigRepository {
async findAll(query: SystemConfigQueryDto = {}) {
const where: Prisma.SystemConfigWhereInput = {};
if (query.category) {
where.category = query.category;
}
if (query.isPublic !== undefined) {
where.isPublic =
typeof query.isPublic === "boolean"
? query.isPublic
: query.isPublic === "true";
}
if (query.search) {
where.OR = [
{ key: { contains: query.search, mode: "insensitive" } },
{ description: { contains: query.search, mode: "insensitive" } },
];
}
const page = Math.max(1, Number(query.page) || 1);
const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100);
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
prisma.systemConfig.findMany({
where,
orderBy: [{ category: "asc" }, { key: "asc" }],
skip,
take: limit,
}),
prisma.systemConfig.count({ where }),
]);
return {
items,
total,
page,
limit,
};
}
async findByKey(key: string) {
return prisma.systemConfig.findUnique({
where: { key },
});
}
async findPublicConfigs() {
return prisma.systemConfig.findMany({
where: { isPublic: true },
orderBy: { key: "asc" },
});
}
async create(data: CreateSystemConfigDto) {
return prisma.systemConfig.create({
data: {
key: data.key,
value: data.value as Prisma.InputJsonValue,
description: data.description ?? null,
category: data.category,
isPublic: data.isPublic ?? false,
},
});
}
async update(key: string, data: UpdateSystemConfigDto) {
const updatePayload: Prisma.SystemConfigUpdateInput = {};
if (data.value !== undefined) {
updatePayload.value = data.value as Prisma.InputJsonValue;
}
if (data.description !== undefined) {
updatePayload.description = data.description;
}
if (data.category !== undefined) {
updatePayload.category = data.category;
}
if (data.isPublic !== undefined) {
updatePayload.isPublic = data.isPublic;
}
return prisma.systemConfig.update({
where: { key },
data: updatePayload,
});
}
async delete(key: string) {
return prisma.systemConfig.delete({
where: { key },
});
}
async ensureDefault(item: DefaultSystemConfigItem) {
return prisma.systemConfig.upsert({
where: { key: item.key },
update: {
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
create: {
key: item.key,
value: item.value as Prisma.InputJsonValue,
description: item.description ?? null,
category: item.category,
isPublic: item.isPublic,
},
});
}
}
import { Router } from "express";
import { SystemConfigController } from "./system-config.controller";
import { authMiddleware } from "../../middlewares/auth.middleware";
import { requirePermission } from "../../middlewares/permission.middleware";
import {
validate,
validateQuery,
validateParams,
} from "../../middlewares/validate.middleware";
import {
createSystemConfigSchema,
updateSystemConfigSchema,
systemConfigKeyParamSchema,
systemConfigQuerySchema,
} from "./system-config.validation";
import { PERMISSIONS } from "../../common/constants/permission.constant";
const router = Router();
const controller = new SystemConfigController();
// 1. GET /api/v1/system/public (Public client access)
router.get("/public", controller.getPublic);
// 2. GET /api/v1/system/configs (Admin list configs with search and category filter)
router.get(
"/configs",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_READ),
validateQuery(systemConfigQuerySchema),
controller.findAll,
);
// 3. GET /api/v1/system/configs/:key (Get single config detail)
router.get(
"/configs/:key",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_READ),
validateParams(systemConfigKeyParamSchema),
controller.findByKey,
);
// 4. POST /api/v1/system/configs (Create new config)
router.post(
"/configs",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_MANAGE),
validate(createSystemConfigSchema),
controller.create,
);
// 5. PUT /api/v1/system/configs/:key (Update config)
router.put(
"/configs/:key",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_MANAGE),
validateParams(systemConfigKeyParamSchema),
validate(updateSystemConfigSchema),
controller.update,
);
// 6. PATCH /api/v1/system/features/:key/toggle (Quick toggle for boolean Feature Flag)
router.patch(
"/features/:key/toggle",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_MANAGE),
validateParams(systemConfigKeyParamSchema),
controller.toggleFeature,
);
// 7. DELETE /api/v1/system/configs/:key (Delete config)
router.delete(
"/configs/:key",
authMiddleware,
requirePermission(PERMISSIONS.SYSTEM_CONFIG_MANAGE),
validateParams(systemConfigKeyParamSchema),
controller.delete,
);
export default router;
import { SystemConfigRepository } from "./system-config.repository";
import {
CreateSystemConfigDto,
UpdateSystemConfigDto,
SystemConfigQueryDto,
PublicConfigsResponseDto,
SystemConfigEventPayload,
SystemConfigResponseDto,
} from "./system-config.dto";
import {
DEFAULT_SYSTEM_CONFIGS,
SYSTEM_CONFIG_CACHE_TTL_MS,
SYSTEM_CONFIG_EVENTS_CHANNEL,
} from "../../common/constants/system-config.constant";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
import { AuditLogService } from "../audit-logs/audit-log.service";
import {
getRedisPublisher,
getRedisSubscriber,
} from "../../common/redis/redis-pubsub";
interface AuditContext {
actorId?: string;
ipAddress?: string;
userAgent?: string;
}
interface CacheEntry {
value: unknown;
expiresAt: number;
config: SystemConfigResponseDto;
}
export class SystemConfigService {
private cache = new Map<string, CacheEntry>();
private publicConfigsCache: {
data: PublicConfigsResponseDto;
expiresAt: number;
} | null = null;
private isSubscriberInitialized = false;
constructor(
private readonly repository = new SystemConfigRepository(),
private readonly auditLogService = new AuditLogService(),
) {}
/**
* Khởi tạo Redis Subscriber để lắng nghe thông điệp xóa cache từ các node khác
*/
public initRedisSubscriber(): void {
if (this.isSubscriberInitialized) return;
const subscriber = getRedisSubscriber();
if (!subscriber) return;
try {
subscriber.subscribe(SYSTEM_CONFIG_EVENTS_CHANNEL, (err) => {
if (err) {
console.warn(
"[SystemConfig:RedisSub] Failed to subscribe:",
err.message,
);
} else {
this.isSubscriberInitialized = true;
console.log(
`[SystemConfig] Subscribed to Redis channel: ${SYSTEM_CONFIG_EVENTS_CHANNEL}`,
);
}
});
subscriber.on("message", (channel, message) => {
if (channel === SYSTEM_CONFIG_EVENTS_CHANNEL) {
try {
const event: SystemConfigEventPayload = JSON.parse(message);
this.clearLocalCache(event.key);
} catch {
this.clearLocalCache();
}
}
});
} catch (error) {
console.warn("[SystemConfig:RedisSub] Subscriber setup failed:", error);
}
}
/**
* Phát thông điệp xóa cache toàn cluster qua Redis Pub/Sub
*/
public async publishInvalidation(
key?: string,
action: SystemConfigEventPayload["action"] = "invalidate",
): Promise<void> {
this.clearLocalCache(key);
const publisher = getRedisPublisher();
if (!publisher) return;
try {
const payload: SystemConfigEventPayload = {
key,
action,
timestamp: Date.now(),
};
await publisher.publish(
SYSTEM_CONFIG_EVENTS_CHANNEL,
JSON.stringify(payload),
);
} catch {
// Degraded mode: local cache already invalidated
}
}
/**
* Xóa RAM cache cục bộ (Level 1)
*/
public clearLocalCache(key?: string): void {
if (key) {
this.cache.delete(key);
} else {
this.cache.clear();
}
this.publicConfigsCache = null;
}
/**
* Level 1 Cache Getter: Truy xuất giá trị cấu hình theo key với tốc độ < 0.1ms
*/
async get<T = unknown>(key: string, defaultValue?: T): Promise<T> {
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.value as T;
}
const record = await this.repository.findByKey(key);
if (!record) {
return defaultValue as T;
}
this.cache.set(key, {
value: record.value,
expiresAt: Date.now() + SYSTEM_CONFIG_CACHE_TTL_MS,
config: record as SystemConfigResponseDto,
});
return record.value as T;
}
/**
* Kiểm tra nhanh Feature Flag dạng boolean cho các service nội bộ
*/
async isFeatureEnabled(
key: string,
defaultValue: boolean = false,
): Promise<boolean> {
const value = await this.get(key, defaultValue);
if (typeof value === "boolean") {
return value;
}
if (value === "true" || value === 1 || value === "1") {
return true;
}
if (value === "false" || value === 0 || value === "0") {
return false;
}
return Boolean(value);
}
/**
* Lấy danh sách cấu hình công khai (isPublic: true) cho client
*/
async getPublicConfigs(): Promise<PublicConfigsResponseDto> {
if (
this.publicConfigsCache &&
Date.now() < this.publicConfigsCache.expiresAt
) {
return this.publicConfigsCache.data;
}
const records = await this.repository.findPublicConfigs();
const configs = records.map((r) => ({
key: r.key,
value: r.value,
category: r.category,
description: r.description,
}));
const map: Record<string, unknown> = {};
for (const item of configs) {
map[item.key] = item.value;
}
const result: PublicConfigsResponseDto = { configs, map };
this.publicConfigsCache = {
data: result,
expiresAt: Date.now() + SYSTEM_CONFIG_CACHE_TTL_MS,
};
return result;
}
/**
* Lấy danh sách cấu hình hệ thống (Admin)
*/
async findAll(query: SystemConfigQueryDto = {}) {
return this.repository.findAll(query);
}
/**
* Lấy chi tiết 1 cấu hình theo key
*/
async findByKey(key: string): Promise<SystemConfigResponseDto> {
const cached = this.cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.config;
}
const config = await this.repository.findByKey(key);
if (!config) {
throw new AppError(
`Không tìm thấy cấu hình với khóa [${key}]`,
404,
ERROR_CODE.NOT_FOUND,
);
}
const dto = config as SystemConfigResponseDto;
this.cache.set(key, {
value: config.value,
expiresAt: Date.now() + SYSTEM_CONFIG_CACHE_TTL_MS,
config: dto,
});
return dto;
}
/**
* Tạo cấu hình mới
*/
async create(
data: CreateSystemConfigDto,
context?: AuditContext,
): Promise<SystemConfigResponseDto> {
const existing = await this.repository.findByKey(data.key);
if (existing) {
throw new AppError(
`Khóa cấu hình [${data.key}] đã tồn tại`,
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
const created = await this.repository.create(data);
await this.publishInvalidation(data.key, "create");
await this.auditLogService.log({
userId: context?.actorId,
action: AUDIT_ACTIONS.SYSTEM_CONFIG_CREATED,
details: {
key: created.key,
category: created.category,
isPublic: created.isPublic,
},
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
});
return created as SystemConfigResponseDto;
}
/**
* Cập nhật cấu hình
*/
async update(
key: string,
data: UpdateSystemConfigDto,
context?: AuditContext,
): Promise<SystemConfigResponseDto> {
const existing = await this.repository.findByKey(key);
if (!existing) {
throw new AppError(
`Không tìm thấy cấu hình với khóa [${key}]`,
404,
ERROR_CODE.NOT_FOUND,
);
}
const updated = await this.repository.update(key, data);
await this.publishInvalidation(key, "update");
await this.auditLogService.log({
userId: context?.actorId,
action: AUDIT_ACTIONS.SYSTEM_CONFIG_UPDATED,
details: {
key,
oldValue: existing.value,
newValue: updated.value,
category: updated.category,
isPublic: updated.isPublic,
},
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
});
return updated as SystemConfigResponseDto;
}
/**
* Bật/tắt nhanh Feature Flag dạng boolean
*/
async toggleFeature(
key: string,
context?: AuditContext,
): Promise<SystemConfigResponseDto> {
const existing = await this.repository.findByKey(key);
if (!existing) {
throw new AppError(
`Không tìm thấy Feature Flag với khóa [${key}]`,
404,
ERROR_CODE.NOT_FOUND,
);
}
let currentBool: boolean;
if (typeof existing.value === "boolean") {
currentBool = existing.value;
} else if (existing.value === "true" || existing.value === "1") {
currentBool = true;
} else if (existing.value === "false" || existing.value === "0") {
currentBool = false;
} else {
throw new AppError(
`Cấu hình [${key}] không phải là cờ tính năng (boolean)`,
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
const nextValue = !currentBool;
const updated = await this.repository.update(key, { value: nextValue });
await this.publishInvalidation(key, "toggle");
await this.auditLogService.log({
userId: context?.actorId,
action: AUDIT_ACTIONS.SYSTEM_CONFIG_TOGGLED,
details: {
key,
previousState: currentBool,
newState: nextValue,
},
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
});
return updated as SystemConfigResponseDto;
}
/**
* Xóa cấu hình
*/
async delete(
key: string,
context?: AuditContext,
): Promise<{ success: boolean }> {
const existing = await this.repository.findByKey(key);
if (!existing) {
throw new AppError(
`Không tìm thấy cấu hình với khóa [${key}]`,
404,
ERROR_CODE.NOT_FOUND,
);
}
await this.repository.delete(key);
await this.publishInvalidation(key, "delete");
await this.auditLogService.log({
userId: context?.actorId,
action: AUDIT_ACTIONS.SYSTEM_CONFIG_DELETED,
details: {
key,
category: existing.category,
},
ipAddress: context?.ipAddress,
userAgent: context?.userAgent,
});
return { success: true };
}
/**
* Khởi tạo cấu hình mặc định nếu chưa tồn tại trong Database
*/
async ensureDefaultConfigs(): Promise<void> {
for (const item of DEFAULT_SYSTEM_CONFIGS) {
try {
await this.repository.ensureDefault(item);
} catch (err) {
console.warn(
`[SystemConfig] ensureDefault error for ${item.key}:`,
err,
);
}
}
this.clearLocalCache();
}
}
export const systemConfigService = new SystemConfigService();
import { z } from "zod";
import { SYSTEM_CONFIG_CATEGORY } from "../../common/constants/system-config.constant";
export const createSystemConfigSchema = z.object({
key: z
.string({ required_error: "Khóa cấu hình là bắt buộc" })
.min(2, "Khóa cấu hình phải có ít nhất 2 ký tự")
.max(100, "Khóa cấu hình tối đa 100 ký tự")
.regex(
/^[a-zA-Z0-9_.-]+$/,
"Khóa cấu hình chỉ được chứa chữ cái, chữ số, dấu chấm (.), gạch dưới (_) và gạch ngang (-)",
),
value: z
.any()
.refine((val) => val !== undefined, {
message: "Giá trị cấu hình không được để trống",
}),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional().nullable(),
category: z
.nativeEnum(SYSTEM_CONFIG_CATEGORY)
.optional()
.default(SYSTEM_CONFIG_CATEGORY.GENERAL),
isPublic: z.boolean().optional().default(false),
});
export const updateSystemConfigSchema = z
.object({
value: z.any().optional(),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional().nullable(),
category: z.nativeEnum(SYSTEM_CONFIG_CATEGORY).optional(),
isPublic: z.boolean().optional(),
})
.refine(
(data) =>
data.value !== undefined ||
data.description !== undefined ||
data.category !== undefined ||
data.isPublic !== undefined,
{
message: "Phải cung cấp ít nhất một trường để cập nhật",
},
);
export const systemConfigKeyParamSchema = z.object({
key: z.string().min(1, "Khóa cấu hình không được để trống"),
});
export const systemConfigQuerySchema = z.object({
category: z.nativeEnum(SYSTEM_CONFIG_CATEGORY).optional(),
search: z.string().optional(),
isPublic: z
.union([z.boolean(), z.enum(["true", "false"])])
.optional(),
page: z
.union([z.number(), z.string()])
.optional()
.transform((val) => {
if (val === undefined) return 1;
const parsed = typeof val === "string" ? parseInt(val, 10) : val;
return isNaN(parsed) || parsed < 1 ? 1 : parsed;
}),
limit: z
.union([z.number(), z.string()])
.optional()
.transform((val) => {
if (val === undefined) return 20;
const parsed = typeof val === "string" ? parseInt(val, 10) : val;
return isNaN(parsed) || parsed < 1 ? 20 : Math.min(parsed, 100);
}),
});
...@@ -4,6 +4,7 @@ import { UserQueryDto } from "./user.dto"; ...@@ -4,6 +4,7 @@ import { UserQueryDto } from "./user.dto";
import { envConfig } from "../../config/env.config"; import { envConfig } from "../../config/env.config";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { SYSTEM_ROLE_SLUGS } from "../../common/constants/system-role.constant"; import { SYSTEM_ROLE_SLUGS } from "../../common/constants/system-role.constant";
import { systemConfigService } from "../system-config/system-config.service";
export class UserRepository { export class UserRepository {
async findAll(query: UserQueryDto = {}) { async findAll(query: UserQueryDto = {}) {
...@@ -94,7 +95,7 @@ export class UserRepository { ...@@ -94,7 +95,7 @@ export class UserRepository {
}); });
} }
create(data: { async create(data: {
email: string; email: string;
passwordHash: string; passwordHash: string;
fullName?: string; fullName?: string;
...@@ -104,6 +105,19 @@ export class UserRepository { ...@@ -104,6 +105,19 @@ export class UserRepository {
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number; maxConcurrentJobsLimit?: number;
}): Promise<User> { }): Promise<User> {
const defaultMaxPages = await systemConfigService.get<number>(
"quota.user_max_pages",
envConfig.quota.defaultMaxPages,
);
const defaultMaxJobsPerDay = await systemConfigService.get<number>(
"quota.user_max_jobs_per_day",
envConfig.quota.defaultMaxJobsPerDay,
);
const defaultMaxConcurrentJobs = await systemConfigService.get<number>(
"quota.user_max_concurrent_jobs",
envConfig.quota.defaultMaxConcurrentJobs,
);
return prisma.user.create({ return prisma.user.create({
data: { data: {
email: data.email, email: data.email,
...@@ -111,12 +125,10 @@ export class UserRepository { ...@@ -111,12 +125,10 @@ export class UserRepository {
fullName: data.fullName, fullName: data.fullName,
avatarUrl: data.avatarUrl, avatarUrl: data.avatarUrl,
role: data.role ?? ROLES.CRAWLER_USER, role: data.role ?? ROLES.CRAWLER_USER,
maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages, maxPagesLimit: data.maxPagesLimit ?? defaultMaxPages,
maxJobsPerDayLimit: maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? defaultMaxJobsPerDay,
data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay,
maxConcurrentJobsLimit: maxConcurrentJobsLimit:
data.maxConcurrentJobsLimit ?? data.maxConcurrentJobsLimit ?? defaultMaxConcurrentJobs,
envConfig.quota.defaultMaxConcurrentJobs,
}, },
}); });
} }
......
...@@ -22,6 +22,7 @@ import { CRAWL_MODE } from "../common/constants/crawl-mode.constant"; ...@@ -22,6 +22,7 @@ import { CRAWL_MODE } from "../common/constants/crawl-mode.constant";
import { ASSET_TYPE } from "../common/constants/asset-type.constant"; import { ASSET_TYPE } from "../common/constants/asset-type.constant";
import { CRAWL_PAGE_STATUS } from "../common/constants/crawl-page-status.constant"; import { CRAWL_PAGE_STATUS } from "../common/constants/crawl-page-status.constant";
import { WEBHOOK_EVENT } from "../common/constants/webhook.constant"; import { WEBHOOK_EVENT } from "../common/constants/webhook.constant";
import { systemConfigService } from "../modules/system-config/system-config.service";
// Lazy getters — instantiated on first use so Jest mocks replace constructors before creation // Lazy getters — instantiated on first use so Jest mocks replace constructors before creation
const getJobRepository = () => new CrawlJobRepository(); const getJobRepository = () => new CrawlJobRepository();
const getPageRepository = () => new CrawlPageRepository(); const getPageRepository = () => new CrawlPageRepository();
...@@ -704,7 +705,12 @@ export async function processCrawlJob(job: Job): Promise<void> { ...@@ -704,7 +705,12 @@ export async function processCrawlJob(job: Job): Promise<void> {
}); });
} }
// Generate diff_report.json upon successful job completion // Generate diff_report.json upon successful job completion (if feature flag is enabled)
const isDiffEnabled = await systemConfigService.isFeatureEnabled(
"feature.change_detection.enabled",
true,
);
if (isDiffEnabled) {
try { try {
const { ChangeDetectionService } = const { ChangeDetectionService } =
await import("../modules/change-detection/change-detection.service"); await import("../modules/change-detection/change-detection.service");
...@@ -720,6 +726,11 @@ export async function processCrawlJob(job: Job): Promise<void> { ...@@ -720,6 +726,11 @@ export async function processCrawlJob(job: Job): Promise<void> {
getErrorMessage(diffErr), getErrorMessage(diffErr),
); );
} }
} else {
console.log(
`[Worker] Skipped diff report for job ${jobId}: feature.change_detection.enabled is false`,
);
}
} catch (error: unknown) { } catch (error: unknown) {
await getJobRepository().updateStatus(jobId, JOB_STATUS.FAILED, { await getJobRepository().updateStatus(jobId, JOB_STATUS.FAILED, {
finishedAt: new Date(), finishedAt: new Date(),
...@@ -735,6 +746,11 @@ export async function processCrawlJob(job: Job): Promise<void> { ...@@ -735,6 +746,11 @@ export async function processCrawlJob(job: Job): Promise<void> {
(updatedJob.status === JOB_STATUS.COMPLETED || (updatedJob.status === JOB_STATUS.COMPLETED ||
updatedJob.status === JOB_STATUS.FAILED) updatedJob.status === JOB_STATUS.FAILED)
) { ) {
const isWebhookEnabled = await systemConfigService.isFeatureEnabled(
"feature.webhook.deliveries.enabled",
true,
);
if (isWebhookEnabled) {
const { WebhookDeliveryService } = const { WebhookDeliveryService } =
await import("../modules/webhooks/webhook-delivery.service"); await import("../modules/webhooks/webhook-delivery.service");
const webhookDeliveryService = new WebhookDeliveryService(); const webhookDeliveryService = new WebhookDeliveryService();
...@@ -776,6 +792,7 @@ export async function processCrawlJob(job: Job): Promise<void> { ...@@ -776,6 +792,7 @@ export async function processCrawlJob(job: Job): Promise<void> {
payload, payload,
); );
} }
}
} catch (webhookErr) { } catch (webhookErr) {
console.error( console.error(
`[Worker] Failed to dispatch webhook for job ${jobId}:`, `[Worker] Failed to dispatch webhook for job ${jobId}:`,
......
...@@ -12,6 +12,7 @@ import healthRoute from "../modules/health/health.route"; ...@@ -12,6 +12,7 @@ import healthRoute from "../modules/health/health.route";
import dashboardRoute from "../modules/dashboard/dashboard.route"; import dashboardRoute from "../modules/dashboard/dashboard.route";
import roleRoute from "../modules/roles/role.route"; import roleRoute from "../modules/roles/role.route";
import permissionRoute from "../modules/permissions/permission.route"; import permissionRoute from "../modules/permissions/permission.route";
import systemConfigRoute from "../modules/system-config/system-config.route";
const router = Router(); const router = Router();
...@@ -20,6 +21,7 @@ router.use("/auth", authRoute); ...@@ -20,6 +21,7 @@ router.use("/auth", authRoute);
router.use("/users", userRoute); router.use("/users", userRoute);
router.use("/roles", roleRoute); router.use("/roles", roleRoute);
router.use("/permissions", permissionRoute); router.use("/permissions", permissionRoute);
router.use("/system", systemConfigRoute);
router.use("/dashboard", dashboardRoute); router.use("/dashboard", dashboardRoute);
router.use("/crawl-jobs", crawlJobRoute); router.use("/crawl-jobs", crawlJobRoute);
router.use("/crawl-schedules", crawlScheduleRoute); router.use("/crawl-schedules", crawlScheduleRoute);
......
...@@ -45,7 +45,19 @@ async function bootstrap() { ...@@ -45,7 +45,19 @@ async function bootstrap() {
initLocalStorage(); initLocalStorage();
const { systemConfigService } = await import(
"./modules/system-config/system-config.service"
);
try {
await systemConfigService.ensureDefaultConfigs();
console.log("[Server] Default system configs initialized successfully.");
} catch (err) {
console.warn("[Server] Failed to initialize default system configs:", err);
}
if (isRedisAvailable) { if (isRedisAvailable) {
systemConfigService.initRedisSubscriber();
await import("./queues/webhook.worker"); await import("./queues/webhook.worker");
console.log("[Server] Webhook worker initialized in background."); console.log("[Server] Webhook worker initialized in background.");
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment