Commit 444c7509 authored by ThinhNC's avatar ThinhNC

Merge branch 'fix/redis-rate-limiter-and-health-routes' into 'develop'

fix(redis): handle offline state in rate limiter and mount health routes under /api/v1

See merge request !18
parents d6c2e6c7 ded020b6
This diff is collapsed.
This diff is collapsed.
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- DropIndex
DROP INDEX IF EXISTS "crawl_jobs_schedule_id_idx";
-- DropIndex
DROP INDEX IF EXISTS "webhook_configs_user_id_idx";
-- AlterTable crawl_pages: normalized_url nullable without default
ALTER TABLE "crawl_pages" ALTER COLUMN "normalized_url" DROP NOT NULL;
ALTER TABLE "crawl_pages" ALTER COLUMN "normalized_url" DROP DEFAULT;
-- AlterTable webhook_deliveries: safely cast status column to WebhookDeliveryStatus enum without dropping
ALTER TABLE "webhook_deliveries" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "webhook_deliveries"
ALTER COLUMN "status" TYPE "WebhookDeliveryStatus" USING (
CASE
WHEN "status" = 'SUCCESS' THEN 'SUCCESS'::"WebhookDeliveryStatus"
WHEN "status" = 'FAILED' THEN 'FAILED'::"WebhookDeliveryStatus"
ELSE 'PENDING'::"WebhookDeliveryStatus"
END
);
ALTER TABLE "webhook_deliveries" ALTER COLUMN "status" SET DEFAULT 'PENDING';
-- Invalidate legacy unhashed refresh tokens (BUG-005) so users re-login once with hashed tokens
DELETE FROM "refresh_tokens";
-- CreateIndex
CREATE INDEX IF NOT EXISTS "crawl_job_logs_job_id_level_idx" ON "crawl_job_logs"("job_id", "level");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "crawl_jobs_schedule_id_deleted_at_idx" ON "crawl_jobs"("schedule_id", "deleted_at");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "crawl_pages_job_id_content_hash_idx" ON "crawl_pages"("job_id", "content_hash");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "webhook_configs_user_id_is_active_idx" ON "webhook_configs"("user_id", "is_active");
...@@ -80,6 +80,12 @@ enum ScheduleFrequency { ...@@ -80,6 +80,12 @@ enum ScheduleFrequency {
CUSTOM CUSTOM
} }
enum WebhookDeliveryStatus {
PENDING
SUCCESS
FAILED
}
model User { model User {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
email String @unique email String @unique
...@@ -159,7 +165,7 @@ model CrawlJob { ...@@ -159,7 +165,7 @@ model CrawlJob {
@@index([createdAt]) @@index([createdAt])
@@index([userId, status]) @@index([userId, status])
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([scheduleId]) @@index([scheduleId, deletedAt])
@@index([deletedAt]) @@index([deletedAt])
@@index([userId, deletedAt]) @@index([userId, deletedAt])
@@map("crawl_jobs") @@map("crawl_jobs")
...@@ -184,7 +190,7 @@ model CrawlPage { ...@@ -184,7 +190,7 @@ model CrawlPage {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
normalizedUrl String @default("") @map("normalized_url") normalizedUrl String? @map("normalized_url")
contentHash String? @map("content_hash") contentHash String? @map("content_hash")
wordCount Int @default(0) @map("word_count") wordCount Int @default(0) @map("word_count")
dataQualityScore Int? @map("data_quality_score") dataQualityScore Int? @map("data_quality_score")
...@@ -194,7 +200,8 @@ model CrawlPage { ...@@ -194,7 +200,8 @@ model CrawlPage {
assets CrawlAsset[] assets CrawlAsset[]
@@unique([jobId, url]) @@unique([jobId, url])
@@index([jobId]) @@index([jobId, status])
@@index([jobId, contentHash])
@@index([status]) @@index([status])
@@map("crawl_pages") @@map("crawl_pages")
} }
...@@ -261,6 +268,7 @@ model CrawlJobLog { ...@@ -261,6 +268,7 @@ model CrawlJobLog {
job CrawlJob @relation(fields: [jobId], references: [id], onDelete: Cascade) job CrawlJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId, createdAt]) @@index([jobId, createdAt])
@@index([jobId, level])
@@map("crawl_job_logs") @@map("crawl_job_logs")
} }
...@@ -276,6 +284,7 @@ model RefreshToken { ...@@ -276,6 +284,7 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([expiresAt])
@@map("refresh_tokens") @@map("refresh_tokens")
} }
...@@ -329,7 +338,7 @@ model WebhookConfig { ...@@ -329,7 +338,7 @@ model WebhookConfig {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deliveries WebhookDelivery[] deliveries WebhookDelivery[]
@@index([userId]) @@index([userId, isActive])
@@map("webhook_configs") @@map("webhook_configs")
} }
...@@ -339,8 +348,8 @@ model WebhookDelivery { ...@@ -339,8 +348,8 @@ model WebhookDelivery {
crawlJobId String @map("crawl_job_id") @db.Uuid crawlJobId String @map("crawl_job_id") @db.Uuid
event String event String
payload Json payload Json
status String @default("PENDING") status WebhookDeliveryStatus @default(PENDING)
statusCode Int? @map("status_code") statusCode Int? @map("status_code")
attempt Int @default(1) attempt Int @default(1)
responseBody String? @map("response_body") responseBody String? @map("response_body")
errorMessage String? @map("error_message") errorMessage String? @map("error_message")
......
...@@ -10,7 +10,7 @@ if (!process.env.DATABASE_URL) { ...@@ -10,7 +10,7 @@ if (!process.env.DATABASE_URL) {
const isSupabase = const isSupabase =
host.includes("supabase.co") || host.includes("pooler.supabase.com"); host.includes("supabase.co") || host.includes("pooler.supabase.com");
const ssl = const ssl =
process.env.DB_SSL === "true" || isSupabase ? "&sslmode=require" : ""; (process.env.DB_SSL === "true" || isSupabase) ? "&sslmode=require" : "";
process.env.DATABASE_URL = `postgresql://${user}:${password}@${host}:${port}/${name}?schema=public${ssl}`; process.env.DATABASE_URL = `postgresql://${user}:${password}@${host}:${port}/${name}?schema=public${ssl}`;
} }
......
...@@ -12,9 +12,10 @@ import routes from "./routes"; ...@@ -12,9 +12,10 @@ 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";
import { AppError } from "./common/errors/app-error";
import { ERROR_CODE } from "./common/errors/error-code";
const app = express(); const app = express();
...@@ -34,20 +35,40 @@ app.use( ...@@ -34,20 +35,40 @@ app.use(
if (envConfig.cors.allowedOrigins.includes(origin)) { if (envConfig.cors.allowedOrigins.includes(origin)) {
return callback(null, true); return callback(null, true);
} }
return callback(null, false); return callback(
new AppError(
"Origin not allowed by CORS policy",
403,
ERROR_CODE.FORBIDDEN,
),
);
}, },
credentials: true, credentials: true,
maxAge: 86400, maxAge: 86400,
}), }),
); );
app.use(morgan(envConfig.nodeEnv === "production" ? "combined" : "dev"));
morgan.token("safe-url", (req: express.Request) => {
const url = req.originalUrl || req.url || "";
return url.replace(
/([?&](?:token|code|secret|apiKey)=)[^&]+/gi,
"$1[REDACTED]",
);
});
const morganFormat =
envConfig.nodeEnv === "production"
? ':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'
: ":method :safe-url :status :response-time ms - :res[content-length]";
app.use(morgan(morganFormat));
app.use(cookieParser()); app.use(cookieParser());
app.use(express.json()); app.use(express.json({ limit: "2mb" }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: "2mb" }));
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, maintenanceMiddleware, routes); app.use("/api/v1", rateLimitMiddleware, routes);
app.use(notFoundMiddleware); app.use(notFoundMiddleware);
app.use(errorMiddleware); app.use(errorMiddleware);
......
import { hasAdminPrivilege } from "../rbac.helper";
import { ROLES } from "../../constants/role.constant";
import { SYSTEM_ROLE_SLUGS } from "../../constants/system-role.constant";
describe("rbac.helper - hasAdminPrivilege", () => {
it("returns true for legacy string role ADMIN", () => {
expect(hasAdminPrivilege(ROLES.ADMIN)).toBe(true);
});
it("returns false for legacy string role VIEWER or CRAWLER_USER without admin dynamic roles", () => {
expect(hasAdminPrivilege(ROLES.VIEWER)).toBe(false);
expect(hasAdminPrivilege(ROLES.CRAWLER_USER)).toBe(false);
});
it("returns true if roles array contains admin or super_admin", () => {
expect(
hasAdminPrivilege(ROLES.CRAWLER_USER, [SYSTEM_ROLE_SLUGS.ADMIN]),
).toBe(true);
expect(
hasAdminPrivilege(ROLES.VIEWER, [SYSTEM_ROLE_SLUGS.SUPER_ADMIN]),
).toBe(true);
});
it("returns true for user context object with dynamic admin roles", () => {
expect(
hasAdminPrivilege({
role: ROLES.CRAWLER_USER,
roles: [SYSTEM_ROLE_SLUGS.ADMIN],
}),
).toBe(true);
});
it("returns false for user context object with regular roles", () => {
expect(
hasAdminPrivilege({
role: ROLES.CRAWLER_USER,
roles: [SYSTEM_ROLE_SLUGS.CRAWLER_USER],
}),
).toBe(false);
});
it("returns false for null or undefined", () => {
expect(hasAdminPrivilege(null)).toBe(false);
expect(hasAdminPrivilege(undefined)).toBe(false);
});
});
import { getRedisPublisher, getRedisSubscriber } from "../redis/redis-pubsub";
interface CacheEntry<T> { interface CacheEntry<T> {
data: T; data: T;
expiresAt: number; expiresAt: number;
} }
const AUTH_CACHE_INVALIDATE_CHANNEL = "auth:cache:invalidate";
class AuthorizationCache { class AuthorizationCache {
private readonly permissionCache = new Map<string, CacheEntry<string[]>>(); private readonly permissionCache = new Map<string, CacheEntry<string[]>>();
private readonly roleCache = new Map<string, CacheEntry<string[]>>(); private readonly roleCache = new Map<string, CacheEntry<string[]>>();
private readonly defaultTtlMs = 60 * 1000; // 60 seconds private readonly defaultTtlMs = 30 * 1000; // 30 seconds
getCachedPermissions(userId: string): string[] | null { getCachedPermissions(userId: string): string[] | null {
const entry = this.permissionCache.get(userId); const entry = this.permissionCache.get(userId);
...@@ -50,15 +54,70 @@ class AuthorizationCache { ...@@ -50,15 +54,70 @@ class AuthorizationCache {
}); });
} }
invalidateUser(userId: string): void { invalidateUser(userId: string, propagate = true): void {
this.permissionCache.delete(userId); this.permissionCache.delete(userId);
this.roleCache.delete(userId); this.roleCache.delete(userId);
if (propagate) {
const publisher = getRedisPublisher();
if (publisher) {
publisher
.publish(
AUTH_CACHE_INVALIDATE_CHANNEL,
JSON.stringify({ action: "invalidateUser", userId }),
)
.catch(() => {});
}
}
} }
invalidateAll(): void { invalidateAll(propagate = true): void {
this.permissionCache.clear(); this.permissionCache.clear();
this.roleCache.clear(); this.roleCache.clear();
if (propagate) {
const publisher = getRedisPublisher();
if (publisher) {
publisher
.publish(
AUTH_CACHE_INVALIDATE_CHANNEL,
JSON.stringify({ action: "invalidateAll" }),
)
.catch(() => {});
}
}
}
initRedisSubscriber(): void {
const subscriber = getRedisSubscriber();
if (!subscriber) return;
try {
subscriber.subscribe(AUTH_CACHE_INVALIDATE_CHANNEL, (err) => {
if (err) {
console.warn("[AuthCache:RedisSub] Failed to subscribe:", err);
}
});
subscriber.on("message", (channel, message) => {
if (channel === AUTH_CACHE_INVALIDATE_CHANNEL) {
try {
const data = JSON.parse(message);
if (data.action === "invalidateUser" && data.userId) {
this.invalidateUser(data.userId, false);
} else if (data.action === "invalidateAll") {
this.invalidateAll(false);
}
} catch {
// Ignore malformed messages
}
}
});
} catch {
// Ignore failure in degraded mode
}
} }
} }
export const authorizationCache = new AuthorizationCache(); export const authorizationCache = new AuthorizationCache();
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface PaginatedResult<T> {
items: T[];
meta: PaginationMeta;
}
/**
* Chuẩn hóa đối tượng phân trang trả về cho toàn bộ API backend
*/
export function buildPaginatedResponse<T>(
items: T[],
total: number,
page: number,
limit: number,
maxLimit: number = 100,
): PaginatedResult<T> {
const safeLimit = Math.min(Math.max(1, limit), maxLimit);
const totalPages = Math.max(1, Math.ceil(total / safeLimit));
return {
items,
meta: {
total,
page,
limit: safeLimit,
totalPages,
},
};
}
import { ROLES } from "../constants/role.constant";
import { SYSTEM_ROLE_SLUGS } from "../constants/system-role.constant";
export interface UserAuthContext {
role?: string;
roles?: string[];
permissions?: string[];
}
/**
* Kiểm tra xem người dùng có quyền Quản trị viên (Admin / Super Admin) hay không.
* Hỗ trợ đồng bộ cả vai trò kế thừa (legacy role string: ADMIN)
* lẫn hệ thống RBAC động đa vai trò (dynamic roles: admin, super_admin).
*/
export function hasAdminPrivilege(
userOrRole?: UserAuthContext | string | null,
roles?: string[],
): boolean {
if (!userOrRole) return false;
if (typeof userOrRole === "string") {
if (userOrRole === ROLES.ADMIN) {
return true;
}
if (
roles?.includes(SYSTEM_ROLE_SLUGS.ADMIN) ||
roles?.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
return true;
}
return false;
}
if (userOrRole.role === ROLES.ADMIN) {
return true;
}
const assignedRoles = userOrRole.roles ?? roles;
if (
assignedRoles?.includes(SYSTEM_ROLE_SLUGS.ADMIN) ||
assignedRoles?.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
return true;
}
if (
userOrRole.permissions?.includes("crawl_jobs.manage_all") ||
userOrRole.permissions?.includes("crawl_schedules.manage_all")
) {
return true;
}
return false;
}
import Redis from "ioredis";
import { envConfig } from "../../config/env.config";
let generalClient: Redis | null = null;
export function isRedisConnected(): boolean {
if (!envConfig.redis.enabled || !generalClient) return false;
return (generalClient as any).status === "ready";
}
/**
* Cung cấp Redis client dùng chung cho toàn bộ ứng dụng (Rate Limiter, Distributed Locks).
* Trả về null nếu REDIS_ENABLED=false hoặc không khởi tạo được.
*/
export function getRedisClient(): Redis | null {
if (!envConfig.redis.enabled) return null;
if (
generalClient &&
((generalClient as any).status === "end" ||
(generalClient as any).status === "close")
) {
generalClient = null;
}
if (!generalClient) {
try {
generalClient = new Redis({
host: envConfig.redis.host,
port: envConfig.redis.port,
maxRetriesPerRequest: 1,
lazyConnect: true,
connectTimeout: 2000,
retryStrategy: () => null,
enableOfflineQueue: false,
});
generalClient.on("error", () => {
// Suppress unhandled crash logs on reconnect/timeout
});
} catch {
generalClient = null;
}
}
return generalClient;
}
async function ensureConnected(client: Redis): Promise<boolean> {
const getStatus = (): string => (client as any).status;
if (getStatus() === "ready") return true;
if (getStatus() === "wait") {
try {
await client.connect();
return getStatus() === "ready";
} catch {
return false;
}
}
if (getStatus() === "connecting" || getStatus() === "connect") {
let attempts = 0;
while (getStatus() !== "ready" && attempts < 10) {
await new Promise((r) => setTimeout(r, 50));
attempts++;
}
return getStatus() === "ready";
}
return false;
}
/**
* Khởi tạo và kết nối Redis client dùng chung khi ứng dụng khởi động.
*/
export async function initRedisClient(): Promise<Redis | null> {
const client = getRedisClient();
if (!client) return null;
const ready = await ensureConnected(client);
if (!ready) {
try {
client.disconnect();
} catch {
// Bỏ qua lỗi ngắt kết nối
}
generalClient = null;
return null;
}
return client;
}
import crypto from "crypto";
const localLocks = new Map<string, string>();
const RELEASE_LOCK_LUA = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
/**
* Thu nhận khóa phân tán Redis bằng SET NX PX với Token ngẫu nhiên (chống lock hijacking).
* Nếu Redis tắt hoặc lỗi kết nối, tự động fallback an toàn sang in-memory mutex cục bộ.
*/
export async function acquireDistributedLock(
lockKey: string,
ttlMs: number = 5000,
customToken?: string,
): Promise<string | false> {
const token = customToken || crypto.randomUUID();
const client = getRedisClient();
if (client) {
try {
const ready = await ensureConnected(client);
if (ready) {
const acquired = await client.set(lockKey, token, "PX", ttlMs, "NX");
return acquired === "OK" ? token : false;
}
} catch {
// Fallback cục bộ khi Redis lỗi mạng
}
}
if (localLocks.has(lockKey)) {
return false;
}
localLocks.set(lockKey, token);
setTimeout(() => {
if (localLocks.get(lockKey) === token) {
localLocks.delete(lockKey);
}
}, ttlMs);
return token;
}
/**
* Giải phóng khóa phân tán Redis an toàn qua Lua script (chỉ xóa nếu đúng Token sở hữu).
*/
export async function releaseDistributedLock(
lockKey: string,
token?: string,
): Promise<void> {
const client = getRedisClient();
if (client) {
try {
const ready = await ensureConnected(client);
if (ready) {
if (token) {
await client.eval(RELEASE_LOCK_LUA, 1, lockKey, token);
} else {
await client.del(lockKey);
}
}
} catch {
// Bỏ qua lỗi khi Redis offline
}
}
if (!token || localLocks.get(lockKey) === token) {
localLocks.delete(lockKey);
}
}
...@@ -17,7 +17,7 @@ export const envConfig = { ...@@ -17,7 +17,7 @@ export const envConfig = {
const isSupabase = const isSupabase =
this.database.host.includes("supabase.co") || this.database.host.includes("supabase.co") ||
this.database.host.includes("pooler.supabase.com"); this.database.host.includes("pooler.supabase.com");
const sslParam = this.database.ssl || isSupabase ? "&sslmode=require" : ""; const sslParam = (this.database.ssl || isSupabase) ? "&sslmode=require" : "";
return `postgresql://${encodeURIComponent(this.database.user)}:${encodeURIComponent(this.database.password)}@${this.database.host}:${this.database.port}/${this.database.name}?schema=public${sslParam}`; return `postgresql://${encodeURIComponent(this.database.user)}:${encodeURIComponent(this.database.password)}@${this.database.host}:${this.database.port}/${this.database.name}?schema=public${sslParam}`;
}, },
jwt: { jwt: {
...@@ -41,7 +41,7 @@ export const envConfig = { ...@@ -41,7 +41,7 @@ export const envConfig = {
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d", refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
emailVerificationSecret: emailVerificationSecret:
process.env.JWT_EMAIL_VERIFICATION_SECRET || process.env.JWT_EMAIL_VERIFICATION_SECRET ||
`${process.env.JWT_ACCESS_SECRET || "default_access_secret"}-email-verify`, `${process.env.JWT_ACCESS_SECRET}-email-verify`,
}, },
firecrawl: { firecrawl: {
apiKey: process.env.FIRECRAWL_API_KEY || "", apiKey: process.env.FIRECRAWL_API_KEY || "",
......
...@@ -12,142 +12,6 @@ ...@@ -12,142 +12,6 @@
} }
], ],
"paths": { "paths": {
"/health/liveness": {
"get": {
"description": "Endpoint kiểm tra xem ứng dụng còn phản hồi hay không (dành cho Kubernetes / Docker health check).",
"responses": {
"200": {
"description": "Ứng dụng hoạt động bình thường",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"uptimeSeconds": {
"type": "integer",
"example": 3600
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"nodeVersion": {
"type": "string",
"example": "v22.14.0"
}
}
}
}
}
}
},
"tags": [
"Health"
],
"summary": "Kiểm tra liveness của service"
}
},
"/health/readiness": {
"get": {
"description": "Endpoint kiểm tra kết nối tới cơ sở dữ liệu PostgreSQL và hàng đợi Redis.",
"responses": {
"200": {
"description": "Hệ thống sẵn sàng tiếp nhận request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ready"
},
"checks": {
"type": "object",
"properties": {
"database": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 5
}
}
},
"redis": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 2
}
}
}
}
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"503": {
"description": "Hệ thống chưa sẵn sàng, dịch vụ phụ trợ gặp lỗi"
}
},
"tags": [
"Health"
],
"summary": "Kiểm tra readiness của service (PostgreSQL & Redis)"
}
},
"/health/metrics": {
"get": {
"description": "Trả về thông tin chi tiết về bộ nhớ RAM tiến trình, thời gian uptime và trạng thái các hàng đợi BullMQ.",
"responses": {
"200": {
"description": "Lấy metrics thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"memory": {
"type": "object"
},
"uptime": {
"type": "number"
},
"queues": {
"type": "object"
}
}
}
}
}
}
},
"tags": [
"Health"
],
"summary": "Xem thông số metrics hệ thống và hàng đợi"
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"description": "Xác thực email và mật khẩu để nhận Access Token và Refresh Token.", "description": "Xác thực email và mật khẩu để nhận Access Token và Refresh Token.",
...@@ -5286,6 +5150,142 @@ ...@@ -5286,6 +5150,142 @@
], ],
"summary": "Xóa template trích xuất" "summary": "Xóa template trích xuất"
} }
},
"/health/liveness": {
"get": {
"tags": [
"Health"
],
"summary": "Kiểm tra liveness của service",
"description": "Endpoint kiểm tra xem ứng dụng còn phản hồi hay không (dành cho Kubernetes / Docker health check).",
"responses": {
"200": {
"description": "Ứng dụng hoạt động bình thường",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"uptimeSeconds": {
"type": "integer",
"example": 3600
},
"timestamp": {
"type": "string",
"format": "date-time"
},
"nodeVersion": {
"type": "string",
"example": "v22.14.0"
}
}
}
}
}
}
}
}
},
"/health/readiness": {
"get": {
"tags": [
"Health"
],
"summary": "Kiểm tra readiness của service (PostgreSQL & Redis)",
"description": "Endpoint kiểm tra kết nối tới cơ sở dữ liệu PostgreSQL và hàng đợi Redis.",
"responses": {
"200": {
"description": "Hệ thống sẵn sàng tiếp nhận request",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ready"
},
"checks": {
"type": "object",
"properties": {
"database": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 5
}
}
},
"redis": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "up"
},
"latencyMs": {
"type": "integer",
"example": 2
}
}
}
}
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"503": {
"description": "Hệ thống chưa sẵn sàng, dịch vụ phụ trợ gặp lỗi"
}
}
}
},
"/health/metrics": {
"get": {
"tags": [
"Health"
],
"summary": "Xem thông số metrics hệ thống và hàng đợi",
"description": "Trả về thông tin chi tiết về bộ nhớ RAM tiến trình, thời gian uptime và trạng thái các hàng đợi BullMQ.",
"responses": {
"200": {
"description": "Lấy metrics thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"memory": {
"type": "object"
},
"uptime": {
"type": "number"
},
"queues": {
"type": "object"
}
}
}
}
}
}
}
}
} }
}, },
"components": { "components": {
......
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { ApiKeyService } from "../modules/api-keys/api-key.service"; import { ApiKeyService } from "../modules/api-keys/api-key.service";
import { PermissionService } from "../modules/permissions/permission.service";
import { authMiddleware } from "./auth.middleware"; import { authMiddleware } from "./auth.middleware";
import { AppError } from "../common/errors/app-error"; import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code"; import { ERROR_CODE } from "../common/errors/error-code";
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
const permissionService = new PermissionService();
export async function apiKeyOrAuthMiddleware( export async function apiKeyOrAuthMiddleware(
req: Request, req: Request,
...@@ -36,10 +38,17 @@ export async function apiKeyOrAuthMiddleware( ...@@ -36,10 +38,17 @@ export async function apiKeyOrAuthMiddleware(
return; return;
} }
const [roles, permissions] = await Promise.all([
permissionService.getUserRoles(user.id),
permissionService.getUserPermissions(user.id),
]);
req.user = { req.user = {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
roles,
permissions,
}; };
next(); next();
......
import { Request, Response, NextFunction } from "express"; 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) * Middleware chế độ bảo trì: Hệ thống không áp dụng chế độ bảo trì.
* Khi bảo trì được bật, chặn các request từ người dùng thông thường, * Middleware này đóng vai trò no-op pass-through.
* ngoại trừ các endpoint quản trị cấu hình, đăng nhập và health check.
*/ */
export async function maintenanceMiddleware( export async function maintenanceMiddleware(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, 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(); next();
} }
import rateLimit, { RateLimitRequestHandler } from "express-rate-limit"; import rateLimit, { RateLimitRequestHandler } from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
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"; import { systemConfigService } from "../modules/system-config/system-config.service";
import { getRedisClient, isRedisConnected } from "../common/redis/redis-client";
function createRateLimitStore(prefix: string) {
// If Redis is disabled or not connected/ready, fallback to in-memory store
if (!isRedisConnected()) {
return undefined;
}
const client = getRedisClient();
if (!client || (client as any).status !== "ready") {
return undefined;
}
try {
return new RedisStore({
// @ts-expect-error - ioredis call signature compatibility
sendCommand: async (...args: string[]) => {
if (!isRedisConnected()) {
throw new Error("Redis connection is closed or not ready");
}
return client.call(args[0], ...args.slice(1));
},
prefix,
});
} catch {
return undefined;
}
}
/** /**
* Global API rate limit per IP, configurable for each environment. * Global API rate limit per IP.
* Dùng in-memory store (MemoryStore) phù hợp cho single-instance dev/staging. * Tự động sử dụng RedisStore khi REDIS_ENABLED=true và Redis ready,
* Khi scale multi-instance, swap store sang RedisStore (rate-limit-redis). * hoặc fallback an toàn sang MemoryStore khi Redis offline.
*/ */
export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({
store: createRateLimitStore("rl:global:"),
passOnStoreError: true, // Fail-open: Never crash or block API when Redis drops
windowMs: envConfig.rateLimit.windowMs, windowMs: envConfig.rateLimit.windowMs,
max: async () => max: async () =>
systemConfigService.get<number>( systemConfigService.get<number>(
...@@ -25,6 +56,8 @@ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({ ...@@ -25,6 +56,8 @@ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({
}); });
export const authRateLimiter: RateLimitRequestHandler = rateLimit({ export const authRateLimiter: RateLimitRequestHandler = rateLimit({
store: createRateLimitStore("rl:auth:"),
passOnStoreError: true, // Fail-open: Never block authentication when Redis drops
windowMs: 60 * 1000, // 1 minute windowMs: 60 * 1000, // 1 minute
max: 10, // 10 requests per minute max: 10, // 10 requests per minute
standardHeaders: true, standardHeaders: true,
...@@ -35,3 +68,4 @@ export const authRateLimiter: RateLimitRequestHandler = rateLimit({ ...@@ -35,3 +68,4 @@ export const authRateLimiter: RateLimitRequestHandler = rateLimit({
code: ERROR_CODE.RATE_LIMIT_EXCEEDED, code: ERROR_CODE.RATE_LIMIT_EXCEEDED,
}, },
}); });
import { AuthService } from "../auth.service"; import { AuthService } from "../auth.service";
jest.mock("../../system-config/system-config.service", () => ({
systemConfigService: {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
},
}));
describe("AuthService email verification", () => { describe("AuthService email verification", () => {
const originalNodeEnv = process.env.NODE_ENV; const originalNodeEnv = process.env.NODE_ENV;
...@@ -171,6 +177,38 @@ describe("AuthService registration mail failures", () => { ...@@ -171,6 +177,38 @@ describe("AuthService registration mail failures", () => {
expect(repository.createUser).not.toHaveBeenCalled(); expect(repository.createUser).not.toHaveBeenCalled();
}); });
it("rejects registration when email belongs to a soft-deleted user (deletedAt !== null)", async () => {
const service = new AuthService();
const deletedUser = {
id: "user-deleted",
email: "test@gmail.com",
fullName: "Deleted User",
role: "CRAWLER_USER",
isActive: false,
deletedAt: new Date(),
};
const repository = {
findByEmailWithDeleted: jest.fn().mockResolvedValue(deletedUser),
createUser: jest.fn(),
};
const mutableService = service as unknown as {
repository: typeof repository;
};
mutableService.repository = repository;
await expect(
service.register({
email: "test@gmail.com",
password: "Valid@123",
fullName: "Test User",
}),
).rejects.toMatchObject({
statusCode: 409,
code: "DUPLICATE_ENTRY",
});
expect(repository.createUser).not.toHaveBeenCalled();
});
describe("AuthService forgotPassword security", () => { describe("AuthService forgotPassword security", () => {
const activeUser = { const activeUser = {
id: "user-active", id: "user-active",
...@@ -209,7 +247,7 @@ describe("AuthService registration mail failures", () => { ...@@ -209,7 +247,7 @@ describe("AuthService registration mail failures", () => {
); );
}); });
it("throws 404 NOT_FOUND and does not call mail service if user is not found", async () => { it("returns { success: true } without calling mail service when user is not found (anti-enumeration)", async () => {
const service = new AuthService(); const service = new AuthService();
const repository = { const repository = {
findByEmail: jest.fn().mockResolvedValue(null), findByEmail: jest.fn().mockResolvedValue(null),
...@@ -224,15 +262,15 @@ describe("AuthService registration mail failures", () => { ...@@ -224,15 +262,15 @@ describe("AuthService registration mail failures", () => {
mutableService.repository = repository; mutableService.repository = repository;
mutableService.mailService = mailService; mutableService.mailService = mailService;
await expect( const result = await service.forgotPassword({
service.forgotPassword({ email: "nonexistent@example.com",
email: "nonexistent@example.com", });
})
).rejects.toThrow("Email không tồn tại trong hệ thống."); expect(result).toEqual({ success: true });
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled(); expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
}); });
it("throws 403 USER_INACTIVE and does not call mail service if user is inactive", async () => { it("returns { success: true } without calling mail service when user is inactive (anti-enumeration)", async () => {
const service = new AuthService(); const service = new AuthService();
const repository = { const repository = {
findByEmail: jest findByEmail: jest
...@@ -249,9 +287,9 @@ describe("AuthService registration mail failures", () => { ...@@ -249,9 +287,9 @@ describe("AuthService registration mail failures", () => {
mutableService.repository = repository; mutableService.repository = repository;
mutableService.mailService = mailService; mutableService.mailService = mailService;
await expect( const result = await service.forgotPassword({ email: activeUser.email });
service.forgotPassword({ email: activeUser.email })
).rejects.toThrow("Tài khoản chưa được kích hoạt hoặc đã bị khóa."); expect(result).toEqual({ success: true });
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled(); expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
}); });
}); });
......
...@@ -9,6 +9,12 @@ export class AuthRepository { ...@@ -9,6 +9,12 @@ export class AuthRepository {
}); });
} }
findByEmailWithDeleted(email: string) {
return prisma.user.findFirst({
where: { email },
});
}
findById(id: string) { findById(id: string) {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { id, deletedAt: null }, where: { id, deletedAt: null },
......
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import crypto from "crypto";
import jwt, { SignOptions } from "jsonwebtoken"; import jwt, { SignOptions } from "jsonwebtoken";
import path from "path"; import path from "path";
import { Readable } from "stream"; import { Readable } from "stream";
...@@ -48,6 +49,10 @@ export class AuthService { ...@@ -48,6 +49,10 @@ export class AuthService {
private readonly crawlJobRepository = new CrawlJobRepository(); private readonly crawlJobRepository = new CrawlJobRepository();
private readonly permissionService = new PermissionService(); private readonly permissionService = new PermissionService();
private hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
private async deliverVerificationEmail( private async deliverVerificationEmail(
user: { id: string; email: string }, user: { id: string; email: string },
rollbackOnFailure = false, rollbackOnFailure = false,
...@@ -143,7 +148,7 @@ export class AuthService { ...@@ -143,7 +148,7 @@ export class AuthService {
const expiresAt = new Date(decoded.exp * 1000); const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken( await this.repository.saveRefreshToken(
user.id, user.id,
refreshToken, this.hashToken(refreshToken),
expiresAt, expiresAt,
metadata?.userAgent, metadata?.userAgent,
metadata?.ipAddress, metadata?.ipAddress,
...@@ -198,7 +203,7 @@ export class AuthService { ...@@ -198,7 +203,7 @@ export class AuthService {
try { try {
payload = jwt.verify(token, jwtConfig.refreshSecret) as AuthJwtPayload; payload = jwt.verify(token, jwtConfig.refreshSecret) as AuthJwtPayload;
} catch { } catch {
await this.repository.deleteRefreshToken(token).catch(() => {}); await this.repository.deleteRefreshToken(this.hashToken(token)).catch(() => {});
throw new AppError( throw new AppError(
"Invalid refresh token", "Invalid refresh token",
401, 401,
...@@ -206,7 +211,7 @@ export class AuthService { ...@@ -206,7 +211,7 @@ export class AuthService {
); );
} }
const savedToken = await this.repository.findRefreshToken(token); const savedToken = await this.repository.findRefreshToken(this.hashToken(token));
if (!savedToken) { if (!savedToken) {
throw new AppError( throw new AppError(
"Invalid or expired refresh token", "Invalid or expired refresh token",
...@@ -216,7 +221,7 @@ export class AuthService { ...@@ -216,7 +221,7 @@ export class AuthService {
} }
if (savedToken.expiresAt < new Date()) { if (savedToken.expiresAt < new Date()) {
await this.repository.deleteRefreshToken(token); await this.repository.deleteRefreshToken(this.hashToken(token));
throw new AppError( throw new AppError(
"Refresh token expired", "Refresh token expired",
401, 401,
...@@ -249,13 +254,13 @@ export class AuthService { ...@@ -249,13 +254,13 @@ export class AuthService {
jwtConfig.refreshExpiresIn as unknown as SignOptions["expiresIn"], jwtConfig.refreshExpiresIn as unknown as SignOptions["expiresIn"],
}); });
await this.repository.deleteRefreshToken(token); await this.repository.deleteRefreshToken(this.hashToken(token));
const decoded = jwt.decode(newRefreshToken) as { exp: number }; const decoded = jwt.decode(newRefreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000); const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken( await this.repository.saveRefreshToken(
user.id, user.id,
newRefreshToken, this.hashToken(newRefreshToken),
expiresAt, expiresAt,
metadata?.userAgent, metadata?.userAgent,
metadata?.ipAddress, metadata?.ipAddress,
...@@ -268,7 +273,7 @@ export class AuthService { ...@@ -268,7 +273,7 @@ export class AuthService {
} }
async logout(token: string) { async logout(token: string) {
await this.repository.deleteRefreshToken(token); await this.repository.deleteRefreshToken(this.hashToken(token));
} }
private createEmailVerificationToken(email: string): string { private createEmailVerificationToken(email: string): string {
...@@ -292,9 +297,19 @@ export class AuthService { ...@@ -292,9 +297,19 @@ export class AuthService {
); );
} }
const existing = await this.repository.findByEmail(data.email); const existing = this.repository.findByEmailWithDeleted
? await this.repository.findByEmailWithDeleted(data.email)
: await this.repository.findByEmail(data.email);
if (existing) { if (existing) {
if (existing.deletedAt) {
throw new AppError(
"Tài khoản với email này đã tồn tại trong hệ thống (đang ở trạng thái vô hiệu hóa/đã xóa). Vui lòng liên hệ quản trị viên để khôi phục.",
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
if (!existing.isActive) { if (!existing.isActive) {
await this.deliverVerificationEmail(existing); await this.deliverVerificationEmail(existing);
return { return {
...@@ -671,7 +686,7 @@ export class AuthService { ...@@ -671,7 +686,7 @@ export class AuthService {
const expiresAt = new Date(decoded.exp * 1000); const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken( await this.repository.saveRefreshToken(
user.id, user.id,
refreshToken, this.hashToken(refreshToken),
expiresAt, expiresAt,
metadata?.userAgent, metadata?.userAgent,
metadata?.ipAddress, metadata?.ipAddress,
...@@ -687,20 +702,11 @@ export class AuthService { ...@@ -687,20 +702,11 @@ export class AuthService {
const { email } = data; const { email } = data;
const user = await this.repository.findByEmail(email); const user = await this.repository.findByEmail(email);
if (!user) { // Uniform response: luôn trả về success, không tiết lộ tài khoản có tồn tại hay không
throw new AppError( if (!user || !user.isActive) {
"Email không tồn tại trong hệ thống.", return {
404, success: true,
ERROR_CODE.NOT_FOUND, };
);
}
if (!user.isActive) {
throw new AppError(
"Tài khoản chưa được kích hoạt hoặc đã bị khóa.",
403,
ERROR_CODE.USER_INACTIVE,
);
} }
const secret = `${jwtConfig.accessSecret}-${user.passwordHash}`; const secret = `${jwtConfig.accessSecret}-${user.passwordHash}`;
...@@ -711,7 +717,7 @@ export class AuthService { ...@@ -711,7 +717,7 @@ export class AuthService {
try { try {
await this.mailService.sendPasswordResetEmail(user.email, resetToken); await this.mailService.sendPasswordResetEmail(user.email, resetToken);
} catch (error: unknown) { } catch (error: unknown) {
console.error("[Mail] Password reset delivery failed:", error); console.error("[ALERT][Mail] Password reset delivery failed:", error);
} }
return { return {
......
...@@ -24,7 +24,7 @@ import { CrawlPageStatus } from "../../common/constants/crawl-page-status.consta ...@@ -24,7 +24,7 @@ import { CrawlPageStatus } from "../../common/constants/crawl-page-status.consta
type DiffPage = { type DiffPage = {
id: string; id: string;
url: string; url: string;
normalizedUrl: string; normalizedUrl: string | null;
contentHash: string | null; contentHash: string | null;
wordCount: number; wordCount: number;
status: CrawlPageStatus; status: CrawlPageStatus;
...@@ -45,14 +45,14 @@ export class ChangeDetectionService { ...@@ -45,14 +45,14 @@ export class ChangeDetectionService {
): DiffReportEnvelope { ): DiffReportEnvelope {
const currentPagesMap = new Map<string, DiffPage>(); const currentPagesMap = new Map<string, DiffPage>();
for (const page of currentJob.pages) { for (const page of currentJob.pages) {
const key = normalizeUrl(page.url || page.normalizedUrl).toLowerCase(); const key = normalizeUrl(page.url || page.normalizedUrl || "").toLowerCase();
currentPagesMap.set(key, page); currentPagesMap.set(key, page);
} }
const previousPagesMap = new Map<string, DiffPage>(); const previousPagesMap = new Map<string, DiffPage>();
if (previousJob) { if (previousJob) {
for (const page of previousJob.pages) { for (const page of previousJob.pages) {
const key = normalizeUrl(page.url || page.normalizedUrl).toLowerCase(); const key = normalizeUrl(page.url || page.normalizedUrl || "").toLowerCase();
previousPagesMap.set(key, page); previousPagesMap.set(key, page);
} }
} }
......
...@@ -14,6 +14,7 @@ export class CrawlExportController { ...@@ -14,6 +14,7 @@ export class CrawlExportController {
req.user.id, req.user.id,
req.user.role, req.user.role,
req.params.exportId, req.params.exportId,
req.user?.roles,
); );
await this.auditLogService.log({ await this.auditLogService.log({
...@@ -62,6 +63,7 @@ export class CrawlExportController { ...@@ -62,6 +63,7 @@ export class CrawlExportController {
req.user.id, req.user.id,
req.user.role, req.user.role,
req.params.exportId, req.params.exportId,
req.user?.roles,
); );
res.json(result); res.json(result);
} catch (error) { } catch (error) {
......
...@@ -5,6 +5,7 @@ import { ERROR_CODE } from "../../common/errors/error-code"; ...@@ -5,6 +5,7 @@ import { ERROR_CODE } from "../../common/errors/error-code";
import { ExportType } from "../../common/constants/export-type.constant"; import { ExportType } from "../../common/constants/export-type.constant";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { hasAdminPrivilege } from "../../common/helpers/rbac.helper";
export class CrawlExportService { export class CrawlExportService {
private readonly repository = new CrawlExportRepository(); private readonly repository = new CrawlExportRepository();
...@@ -14,7 +15,12 @@ export class CrawlExportService { ...@@ -14,7 +15,12 @@ export class CrawlExportService {
return this.repository.findByJobId(jobId); return this.repository.findByJobId(jobId);
} }
async findById(userId: string, role: string, id: string) { async findById(
userId: string,
role: string,
id: string,
roles?: string[],
) {
const exportRecord = await this.repository.findById(id); const exportRecord = await this.repository.findById(id);
if (!exportRecord) { if (!exportRecord) {
...@@ -30,7 +36,7 @@ export class CrawlExportService { ...@@ -30,7 +36,7 @@ export class CrawlExportService {
); );
} }
if (role !== ROLES.ADMIN && job.userId !== userId) { if (!hasAdminPrivilege(role, roles) && job.userId !== userId) {
throw new AppError("Export not found", 404, ERROR_CODE.NOT_FOUND); throw new AppError("Export not found", 404, ERROR_CODE.NOT_FOUND);
} }
...@@ -42,6 +48,7 @@ export class CrawlExportService { ...@@ -42,6 +48,7 @@ export class CrawlExportService {
role: string, role: string,
jobId: string, jobId: string,
exportType: ExportType, exportType: ExportType,
roles?: string[],
) { ) {
const job = await this.jobRepository.findById(jobId); const job = await this.jobRepository.findById(jobId);
...@@ -53,7 +60,7 @@ export class CrawlExportService { ...@@ -53,7 +60,7 @@ export class CrawlExportService {
); );
} }
if (role !== ROLES.ADMIN && job.userId !== userId) { if (!hasAdminPrivilege(role, roles) && job.userId !== userId) {
throw new AppError( throw new AppError(
"Crawl job not found", "Crawl job not found",
404, 404,
...@@ -113,8 +120,8 @@ export class CrawlExportService { ...@@ -113,8 +120,8 @@ export class CrawlExportService {
return this.repository.findAllByUser(userId, page, limit); return this.repository.findAllByUser(userId, page, limit);
} }
async delete(userId: string, role: string, id: string) { async delete(userId: string, role: string, id: string, roles?: string[]) {
const exportRecord = await this.findById(userId, role, id); const exportRecord = await this.findById(userId, role, id, roles);
if (exportRecord.filePath) { if (exportRecord.filePath) {
const { StorageFactory } = const { StorageFactory } =
......
...@@ -51,30 +51,15 @@ export class CrawlJobRepository { ...@@ -51,30 +51,15 @@ export class CrawlJobRepository {
} }
if (query.search) { if (query.search) {
const trimmedSearch = query.search.trim(); const trimmedSearch = query.search.trim();
let matchingIds: string[] = []; const isFullUuid =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
try { trimmedSearch,
const searchPattern = `%${trimmedSearch}%`; );
const matched = await prisma.$queryRaw<{ id: string }[]>`
SELECT id FROM "crawl_jobs"
WHERE id::text ILIKE ${searchPattern}
LIMIT 100
`;
matchingIds = matched.map((r) => r.id);
} catch {
const isFullUuid =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
trimmedSearch,
);
if (isFullUuid) {
matchingIds = [trimmedSearch];
}
}
where.OR = [ where.OR = [
...(isFullUuid ? [{ id: trimmedSearch }] : []),
{ startUrl: { contains: trimmedSearch, mode: "insensitive" } }, { startUrl: { contains: trimmedSearch, mode: "insensitive" } },
{ domain: { contains: trimmedSearch, mode: "insensitive" } }, { domain: { contains: trimmedSearch, mode: "insensitive" } },
...(matchingIds.length > 0 ? [{ id: { in: matchingIds } }] : []),
]; ];
} }
...@@ -221,6 +206,28 @@ export class CrawlJobRepository { ...@@ -221,6 +206,28 @@ export class CrawlJobRepository {
}); });
} }
/**
* Cập nhật hàng loạt trạng thái cho nhiều job trong 1 query duy nhất.
* Giải quyết dứt điểm vấn đề N+1 query khi auto-complete các job bị stalled (BUG-006).
*/
async batchUpdateStatus(
ids: string[],
status: CrawlJobStatus,
finishedAt: Date,
) {
if (ids.length === 0) return { count: 0 };
return prisma.crawlJob.updateMany({
where: {
id: { in: ids },
status: { not: JOB_STATUS.CANCELED },
},
data: {
status,
finishedAt,
},
});
}
updateProgress( updateProgress(
id: string, id: string,
data: { totalPages?: number; successPages?: number; failedPages?: number }, data: { totalPages?: number; successPages?: number; failedPages?: number },
...@@ -418,6 +425,12 @@ export class CrawlJobRepository { ...@@ -418,6 +425,12 @@ export class CrawlJobRepository {
} }
} }
/**
* Xóa một CrawlJob:
* Thực hiện theo mô hình "Logical soft-delete parent CrawlJob with heavy data purge" (BUG-018).
* Các dữ liệu dung lượng lớn (assets, logs, exports, pages) được hard-delete để giải phóng dung lượng đĩa và DB.
* Bản ghi gốc CrawlJob được giữ lại với cờ deletedAt và deletedBy phục vụ kiểm toán (Audit Trail) và tính toán hạn mức quota.
*/
async delete(id: string, deletedBy?: string) { async delete(id: string, deletedBy?: string) {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
await tx.crawlAsset.deleteMany({ where: { crawlJobId: id } }); await tx.crawlAsset.deleteMany({ where: { crawlJobId: id } });
......
This diff is collapsed.
...@@ -39,8 +39,6 @@ export class CrawlPageRepository { ...@@ -39,8 +39,6 @@ export class CrawlPageRepository {
{ url: { contains: query.search, mode: "insensitive" } }, { url: { contains: query.search, mode: "insensitive" } },
{ title: { contains: query.search, mode: "insensitive" } }, { title: { contains: query.search, mode: "insensitive" } },
{ description: { contains: query.search, mode: "insensitive" } }, { description: { contains: query.search, mode: "insensitive" } },
{ markdownContent: { contains: query.search, mode: "insensitive" } },
{ content: { contains: query.search, mode: "insensitive" } },
]; ];
} }
...@@ -96,7 +94,9 @@ export class CrawlPageRepository { ...@@ -96,7 +94,9 @@ export class CrawlPageRepository {
const tableConditions: Prisma.CrawlPageWhereInput[] = [ const tableConditions: Prisma.CrawlPageWhereInput[] = [
{ markdownContent: { contains: "<table", mode: insensitiveMode } }, { markdownContent: { contains: "<table", mode: insensitiveMode } },
{ content: { contains: "<table", mode: insensitiveMode } }, { content: { contains: "<table", mode: insensitiveMode } },
{ markdownContent: { contains: "|", mode: insensitiveMode } }, { markdownContent: { contains: "|---", mode: insensitiveMode } },
{ markdownContent: { contains: "| ---", mode: insensitiveMode } },
{ markdownContent: { contains: "|:---", mode: insensitiveMode } },
]; ];
if (isTrue) { if (isTrue) {
andConditions.push({ OR: tableConditions }); andConditions.push({ OR: tableConditions });
...@@ -105,7 +105,9 @@ export class CrawlPageRepository { ...@@ -105,7 +105,9 @@ export class CrawlPageRepository {
AND: [ AND: [
{ markdownContent: { not: { contains: "<table" } } }, { markdownContent: { not: { contains: "<table" } } },
{ content: { not: { contains: "<table" } } }, { content: { not: { contains: "<table" } } },
{ markdownContent: { not: { contains: "|" } } }, { markdownContent: { not: { contains: "|---" } } },
{ markdownContent: { not: { contains: "| ---" } } },
{ markdownContent: { not: { contains: "|:---" } } },
], ],
}); });
} }
...@@ -233,12 +235,17 @@ export class CrawlPageRepository { ...@@ -233,12 +235,17 @@ export class CrawlPageRepository {
contentHash?: string | null; contentHash?: string | null;
dataQualityScore?: number | null; dataQualityScore?: number | null;
warnings?: string[]; warnings?: string[];
structuredData?: Prisma.InputJsonValue;
extractedData?: Prisma.InputJsonValue; extractedData?: Prisma.InputJsonValue;
}, },
) { ) {
const { extractedData, ...rest } = data;
return prisma.crawlPage.update({ return prisma.crawlPage.update({
where: { id }, where: { id },
data, data: {
...rest,
...(extractedData !== undefined ? { structuredData: extractedData } : {}),
},
}); });
} }
...@@ -273,10 +280,17 @@ export class CrawlPageRepository { ...@@ -273,10 +280,17 @@ export class CrawlPageRepository {
dataQualityScore?: number | null; dataQualityScore?: number | null;
warnings?: string[]; warnings?: string[];
hasSensitiveData?: boolean; hasSensitiveData?: boolean;
structuredData?: Prisma.InputJsonValue;
extractedData?: Prisma.InputJsonValue;
}) { }) {
const structuredData = data.structuredData ?? data.extractedData;
const { extractedData: _unused, ...rest } = data;
return prisma.crawlPage.upsert({ return prisma.crawlPage.upsert({
where: { jobId_url: { jobId: data.jobId, url: data.url } }, where: { jobId_url: { jobId: data.jobId, url: data.url } },
create: data, create: {
...rest,
structuredData: structuredData ?? undefined,
},
update: { update: {
normalizedUrl: data.normalizedUrl, normalizedUrl: data.normalizedUrl,
title: data.title, title: data.title,
...@@ -293,6 +307,7 @@ export class CrawlPageRepository { ...@@ -293,6 +307,7 @@ export class CrawlPageRepository {
dataQualityScore: data.dataQualityScore, dataQualityScore: data.dataQualityScore,
warnings: data.warnings, warnings: data.warnings,
hasSensitiveData: data.hasSensitiveData, hasSensitiveData: data.hasSensitiveData,
structuredData: structuredData ?? undefined,
}, },
}); });
} }
......
jest.mock("../../../database/prisma.client", () => ({ jest.mock("../../../database/prisma.client", () => ({
prisma: {}, prisma: {
user: {
findFirst: jest.fn().mockResolvedValue({
id: "user-1",
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
isActive: true,
deletedAt: null,
}),
},
},
})); }));
jest.mock("../crawl-schedule.repository"); jest.mock("../crawl-schedule.repository");
......
import {
createCrawlScheduleSchema,
updateCrawlScheduleSchema,
isValidTimezone,
} from "../crawl-schedule.validation";
describe("CrawlScheduleValidation - Timezone tests (BUG-010)", () => {
it("isValidTimezone validates correct IANA timezones", () => {
expect(isValidTimezone("Asia/Ho_Chi_Minh")).toBe(true);
expect(isValidTimezone("UTC")).toBe(true);
expect(isValidTimezone("America/New_York")).toBe(true);
expect(isValidTimezone("Europe/London")).toBe(true);
});
it("isValidTimezone rejects invalid timezones", () => {
expect(isValidTimezone("UTC+999")).toBe(false);
expect(isValidTimezone("Invalid/Timezone")).toBe(false);
expect(isValidTimezone("Vietnam/Saigon_Fake")).toBe(false);
});
it("rejects invalid timezone in createCrawlScheduleSchema", () => {
const result = createCrawlScheduleSchema.safeParse({
name: "Test Schedule",
startUrl: "https://example.com",
timezone: "Invalid/Fake_Zone",
});
expect(result.success).toBe(false);
if (!result.success) {
const timezoneIssue = result.error.issues.find((i) => i.path.includes("timezone"));
expect(timezoneIssue).toBeDefined();
expect(timezoneIssue?.message).toContain("Invalid IANA timezone identifier");
}
});
it("accepts valid IANA timezone in createCrawlScheduleSchema", () => {
const result = createCrawlScheduleSchema.safeParse({
name: "Test Schedule",
startUrl: "https://example.com",
timezone: "Asia/Ho_Chi_Minh",
});
expect(result.success).toBe(true);
});
it("rejects invalid timezone in updateCrawlScheduleSchema", () => {
const result = updateCrawlScheduleSchema.safeParse({
timezone: "Fake/Timezone",
});
expect(result.success).toBe(false);
if (!result.success) {
const timezoneIssue = result.error.issues.find((i) => i.path.includes("timezone"));
expect(timezoneIssue).toBeDefined();
}
});
});
...@@ -11,6 +11,7 @@ export class CrawlScheduleController { ...@@ -11,6 +11,7 @@ export class CrawlScheduleController {
req.user!.id, req.user!.id,
req.user!.role, req.user!.role,
req.body, req.body,
req.user?.roles,
); );
res.status(201).json({ res.status(201).json({
success: true, success: true,
...@@ -28,6 +29,7 @@ export class CrawlScheduleController { ...@@ -28,6 +29,7 @@ export class CrawlScheduleController {
req.user!.id, req.user!.id,
req.user!.role, req.user!.role,
req.query as unknown as CrawlScheduleQueryDto, req.query as unknown as CrawlScheduleQueryDto,
req.user?.roles,
); );
res.json({ res.json({
success: true, success: true,
...@@ -44,6 +46,7 @@ export class CrawlScheduleController { ...@@ -44,6 +46,7 @@ export class CrawlScheduleController {
req.user!.id, req.user!.id,
req.user!.role, req.user!.role,
req.params.id, req.params.id,
req.user?.roles,
); );
res.json({ res.json({
success: true, success: true,
...@@ -61,6 +64,7 @@ export class CrawlScheduleController { ...@@ -61,6 +64,7 @@ export class CrawlScheduleController {
req.user!.role, req.user!.role,
req.params.id, req.params.id,
req.body, req.body,
req.user?.roles,
); );
res.json({ res.json({
success: true, success: true,
...@@ -74,7 +78,12 @@ export class CrawlScheduleController { ...@@ -74,7 +78,12 @@ export class CrawlScheduleController {
delete = async (req: Request, res: Response, next: NextFunction) => { delete = async (req: Request, res: Response, next: NextFunction) => {
try { try {
await this.service.delete(req.user!.id, req.user!.role, req.params.id); await this.service.delete(
req.user!.id,
req.user!.role,
req.params.id,
req.user?.roles,
);
res.json({ res.json({
success: true, success: true,
message: "Crawl schedule deleted successfully", message: "Crawl schedule deleted successfully",
...@@ -90,6 +99,7 @@ export class CrawlScheduleController { ...@@ -90,6 +99,7 @@ export class CrawlScheduleController {
req.user!.id, req.user!.id,
req.user!.role, req.user!.role,
req.params.id, req.params.id,
req.user?.roles,
); );
res.status(201).json({ res.status(201).json({
success: true, success: true,
...@@ -113,6 +123,7 @@ export class CrawlScheduleController { ...@@ -113,6 +123,7 @@ export class CrawlScheduleController {
req.params.id, req.params.id,
page, page,
limit, limit,
req.user?.roles,
); );
res.json({ res.json({
success: true, success: true,
......
...@@ -4,6 +4,16 @@ import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant"; ...@@ -4,6 +4,16 @@ import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import { CRAWL_MODE } from "../../common/constants/crawl-mode.constant"; import { CRAWL_MODE } from "../../common/constants/crawl-mode.constant";
import { SCHEDULE_FREQUENCY } from "../../common/constants/schedule-frequency.constant"; import { SCHEDULE_FREQUENCY } from "../../common/constants/schedule-frequency.constant";
export function isValidTimezone(tz?: string): boolean {
if (!tz) return true;
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
export const createCrawlScheduleSchema = z export const createCrawlScheduleSchema = z
.object({ .object({
name: z.string().trim().min(1, "Name is required").max(150), name: z.string().trim().min(1, "Name is required").max(150),
...@@ -18,7 +28,12 @@ export const createCrawlScheduleSchema = z ...@@ -18,7 +28,12 @@ export const createCrawlScheduleSchema = z
minute: z.number().int().min(0).max(59).optional().default(0), minute: z.number().int().min(0).max(59).optional().default(0),
dayOfWeek: z.number().int().min(0).max(6).optional(), dayOfWeek: z.number().int().min(0).max(6).optional(),
dayOfMonth: z.number().int().min(1).max(31).optional(), dayOfMonth: z.number().int().min(1).max(31).optional(),
timezone: z.string().trim().optional().default(DEFAULT_TIMEZONE), timezone: z
.string()
.trim()
.refine(isValidTimezone, { message: "Invalid IANA timezone identifier" })
.optional()
.default(DEFAULT_TIMEZONE),
maxPages: z.number().int().min(1).max(1000).optional().default(20), maxPages: z.number().int().min(1).max(1000).optional().default(20),
maxDepth: z.number().int().min(1).max(10).optional().default(1), maxDepth: z.number().int().min(1).max(10).optional().default(1),
urls: z.array(z.string().trim().url()).optional().default([]), urls: z.array(z.string().trim().url()).optional().default([]),
...@@ -59,7 +74,11 @@ export const updateCrawlScheduleSchema = z ...@@ -59,7 +74,11 @@ export const updateCrawlScheduleSchema = z
minute: z.number().int().min(0).max(59).optional(), minute: z.number().int().min(0).max(59).optional(),
dayOfWeek: z.number().int().min(0).max(6).optional(), dayOfWeek: z.number().int().min(0).max(6).optional(),
dayOfMonth: z.number().int().min(1).max(31).optional(), dayOfMonth: z.number().int().min(1).max(31).optional(),
timezone: z.string().trim().optional(), timezone: z
.string()
.trim()
.refine(isValidTimezone, { message: "Invalid IANA timezone identifier" })
.optional(),
maxPages: z.number().int().min(1).max(1000).optional(), maxPages: z.number().int().min(1).max(1000).optional(),
maxDepth: z.number().int().min(1).max(10).optional(), maxDepth: z.number().int().min(1).max(10).optional(),
urls: z.array(z.string().trim().url()).optional(), urls: z.array(z.string().trim().url()).optional(),
......
...@@ -21,6 +21,11 @@ import { ...@@ -21,6 +21,11 @@ import {
CronJobExecutionResultDto, CronJobExecutionResultDto,
CronJobItemDto, CronJobItemDto,
} from "./cron.dto"; } from "./cron.dto";
import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import {
getZonedDateParts,
createUtcDateFromZonedParts,
} from "../../common/helpers/schedule-calculator.helper";
export class CronService { export class CronService {
constructor( constructor(
...@@ -348,8 +353,33 @@ export class CronService { ...@@ -348,8 +353,33 @@ export class CronService {
stats: Record<string, number>; stats: Record<string, number>;
}> { }> {
const now = new Date(); const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0); const zonedParts = getZonedDateParts(now, DEFAULT_TIMEZONE);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999); // Tính ngày hôm trước theo múi giờ UTC+7 (Asia/Ho_Chi_Minh)
const prevDayLocal = new Date(
Date.UTC(zonedParts.year, zonedParts.month, zonedParts.day - 1),
);
const pYear = prevDayLocal.getUTCFullYear();
const pMonth = prevDayLocal.getUTCMonth();
const pDay = prevDayLocal.getUTCDate();
const startDate = createUtcDateFromZonedParts(
pYear,
pMonth,
pDay,
0,
0,
DEFAULT_TIMEZONE,
);
const endDate = new Date(
createUtcDateFromZonedParts(
pYear,
pMonth,
pDay,
23,
59,
DEFAULT_TIMEZONE,
).getTime() + 59999,
);
const [stats, adminEmails] = await Promise.all([ const [stats, adminEmails] = await Promise.all([
this.repository.getDigestStats(startDate, endDate), this.repository.getDigestStats(startDate, endDate),
......
...@@ -6,7 +6,11 @@ export class DashboardController { ...@@ -6,7 +6,11 @@ export class DashboardController {
getStats = async (req: Request, res: Response, next: NextFunction) => { getStats = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const result = await this.service.getStats(req.user.id, req.user.role); const result = await this.service.getStats(
req.user.id,
req.user.role,
req.user.roles,
);
res.json({ res.json({
success: true, success: true,
data: result, data: result,
......
...@@ -2,10 +2,11 @@ import { prisma } from "../../database/prisma.client"; ...@@ -2,10 +2,11 @@ import { prisma } from "../../database/prisma.client";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant"; import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { CRAWL_PAGE_STATUS } from "../../common/constants/crawl-page-status.constant"; import { CRAWL_PAGE_STATUS } from "../../common/constants/crawl-page-status.constant";
import { hasAdminPrivilege } from "../../common/helpers/rbac.helper";
export class DashboardRepository { export class DashboardRepository {
async getStats(userId: string, role: string) { async getStats(userId: string, role: string, roles?: string[]) {
const isGlobal = role === ROLES.ADMIN; const isGlobal = hasAdminPrivilege(role, roles);
const jobWhere = { const jobWhere = {
deletedAt: null, deletedAt: null,
...(isGlobal ? {} : { userId }), ...(isGlobal ? {} : { userId }),
......
...@@ -5,9 +5,9 @@ export class DashboardService { ...@@ -5,9 +5,9 @@ export class DashboardService {
private readonly repository = new DashboardRepository(); private readonly repository = new DashboardRepository();
private readonly authService = new AuthService(); private readonly authService = new AuthService();
async getStats(userId: string, role: string) { async getStats(userId: string, role: string, roles?: string[]) {
const [counts, usageData] = await Promise.all([ const [counts, usageData] = await Promise.all([
this.repository.getStats(userId, role), this.repository.getStats(userId, role, roles),
this.authService.getUsage(userId), this.authService.getUsage(userId),
]); ]);
......
...@@ -18,6 +18,13 @@ interface ParsedTable { ...@@ -18,6 +18,13 @@ interface ParsedTable {
caption: string; caption: string;
} }
function sanitizeExcelValue<T>(value: T): T {
if (typeof value === "string" && /^[=+\-@\t\r]/.test(value)) {
return `'${value}` as unknown as T;
}
return value;
}
export class XlsxExportService extends BaseExportService { export class XlsxExportService extends BaseExportService {
readonly mimeType = EXPORT_MIME_TYPES.XLSX; readonly mimeType = EXPORT_MIME_TYPES.XLSX;
...@@ -75,15 +82,15 @@ export class XlsxExportService extends BaseExportService { ...@@ -75,15 +82,15 @@ export class XlsxExportService extends BaseExportService {
const cleanText = mainContent ? stripMarkdown(mainContent) : ""; const cleanText = mainContent ? stripMarkdown(mainContent) : "";
const row = sheet.addRow({ const row = sheet.addRow({
url: page.url, url: sanitizeExcelValue(page.url),
title: page.title ?? "", title: sanitizeExcelValue(page.title ?? ""),
description: page.description ?? "", description: sanitizeExcelValue(page.description ?? ""),
status: page.status, status: page.status,
statusCode: page.statusCode ?? "", statusCode: page.statusCode ?? "",
rawMarkdown: rawMarkdown.slice(0, 500), rawMarkdown: sanitizeExcelValue(rawMarkdown.slice(0, 500)),
cleanText: cleanText.slice(0, 500), cleanText: sanitizeExcelValue(cleanText.slice(0, 500)),
mainContent: mainContent.slice(0, 500), mainContent: sanitizeExcelValue(mainContent.slice(0, 500)),
errorMessage: page.errorMessage ?? "", errorMessage: sanitizeExcelValue(page.errorMessage ?? ""),
crawledAt: page.crawledAt?.toISOString() ?? "", crawledAt: page.crawledAt?.toISOString() ?? "",
}); });
...@@ -159,7 +166,9 @@ export class XlsxExportService extends BaseExportService { ...@@ -159,7 +166,9 @@ export class XlsxExportService extends BaseExportService {
// Caption row (nếu có) // Caption row (nếu có)
let headerRowIndex = 3; let headerRowIndex = 3;
if (table.caption) { if (table.caption) {
tableSheet.getCell("A3").value = `Caption: ${table.caption}`; tableSheet.getCell("A3").value = sanitizeExcelValue(
`Caption: ${table.caption}`,
);
tableSheet.getCell("A3").font = { bold: true }; tableSheet.getCell("A3").font = { bold: true };
tableSheet.mergeCells(3, 1, 3, Math.max(table.headers.length, 1)); tableSheet.mergeCells(3, 1, 3, Math.max(table.headers.length, 1));
headerRowIndex = 4; headerRowIndex = 4;
...@@ -169,7 +178,7 @@ export class XlsxExportService extends BaseExportService { ...@@ -169,7 +178,7 @@ export class XlsxExportService extends BaseExportService {
if (table.headers.length > 0) { if (table.headers.length > 0) {
const tableHeaderRow = tableSheet.getRow(headerRowIndex); const tableHeaderRow = tableSheet.getRow(headerRowIndex);
table.headers.forEach((h, i) => { table.headers.forEach((h, i) => {
tableHeaderRow.getCell(i + 1).value = h; tableHeaderRow.getCell(i + 1).value = sanitizeExcelValue(h);
}); });
tableHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; tableHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } };
tableHeaderRow.fill = { tableHeaderRow.fill = {
...@@ -185,7 +194,7 @@ export class XlsxExportService extends BaseExportService { ...@@ -185,7 +194,7 @@ export class XlsxExportService extends BaseExportService {
for (const dataRow of table.rows) { for (const dataRow of table.rows) {
const row = tableSheet.getRow(headerRowIndex); const row = tableSheet.getRow(headerRowIndex);
dataRow.forEach((cell, i) => { dataRow.forEach((cell, i) => {
row.getCell(i + 1).value = cell; row.getCell(i + 1).value = sanitizeExcelValue(cell);
}); });
headerRowIndex++; headerRowIndex++;
} }
......
...@@ -43,54 +43,112 @@ function runSelectors( ...@@ -43,54 +43,112 @@ function runSelectors(
return { success: missingRequired.length === 0, data, missingRequired }; return { success: missingRequired.length === 0, data, missingRequired };
} }
interface TemplateCacheEntry {
value: any;
expiresAt: number;
}
const MAX_CACHE_SIZE = 1000;
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const templateCache = new Map<string, TemplateCacheEntry>();
/** /**
* Checks if an ExtractionTemplate exists for the page's domain. * Xóa cache template (dùng sau khi batch kết thúc hoặc khi cập nhật template).
* If found, runs CSS selector extraction against the page's raw HTML.
* Saves result to CrawlPage.structuredData — includes success flag and
* missingRequired list so consumers know if required fields were absent.
* Fails loudly: missingRequired fields are recorded in the result,
* and success=false signals to downstream consumers that the extraction
* did not fully satisfy the template.
* No-ops silently if no template exists for the domain or page has no HTML.
*/ */
export async function runExtractionIfTemplate( export function clearTemplateCache(): void {
jobId: string, templateCache.clear();
pageId: string, }
/**
* Lấy template theo domain có cache trong bộ nhớ để triệt tiêu N+1 queries khi crawl.
* Có cơ chế Bounded Cache (max 1000 domain) và TTL 10 phút chống Memory Leak (DoS/OOM).
*/
export async function getCachedTemplate(domain: string, userId?: string) {
const now = Date.now();
const cacheKey = `${userId || "global"}:${domain}`;
const cached = templateCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const repository = getTemplateRepository();
const template = userId
? await repository.findByUserAndDomain(userId, domain)
: await repository.findByDomain(domain);
// Evict oldest item if capacity reached
if (templateCache.size >= MAX_CACHE_SIZE) {
const firstKey = templateCache.keys().next().value;
if (firstKey) {
templateCache.delete(firstKey);
}
}
templateCache.set(cacheKey, {
value: template ?? null,
expiresAt: now + CACHE_TTL_MS,
});
return template ?? null;
}
/**
* Trích xuất dữ liệu cấu trúc theo template trên bộ nhớ mà không ghi DB.
*/
export async function extractStructuredDataIfTemplate(
pageUrl: string, pageUrl: string,
item: FirecrawlPageResult, item: FirecrawlPageResult,
userId?: string, userId?: string,
): Promise<void> { ): Promise<{
// Extraction requires raw HTML — Firecrawl returns it via the html field templateId: string;
// which is not currently surfaced in FirecrawlPageResult. We fall back to templateName: string;
// markdownContent if html is unavailable. success: boolean;
missingRequired: string[];
data: Record<string, string | null>;
extractedAt: string;
} | null> {
const html = const html =
"html" in item && typeof (item as { html?: string }).html === "string" "html" in item && typeof (item as { html?: string }).html === "string"
? (item as { html: string }).html ? (item as { html: string }).html
: (item.markdown ?? ""); : (item.markdown ?? "");
if (!html) return; if (!html) return null;
const domain = extractDomainFromUrl(pageUrl); const domain = extractDomainFromUrl(pageUrl);
if (!domain) return; if (!domain) return null;
const repository = getTemplateRepository(); const template = await getCachedTemplate(domain, userId);
const template = userId if (!template) return null;
? await repository.findByUserAndDomain(userId, domain)
: await repository.findByDomain(domain);
if (!template) return;
const fields = template.fields as unknown as ExtractionFieldDto[]; const fields = template.fields as unknown as ExtractionFieldDto[];
if (!fields || fields.length === 0) return; if (!fields || fields.length === 0) return null;
const result = runSelectors(html, fields); const result = runSelectors(html, fields);
return {
templateId: template.id,
templateName: template.name,
success: result.success,
missingRequired: result.missingRequired,
data: result.data,
extractedAt: new Date().toISOString(),
};
}
await getPageRepository().update(pageId, { /**
extractedData: { * Checks if an ExtractionTemplate exists for the page's domain.
templateId: template.id, * If found, runs CSS selector extraction against the page's raw HTML.
templateName: template.name, * Saves result to CrawlPage.extractedData.
success: result.success, */
missingRequired: result.missingRequired, export async function runExtractionIfTemplate(
data: result.data, jobId: string,
extractedAt: new Date().toISOString(), pageId: string,
}, pageUrl: string,
}); item: FirecrawlPageResult,
userId?: string,
): Promise<void> {
const extractedData = await extractStructuredDataIfTemplate(pageUrl, item, userId);
if (extractedData) {
await getPageRepository().update(pageId, {
extractedData,
});
}
} }
...@@ -5,13 +5,16 @@ import { ...@@ -5,13 +5,16 @@ import {
} from "./extraction-template.dto"; } from "./extraction-template.dto";
import { AppError } from "../../common/errors/app-error"; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code"; import { ERROR_CODE } from "../../common/errors/error-code";
import { clearTemplateCache } from "./extraction-runner";
export class ExtractionTemplateService { export class ExtractionTemplateService {
private readonly repository = new ExtractionTemplateRepository(); private readonly repository = new ExtractionTemplateRepository();
async create(userId: string, payload: CreateExtractionTemplateDto) { async create(userId: string, payload: CreateExtractionTemplateDto) {
try { try {
return await this.repository.create(userId, payload); const result = await this.repository.create(userId, payload);
clearTemplateCache();
return result;
} catch (err: unknown) { } catch (err: unknown) {
if ( if (
err && err &&
...@@ -51,11 +54,15 @@ export class ExtractionTemplateService { ...@@ -51,11 +54,15 @@ export class ExtractionTemplateService {
payload: UpdateExtractionTemplateDto, payload: UpdateExtractionTemplateDto,
) { ) {
await this.findById(userId, id); await this.findById(userId, id);
return this.repository.update(id, payload); const result = await this.repository.update(id, payload);
clearTemplateCache();
return result;
} }
async delete(userId: string, id: string) { async delete(userId: string, id: string) {
await this.findById(userId, id); await this.findById(userId, id);
return this.repository.delete(id); const result = await this.repository.delete(id);
clearTemplateCache();
return result;
} }
} }
...@@ -18,6 +18,27 @@ export class PermissionController { ...@@ -18,6 +18,27 @@ export class PermissionController {
const result = await this.service.findAll(query); const result = await this.service.findAll(query);
if (req.query.page || req.query.limit) {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.max(1, Number(req.query.limit) || 20);
const total = result.length;
const totalPages = Math.ceil(total / limit);
const paginatedItems = result.slice((page - 1) * limit, page * limit);
res.json({
success: true,
data: {
items: paginatedItems,
meta: {
total,
page,
limit,
totalPages,
},
},
});
return;
}
res.json({ res.json({
success: true, success: true,
data: result, data: result,
......
...@@ -4,7 +4,6 @@ import { UserQueryDto } from "./user.dto"; ...@@ -4,7 +4,6 @@ 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";
import { AppError } from "../../common/errors/app-error"; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code"; import { ERROR_CODE } from "../../common/errors/error-code";
...@@ -109,27 +108,6 @@ export class UserRepository { ...@@ -109,27 +108,6 @@ export class UserRepository {
maxPagesPerMonthLimit?: number | null; maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null; maxJobsPerMonthLimit?: number | null;
}): 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,
);
const defaultMaxPagesPerMonth = await systemConfigService.get<number>(
"quota.user_max_pages_per_month",
envConfig.quota.defaultMaxPagesPerMonth,
);
const defaultMaxJobsPerMonth = await systemConfigService.get<number>(
"quota.user_max_jobs_per_month",
envConfig.quota.defaultMaxJobsPerMonth,
);
return prisma.user.create({ return prisma.user.create({
data: { data: {
email: data.email, email: data.email,
...@@ -137,14 +115,18 @@ export class UserRepository { ...@@ -137,14 +115,18 @@ 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 ?? defaultMaxPages, maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages,
maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? defaultMaxJobsPerDay, maxJobsPerDayLimit:
data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay,
maxConcurrentJobsLimit: maxConcurrentJobsLimit:
data.maxConcurrentJobsLimit ?? defaultMaxConcurrentJobs, data.maxConcurrentJobsLimit ??
envConfig.quota.defaultMaxConcurrentJobs,
maxPagesPerMonthLimit: maxPagesPerMonthLimit:
data.maxPagesPerMonthLimit ?? defaultMaxPagesPerMonth, data.maxPagesPerMonthLimit ??
envConfig.quota.defaultMaxPagesPerMonth,
maxJobsPerMonthLimit: maxJobsPerMonthLimit:
data.maxJobsPerMonthLimit ?? defaultMaxJobsPerMonth, data.maxJobsPerMonthLimit ??
envConfig.quota.defaultMaxJobsPerMonth,
}, },
}); });
} }
...@@ -207,7 +189,7 @@ export class UserRepository { ...@@ -207,7 +189,7 @@ export class UserRepository {
where: { slug: user.role.toLowerCase() }, where: { slug: user.role.toLowerCase() },
})); }));
const updateData: any = { const updateData: Prisma.UserUpdateInput = {
quotaResetAt: now, quotaResetAt: now,
}; };
......
...@@ -15,6 +15,8 @@ import { ...@@ -15,6 +15,8 @@ import {
UserResponseDto, UserResponseDto,
UserQueryDto, UserQueryDto,
} from "./user.dto"; } from "./user.dto";
import { systemConfigService } from "../system-config/system-config.service";
import { envConfig } from "../../config/env.config";
interface AuditContext { interface AuditContext {
actorId?: string; actorId?: string;
...@@ -22,16 +24,31 @@ interface AuditContext { ...@@ -22,16 +24,31 @@ interface AuditContext {
userAgent?: string; userAgent?: string;
} }
interface UserRoleItem {
role?: {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
};
}
interface UserWithRoles extends User {
userRoles?: UserRoleItem[];
}
export class UserService { export class UserService {
private readonly repository = new UserRepository(); private readonly repository = new UserRepository();
private readonly roleRepository = new RoleRepository(); private readonly roleRepository = new RoleRepository();
private readonly auditLogService = new AuditLogService(); private readonly auditLogService = new AuditLogService();
private formatUser(user: any): UserResponseDto { private formatUser(user: UserWithRoles): UserResponseDto {
const roles = Array.isArray(user.userRoles) const roles = Array.isArray(user.userRoles)
? user.userRoles ? user.userRoles
.filter((ur: any) => ur.role) .filter((ur): ur is { role: NonNullable<UserRoleItem["role"]> } => Boolean(ur.role))
.map((ur: any) => ({ .map((ur) => ({
id: ur.role.id, id: ur.role.id,
name: ur.role.name, name: ur.role.name,
slug: ur.role.slug, slug: ur.role.slug,
...@@ -96,16 +113,40 @@ export class UserService { ...@@ -96,16 +113,40 @@ export class UserService {
const passwordHash = await bcrypt.hash(data.password, 10); const passwordHash = await bcrypt.hash(data.password, 10);
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,
);
const defaultMaxPagesPerMonth = await systemConfigService.get<number>(
"quota.user_max_pages_per_month",
envConfig.quota.defaultMaxPagesPerMonth,
);
const defaultMaxJobsPerMonth = await systemConfigService.get<number>(
"quota.user_max_jobs_per_month",
envConfig.quota.defaultMaxJobsPerMonth,
);
const user = await this.repository.create({ const user = await this.repository.create({
email: data.email, email: data.email,
passwordHash, passwordHash,
fullName: data.fullName, fullName: data.fullName,
role: data.role, role: data.role,
maxPagesLimit: data.maxPagesLimit, maxPagesLimit: data.maxPagesLimit ?? defaultMaxPages,
maxJobsPerDayLimit: data.maxJobsPerDayLimit, maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? defaultMaxJobsPerDay,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit, maxConcurrentJobsLimit:
maxPagesPerMonthLimit: data.maxPagesPerMonthLimit, data.maxConcurrentJobsLimit ?? defaultMaxConcurrentJobs,
maxJobsPerMonthLimit: data.maxJobsPerMonthLimit, maxPagesPerMonthLimit:
data.maxPagesPerMonthLimit ?? defaultMaxPagesPerMonth,
maxJobsPerMonthLimit:
data.maxJobsPerMonthLimit ?? defaultMaxJobsPerMonth,
}); });
// Auto assign matching default system role // Auto assign matching default system role
......
...@@ -7,7 +7,10 @@ import { getErrorMessage } from "../../common/helpers/error-mapping.helper"; ...@@ -7,7 +7,10 @@ import { getErrorMessage } from "../../common/helpers/error-mapping.helper";
import { AppError } from "../../common/errors/app-error"; import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code"; import { ERROR_CODE } from "../../common/errors/error-code";
import { WEBHOOK_DELIVERY_STATUS } from "../../common/constants/webhook.constant"; import {
WEBHOOK_DELIVERY_STATUS,
WebhookDeliveryStatus,
} from "../../common/constants/webhook.constant";
export class WebhookDeliveryService { export class WebhookDeliveryService {
private readonly repository = new WebhookRepository(); private readonly repository = new WebhookRepository();
...@@ -187,7 +190,12 @@ export class WebhookDeliveryService { ...@@ -187,7 +190,12 @@ export class WebhookDeliveryService {
async listDeliveries( async listDeliveries(
userId: string, userId: string,
query: { jobId?: string; status?: string; page?: number; limit?: number }, query: {
jobId?: string;
status?: WebhookDeliveryStatus;
page?: number;
limit?: number;
},
) { ) {
return this.repository.listDeliveries(userId, query); return this.repository.listDeliveries(userId, query);
} }
......
...@@ -3,6 +3,7 @@ import { WebhookConfigService } from "./webhook-config.service"; ...@@ -3,6 +3,7 @@ import { WebhookConfigService } from "./webhook-config.service";
import { WebhookDeliveryService } from "./webhook-delivery.service"; import { WebhookDeliveryService } from "./webhook-delivery.service";
import { AuditLogService } from "../audit-logs/audit-log.service"; import { AuditLogService } from "../audit-logs/audit-log.service";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant"; import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { WebhookDeliveryStatus } from "../../common/constants/webhook.constant";
export class WebhookController { export class WebhookController {
private readonly configService = new WebhookConfigService(); private readonly configService = new WebhookConfigService();
...@@ -142,7 +143,7 @@ export class WebhookController { ...@@ -142,7 +143,7 @@ export class WebhookController {
try { try {
const userId = req.user.id; const userId = req.user.id;
const jobId = req.query.jobId as string | undefined; const jobId = req.query.jobId as string | undefined;
const status = req.query.status as string | undefined; const status = req.query.status as WebhookDeliveryStatus | undefined;
const page = req.query.page ? Number(req.query.page) : undefined; const page = req.query.page ? Number(req.query.page) : undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined; const limit = req.query.limit ? Number(req.query.limit) : undefined;
......
import { prisma } from "../../database/prisma.client"; import { prisma } from "../../database/prisma.client";
import { WebhookConfig, WebhookDelivery, Prisma } from "@prisma/client"; import { WebhookConfig, WebhookDelivery, Prisma } from "@prisma/client";
import { WebhookDeliveryStatus } from "../../common/constants/webhook.constant";
export class WebhookRepository { export class WebhookRepository {
createConfig(data: { createConfig(data: {
...@@ -72,7 +73,7 @@ export class WebhookRepository { ...@@ -72,7 +73,7 @@ export class WebhookRepository {
crawlJobId: string; crawlJobId: string;
event: string; event: string;
payload: Prisma.InputJsonValue; payload: Prisma.InputJsonValue;
status: string; status: WebhookDeliveryStatus;
attempt: number; attempt: number;
}): Promise<WebhookDelivery> { }): Promise<WebhookDelivery> {
return prisma.webhookDelivery.create({ return prisma.webhookDelivery.create({
...@@ -106,7 +107,7 @@ export class WebhookRepository { ...@@ -106,7 +107,7 @@ export class WebhookRepository {
async listDeliveries( async listDeliveries(
userId: string, userId: string,
query: { jobId?: string; status?: string; page?: number; limit?: number }, query: { jobId?: string; status?: WebhookDeliveryStatus; page?: number; limit?: number },
) { ) {
const where: Prisma.WebhookDeliveryWhereInput = { const where: Prisma.WebhookDeliveryWhereInput = {
webhookConfig: { webhookConfig: {
......
...@@ -13,6 +13,12 @@ jest.mock("../../modules/firecrawl/firecrawl.service"); ...@@ -13,6 +13,12 @@ jest.mock("../../modules/firecrawl/firecrawl.service");
jest.mock("../../modules/crawl-pages/crawl-page-processor.service"); jest.mock("../../modules/crawl-pages/crawl-page-processor.service");
jest.mock("../../modules/crawl-pages/sensitive-scan.service"); jest.mock("../../modules/crawl-pages/sensitive-scan.service");
jest.mock("../../common/helpers/url.helper"); jest.mock("../../common/helpers/url.helper");
jest.mock("../../modules/system-config/system-config.service", () => ({
systemConfigService: {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
get: jest.fn().mockResolvedValue(null),
},
}));
import { processCrawlJob } from "../crawl.worker.processor"; import { processCrawlJob } from "../crawl.worker.processor";
......
...@@ -16,7 +16,11 @@ import { ...@@ -16,7 +16,11 @@ import {
FirecrawlPageResult, FirecrawlPageResult,
CrawlStatusResult, CrawlStatusResult,
} from "../modules/firecrawl/firecrawl.dto"; } from "../modules/firecrawl/firecrawl.dto";
import { runExtractionIfTemplate } from "../modules/extraction-templates/extraction-runner"; import {
runExtractionIfTemplate,
extractStructuredDataIfTemplate,
clearTemplateCache,
} from "../modules/extraction-templates/extraction-runner";
import { JOB_STATUS } from "../common/constants/job-status.constant"; import { JOB_STATUS } from "../common/constants/job-status.constant";
import { CRAWL_MODE } from "../common/constants/crawl-mode.constant"; 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";
...@@ -138,15 +142,34 @@ export async function persistSinglePage( ...@@ -138,15 +142,34 @@ export async function persistSinglePage(
): Promise<{ success: boolean; saved: boolean }> { ): Promise<{ success: boolean; saved: boolean }> {
try { try {
const normalized = getPageProcessor().normalize(item, jobId); const normalized = getPageProcessor().normalize(item, jobId);
const page = await getPageRepository().upsert(normalized);
await savePageAssets(jobId, page.id, item); // Quét nhạy cảm in-memory trước khi ghi DB (loại bỏ 1 lệnh update riêng)
await scanAndFlagPage( const combinedTexts = [
page.id,
normalized.markdownContent, normalized.markdownContent,
normalized.title, normalized.title,
normalized.description, normalized.description,
]
.filter(Boolean)
.join(" ");
const hasSensitiveData = combinedTexts
? getSensitiveScanner().hasSensitiveData(combinedTexts)
: false;
// Trích xuất cấu trúc in-memory theo template cache (loại bỏ 1 lệnh update và N+1 query)
const extractedData = await extractStructuredDataIfTemplate(
item.url,
item,
userId,
); );
await runExtractionIfTemplate(jobId, page.id, item.url, item, userId);
// Gom cụm 1 lần Upsert duy nhất cho mỗi trang cào
const page = await getPageRepository().upsert({
...normalized,
hasSensitiveData,
extractedData: extractedData ?? undefined,
});
await savePageAssets(jobId, page.id, item);
return { success: item.success, saved: true }; return { success: item.success, saved: true };
} catch (err: unknown) { } catch (err: unknown) {
console.error( console.error(
...@@ -798,6 +821,8 @@ export async function processCrawlJob(job: Job): Promise<void> { ...@@ -798,6 +821,8 @@ export async function processCrawlJob(job: Job): Promise<void> {
`[Worker] Failed to dispatch webhook for job ${jobId}:`, `[Worker] Failed to dispatch webhook for job ${jobId}:`,
webhookErr, webhookErr,
); );
} finally {
clearTemplateCache();
} }
} }
} }
...@@ -8,12 +8,12 @@ import apiKeyRoute from "../modules/api-keys/api-key.route"; ...@@ -8,12 +8,12 @@ import apiKeyRoute from "../modules/api-keys/api-key.route";
import webhookRoute from "../modules/webhooks/webhook.route"; import webhookRoute from "../modules/webhooks/webhook.route";
import extractionTemplateRoute from "../modules/extraction-templates/extraction-template.route"; import extractionTemplateRoute from "../modules/extraction-templates/extraction-template.route";
import crawlScheduleRoute from "../modules/crawl-schedules/crawl-schedule.route"; import crawlScheduleRoute from "../modules/crawl-schedules/crawl-schedule.route";
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"; import systemConfigRoute from "../modules/system-config/system-config.route";
import cronRoute from "../modules/cron/cron.route"; import cronRoute from "../modules/cron/cron.route";
import healthRoute from "../modules/health/health.route";
const router = Router(); const router = Router();
......
import "dotenv/config"; import "dotenv/config";
import { envConfig } from "./config/env.config"; import { envConfig } from "./config/env.config";
import Redis from "ioredis"; import { authorizationCache } from "./common/helpers/authorization-cache.helper";
async function bootstrap() { async function bootstrap() {
let isRedisAvailable = false; let isRedisAvailable = false;
if (envConfig.redis.enabled) { if (envConfig.redis.enabled) {
const redis = new Redis({ const { initRedisClient } = await import("./common/redis/redis-client");
host: envConfig.redis.host, const client = await initRedisClient();
port: envConfig.redis.port, if (client) {
maxRetriesPerRequest: 0,
lazyConnect: true,
connectTimeout: 1500,
retryStrategy: () => null,
enableOfflineQueue: false,
});
redis.on("error", () => {});
try {
await Promise.race([
redis.connect(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Redis connection timeout")), 1500)),
]);
await Promise.race([
redis.ping(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Redis ping timeout")), 1500)),
]);
await redis.quit();
isRedisAvailable = true; isRedisAvailable = true;
console.log("[Server] Redis connection confirmed."); console.log("[Server] Redis connection confirmed and client initialized.");
} catch { } else {
try { console.warn(
redis.disconnect(); "[Server] Redis is offline. Running in degraded mode without queue workers (Database & APIs active).",
} catch {} );
console.warn("[Server] Redis is offline. Running in degraded mode without queue workers (Database & APIs active).");
} }
} else { } else {
console.warn("[Server] REDIS_ENABLED is false. Running in degraded mode without queue workers (Database & APIs active)."); console.warn(
"[Server] REDIS_ENABLED is false. Running in degraded mode without queue workers (Database & APIs active).",
);
} }
// Import app so Database endpoints and Express routes are fully available // Import app so Database endpoints and Express routes are fully available
...@@ -67,6 +49,7 @@ async function bootstrap() { ...@@ -67,6 +49,7 @@ async function bootstrap() {
if (isRedisAvailable) { if (isRedisAvailable) {
systemConfigService.initRedisSubscriber(); systemConfigService.initRedisSubscriber();
authorizationCache.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