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 {
CUSTOM
}
enum WebhookDeliveryStatus {
PENDING
SUCCESS
FAILED
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
......@@ -159,7 +165,7 @@ model CrawlJob {
@@index([createdAt])
@@index([userId, status])
@@index([userId, createdAt])
@@index([scheduleId])
@@index([scheduleId, deletedAt])
@@index([deletedAt])
@@index([userId, deletedAt])
@@map("crawl_jobs")
......@@ -184,7 +190,7 @@ model CrawlPage {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
normalizedUrl String @default("") @map("normalized_url")
normalizedUrl String? @map("normalized_url")
contentHash String? @map("content_hash")
wordCount Int @default(0) @map("word_count")
dataQualityScore Int? @map("data_quality_score")
......@@ -194,7 +200,8 @@ model CrawlPage {
assets CrawlAsset[]
@@unique([jobId, url])
@@index([jobId])
@@index([jobId, status])
@@index([jobId, contentHash])
@@index([status])
@@map("crawl_pages")
}
......@@ -261,6 +268,7 @@ model CrawlJobLog {
job CrawlJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId, createdAt])
@@index([jobId, level])
@@map("crawl_job_logs")
}
......@@ -276,6 +284,7 @@ model RefreshToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("refresh_tokens")
}
......@@ -329,7 +338,7 @@ model WebhookConfig {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deliveries WebhookDelivery[]
@@index([userId])
@@index([userId, isActive])
@@map("webhook_configs")
}
......@@ -339,7 +348,7 @@ model WebhookDelivery {
crawlJobId String @map("crawl_job_id") @db.Uuid
event String
payload Json
status String @default("PENDING")
status WebhookDeliveryStatus @default(PENDING)
statusCode Int? @map("status_code")
attempt Int @default(1)
responseBody String? @map("response_body")
......
......@@ -10,7 +10,7 @@ if (!process.env.DATABASE_URL) {
const isSupabase =
host.includes("supabase.co") || host.includes("pooler.supabase.com");
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}`;
}
......
......@@ -12,9 +12,10 @@ import routes from "./routes";
import swaggerDocument from "./docs/swagger.json";
import healthRoute from "./modules/health/health.route";
import { rateLimitMiddleware } from "./middlewares/rate-limit.middleware";
import { maintenanceMiddleware } from "./middlewares/maintenance.middleware";
import { envConfig } from "./config/env.config";
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();
......@@ -34,20 +35,40 @@ app.use(
if (envConfig.cors.allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, false);
return callback(
new AppError(
"Origin not allowed by CORS policy",
403,
ERROR_CODE.FORBIDDEN,
),
);
},
credentials: true,
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(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: "2mb" }));
app.use(express.urlencoded({ extended: true, limit: "2mb" }));
app.use("/health", healthRoute);
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(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> {
data: T;
expiresAt: number;
}
const AUTH_CACHE_INVALIDATE_CHANNEL = "auth:cache:invalidate";
class AuthorizationCache {
private readonly permissionCache = 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 {
const entry = this.permissionCache.get(userId);
......@@ -50,15 +54,70 @@ class AuthorizationCache {
});
}
invalidateUser(userId: string): void {
invalidateUser(userId: string, propagate = true): void {
this.permissionCache.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.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 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 = {
const isSupabase =
this.database.host.includes("supabase.co") ||
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}`;
},
jwt: {
......@@ -41,7 +41,7 @@ export const envConfig = {
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
emailVerificationSecret:
process.env.JWT_EMAIL_VERIFICATION_SECRET ||
`${process.env.JWT_ACCESS_SECRET || "default_access_secret"}-email-verify`,
`${process.env.JWT_ACCESS_SECRET}-email-verify`,
},
firecrawl: {
apiKey: process.env.FIRECRAWL_API_KEY || "",
......
......@@ -12,142 +12,6 @@
}
],
"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": {
"post": {
"description": "Xác thực email và mật khẩu để nhận Access Token và Refresh Token.",
......@@ -5286,6 +5150,142 @@
],
"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": {
......
import { Request, Response, NextFunction } from "express";
import { ApiKeyService } from "../modules/api-keys/api-key.service";
import { PermissionService } from "../modules/permissions/permission.service";
import { authMiddleware } from "./auth.middleware";
import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code";
const apiKeyService = new ApiKeyService();
const permissionService = new PermissionService();
export async function apiKeyOrAuthMiddleware(
req: Request,
......@@ -36,10 +38,17 @@ export async function apiKeyOrAuthMiddleware(
return;
}
const [roles, permissions] = await Promise.all([
permissionService.getUserRoles(user.id),
permissionService.getUserPermissions(user.id),
]);
req.user = {
id: user.id,
email: user.email,
role: user.role,
roles,
permissions,
};
next();
......
import { Request, Response, NextFunction } from "express";
import { systemConfigService } from "../modules/system-config/system-config.service";
import { ERROR_CODE } from "../common/errors/error-code";
/**
* Middleware kiểm tra chế độ bảo trì toàn hệ thống (feature.maintenance_mode.enabled)
* Khi bảo trì được bật, chặn các request từ người dùng thông thường,
* ngoại trừ các endpoint quản trị cấu hình, đăng nhập và health check.
* Middleware chế độ bảo trì: Hệ thống không áp dụng chế độ bảo trì.
* Middleware này đóng vai trò no-op pass-through.
*/
export async function maintenanceMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
// Bỏ qua các endpoint thiết yếu để Admin vẫn có thể đăng nhập và tắt chế độ bảo trì
const publicPaths = [
"/system",
"/auth/login",
"/auth/refresh",
"/api-docs",
"/health",
];
const isPublicOrAdminExempt = publicPaths.some(
(prefix) => req.path === prefix || req.path.startsWith(prefix + "/"),
);
if (isPublicOrAdminExempt) {
return next();
}
const isMaintenanceMode = await systemConfigService.isFeatureEnabled(
"feature.maintenance_mode.enabled",
false,
);
if (isMaintenanceMode) {
// Nếu là Admin thì cho phép qua
const user = (req as any).user;
if (user?.role === "ADMIN") {
return next();
}
return res.status(503).json({
success: false,
message:
"Hệ thống đang trong chế độ bảo trì định kỳ để nâng cấp. Vui lòng quay lại sau ít phút.",
code: ERROR_CODE.INTERNAL_SERVER_ERROR,
});
}
next();
}
import rateLimit, { RateLimitRequestHandler } from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { envConfig } from "../config/env.config";
import { ERROR_CODE } from "../common/errors/error-code";
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.
* Dùng in-memory store (MemoryStore) phù hợp cho single-instance dev/staging.
* Khi scale multi-instance, swap store sang RedisStore (rate-limit-redis).
* Global API rate limit per IP.
* Tự động sử dụng RedisStore khi REDIS_ENABLED=true và Redis ready,
* hoặc fallback an toàn sang MemoryStore khi Redis offline.
*/
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,
max: async () =>
systemConfigService.get<number>(
......@@ -25,6 +56,8 @@ export const rateLimitMiddleware: 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
max: 10, // 10 requests per minute
standardHeaders: true,
......@@ -35,3 +68,4 @@ export const authRateLimiter: RateLimitRequestHandler = rateLimit({
code: ERROR_CODE.RATE_LIMIT_EXCEEDED,
},
});
import { AuthService } from "../auth.service";
jest.mock("../../system-config/system-config.service", () => ({
systemConfigService: {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
},
}));
describe("AuthService email verification", () => {
const originalNodeEnv = process.env.NODE_ENV;
......@@ -171,6 +177,38 @@ describe("AuthService registration mail failures", () => {
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", () => {
const activeUser = {
id: "user-active",
......@@ -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 repository = {
findByEmail: jest.fn().mockResolvedValue(null),
......@@ -224,15 +262,15 @@ describe("AuthService registration mail failures", () => {
mutableService.repository = repository;
mutableService.mailService = mailService;
await expect(
service.forgotPassword({
const result = await service.forgotPassword({
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();
});
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 repository = {
findByEmail: jest
......@@ -249,9 +287,9 @@ describe("AuthService registration mail failures", () => {
mutableService.repository = repository;
mutableService.mailService = mailService;
await expect(
service.forgotPassword({ email: activeUser.email })
).rejects.toThrow("Tài khoản chưa được kích hoạt hoặc đã bị khóa.");
const result = await service.forgotPassword({ email: activeUser.email });
expect(result).toEqual({ success: true });
expect(mailService.sendPasswordResetEmail).not.toHaveBeenCalled();
});
});
......
......@@ -9,6 +9,12 @@ export class AuthRepository {
});
}
findByEmailWithDeleted(email: string) {
return prisma.user.findFirst({
where: { email },
});
}
findById(id: string) {
return prisma.user.findFirst({
where: { id, deletedAt: null },
......
import bcrypt from "bcryptjs";
import crypto from "crypto";
import jwt, { SignOptions } from "jsonwebtoken";
import path from "path";
import { Readable } from "stream";
......@@ -48,6 +49,10 @@ export class AuthService {
private readonly crawlJobRepository = new CrawlJobRepository();
private readonly permissionService = new PermissionService();
private hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
private async deliverVerificationEmail(
user: { id: string; email: string },
rollbackOnFailure = false,
......@@ -143,7 +148,7 @@ export class AuthService {
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(
user.id,
refreshToken,
this.hashToken(refreshToken),
expiresAt,
metadata?.userAgent,
metadata?.ipAddress,
......@@ -198,7 +203,7 @@ export class AuthService {
try {
payload = jwt.verify(token, jwtConfig.refreshSecret) as AuthJwtPayload;
} catch {
await this.repository.deleteRefreshToken(token).catch(() => {});
await this.repository.deleteRefreshToken(this.hashToken(token)).catch(() => {});
throw new AppError(
"Invalid refresh token",
401,
......@@ -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) {
throw new AppError(
"Invalid or expired refresh token",
......@@ -216,7 +221,7 @@ export class AuthService {
}
if (savedToken.expiresAt < new Date()) {
await this.repository.deleteRefreshToken(token);
await this.repository.deleteRefreshToken(this.hashToken(token));
throw new AppError(
"Refresh token expired",
401,
......@@ -249,13 +254,13 @@ export class AuthService {
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 expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(
user.id,
newRefreshToken,
this.hashToken(newRefreshToken),
expiresAt,
metadata?.userAgent,
metadata?.ipAddress,
......@@ -268,7 +273,7 @@ export class AuthService {
}
async logout(token: string) {
await this.repository.deleteRefreshToken(token);
await this.repository.deleteRefreshToken(this.hashToken(token));
}
private createEmailVerificationToken(email: string): string {
......@@ -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.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) {
await this.deliverVerificationEmail(existing);
return {
......@@ -671,7 +686,7 @@ export class AuthService {
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(
user.id,
refreshToken,
this.hashToken(refreshToken),
expiresAt,
metadata?.userAgent,
metadata?.ipAddress,
......@@ -687,20 +702,11 @@ export class AuthService {
const { email } = data;
const user = await this.repository.findByEmail(email);
if (!user) {
throw new AppError(
"Email không tồn tại trong hệ thống.",
404,
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,
);
// Uniform response: luôn trả về success, không tiết lộ tài khoản có tồn tại hay không
if (!user || !user.isActive) {
return {
success: true,
};
}
const secret = `${jwtConfig.accessSecret}-${user.passwordHash}`;
......@@ -711,7 +717,7 @@ export class AuthService {
try {
await this.mailService.sendPasswordResetEmail(user.email, resetToken);
} catch (error: unknown) {
console.error("[Mail] Password reset delivery failed:", error);
console.error("[ALERT][Mail] Password reset delivery failed:", error);
}
return {
......
......@@ -24,7 +24,7 @@ import { CrawlPageStatus } from "../../common/constants/crawl-page-status.consta
type DiffPage = {
id: string;
url: string;
normalizedUrl: string;
normalizedUrl: string | null;
contentHash: string | null;
wordCount: number;
status: CrawlPageStatus;
......@@ -45,14 +45,14 @@ export class ChangeDetectionService {
): DiffReportEnvelope {
const currentPagesMap = new Map<string, DiffPage>();
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);
}
const previousPagesMap = new Map<string, DiffPage>();
if (previousJob) {
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);
}
}
......
......@@ -14,6 +14,7 @@ export class CrawlExportController {
req.user.id,
req.user.role,
req.params.exportId,
req.user?.roles,
);
await this.auditLogService.log({
......@@ -62,6 +63,7 @@ export class CrawlExportController {
req.user.id,
req.user.role,
req.params.exportId,
req.user?.roles,
);
res.json(result);
} catch (error) {
......
......@@ -5,6 +5,7 @@ import { ERROR_CODE } from "../../common/errors/error-code";
import { ExportType } from "../../common/constants/export-type.constant";
import { ROLES } from "../../common/constants/role.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { hasAdminPrivilege } from "../../common/helpers/rbac.helper";
export class CrawlExportService {
private readonly repository = new CrawlExportRepository();
......@@ -14,7 +15,12 @@ export class CrawlExportService {
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);
if (!exportRecord) {
......@@ -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);
}
......@@ -42,6 +48,7 @@ export class CrawlExportService {
role: string,
jobId: string,
exportType: ExportType,
roles?: string[],
) {
const job = await this.jobRepository.findById(jobId);
......@@ -53,7 +60,7 @@ export class CrawlExportService {
);
}
if (role !== ROLES.ADMIN && job.userId !== userId) {
if (!hasAdminPrivilege(role, roles) && job.userId !== userId) {
throw new AppError(
"Crawl job not found",
404,
......@@ -113,8 +120,8 @@ export class CrawlExportService {
return this.repository.findAllByUser(userId, page, limit);
}
async delete(userId: string, role: string, id: string) {
const exportRecord = await this.findById(userId, role, id);
async delete(userId: string, role: string, id: string, roles?: string[]) {
const exportRecord = await this.findById(userId, role, id, roles);
if (exportRecord.filePath) {
const { StorageFactory } =
......
......@@ -51,30 +51,15 @@ export class CrawlJobRepository {
}
if (query.search) {
const trimmedSearch = query.search.trim();
let matchingIds: string[] = [];
try {
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 = [
...(isFullUuid ? [{ id: trimmedSearch }] : []),
{ startUrl: { contains: trimmedSearch, mode: "insensitive" } },
{ domain: { contains: trimmedSearch, mode: "insensitive" } },
...(matchingIds.length > 0 ? [{ id: { in: matchingIds } }] : []),
];
}
......@@ -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(
id: string,
data: { totalPages?: number; successPages?: number; failedPages?: number },
......@@ -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) {
return prisma.$transaction(async (tx) => {
await tx.crawlAsset.deleteMany({ where: { crawlJobId: id } });
......
......@@ -21,6 +21,11 @@ import { CRAWL_MODE } from "../../common/constants/crawl-mode.constant";
import { CreateCrawlJobDto, CrawlJobQueryDto } from "./crawl-job.dto";
import { StorageFactory } from "../../common/storage/storage.factory";
import { getErrorMessage } from "../../common/helpers/error-mapping.helper";
import {
acquireDistributedLock,
releaseDistributedLock,
} from "../../common/redis/redis-client";
import { hasAdminPrivilege } from "../../common/helpers/rbac.helper";
export class CrawlJobService {
private readonly repository = new CrawlJobRepository();
......@@ -53,7 +58,7 @@ export class CrawlJobService {
);
if (
!schedule ||
(user.role !== ROLES.ADMIN && schedule.userId !== userId)
(!hasAdminPrivilege(user) && schedule.userId !== userId)
) {
throw new AppError(
"Crawl schedule not found",
......@@ -86,7 +91,18 @@ export class CrawlJobService {
}
}
if (user.role !== ROLES.ADMIN) {
const quotaLockKey = `lock:quota:${userId}`;
const acquiredQuotaLock = await acquireDistributedLock(quotaLockKey, 7000);
if (!acquiredQuotaLock) {
throw new AppError(
"Hệ thống đang xử lý yêu cầu tạo job trước đó của bạn. Vui lòng thử lại sau giây lát.",
429,
ERROR_CODE.RATE_LIMIT_EXCEEDED,
);
}
try {
if (!hasAdminPrivilege(user)) {
const requestedPages = isUrlList
? deduplicatedUrls.length
: (payload.maxPages ?? 20);
......@@ -110,9 +126,13 @@ export class CrawlJobService {
DEFAULT_TIMEZONE,
);
// Áp dụng quotaResetAt nếu được reset sau startOfDay (BUG-014)
const quotaResetAt = user.quotaResetAt ? new Date(user.quotaResetAt) : null;
const effectiveSince = quotaResetAt && quotaResetAt > startOfDay ? quotaResetAt : startOfDay;
const jobsTodayCount = await this.repository.countJobsSince(
userId,
startOfDay,
effectiveSince,
);
if (jobsTodayCount >= user.maxJobsPerDayLimit) {
......@@ -168,29 +188,52 @@ export class CrawlJobService {
await crawlQueue.add("crawl-job", { jobId: job.id }, { jobId: job.id });
return job;
} finally {
await releaseDistributedLock(
quotaLockKey,
typeof acquiredQuotaLock === "string" ? acquiredQuotaLock : undefined,
);
}
}
async findAllByUser(userId: string, role: string, query: CrawlJobQueryDto) {
const result = role === ROLES.ADMIN
async findAllByUser(
userId: string,
role: string,
query: CrawlJobQueryDto,
roles?: string[],
) {
const result = hasAdminPrivilege(role, roles)
? await this.repository.findAll(query)
: await this.repository.findAllByUser(userId, query);
// Auto-complete any jobs that reached all target pages but were left in RUNNING
// Auto-complete any jobs that reached all target pages but were left in RUNNING (Batch query: BUG-006)
const stalledIds: string[] = [];
for (const job of result.items) {
const processed = (job.successPages ?? 0) + (job.failedPages ?? 0);
const target = job.totalPages > 0 ? Math.min(job.maxPages, job.totalPages) : job.maxPages;
if (job.status === JOB_STATUS.RUNNING && job.totalPages > 0 && processed >= target) {
job.status = JOB_STATUS.COMPLETED;
void this.repository.updateStatus(job.id, JOB_STATUS.COMPLETED, {
finishedAt: job.finishedAt || new Date(),
});
stalledIds.push(job.id);
}
}
if (stalledIds.length > 0) {
void this.repository.batchUpdateStatus(
stalledIds,
JOB_STATUS.COMPLETED,
new Date(),
);
}
return result;
}
async findById(userId: string, role: string, jobId: string) {
async findById(
userId: string,
role: string,
jobId: string,
roles?: string[],
) {
const job = await this.repository.findById(jobId);
if (!job) {
......@@ -201,7 +244,7 @@ export class CrawlJobService {
);
}
if (role !== ROLES.ADMIN && job.userId !== userId) {
if (!hasAdminPrivilege(role, roles) && job.userId !== userId) {
throw new AppError(
"Crawl job not found",
404,
......@@ -230,8 +273,13 @@ export class CrawlJobService {
return job;
}
async cancel(userId: string, role: string, jobId: string) {
const job = await this.findById(userId, role, jobId);
async cancel(
userId: string,
role: string,
jobId: string,
roles?: string[],
) {
const job = await this.findById(userId, role, jobId, roles);
if (job.status === JOB_STATUS.COMPLETED) {
throw new AppError(
......@@ -247,24 +295,8 @@ export class CrawlJobService {
JOB_STATUS.CANCELED,
);
// Remove from BullMQ queue if still waiting/delayed
if (crawlQueue) {
try {
const bullJob = await crawlQueue.getJob(jobId);
if (bullJob) {
await bullJob.remove();
} else {
const waitingJobs = await crawlQueue.getJobs(["waiting", "delayed", "prioritized"]);
for (const wj of waitingJobs) {
if (wj.data?.jobId === jobId) {
await wj.remove();
}
}
}
} catch {
// Ignored
}
}
// Remove from BullMQ queue if still waiting/delayed (BUG-030)
await this.removeBullMQJob(jobId);
// For CRAWL mode: also cancel at the Firecrawl provider level to stop
// quota consumption. firecrawlJobId is saved by the worker as soon as
......@@ -282,8 +314,13 @@ export class CrawlJobService {
return updated;
}
async getDownloadFile(userId: string, role: string, jobId: string) {
const job = await this.findById(userId, role, jobId);
async getDownloadFile(
userId: string,
role: string,
jobId: string,
roles?: string[],
) {
const job = await this.findById(userId, role, jobId, roles);
if (job.status !== JOB_STATUS.COMPLETED) {
throw new AppError(
......@@ -311,8 +348,8 @@ export class CrawlJobService {
return exportService.generate(job, EXPORT_TYPE.ZIP);
}
async delete(userId: string, role: string, jobId: string) {
const job = await this.findById(userId, role, jobId);
async delete(userId: string, role: string, jobId: string, roles?: string[]) {
const job = await this.findById(userId, role, jobId, roles);
if (
job.status === JOB_STATUS.RUNNING ||
......@@ -338,35 +375,38 @@ export class CrawlJobService {
await storage.deleteFile(job.diffReportPath).catch(() => {});
}
if (crawlQueue) {
// Remove from BullMQ queue if still waiting/delayed (BUG-030)
await this.removeBullMQJob(jobId);
await this.repository.delete(jobId, userId);
return { success: true, message: "Crawl job deleted successfully" };
}
private async removeBullMQJob(jobId: string): Promise<void> {
if (!crawlQueue) return;
try {
const bullJob = await crawlQueue.getJob(jobId);
if (bullJob) {
await bullJob.remove();
} else {
return;
}
const waitingJobs = await crawlQueue.getJobs(["waiting", "delayed", "prioritized"]);
for (const wj of waitingJobs) {
if (wj.data?.jobId === jobId) {
await wj.remove();
}
}
}
} catch {
// Ignored
}
}
await this.repository.delete(jobId, userId);
return { success: true, message: "Crawl job deleted successfully" };
}
async rerun(userId: string, role: string, jobId: string, roles?: string[]) {
const existing = await this.findById(userId, role, jobId, roles);
private static readonly rerunLocks = new Set<string>();
async rerun(userId: string, role: string, jobId: string) {
const existing = await this.findById(userId, role, jobId);
const lockKey = `${userId}:${jobId}`;
if (CrawlJobService.rerunLocks.has(lockKey)) {
const lockKey = `lock:rerun:${userId}:${jobId}`;
const acquired = await acquireDistributedLock(lockKey, 5000);
if (!acquired) {
if (this.repository.findRecentActiveJob) {
const recent = await this.repository.findRecentActiveJob(
userId,
......@@ -375,6 +415,11 @@ export class CrawlJobService {
);
if (recent) return recent;
}
throw new AppError(
"Yêu cầu chạy lại job này đang được xử lý",
429,
ERROR_CODE.RATE_LIMIT_EXCEEDED,
);
}
if (this.repository.findRecentActiveJob) {
......@@ -384,11 +429,14 @@ export class CrawlJobService {
5000,
);
if (recent) {
await releaseDistributedLock(
lockKey,
typeof acquired === "string" ? acquired : undefined,
);
return recent;
}
}
CrawlJobService.rerunLocks.add(lockKey);
try {
return await this.create(userId, {
startUrl: existing.startUrl,
......@@ -398,7 +446,14 @@ export class CrawlJobService {
urls: existing.urls,
});
} finally {
setTimeout(() => CrawlJobService.rerunLocks.delete(lockKey), 3000);
setTimeout(
() =>
releaseDistributedLock(
lockKey,
typeof acquired === "string" ? acquired : undefined,
),
3000,
);
}
}
......@@ -413,3 +468,5 @@ export class CrawlJobService {
return this.repository.findLogsByJobId(jobId, page, limit);
}
}
export const crawlJobService = new CrawlJobService();
......@@ -39,8 +39,6 @@ export class CrawlPageRepository {
{ url: { contains: query.search, mode: "insensitive" } },
{ title: { 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 {
const tableConditions: Prisma.CrawlPageWhereInput[] = [
{ markdownContent: { 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) {
andConditions.push({ OR: tableConditions });
......@@ -105,7 +105,9 @@ export class CrawlPageRepository {
AND: [
{ markdownContent: { 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 {
contentHash?: string | null;
dataQualityScore?: number | null;
warnings?: string[];
structuredData?: Prisma.InputJsonValue;
extractedData?: Prisma.InputJsonValue;
},
) {
const { extractedData, ...rest } = data;
return prisma.crawlPage.update({
where: { id },
data,
data: {
...rest,
...(extractedData !== undefined ? { structuredData: extractedData } : {}),
},
});
}
......@@ -273,10 +280,17 @@ export class CrawlPageRepository {
dataQualityScore?: number | null;
warnings?: string[];
hasSensitiveData?: boolean;
structuredData?: Prisma.InputJsonValue;
extractedData?: Prisma.InputJsonValue;
}) {
const structuredData = data.structuredData ?? data.extractedData;
const { extractedData: _unused, ...rest } = data;
return prisma.crawlPage.upsert({
where: { jobId_url: { jobId: data.jobId, url: data.url } },
create: data,
create: {
...rest,
structuredData: structuredData ?? undefined,
},
update: {
normalizedUrl: data.normalizedUrl,
title: data.title,
......@@ -293,6 +307,7 @@ export class CrawlPageRepository {
dataQualityScore: data.dataQualityScore,
warnings: data.warnings,
hasSensitiveData: data.hasSensitiveData,
structuredData: structuredData ?? undefined,
},
});
}
......
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");
......
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 {
req.user!.id,
req.user!.role,
req.body,
req.user?.roles,
);
res.status(201).json({
success: true,
......@@ -28,6 +29,7 @@ export class CrawlScheduleController {
req.user!.id,
req.user!.role,
req.query as unknown as CrawlScheduleQueryDto,
req.user?.roles,
);
res.json({
success: true,
......@@ -44,6 +46,7 @@ export class CrawlScheduleController {
req.user!.id,
req.user!.role,
req.params.id,
req.user?.roles,
);
res.json({
success: true,
......@@ -61,6 +64,7 @@ export class CrawlScheduleController {
req.user!.role,
req.params.id,
req.body,
req.user?.roles,
);
res.json({
success: true,
......@@ -74,7 +78,12 @@ export class CrawlScheduleController {
delete = async (req: Request, res: Response, next: NextFunction) => {
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({
success: true,
message: "Crawl schedule deleted successfully",
......@@ -90,6 +99,7 @@ export class CrawlScheduleController {
req.user!.id,
req.user!.role,
req.params.id,
req.user?.roles,
);
res.status(201).json({
success: true,
......@@ -113,6 +123,7 @@ export class CrawlScheduleController {
req.params.id,
page,
limit,
req.user?.roles,
);
res.json({
success: true,
......
......@@ -4,6 +4,16 @@ import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import { CRAWL_MODE } from "../../common/constants/crawl-mode.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
.object({
name: z.string().trim().min(1, "Name is required").max(150),
......@@ -18,7 +28,12 @@ export const createCrawlScheduleSchema = z
minute: z.number().int().min(0).max(59).optional().default(0),
dayOfWeek: z.number().int().min(0).max(6).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),
maxDepth: z.number().int().min(1).max(10).optional().default(1),
urls: z.array(z.string().trim().url()).optional().default([]),
......@@ -59,7 +74,11 @@ export const updateCrawlScheduleSchema = z
minute: z.number().int().min(0).max(59).optional(),
dayOfWeek: z.number().int().min(0).max(6).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(),
maxDepth: z.number().int().min(1).max(10).optional(),
urls: z.array(z.string().trim().url()).optional(),
......
......@@ -21,6 +21,11 @@ import {
CronJobExecutionResultDto,
CronJobItemDto,
} from "./cron.dto";
import { DEFAULT_TIMEZONE } from "../../common/constants/timezone.constant";
import {
getZonedDateParts,
createUtcDateFromZonedParts,
} from "../../common/helpers/schedule-calculator.helper";
export class CronService {
constructor(
......@@ -348,8 +353,33 @@ export class CronService {
stats: Record<string, number>;
}> {
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0);
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999);
const zonedParts = getZonedDateParts(now, DEFAULT_TIMEZONE);
// 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([
this.repository.getDigestStats(startDate, endDate),
......
......@@ -6,7 +6,11 @@ export class DashboardController {
getStats = async (req: Request, res: Response, next: NextFunction) => {
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({
success: true,
data: result,
......
......@@ -2,10 +2,11 @@ import { prisma } from "../../database/prisma.client";
import { ROLES } from "../../common/constants/role.constant";
import { JOB_STATUS } from "../../common/constants/job-status.constant";
import { CRAWL_PAGE_STATUS } from "../../common/constants/crawl-page-status.constant";
import { hasAdminPrivilege } from "../../common/helpers/rbac.helper";
export class DashboardRepository {
async getStats(userId: string, role: string) {
const isGlobal = role === ROLES.ADMIN;
async getStats(userId: string, role: string, roles?: string[]) {
const isGlobal = hasAdminPrivilege(role, roles);
const jobWhere = {
deletedAt: null,
...(isGlobal ? {} : { userId }),
......
......@@ -5,9 +5,9 @@ export class DashboardService {
private readonly repository = new DashboardRepository();
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([
this.repository.getStats(userId, role),
this.repository.getStats(userId, role, roles),
this.authService.getUsage(userId),
]);
......
......@@ -18,6 +18,13 @@ interface ParsedTable {
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 {
readonly mimeType = EXPORT_MIME_TYPES.XLSX;
......@@ -75,15 +82,15 @@ export class XlsxExportService extends BaseExportService {
const cleanText = mainContent ? stripMarkdown(mainContent) : "";
const row = sheet.addRow({
url: page.url,
title: page.title ?? "",
description: page.description ?? "",
url: sanitizeExcelValue(page.url),
title: sanitizeExcelValue(page.title ?? ""),
description: sanitizeExcelValue(page.description ?? ""),
status: page.status,
statusCode: page.statusCode ?? "",
rawMarkdown: rawMarkdown.slice(0, 500),
cleanText: cleanText.slice(0, 500),
mainContent: mainContent.slice(0, 500),
errorMessage: page.errorMessage ?? "",
rawMarkdown: sanitizeExcelValue(rawMarkdown.slice(0, 500)),
cleanText: sanitizeExcelValue(cleanText.slice(0, 500)),
mainContent: sanitizeExcelValue(mainContent.slice(0, 500)),
errorMessage: sanitizeExcelValue(page.errorMessage ?? ""),
crawledAt: page.crawledAt?.toISOString() ?? "",
});
......@@ -159,7 +166,9 @@ export class XlsxExportService extends BaseExportService {
// Caption row (nếu có)
let headerRowIndex = 3;
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.mergeCells(3, 1, 3, Math.max(table.headers.length, 1));
headerRowIndex = 4;
......@@ -169,7 +178,7 @@ export class XlsxExportService extends BaseExportService {
if (table.headers.length > 0) {
const tableHeaderRow = tableSheet.getRow(headerRowIndex);
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.fill = {
......@@ -185,7 +194,7 @@ export class XlsxExportService extends BaseExportService {
for (const dataRow of table.rows) {
const row = tableSheet.getRow(headerRowIndex);
dataRow.forEach((cell, i) => {
row.getCell(i + 1).value = cell;
row.getCell(i + 1).value = sanitizeExcelValue(cell);
});
headerRowIndex++;
}
......
......@@ -43,54 +43,112 @@ function runSelectors(
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.
* 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.
* Xóa cache template (dùng sau khi batch kết thúc hoặc khi cập nhật template).
*/
export async function runExtractionIfTemplate(
jobId: string,
pageId: string,
export function clearTemplateCache(): void {
templateCache.clear();
}
/**
* 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,
item: FirecrawlPageResult,
userId?: string,
): Promise<void> {
// Extraction requires raw HTML — Firecrawl returns it via the html field
// which is not currently surfaced in FirecrawlPageResult. We fall back to
// markdownContent if html is unavailable.
): Promise<{
templateId: string;
templateName: string;
success: boolean;
missingRequired: string[];
data: Record<string, string | null>;
extractedAt: string;
} | null> {
const html =
"html" in item && typeof (item as { html?: string }).html === "string"
? (item as { html: string }).html
: (item.markdown ?? "");
if (!html) return;
if (!html) return null;
const domain = extractDomainFromUrl(pageUrl);
if (!domain) return;
if (!domain) return null;
const repository = getTemplateRepository();
const template = userId
? await repository.findByUserAndDomain(userId, domain)
: await repository.findByDomain(domain);
if (!template) return;
const template = await getCachedTemplate(domain, userId);
if (!template) return null;
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);
await getPageRepository().update(pageId, {
extractedData: {
return {
templateId: template.id,
templateName: template.name,
success: result.success,
missingRequired: result.missingRequired,
data: result.data,
extractedAt: new Date().toISOString(),
},
};
}
/**
* Checks if an ExtractionTemplate exists for the page's domain.
* If found, runs CSS selector extraction against the page's raw HTML.
* Saves result to CrawlPage.extractedData.
*/
export async function runExtractionIfTemplate(
jobId: string,
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 {
} from "./extraction-template.dto";
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
import { clearTemplateCache } from "./extraction-runner";
export class ExtractionTemplateService {
private readonly repository = new ExtractionTemplateRepository();
async create(userId: string, payload: CreateExtractionTemplateDto) {
try {
return await this.repository.create(userId, payload);
const result = await this.repository.create(userId, payload);
clearTemplateCache();
return result;
} catch (err: unknown) {
if (
err &&
......@@ -51,11 +54,15 @@ export class ExtractionTemplateService {
payload: UpdateExtractionTemplateDto,
) {
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) {
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 {
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({
success: true,
data: result,
......
......@@ -4,7 +4,6 @@ import { UserQueryDto } from "./user.dto";
import { envConfig } from "../../config/env.config";
import { ROLES } from "../../common/constants/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 { ERROR_CODE } from "../../common/errors/error-code";
......@@ -109,27 +108,6 @@ export class UserRepository {
maxPagesPerMonthLimit?: number | null;
maxJobsPerMonthLimit?: number | null;
}): 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({
data: {
email: data.email,
......@@ -137,14 +115,18 @@ export class UserRepository {
fullName: data.fullName,
avatarUrl: data.avatarUrl,
role: data.role ?? ROLES.CRAWLER_USER,
maxPagesLimit: data.maxPagesLimit ?? defaultMaxPages,
maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? defaultMaxJobsPerDay,
maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages,
maxJobsPerDayLimit:
data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay,
maxConcurrentJobsLimit:
data.maxConcurrentJobsLimit ?? defaultMaxConcurrentJobs,
data.maxConcurrentJobsLimit ??
envConfig.quota.defaultMaxConcurrentJobs,
maxPagesPerMonthLimit:
data.maxPagesPerMonthLimit ?? defaultMaxPagesPerMonth,
data.maxPagesPerMonthLimit ??
envConfig.quota.defaultMaxPagesPerMonth,
maxJobsPerMonthLimit:
data.maxJobsPerMonthLimit ?? defaultMaxJobsPerMonth,
data.maxJobsPerMonthLimit ??
envConfig.quota.defaultMaxJobsPerMonth,
},
});
}
......@@ -207,7 +189,7 @@ export class UserRepository {
where: { slug: user.role.toLowerCase() },
}));
const updateData: any = {
const updateData: Prisma.UserUpdateInput = {
quotaResetAt: now,
};
......
......@@ -15,6 +15,8 @@ import {
UserResponseDto,
UserQueryDto,
} from "./user.dto";
import { systemConfigService } from "../system-config/system-config.service";
import { envConfig } from "../../config/env.config";
interface AuditContext {
actorId?: string;
......@@ -22,16 +24,31 @@ interface AuditContext {
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 {
private readonly repository = new UserRepository();
private readonly roleRepository = new RoleRepository();
private readonly auditLogService = new AuditLogService();
private formatUser(user: any): UserResponseDto {
private formatUser(user: UserWithRoles): UserResponseDto {
const roles = Array.isArray(user.userRoles)
? user.userRoles
.filter((ur: any) => ur.role)
.map((ur: any) => ({
.filter((ur): ur is { role: NonNullable<UserRoleItem["role"]> } => Boolean(ur.role))
.map((ur) => ({
id: ur.role.id,
name: ur.role.name,
slug: ur.role.slug,
......@@ -96,16 +113,40 @@ export class UserService {
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({
email: data.email,
passwordHash,
fullName: data.fullName,
role: data.role,
maxPagesLimit: data.maxPagesLimit,
maxJobsPerDayLimit: data.maxJobsPerDayLimit,
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
maxPagesPerMonthLimit: data.maxPagesPerMonthLimit,
maxJobsPerMonthLimit: data.maxJobsPerMonthLimit,
maxPagesLimit: data.maxPagesLimit ?? defaultMaxPages,
maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? defaultMaxJobsPerDay,
maxConcurrentJobsLimit:
data.maxConcurrentJobsLimit ?? defaultMaxConcurrentJobs,
maxPagesPerMonthLimit:
data.maxPagesPerMonthLimit ?? defaultMaxPagesPerMonth,
maxJobsPerMonthLimit:
data.maxJobsPerMonthLimit ?? defaultMaxJobsPerMonth,
});
// Auto assign matching default system role
......
......@@ -7,7 +7,10 @@ import { getErrorMessage } from "../../common/helpers/error-mapping.helper";
import { AppError } from "../../common/errors/app-error";
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 {
private readonly repository = new WebhookRepository();
......@@ -187,7 +190,12 @@ export class WebhookDeliveryService {
async listDeliveries(
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);
}
......
......@@ -3,6 +3,7 @@ import { WebhookConfigService } from "./webhook-config.service";
import { WebhookDeliveryService } from "./webhook-delivery.service";
import { AuditLogService } from "../audit-logs/audit-log.service";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { WebhookDeliveryStatus } from "../../common/constants/webhook.constant";
export class WebhookController {
private readonly configService = new WebhookConfigService();
......@@ -142,7 +143,7 @@ export class WebhookController {
try {
const userId = req.user.id;
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 limit = req.query.limit ? Number(req.query.limit) : undefined;
......
import { prisma } from "../../database/prisma.client";
import { WebhookConfig, WebhookDelivery, Prisma } from "@prisma/client";
import { WebhookDeliveryStatus } from "../../common/constants/webhook.constant";
export class WebhookRepository {
createConfig(data: {
......@@ -72,7 +73,7 @@ export class WebhookRepository {
crawlJobId: string;
event: string;
payload: Prisma.InputJsonValue;
status: string;
status: WebhookDeliveryStatus;
attempt: number;
}): Promise<WebhookDelivery> {
return prisma.webhookDelivery.create({
......@@ -106,7 +107,7 @@ export class WebhookRepository {
async listDeliveries(
userId: string,
query: { jobId?: string; status?: string; page?: number; limit?: number },
query: { jobId?: string; status?: WebhookDeliveryStatus; page?: number; limit?: number },
) {
const where: Prisma.WebhookDeliveryWhereInput = {
webhookConfig: {
......
......@@ -13,6 +13,12 @@ jest.mock("../../modules/firecrawl/firecrawl.service");
jest.mock("../../modules/crawl-pages/crawl-page-processor.service");
jest.mock("../../modules/crawl-pages/sensitive-scan.service");
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";
......
......@@ -16,7 +16,11 @@ import {
FirecrawlPageResult,
CrawlStatusResult,
} 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 { CRAWL_MODE } from "../common/constants/crawl-mode.constant";
import { ASSET_TYPE } from "../common/constants/asset-type.constant";
......@@ -138,15 +142,34 @@ export async function persistSinglePage(
): Promise<{ success: boolean; saved: boolean }> {
try {
const normalized = getPageProcessor().normalize(item, jobId);
const page = await getPageRepository().upsert(normalized);
await savePageAssets(jobId, page.id, item);
await scanAndFlagPage(
page.id,
// Quét nhạy cảm in-memory trước khi ghi DB (loại bỏ 1 lệnh update riêng)
const combinedTexts = [
normalized.markdownContent,
normalized.title,
normalized.description,
);
await runExtractionIfTemplate(jobId, page.id, item.url, item, userId);
]
.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,
);
// 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 };
} catch (err: unknown) {
console.error(
......@@ -798,6 +821,8 @@ export async function processCrawlJob(job: Job): Promise<void> {
`[Worker] Failed to dispatch webhook for job ${jobId}:`,
webhookErr,
);
} finally {
clearTemplateCache();
}
}
}
......@@ -8,12 +8,12 @@ import apiKeyRoute from "../modules/api-keys/api-key.route";
import webhookRoute from "../modules/webhooks/webhook.route";
import extractionTemplateRoute from "../modules/extraction-templates/extraction-template.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 roleRoute from "../modules/roles/role.route";
import permissionRoute from "../modules/permissions/permission.route";
import systemConfigRoute from "../modules/system-config/system-config.route";
import cronRoute from "../modules/cron/cron.route";
import healthRoute from "../modules/health/health.route";
const router = Router();
......
import "dotenv/config";
import { envConfig } from "./config/env.config";
import Redis from "ioredis";
import { authorizationCache } from "./common/helpers/authorization-cache.helper";
async function bootstrap() {
let isRedisAvailable = false;
if (envConfig.redis.enabled) {
const redis = new Redis({
host: envConfig.redis.host,
port: envConfig.redis.port,
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();
const { initRedisClient } = await import("./common/redis/redis-client");
const client = await initRedisClient();
if (client) {
isRedisAvailable = true;
console.log("[Server] Redis connection confirmed.");
} catch {
try {
redis.disconnect();
} catch {}
console.warn("[Server] Redis is offline. Running in degraded mode without queue workers (Database & APIs active).");
console.log("[Server] Redis connection confirmed and client initialized.");
} else {
console.warn(
"[Server] Redis is offline. Running in degraded mode without queue workers (Database & APIs active).",
);
}
} 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
......@@ -67,6 +49,7 @@ async function bootstrap() {
if (isRedisAvailable) {
systemConfigService.initRedisSubscriber();
authorizationCache.initRedisSubscriber();
await import("./queues/webhook.worker");
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