Commit 96f9a925 authored by ThinhNC's avatar ThinhNC

fix(core): remediate backend audit findings across P0-P3 vulnerabilities and performance issues

parent fc8cce16
...@@ -14,8 +14,9 @@ DB_SSL=true ...@@ -14,8 +14,9 @@ DB_SSL=true
# Cách 2 (Tùy chọn): Sử dụng trực tiếp connection string từ Supabase Dashboard # Cách 2 (Tùy chọn): Sử dụng trực tiếp connection string từ Supabase Dashboard
# DATABASE_URL="postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres?sslmode=require" # DATABASE_URL="postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres?sslmode=require"
JWT_ACCESS_SECRET=change_me_access_secret # REQUIRED - minimum 32 characters each. Generate with: openssl rand -hex 32
JWT_REFRESH_SECRET=change_me_refresh_secret JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
JWT_ACCESS_EXPIRES_IN=1d JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
...@@ -60,7 +61,8 @@ SMTP_USER=your_smtp_user ...@@ -60,7 +61,8 @@ SMTP_USER=your_smtp_user
SMTP_PASS=your_16_character_app_password SMTP_PASS=your_16_character_app_password
SMTP_FROM="Data Crawler <no-reply@datacrawler.com>" SMTP_FROM="Data Crawler <no-reply@datacrawler.com>"
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# Webhook Configuration # REQUIRED - must be a 64-character lowercase hex string. Generate with: openssl rand -hex 32
WEBHOOK_ENCRYPTION_KEY=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 WEBHOOK_ENCRYPTION_KEY=
WEBHOOK_QUEUE_NAME=webhook-delivery WEBHOOK_QUEUE_NAME=webhook-delivery
This diff is collapsed.
...@@ -7,4 +7,5 @@ module.exports = { ...@@ -7,4 +7,5 @@ module.exports = {
'node-html-parser': '<rootDir>/src/__mocks__/node-html-parser.ts', 'node-html-parser': '<rootDir>/src/__mocks__/node-html-parser.ts',
}, },
modulePathIgnorePatterns: ['<rootDir>/dist/'], modulePathIgnorePatterns: ['<rootDir>/dist/'],
setupFiles: ['<rootDir>/jest.setup.ts'],
}; };
\ No newline at end of file
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret-for-jest-must-be-32-chars-long';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret-for-jest-must-be-32-chars-long';
process.env.WEBHOOK_ENCRYPTION_KEY = process.env.WEBHOOK_ENCRYPTION_KEY || 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
...@@ -248,6 +248,9 @@ model CrawlJobLog { ...@@ -248,6 +248,9 @@ model CrawlJobLog {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
job CrawlJob @relation(fields: [jobId], references: [id], onDelete: Cascade) job CrawlJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId, createdAt])
@@map("crawl_job_logs")
} }
model RefreshToken { model RefreshToken {
...@@ -279,6 +282,7 @@ model AuditLog { ...@@ -279,6 +282,7 @@ model AuditLog {
@@index([userId, createdAt]) @@index([userId, createdAt])
@@index([action]) @@index([action])
@@index([createdAt]) @@index([createdAt])
@@index([ipAddress])
@@map("audit_logs") @@map("audit_logs")
} }
......
...@@ -7,6 +7,7 @@ import swaggerUi from 'swagger-ui-express'; ...@@ -7,6 +7,7 @@ import swaggerUi from 'swagger-ui-express';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware'; import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes'; import routes from './routes';
import swaggerDocument from './docs/swagger.json'; import swaggerDocument from './docs/swagger.json';
import healthRoute from './modules/health/health.route';
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware'; import { rateLimitMiddleware } from './middlewares/rate-limit.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';
...@@ -22,8 +23,18 @@ app.use( ...@@ -22,8 +23,18 @@ app.use(
); );
app.use( app.use(
cors({ cors({
origin: envConfig.mail.frontendUrl, origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (
envConfig.cors.allowedOrigins.includes(origin) ||
envConfig.cors.allowedOrigins.includes('*')
) {
return callback(null, true);
}
return callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, credentials: true,
maxAge: 86400,
}), }),
); );
app.use(morgan('dev')); app.use(morgan('dev'));
...@@ -31,6 +42,7 @@ app.use(cookieParser()); ...@@ -31,6 +42,7 @@ app.use(cookieParser());
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use('/health', healthRoute);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use('/api/v1', rateLimitMiddleware, routes); app.use('/api/v1', rateLimitMiddleware, routes);
......
...@@ -19,6 +19,7 @@ export const AUDIT_ACTIONS = { ...@@ -19,6 +19,7 @@ export const AUDIT_ACTIONS = {
REVOKE_API_KEY: 'REVOKE_API_KEY', REVOKE_API_KEY: 'REVOKE_API_KEY',
CREATE_WEBHOOK_CONFIG: 'CREATE_WEBHOOK_CONFIG', CREATE_WEBHOOK_CONFIG: 'CREATE_WEBHOOK_CONFIG',
DELETE_WEBHOOK_CONFIG: 'DELETE_WEBHOOK_CONFIG', DELETE_WEBHOOK_CONFIG: 'DELETE_WEBHOOK_CONFIG',
REDELIVER_WEBHOOK: 'REDELIVER_WEBHOOK',
} as const; } as const;
export type AuditAction = typeof AUDIT_ACTIONS[keyof typeof AUDIT_ACTIONS]; export type AuditAction = typeof AUDIT_ACTIONS[keyof typeof AUDIT_ACTIONS];
...@@ -3,12 +3,14 @@ import { ErrorCode } from './error-code'; ...@@ -3,12 +3,14 @@ import { ErrorCode } from './error-code';
export class AppError extends Error { export class AppError extends Error {
public readonly statusCode: number; public readonly statusCode: number;
public readonly code?: ErrorCode; public readonly code?: ErrorCode;
public readonly details?: unknown;
public readonly isOperational: boolean; public readonly isOperational: boolean;
constructor(message: string, statusCode: number = 500, code?: ErrorCode) { constructor(message: string, statusCode: number = 500, code?: ErrorCode, details?: unknown) {
super(message); super(message);
this.statusCode = statusCode; this.statusCode = statusCode;
this.code = code; this.code = code;
this.details = details;
this.isOperational = true; this.isOperational = true;
Object.setPrototypeOf(this, new.target.prototype); Object.setPrototypeOf(this, new.target.prototype);
......
...@@ -88,3 +88,21 @@ export function mapCrawlError(rawError: string | null | undefined): string { ...@@ -88,3 +88,21 @@ export function mapCrawlError(rawError: string | null | undefined): string {
// Raw error được log ở tầng worker, không expose technical detail lên frontend // Raw error được log ở tầng worker, không expose technical detail lên frontend
return 'Đã xảy ra lỗi trong quá trình cào dữ liệu. Vui lòng thử lại hoặc kiểm tra URL đích.'; return 'Đã xảy ra lỗi trong quá trình cào dữ liệu. Vui lòng thử lại hoặc kiểm tra URL đích.';
} }
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
) {
return (error as { message: string }).message;
}
return String(error);
}
...@@ -158,38 +158,33 @@ export function extractDomain(url: string): string { ...@@ -158,38 +158,33 @@ export function extractDomain(url: string): string {
} }
// Secure DNS lookup for private IP addresses // Secure DNS lookup for private IP addresses
export function secureLookup( export type LookupFn = NonNullable<http.AgentOptions['lookup']>;
hostname: string,
options: any,
callback: (err: Error | null, address: any, family?: number) => void,
): void {
let actualOptions = options;
let actualCallback = callback;
if (typeof options === 'function') {
actualCallback = options;
actualOptions = {};
}
dns.lookup(hostname, actualOptions, (err, address, family) => { export const secureLookup: LookupFn = (
hostname: string,
options: dns.LookupOptions,
callback: (err: NodeJS.ErrnoException | null, address: string | dns.LookupAddress[], family: number) => void,
): void => {
dns.lookup(hostname, options, (err, address, family) => {
if (err) { if (err) {
return actualCallback(err, address, family); return callback(err, address as string | dns.LookupAddress[], family);
} }
if (Array.isArray(address)) { if (Array.isArray(address)) {
for (const addr of address) { for (const addr of address) {
if (checkAgainstPrivatePatterns(addr.address)) { if (checkAgainstPrivatePatterns(addr.address)) {
return actualCallback(new Error(`Access to private IP ${addr.address} is blocked`), [], 0); return callback(new Error(`Access to private IP ${addr.address} is blocked`) as NodeJS.ErrnoException, [], 0);
} }
} }
return actualCallback(null, address, family); return callback(null, address, family);
} else { } else {
if (checkAgainstPrivatePatterns(address)) { if (address && checkAgainstPrivatePatterns(address)) {
return actualCallback(new Error(`Access to private IP ${address} is blocked`), null, 0); return callback(new Error(`Access to private IP ${address} is blocked`) as NodeJS.ErrnoException, '', 0);
} }
return actualCallback(null, address, family); return callback(null, address, family);
} }
}); });
} };
// HTTP agent with secure lookup for DNS resolution // HTTP agent with secure lookup for DNS resolution
export const secureHttpAgent = new http.Agent({ export const secureHttpAgent = new http.Agent({
...@@ -203,7 +198,7 @@ export const secureHttpsAgent = new https.Agent({ ...@@ -203,7 +198,7 @@ export const secureHttpsAgent = new https.Agent({
lookup: secureLookup, lookup: secureLookup,
}); });
let secureAxiosInstance: any = null; let secureAxiosInstance: AxiosInstance | null = null;
// Secure Axios instance with interceptors for SSRF and redirect validation // Secure Axios instance with interceptors for SSRF and redirect validation
export function getSecureAxios(): AxiosInstance { export function getSecureAxios(): AxiosInstance {
...@@ -214,11 +209,11 @@ export function getSecureAxios(): AxiosInstance { ...@@ -214,11 +209,11 @@ export function getSecureAxios(): AxiosInstance {
}); });
if (!instance || !instance.interceptors) { if (!instance || !instance.interceptors) {
return (instance || axios) as any; return instance || axios;
} }
instance.interceptors.request.use((config: any) => { instance.interceptors.request.use((config) => {
config.beforeRedirect = (options: any) => { (config as unknown as { beforeRedirect?: (opts: { hostname?: string }) => void }).beforeRedirect = (options: { hostname?: string }) => {
if (options.hostname && checkAgainstPrivatePatterns(options.hostname)) { if (options.hostname && checkAgainstPrivatePatterns(options.hostname)) {
throw new Error(`Redirect blocked: host ${options.hostname} resolves to or is a private/local IP address`); throw new Error(`Redirect blocked: host ${options.hostname} resolves to or is a private/local IP address`);
} }
......
...@@ -19,10 +19,19 @@ export const envConfig = { ...@@ -19,10 +19,19 @@ export const envConfig = {
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: {
accessSecret: process.env.JWT_ACCESS_SECRET || 'default_access_secret', accessSecret: (() => {
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default_refresh_secret', const s = process.env.JWT_ACCESS_SECRET;
if (!s || s.length < 32) throw new Error('[Startup] JWT_ACCESS_SECRET must be set and at least 32 characters long');
return s;
})(),
refreshSecret: (() => {
const s = process.env.JWT_REFRESH_SECRET;
if (!s || s.length < 32) throw new Error('[Startup] JWT_REFRESH_SECRET must be set and at least 32 characters long');
return s;
})(),
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '1d', accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '1d',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', 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`,
}, },
firecrawl: { firecrawl: {
apiKey: process.env.FIRECRAWL_API_KEY || '', apiKey: process.env.FIRECRAWL_API_KEY || '',
...@@ -64,6 +73,12 @@ export const envConfig = { ...@@ -64,6 +73,12 @@ export const envConfig = {
defaultMaxJobsPerDay: parseInt(process.env.USER_MAX_JOBS_PER_DAY || '10', 10), defaultMaxJobsPerDay: parseInt(process.env.USER_MAX_JOBS_PER_DAY || '10', 10),
defaultMaxConcurrentJobs: parseInt(process.env.USER_MAX_CONCURRENT_JOBS || '3', 10), defaultMaxConcurrentJobs: parseInt(process.env.USER_MAX_CONCURRENT_JOBS || '3', 10),
}, },
cors: {
allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS || process.env.FRONTEND_URL || 'http://localhost:5173')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
mail: { mail: {
host: process.env.SMTP_HOST || 'smtp.mailtrap.io', host: process.env.SMTP_HOST || 'smtp.mailtrap.io',
port: parseInt(process.env.SMTP_PORT || '2525', 10), port: parseInt(process.env.SMTP_PORT || '2525', 10),
...@@ -73,7 +88,13 @@ export const envConfig = { ...@@ -73,7 +88,13 @@ export const envConfig = {
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
}, },
webhook: { webhook: {
encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY || 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', encryptionKey: (() => {
const k = process.env.WEBHOOK_ENCRYPTION_KEY;
if (!k || !/^[0-9a-fA-F]{64}$/.test(k)) {
throw new Error('[Startup] WEBHOOK_ENCRYPTION_KEY must be set as a 64-character hex string');
}
return k;
})(),
queueName: process.env.WEBHOOK_QUEUE_NAME || 'webhook-delivery', queueName: process.env.WEBHOOK_QUEUE_NAME || 'webhook-delivery',
}, },
}; };
...@@ -821,10 +821,12 @@ export const swaggerPaths: Record<string, any> = { ...@@ -821,10 +821,12 @@ export const swaggerPaths: Record<string, any> = {
'/crawl-jobs/{id}/assets': { '/crawl-jobs/{id}/assets': {
get: { get: {
tags: ['Crawl Jobs'], tags: ['Crawl Jobs'],
summary: 'Lấy danh sách assets của job', summary: 'Lấy danh sách assets của job (có phân trang)',
description: 'Lấy danh sách các assets (IMAGE, LINK, PDF) đã được thu thập trong job. Hỗ trợ lọc theo loại asset.', description: 'Lấy danh sách phân trang các assets (IMAGE, LINK, PDF...) đã được thu thập trong job. Hỗ trợ lọc theo loại asset.',
parameters: [ parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'ID của crawl job' }, { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'ID của crawl job' },
{ name: 'page', in: 'query', schema: { type: 'integer', default: 1 }, description: 'Số trang cần lấy' },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 50 }, description: 'Số lượng bản ghi mỗi trang (tối đa 500)' },
{ {
name: 'assetType', name: 'assetType',
in: 'query', in: 'query',
...@@ -843,18 +845,30 @@ export const swaggerPaths: Record<string, any> = { ...@@ -843,18 +845,30 @@ export const swaggerPaths: Record<string, any> = {
properties: { properties: {
success: { type: 'boolean', example: true }, success: { type: 'boolean', example: true },
data: { data: {
type: 'array', type: 'object',
items: { properties: {
type: 'object', items: {
properties: { type: 'array',
id: { type: 'string' }, items: {
pageId: { type: 'string', nullable: true }, type: 'object',
crawlJobId: { type: 'string' }, properties: {
assetType: { type: 'string', enum: ['IMAGE', 'LINK', 'PDF', 'FILE', 'VIDEO', 'OTHER'] }, id: { type: 'string' },
url: { type: 'string' }, pageId: { type: 'string', nullable: true },
altText: { type: 'string', nullable: true }, crawlJobId: { type: 'string' },
mimeType: { type: 'string', nullable: true }, assetType: { type: 'string', enum: ['IMAGE', 'LINK', 'PDF', 'FILE', 'VIDEO', 'OTHER'] },
createdAt: { type: 'string', format: 'date-time' } url: { type: 'string' },
altText: { type: 'string', nullable: true },
mimeType: { type: 'string', nullable: true },
createdAt: { type: 'string', format: 'date-time' }
}
}
},
meta: {
type: 'object',
properties: {
page: { type: 'integer' },
limit: { type: 'integer' }
}
} }
} }
} }
...@@ -1285,7 +1299,13 @@ export const swaggerPaths: Record<string, any> = { ...@@ -1285,7 +1299,13 @@ export const swaggerPaths: Record<string, any> = {
description: 'Lấy báo cáo thay đổi thành công', description: 'Lấy báo cáo thay đổi thành công',
content: { content: {
'application/json': { 'application/json': {
schema: { $ref: '#/components/schemas/DiffReport' } schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
data: { $ref: '#/components/schemas/DiffReport' }
}
}
} }
} }
}, },
......
...@@ -12,12 +12,32 @@ ...@@ -12,12 +12,32 @@
} }
], ],
"paths": { "paths": {
"/health": { "/health/liveness": {
"get": { "get": {
"description": "", "description": "",
"responses": { "responses": {
"200": { "default": {
"description": "OK" "description": ""
}
}
}
},
"/health/readiness": {
"get": {
"description": "",
"responses": {
"default": {
"description": ""
}
}
}
},
"/health/metrics": {
"get": {
"description": "",
"responses": {
"default": {
"description": ""
} }
} }
} }
...@@ -1008,6 +1028,33 @@ ...@@ -1008,6 +1028,33 @@
"summary": "Lấy thông tin chi tiết một crawl job" "summary": "Lấy thông tin chi tiết một crawl job"
} }
}, },
"/crawl-jobs/{id}/events": {
"get": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "x-api-key",
"in": "header",
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"description": ""
}
}
}
},
"/crawl-jobs/{id}/cancel": { "/crawl-jobs/{id}/cancel": {
"post": { "post": {
"description": "Hủy một job đang ở trạng thái chờ hoặc đang chạy.", "description": "Hủy một job đang ở trạng thái chờ hoặc đang chạy.",
...@@ -1795,7 +1842,7 @@ ...@@ -1795,7 +1842,7 @@
}, },
"/crawl-jobs/{id}/assets": { "/crawl-jobs/{id}/assets": {
"get": { "get": {
"description": "Lấy danh sách các assets (IMAGE, LINK, PDF) đã được thu thập trong job. Hỗ trợ lọc theo loại asset.", "description": "Lấy danh sách phân trang các assets (IMAGE, LINK, PDF...) đã được thu thập trong job. Hỗ trợ lọc theo loại asset.",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
...@@ -1806,6 +1853,24 @@ ...@@ -1806,6 +1853,24 @@
}, },
"description": "ID của crawl job" "description": "ID của crawl job"
}, },
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1
},
"description": "Số trang cần lấy"
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"default": 50
},
"description": "Số lượng bản ghi mỗi trang (tối đa 500)"
},
{ {
"name": "assetType", "name": "assetType",
"in": "query", "in": "query",
...@@ -1837,45 +1902,61 @@ ...@@ -1837,45 +1902,61 @@
"example": true "example": true
}, },
"data": { "data": {
"type": "array", "type": "object",
"items": { "properties": {
"type": "object", "items": {
"properties": { "type": "array",
"id": { "items": {
"type": "string" "type": "object",
}, "properties": {
"pageId": { "id": {
"type": "string", "type": "string"
"nullable": true },
}, "pageId": {
"crawlJobId": { "type": "string",
"type": "string" "nullable": true
}, },
"assetType": { "crawlJobId": {
"type": "string", "type": "string"
"enum": [ },
"IMAGE", "assetType": {
"LINK", "type": "string",
"PDF", "enum": [
"FILE", "IMAGE",
"VIDEO", "LINK",
"OTHER" "PDF",
] "FILE",
}, "VIDEO",
"url": { "OTHER"
"type": "string" ]
}, },
"altText": { "url": {
"type": "string", "type": "string"
"nullable": true },
}, "altText": {
"mimeType": { "type": "string",
"type": "string", "nullable": true
"nullable": true },
}, "mimeType": {
"createdAt": { "type": "string",
"type": "string", "nullable": true
"format": "date-time" },
"createdAt": {
"type": "string",
"format": "date-time"
}
}
}
},
"meta": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"limit": {
"type": "integer"
}
} }
} }
} }
...@@ -1898,7 +1979,7 @@ ...@@ -1898,7 +1979,7 @@
"tags": [ "tags": [
"Crawl Jobs" "Crawl Jobs"
], ],
"summary": "Lấy danh sách assets của job" "summary": "Lấy danh sách assets của job (có phân trang)"
} }
}, },
"/crawl-jobs/{id}/diff": { "/crawl-jobs/{id}/diff": {
...@@ -1929,7 +2010,16 @@ ...@@ -1929,7 +2010,16 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/DiffReport" "type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"$ref": "#/components/schemas/DiffReport"
}
}
} }
} }
} }
...@@ -2948,6 +3038,26 @@ ...@@ -2948,6 +3038,26 @@
"summary": "Xem lịch sử gửi Webhook" "summary": "Xem lịch sử gửi Webhook"
} }
}, },
"/webhooks/deliveries/{id}/redeliver": {
"post": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"description": ""
}
}
}
},
"/extraction-templates": { "/extraction-templates": {
"post": { "post": {
"description": "", "description": "",
......
...@@ -542,7 +542,7 @@ async function generate() { ...@@ -542,7 +542,7 @@ async function generate() {
const endpoint = swaggerJson.paths[path][method]; const endpoint = swaggerJson.paths[path][method];
if (endpoint.parameters) { if (endpoint.parameters) {
endpoint.parameters = endpoint.parameters.filter( endpoint.parameters = endpoint.parameters.filter(
(param: any) => param.name.toLowerCase() !== 'authorization' (param: { name?: string }) => param.name?.toLowerCase() !== 'authorization'
); );
// Nếu mảng parameters rỗng thì xóa luôn thuộc tính parameters // Nếu mảng parameters rỗng thì xóa luôn thuộc tính parameters
if (endpoint.parameters.length === 0) { if (endpoint.parameters.length === 0) {
......
import { z } from 'zod';
import { validate, validateQuery } from '../validate.middleware';
import { errorMiddleware } from '../error.middleware';
import { AppError } from '../../common/errors/app-error';
describe('Validation Middleware', () => {
const schema = z.object({
name: z.string().min(3),
age: z.number().int().min(18),
});
it('passes valid request body to next()', () => {
const req: any = { body: { name: 'Alice', age: 25 } };
const res: any = {};
const next = jest.fn();
validate(schema)(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(req.body).toEqual({ name: 'Alice', age: 25 });
});
it('attaches structured error details to AppError on invalid body', () => {
const req: any = { body: { name: 'Al', age: 15 } };
const res: any = {};
const next = jest.fn();
validate(schema)(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(AppError));
const error: AppError = next.mock.calls[0][0];
expect(error.statusCode).toBe(422);
expect(error.details).toEqual([
{ field: 'name', message: 'String must contain at least 3 character(s)' },
{ field: 'age', message: 'Number must be greater than or equal to 18' },
]);
});
it('formats JSON response with errors array in errorMiddleware', () => {
const error = new AppError('Validation error', 422, 'VALIDATION_ERROR' as any, [
{ field: 'email', message: 'Invalid email' },
]);
const req: any = {};
const res: any = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const next = jest.fn();
errorMiddleware(error, req, res, next);
expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith({
success: false,
message: 'Validation error',
code: 'VALIDATION_ERROR',
errors: [{ field: 'email', message: 'Invalid email' }],
});
});
it('validates request path params and passes valid params to next()', () => {
const paramSchema = z.object({
id: z.string().uuid('ID must be a valid UUID'),
});
const req: any = { params: { id: '123e4567-e89b-12d3-a456-426614174000' } };
const res: any = {};
const next = jest.fn();
const { validateParams } = require('../validate.middleware');
validateParams(paramSchema)(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(req.params).toEqual({ id: '123e4567-e89b-12d3-a456-426614174000' });
});
it('rejects invalid path params with 422 AppError', () => {
const paramSchema = z.object({
id: z.string().uuid('ID must be a valid UUID'),
});
const req: any = { params: { id: 'invalid-not-uuid' } };
const res: any = {};
const next = jest.fn();
const { validateParams } = require('../validate.middleware');
validateParams(paramSchema)(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(AppError));
const error: AppError = next.mock.calls[0][0];
expect(error.statusCode).toBe(422);
expect(error.details).toEqual([
{ field: 'id', message: 'ID must be a valid UUID' },
]);
});
});
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 { UserRepository } from '../modules/users/user.repository';
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 userRepository = new UserRepository();
export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> { export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
const apiKey = req.headers['x-api-key'] as string | undefined; const apiKey = req.headers['x-api-key'] as string | undefined;
...@@ -14,8 +12,7 @@ export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next: ...@@ -14,8 +12,7 @@ export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next:
if (apiKey) { if (apiKey) {
try { try {
const validKeyRecord = await apiKeyService.validate(apiKey); const validKeyRecord = await apiKeyService.validate(apiKey);
const user = validKeyRecord.user;
const user = await userRepository.findById(validKeyRecord.userId);
if (!user) { if (!user) {
next(new AppError('User associated with API key not found', 401, ERROR_CODE.UNAUTHORIZED)); next(new AppError('User associated with API key not found', 401, ERROR_CODE.UNAUTHORIZED));
...@@ -41,3 +38,4 @@ export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next: ...@@ -41,3 +38,4 @@ export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next:
authMiddleware(req, res, next); authMiddleware(req, res, next);
} }
} }
...@@ -43,9 +43,9 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc ...@@ -43,9 +43,9 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
} }
req.user = { req.user = {
id: payload.id, id: user.id,
email: payload.email, email: user.email,
role: payload.role as UserRole, role: user.role,
}; };
next(); next();
......
...@@ -21,6 +21,7 @@ export function errorMiddleware( ...@@ -21,6 +21,7 @@ export function errorMiddleware(
success: false, success: false,
message: error.message, message: error.message,
code: error.code, code: error.code,
...(error.details ? { errors: error.details } : {}),
}); });
return; return;
} }
......
...@@ -17,3 +17,15 @@ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({ ...@@ -17,3 +17,15 @@ export const rateLimitMiddleware: RateLimitRequestHandler = rateLimit({
code: 'RATE_LIMIT_EXCEEDED', code: 'RATE_LIMIT_EXCEEDED',
}, },
}); });
export const authRateLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 requests per minute
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Quá nhiều yêu cầu xác thực. Vui lòng thử lại sau 1 phút.',
code: 'RATE_LIMIT_EXCEEDED',
},
});
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod'; import { ZodTypeAny } from 'zod';
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';
export function validate(schema: ZodSchema) { export function validate(schema: ZodTypeAny) {
return (req: Request, res: Response, next: NextFunction): void => { return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body); const result = schema.safeParse(req.body);
if (!result.success) { if (!result.success) {
const messages = result.error.errors const details = result.error.errors.map((e) => ({
.map((e) => `${e.path.join('.')}: ${e.message}`) field: e.path.join('.'),
.join(', '); message: e.message,
next(new AppError(messages, 422, ERROR_CODE.VALIDATION_ERROR)); }));
const messages = details.map((d) => `${d.field}: ${d.message}`).join(', ');
next(new AppError(messages, 422, ERROR_CODE.VALIDATION_ERROR, details));
return; return;
} }
...@@ -20,18 +22,38 @@ export function validate(schema: ZodSchema) { ...@@ -20,18 +22,38 @@ export function validate(schema: ZodSchema) {
}; };
} }
export function validateQuery(schema: ZodSchema) { export function validateQuery(schema: ZodTypeAny) {
return (req: Request, res: Response, next: NextFunction): void => { return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.query); const result = schema.safeParse(req.query);
if (!result.success) { if (!result.success) {
const messages = result.error.errors const details = result.error.errors.map((e) => ({
.map((e) => `${e.path.join('.')}: ${e.message}`) field: e.path.join('.'),
.join(', '); message: e.message,
next(new AppError(messages, 422, ERROR_CODE.VALIDATION_ERROR)); }));
const messages = details.map((d) => `${d.field}: ${d.message}`).join(', ');
next(new AppError(messages, 422, ERROR_CODE.VALIDATION_ERROR, details));
return; return;
} }
req.query = result.data as any; req.query = result.data as unknown as Request['query'];
next();
};
}
export function validateParams(schema: ZodTypeAny) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.params);
if (!result.success) {
const details = result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
}));
const messages = details.map((d) => `${d.field}: ${d.message}`).join(', ');
next(new AppError(messages, 422, ERROR_CODE.VALIDATION_ERROR, details));
return;
}
req.params = result.data as unknown as Request['params'];
next(); next();
}; };
} }
......
...@@ -86,6 +86,7 @@ describe('ApiKeyService', () => { ...@@ -86,6 +86,7 @@ describe('ApiKeyService', () => {
expect(validatedKey.userId).toBe(mockApiKeyRecord.userId); expect(validatedKey.userId).toBe(mockApiKeyRecord.userId);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { keyHash: hashed }, where: { keyHash: hashed },
include: { user: true },
}); });
// Verification of background lastUsedAt update // Verification of background lastUsedAt update
expect(prisma.apiKey.update).toHaveBeenCalledWith({ expect(prisma.apiKey.update).toHaveBeenCalledWith({
......
...@@ -37,9 +37,12 @@ export class ApiKeyRepository { ...@@ -37,9 +37,12 @@ export class ApiKeyRepository {
}); });
} }
async findByHash(keyHash: string): Promise<ApiKey | null> { async findByHash(keyHash: string) {
return prisma.apiKey.findUnique({ return prisma.apiKey.findUnique({
where: { keyHash }, where: { keyHash },
include: {
user: true,
},
}); });
} }
......
...@@ -56,7 +56,7 @@ export class ApiKeyService { ...@@ -56,7 +56,7 @@ export class ApiKeyService {
return toPublicApiKey(deletedKey); return toPublicApiKey(deletedKey);
} }
async validate(rawKey: string): Promise<ApiKey> { async validate(rawKey: string) {
if (!rawKey || !rawKey.startsWith('dc_')) { if (!rawKey || !rawKey.startsWith('dc_')) {
throw new AppError('Invalid API Key format', 401, ERROR_CODE.API_KEY_INVALID); throw new AppError('Invalid API Key format', 401, ERROR_CODE.API_KEY_INVALID);
} }
......
...@@ -15,7 +15,7 @@ export interface AuditLogQueryDto { ...@@ -15,7 +15,7 @@ export interface AuditLogQueryDto {
export interface CreateAuditLogDto { export interface CreateAuditLogDto {
userId?: string | null; userId?: string | null;
action: AuditAction; action: AuditAction;
details?: any; details?: Record<string, unknown> | null;
ipAddress?: string; ipAddress?: string;
userAgent?: string; userAgent?: string;
} }
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { Prisma } from '@prisma/client';
import { AuditLogQueryDto, CreateAuditLogDto } from './audit-log.dto'; import { AuditLogQueryDto, CreateAuditLogDto } from './audit-log.dto';
export class AuditLogRepository { export class AuditLogRepository {
...@@ -7,7 +8,7 @@ export class AuditLogRepository { ...@@ -7,7 +8,7 @@ export class AuditLogRepository {
data: { data: {
userId: data.userId || null, userId: data.userId || null,
action: data.action, action: data.action,
details: data.details || undefined, details: (data.details ?? undefined) as Prisma.InputJsonValue | undefined,
ipAddress: data.ipAddress || null, ipAddress: data.ipAddress || null,
userAgent: data.userAgent || null, userAgent: data.userAgent || null,
}, },
...@@ -15,7 +16,7 @@ export class AuditLogRepository { ...@@ -15,7 +16,7 @@ export class AuditLogRepository {
} }
async findAll(query: AuditLogQueryDto = {}) { async findAll(query: AuditLogQueryDto = {}) {
const where: any = {}; const where: Prisma.AuditLogWhereInput = {};
if (query.userId) { if (query.userId) {
where.userId = query.userId; where.userId = query.userId;
...@@ -50,9 +51,9 @@ export class AuditLogRepository { ...@@ -50,9 +51,9 @@ export class AuditLogRepository {
} }
const sortBy = query.sortBy || 'createdAt'; const sortBy = query.sortBy || 'createdAt';
const order = query.order || 'desc'; const order = (query.order || 'desc') as Prisma.SortOrder;
const allowedSortFields = ['createdAt', 'action']; const allowedSortFields = ['createdAt', 'action'];
const orderBy: any = allowedSortFields.includes(sortBy) const orderBy: Prisma.AuditLogOrderByWithRelationInput = allowedSortFields.includes(sortBy)
? { [sortBy]: order } ? { [sortBy]: order }
: { createdAt: 'desc' }; : { createdAt: 'desc' };
......
import { Router } from 'express'; import { Router } from 'express';
import { AuditLogController } from './audit-log.controller'; import { AuditLogController } from './audit-log.controller';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { validateQuery } from '../../middlewares/validate.middleware';
import { listAuditLogsQuerySchema } from './audit-log.validation';
import { requireRole } from '../../middlewares/role.middleware'; import { requireRole } from '../../middlewares/role.middleware';
import { ROLES } from '../../common/constants/role.constant'; import { ROLES } from '../../common/constants/role.constant';
const router = Router(); const router = Router();
const controller = new AuditLogController(); const controller = new AuditLogController();
router.get('/', authMiddleware, requireRole(ROLES.ADMIN), controller.findAll); router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validateQuery(listAuditLogsQuerySchema), controller.findAll);
export default router; export default router;
import { z } from 'zod';
export const listAuditLogsQuerySchema = z.object({
userId: z.string().uuid().optional(),
action: z.string().optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
search: z.string().trim().optional(),
sortBy: z.enum(['createdAt', 'action']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
});
...@@ -260,11 +260,6 @@ export class AuthController { ...@@ -260,11 +260,6 @@ export class AuthController {
}); });
await this.mailService.sendPasswordResetEmail(forgotPasswordDto.email, result.resetToken); await this.mailService.sendPasswordResetEmail(forgotPasswordDto.email, result.resetToken);
if (process.env.NODE_ENV !== 'production') {
const { mailConfig } = await import('../../config/mail.config');
console.log(`[DEV ONLY] Reset Link: ${mailConfig.frontendUrl}/reset-password?token=${result.resetToken}`);
}
} }
res.json({ res.json({
......
import { Router } from 'express'; import { Router } from 'express';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { authMiddleware, copyRefreshTokenToBody } from '../../middlewares/auth.middleware'; import { authMiddleware, copyRefreshTokenToBody } from '../../middlewares/auth.middleware';
import { authRateLimiter } from '../../middlewares/rate-limit.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate } from '../../middlewares/validate.middleware';
import { loginSchema, refreshSchema, logoutSchema, registerSchema, updateMeSchema, forgotPasswordSchema, resetPasswordSchema, verifyEmailSchema, resendVerificationSchema, changePasswordSchema } from './auth.validation'; import { loginSchema, refreshSchema, logoutSchema, registerSchema, updateMeSchema, forgotPasswordSchema, resetPasswordSchema, verifyEmailSchema, resendVerificationSchema, changePasswordSchema } from './auth.validation';
const router = Router(); const router = Router();
const controller = new AuthController(); const controller = new AuthController();
router.post('/login', validate(loginSchema), (req, res, next) => { router.post('/login', authRateLimiter, validate(loginSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/LoginRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/LoginRequest' } }
controller.login(req, res, next); controller.login(req, res, next);
}); });
...@@ -28,11 +29,11 @@ router.post('/change-password', authMiddleware, validate(changePasswordSchema), ...@@ -28,11 +29,11 @@ router.post('/change-password', authMiddleware, validate(changePasswordSchema),
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ChangePasswordRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/ChangePasswordRequest' } }
controller.changePassword(req, res, next); controller.changePassword(req, res, next);
}); });
router.post("/register", validate(registerSchema), (req, res, next) => { router.post("/register", authRateLimiter, validate(registerSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/RegisterRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/RegisterRequest' } }
controller.register(req, res, next); controller.register(req, res, next);
}); });
router.post("/forgot-password", validate(forgotPasswordSchema), (req, res, next) => { router.post("/forgot-password", authRateLimiter, validate(forgotPasswordSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ForgotPasswordRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/ForgotPasswordRequest' } }
controller.forgotPassword(req, res, next); controller.forgotPassword(req, res, next);
}); });
...@@ -40,7 +41,7 @@ router.post("/reset-password", validate(resetPasswordSchema), (req, res, next) = ...@@ -40,7 +41,7 @@ router.post("/reset-password", validate(resetPasswordSchema), (req, res, next) =
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ResetPasswordRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/ResetPasswordRequest' } }
controller.resetPassword(req, res, next); controller.resetPassword(req, res, next);
}); });
router.post("/resend-verification", validate(resendVerificationSchema), (req, res, next) => { router.post("/resend-verification", authRateLimiter, validate(resendVerificationSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ResendVerificationRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/ResendVerificationRequest' } }
controller.resendVerification(req, res, next); controller.resendVerification(req, res, next);
}); });
......
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt, { SignOptions } from 'jsonwebtoken';
import { AuthRepository } from './auth.repository'; import { AuthRepository } from './auth.repository';
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';
...@@ -7,6 +7,13 @@ import { jwtConfig } from '../../config/jwt.config'; ...@@ -7,6 +7,13 @@ import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, AuthTokensDto, MeDto, LoginResponseDto, RegisterDto, UpdateMeDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto } from './auth.dto'; import { LoginDto, AuthTokensDto, MeDto, LoginResponseDto, RegisterDto, UpdateMeDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto } from './auth.dto';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
interface AuthJwtPayload {
id: string;
email: string;
role: string;
purpose?: string;
}
export class AuthService { export class AuthService {
private readonly repository = new AuthRepository(); private readonly repository = new AuthRepository();
private readonly mailService = new MailService(); private readonly mailService = new MailService();
...@@ -65,14 +72,14 @@ export class AuthService { ...@@ -65,14 +72,14 @@ export class AuthService {
throw new AppError('Tài khoản hoặc mật khẩu không chính xác.', 401, ERROR_CODE.INVALID_CREDENTIALS); throw new AppError('Tài khoản hoặc mật khẩu không chính xác.', 401, ERROR_CODE.INVALID_CREDENTIALS);
} }
const payload = { id: user.id, email: user.email, role: user.role }; const payload: AuthJwtPayload = { id: user.id, email: user.email, role: user.role };
const accessToken = jwt.sign(payload, jwtConfig.accessSecret, { const accessToken = jwt.sign(payload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any, expiresIn: jwtConfig.accessExpiresIn as unknown as SignOptions['expiresIn'],
}); });
const refreshToken = jwt.sign(payload, jwtConfig.refreshSecret, { const refreshToken = jwt.sign(payload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any, expiresIn: jwtConfig.refreshExpiresIn as unknown as SignOptions['expiresIn'],
}); });
const decoded = jwt.decode(refreshToken) as { exp: number }; const decoded = jwt.decode(refreshToken) as { exp: number };
...@@ -109,10 +116,10 @@ export class AuthService { ...@@ -109,10 +116,10 @@ export class AuthService {
} }
async refresh(token: string, metadata?: { userAgent?: string; ipAddress?: string }): Promise<AuthTokensDto> { async refresh(token: string, metadata?: { userAgent?: string; ipAddress?: string }): Promise<AuthTokensDto> {
let payload: any; let payload: AuthJwtPayload;
try { try {
payload = jwt.verify(token, jwtConfig.refreshSecret); payload = jwt.verify(token, jwtConfig.refreshSecret) as AuthJwtPayload;
} catch (error) { } catch {
await this.repository.deleteRefreshToken(token).catch(() => { }); await this.repository.deleteRefreshToken(token).catch(() => { });
throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID); throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID);
} }
...@@ -132,14 +139,14 @@ export class AuthService { ...@@ -132,14 +139,14 @@ export class AuthService {
throw new AppError('User not found or inactive', 401, ERROR_CODE.USER_INACTIVE); throw new AppError('User not found or inactive', 401, ERROR_CODE.USER_INACTIVE);
} }
const newPayload = { id: user.id, email: user.email, role: user.role }; const newPayload: AuthJwtPayload = { id: user.id, email: user.email, role: user.role };
const newAccessToken = jwt.sign(newPayload, jwtConfig.accessSecret, { const newAccessToken = jwt.sign(newPayload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any, expiresIn: jwtConfig.accessExpiresIn as unknown as SignOptions['expiresIn'],
}); });
const newRefreshToken = jwt.sign(newPayload, jwtConfig.refreshSecret, { const newRefreshToken = jwt.sign(newPayload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any, expiresIn: jwtConfig.refreshExpiresIn as unknown as SignOptions['expiresIn'],
}); });
await this.repository.deleteRefreshToken(token); await this.repository.deleteRefreshToken(token);
...@@ -161,7 +168,7 @@ export class AuthService { ...@@ -161,7 +168,7 @@ export class AuthService {
private createEmailVerificationToken(email: string): string { private createEmailVerificationToken(email: string): string {
return jwt.sign( return jwt.sign(
{ email, purpose: 'email-verification' }, { email, purpose: 'email-verification' },
jwtConfig.accessSecret, jwtConfig.emailVerificationSecret,
{ expiresIn: '24h' }, { expiresIn: '24h' },
); );
} }
...@@ -299,14 +306,14 @@ export class AuthService { ...@@ -299,14 +306,14 @@ export class AuthService {
await this.repository.deleteUserRefreshTokens(userId); await this.repository.deleteUserRefreshTokens(userId);
// Generate new tokens for the current session // Generate new tokens for the current session
const payload = { id: user.id, email: user.email, role: user.role }; const payload: AuthJwtPayload = { id: user.id, email: user.email, role: user.role };
const accessToken = jwt.sign(payload, jwtConfig.accessSecret, { const accessToken = jwt.sign(payload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any, expiresIn: jwtConfig.accessExpiresIn as unknown as SignOptions['expiresIn'],
}); });
const refreshToken = jwt.sign(payload, jwtConfig.refreshSecret, { const refreshToken = jwt.sign(payload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any, expiresIn: jwtConfig.refreshExpiresIn as unknown as SignOptions['expiresIn'],
}); });
const decoded = jwt.decode(refreshToken) as { exp: number }; const decoded = jwt.decode(refreshToken) as { exp: number };
...@@ -347,25 +354,34 @@ export class AuthService { ...@@ -347,25 +354,34 @@ export class AuthService {
}; };
} }
async resetPassword(data: ResetPasswordDto): Promise<{ success: boolean; userId: string }> { /**
const { token, password } = data; * Verifies a password-reset JWT and returns the authenticated User.
*
let payload: any; * Pattern: decode (untrusted) → fetch user by id → verify with passwordHash-bound secret.
* This is intentional: the reset token is stateless and embeds userId so we can derive the
* per-user secret (accessSecret + passwordHash). The decode step only extracts the userId
* for the DB lookup; NO business logic is performed until jwt.verify() has succeeded.
*/
private async verifyResetToken(token: string) {
// Step 1: structural decode only — do NOT trust any field yet
let untrustedPayload: AuthJwtPayload | null = null;
try { try {
payload = jwt.decode(token); untrustedPayload = jwt.decode(token) as AuthJwtPayload | null;
} catch (error) { } catch {
throw new AppError('Invalid token', 400, ERROR_CODE.TOKEN_INVALID); throw new AppError('Invalid token', 400, ERROR_CODE.TOKEN_INVALID);
} }
if (!payload || !payload.id) { if (!untrustedPayload || !untrustedPayload.id) {
throw new AppError('Invalid token payload', 400, ERROR_CODE.TOKEN_INVALID); throw new AppError('Invalid token payload', 400, ERROR_CODE.TOKEN_INVALID);
} }
const user = await this.repository.findById(payload.id); // Step 2: fetch user needed to derive the per-user signing secret
const user = await this.repository.findById(untrustedPayload.id);
if (!user || !user.isActive) { if (!user || !user.isActive) {
throw new AppError('User not found or inactive', 404, ERROR_CODE.NOT_FOUND); throw new AppError('User not found or inactive', 404, ERROR_CODE.NOT_FOUND);
} }
// Step 3: cryptographic verification — all business logic below this point is safe
const secret = `${jwtConfig.accessSecret}-${user.passwordHash}`; const secret = `${jwtConfig.accessSecret}-${user.passwordHash}`;
try { try {
jwt.verify(token, secret); jwt.verify(token, secret);
...@@ -376,8 +392,16 @@ export class AuthService { ...@@ -376,8 +392,16 @@ export class AuthService {
throw new AppError('Invalid reset token', 400, ERROR_CODE.TOKEN_INVALID); throw new AppError('Invalid reset token', 400, ERROR_CODE.TOKEN_INVALID);
} }
const passwordHash = await bcrypt.hash(password, 10); return user; // fully authenticated — caller may trust this object
}
async resetPassword(data: ResetPasswordDto): Promise<{ success: boolean; userId: string }> {
const { token, password } = data;
// verifyResetToken throws on any invalid/expired/tampered token
const user = await this.verifyResetToken(token);
const passwordHash = await bcrypt.hash(password, 10);
await this.repository.updateUser(user.id, { passwordHash }); await this.repository.updateUser(user.id, { passwordHash });
await this.repository.deleteUserRefreshTokens(user.id); await this.repository.deleteUserRefreshTokens(user.id);
...@@ -385,9 +409,9 @@ export class AuthService { ...@@ -385,9 +409,9 @@ export class AuthService {
} }
async verifyEmail(token: string): Promise<{ success: boolean; userId: string }> { async verifyEmail(token: string): Promise<{ success: boolean; userId: string }> {
let payload: any; let payload: AuthJwtPayload | null = null;
try { try {
payload = jwt.verify(token, jwtConfig.accessSecret); payload = jwt.verify(token, jwtConfig.emailVerificationSecret) as AuthJwtPayload;
} catch (error) { } catch (error) {
if (error instanceof jwt.TokenExpiredError) { if (error instanceof jwt.TokenExpiredError) {
throw new AppError('Verification token has expired', 400, ERROR_CODE.TOKEN_EXPIRED); throw new AppError('Verification token has expired', 400, ERROR_CODE.TOKEN_EXPIRED);
......
...@@ -53,6 +53,13 @@ export const changePasswordSchema = z ...@@ -53,6 +53,13 @@ export const changePasswordSchema = z
message: 'Mật khẩu xác nhận không khớp.', message: 'Mật khẩu xác nhận không khớp.',
path: ["confirmPassword"], path: ["confirmPassword"],
} }
)
.refine(
(data) => data.currentPassword !== data.newPassword,
{
message: 'Mật khẩu mới phải khác mật khẩu hiện tại.',
path: ["newPassword"],
}
); );
export const forgotPasswordSchema = z.object({ export const forgotPasswordSchema = z.object({
......
import fs from 'fs'; import fs from 'fs';
import { CrawlJob, CrawlPage } from '@prisma/client'; import { CrawlJob, Prisma } from '@prisma/client';
import { CrawlJobRepository } from '../crawl-jobs/crawl-job.repository'; import { CrawlJobRepository } from '../crawl-jobs/crawl-job.repository';
import { import {
DiffReportEnvelope, DiffReportEnvelope,
...@@ -15,6 +15,23 @@ import { normalizeUrl } from '../../common/helpers/data-contract.helper'; ...@@ -15,6 +15,23 @@ import { normalizeUrl } from '../../common/helpers/data-contract.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';
/**
* Minimal page shape required for diff comparison.
* Matches the `select` fields used by findByIdWithPages / findPreviousCompleted*
* to avoid loading full page content into worker memory.
*/
type DiffPage = {
id: string;
url: string;
normalizedUrl: string;
contentHash: string | null;
wordCount: number;
status: import('@prisma/client').CrawlPageStatus;
statusCode: number | null;
title: string | null;
crawledAt: Date | null;
};
export class ChangeDetectionService { export class ChangeDetectionService {
private readonly jobRepository = new CrawlJobRepository(); private readonly jobRepository = new CrawlJobRepository();
...@@ -22,16 +39,16 @@ export class ChangeDetectionService { ...@@ -22,16 +39,16 @@ export class ChangeDetectionService {
* Pure comparison algorithm between two jobs and their pages. * Pure comparison algorithm between two jobs and their pages.
*/ */
public comparePageSets( public comparePageSets(
currentJob: CrawlJob & { pages: CrawlPage[] }, currentJob: CrawlJob & { pages: DiffPage[] },
previousJob?: (CrawlJob & { pages: CrawlPage[] }) | null, previousJob?: (CrawlJob & { pages: DiffPage[] }) | null,
): DiffReportEnvelope { ): DiffReportEnvelope {
const currentPagesMap = new Map<string, CrawlPage>(); 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, CrawlPage>(); 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();
...@@ -149,7 +166,7 @@ export class ChangeDetectionService { ...@@ -149,7 +166,7 @@ export class ChangeDetectionService {
*/ */
public async findBaselineJob( public async findBaselineJob(
currentJob: CrawlJob, currentJob: CrawlJob,
): Promise<(CrawlJob & { pages: CrawlPage[] }) | null> { ): Promise<(CrawlJob & { pages: DiffPage[] }) | null> {
if (currentJob.scheduleId) { if (currentJob.scheduleId) {
const prevScheduleJob = await this.jobRepository.findPreviousCompletedJobForSchedule( const prevScheduleJob = await this.jobRepository.findPreviousCompletedJobForSchedule(
currentJob.scheduleId, currentJob.scheduleId,
...@@ -178,7 +195,7 @@ export class ChangeDetectionService { ...@@ -178,7 +195,7 @@ export class ChangeDetectionService {
throw new AppError('Crawl job not found', 404, ERROR_CODE.CRAWL_JOB_NOT_FOUND); throw new AppError('Crawl job not found', 404, ERROR_CODE.CRAWL_JOB_NOT_FOUND);
} }
let previousJob: (CrawlJob & { pages: CrawlPage[] }) | null = null; let previousJob: (CrawlJob & { pages: DiffPage[] }) | null = null;
if (explicitCompareJobId) { if (explicitCompareJobId) {
previousJob = await this.jobRepository.findByIdWithPages(explicitCompareJobId); previousJob = await this.jobRepository.findByIdWithPages(explicitCompareJobId);
if (!previousJob) { if (!previousJob) {
...@@ -195,7 +212,7 @@ export class ChangeDetectionService { ...@@ -195,7 +212,7 @@ export class ChangeDetectionService {
fs.writeFileSync(filePath, JSON.stringify(diffReport, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(diffReport, null, 2), 'utf-8');
// Persist diff summary & path on the crawl job // Persist diff summary & path on the crawl job
await this.jobRepository.updateDiffReport(currentJobId, filePath, diffReport.summary); await this.jobRepository.updateDiffReport(currentJobId, filePath, diffReport.summary as unknown as Prisma.InputJsonValue);
return diffReport; return diffReport;
} }
......
...@@ -40,14 +40,19 @@ export class CrawlAssetRepository { ...@@ -40,14 +40,19 @@ export class CrawlAssetRepository {
return prisma.crawlAsset.createMany({ data: prismaAssets }); return prisma.crawlAsset.createMany({ data: prismaAssets });
} }
findByJobId(jobId: string, assetType?: AssetType) { findByJobId(jobId: string, assetType?: AssetType, page = 1, limit = 50) {
return prisma.crawlAsset.findMany({ const safeLimit = Math.min(Math.max(1, limit), 500);
where: { const safePage = Math.max(1, page);
crawlJobId: jobId, const skip = (safePage - 1) * safeLimit;
...(assetType ? { assetType } : {}), return prisma.crawlAsset.findMany({
}, where: {
orderBy: { createdAt: 'asc' }, crawlJobId: jobId,
}); ...(assetType ? { assetType } : {}),
},
orderBy: { createdAt: 'asc' },
skip,
take: safeLimit,
});
} }
findAssetsForJsonExport(jobId: string) { findAssetsForJsonExport(jobId: string) {
......
...@@ -4,8 +4,8 @@ import { AssetType } from '@prisma/client'; ...@@ -4,8 +4,8 @@ import { AssetType } from '@prisma/client';
export class CrawlAssetService { export class CrawlAssetService {
private readonly repository = new CrawlAssetRepository(); private readonly repository = new CrawlAssetRepository();
async findByJobId(jobId: string, assetType?: AssetType) { async findByJobId(jobId: string, assetType?: AssetType, page = 1, limit = 50) {
return this.repository.findByJobId(jobId, assetType); return this.repository.findByJobId(jobId, assetType, page, limit);
} }
async create(data: { async create(data: {
......
import { CrawlJobController } from '../crawl-job.controller';
import { CrawlJobService } from '../crawl-job.service';
jest.mock('../crawl-job.service');
jest.mock('../../audit-logs/audit-log.service');
describe('CrawlJobController - SSE Events', () => {
let controller: CrawlJobController;
let mockService: jest.Mocked<CrawlJobService>;
beforeEach(() => {
jest.clearAllMocks();
mockService = {
findById: jest.fn(),
} as any;
(CrawlJobService as jest.Mock).mockReturnValue(mockService);
controller = new CrawlJobController();
});
it('sets text/event-stream headers and writes initial job data', async () => {
mockService.findById.mockResolvedValue({
id: 'job-1',
status: 'COMPLETED',
totalPages: 10,
} as any);
const written: string[] = [];
const headers: Record<string, string> = {};
const req: any = {
params: { id: 'job-1' },
user: { id: 'user-1', role: 'CRAWLER_USER' },
on: jest.fn(),
};
const res: any = {
setHeader: jest.fn((k, v) => {
headers[k] = v;
}),
write: jest.fn((chunk) => {
written.push(chunk);
}),
flushHeaders: jest.fn(),
end: jest.fn(),
};
const next = jest.fn();
await controller.streamEvents(req, res, next);
expect(headers['Content-Type']).toBe('text/event-stream');
expect(headers['Cache-Control']).toBe('no-cache');
expect(written[0]).toContain('event: initial');
expect(written[0]).toContain('"id":"job-1"');
expect(written[1]).toContain('event: done');
expect(res.end).toHaveBeenCalled();
});
});
...@@ -149,19 +149,28 @@ export class CrawlJobController { ...@@ -149,19 +149,28 @@ export class CrawlJobController {
return; return;
} }
const assetType = rawType as AssetType | undefined; const assetType = rawType as AssetType | undefined;
const result = await this.assetService.findByJobId( const page = Math.max(1, parseInt(req.query.page as string || '1', 10));
const limit = Math.min(Math.max(1, parseInt(req.query.limit as string || '50', 10)), 500);
const items = await this.assetService.findByJobId(
req.params.id, req.params.id,
assetType, assetType,
page,
limit,
); );
res.json({ res.json({
success: true, success: true,
data: result, data: {
items,
meta: { page, limit },
},
}); });
} catch (error) { } catch (error) {
next(error); next(error);
} }
}; };
getExports = async (req: Request, res: Response, next: NextFunction) => { getExports = async (req: Request, res: Response, next: NextFunction) => {
try { try {
await this.service.findById(req.user.id, req.user.role, req.params.id); await this.service.findById(req.user.id, req.user.role, req.params.id);
...@@ -228,7 +237,10 @@ export class CrawlJobController { ...@@ -228,7 +237,10 @@ export class CrawlJobController {
const changeDetectionService = new ChangeDetectionService(); const changeDetectionService = new ChangeDetectionService();
const compareWithJobId = req.query.compareWithJobId as string | undefined; const compareWithJobId = req.query.compareWithJobId as string | undefined;
const diffReport = await changeDetectionService.getDiffReport(req.params.id, compareWithJobId); const diffReport = await changeDetectionService.getDiffReport(req.params.id, compareWithJobId);
res.json(diffReport); res.json({
success: true,
data: diffReport,
});
} catch (error) { } catch (error) {
next(error); next(error);
} }
...@@ -250,4 +262,83 @@ export class CrawlJobController { ...@@ -250,4 +262,83 @@ export class CrawlJobController {
next(error); next(error);
} }
}; };
streamEvents = async (req: Request, res: Response, next: NextFunction) => {
try {
const jobId = req.params.id;
const initialJob = await this.service.findById(req.user.id, req.user.role, jobId);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
if (res.flushHeaders) {
res.flushHeaders();
}
res.write(`event: initial\ndata: ${JSON.stringify(initialJob)}\n\n`);
const TERMINAL_STATUSES = ['COMPLETED', 'FAILED', 'CANCELED'];
if (TERMINAL_STATUSES.includes(initialJob.status)) {
res.write(`event: done\ndata: ${JSON.stringify({ status: initialJob.status })}\n\n`);
res.end();
return;
}
let isClosed = false;
let interval: NodeJS.Timeout | null = null;
let maxDurationTimeout: NodeJS.Timeout | null = null;
const cleanup = () => {
if (interval) {
clearInterval(interval);
interval = null;
}
if (maxDurationTimeout) {
clearTimeout(maxDurationTimeout);
maxDurationTimeout = null;
}
};
req.on('close', () => {
isClosed = true;
cleanup();
});
// Max stream duration guard (30 minutes)
const MAX_STREAM_DURATION_MS = 30 * 60 * 1000;
maxDurationTimeout = setTimeout(() => {
if (!isClosed) {
isClosed = true;
cleanup();
res.write(`event: done\ndata: ${JSON.stringify({ status: 'TIMEOUT', message: 'Stream reached max duration' })}\n\n`);
res.end();
}
}, MAX_STREAM_DURATION_MS);
interval = setInterval(async () => {
if (isClosed) return;
try {
const currentJob = await this.service.findById(req.user.id, req.user.role, jobId);
res.write(`event: progress\ndata: ${JSON.stringify(currentJob)}\n\n`);
if (TERMINAL_STATUSES.includes(currentJob.status)) {
res.write(`event: done\ndata: ${JSON.stringify({ status: currentJob.status })}\n\n`);
cleanup();
if (!isClosed) {
isClosed = true;
res.end();
}
}
} catch {
cleanup();
if (!isClosed) {
isClosed = true;
res.end();
}
}
}, 3000);
} catch (error) {
next(error);
}
};
} }
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { CrawlJobStatus, CrawlMode } from '@prisma/client'; import { CrawlJobStatus, CrawlMode, Prisma } from '@prisma/client';
import { CrawlJobQueryDto } from './crawl-job.dto'; import { CrawlJobQueryDto } from './crawl-job.dto';
import { JOB_STATUS } from '../../common/constants/job-status.constant'; import { JOB_STATUS } from '../../common/constants/job-status.constant';
...@@ -24,11 +24,12 @@ export class CrawlJobRepository { ...@@ -24,11 +24,12 @@ export class CrawlJobRepository {
maxDepth: data.maxDepth ?? 1, maxDepth: data.maxDepth ?? 1,
urls: data.urls ?? [], urls: data.urls ?? [],
scheduleId: data.scheduleId, scheduleId: data.scheduleId,
status: JOB_STATUS.PENDING,
}, },
}); });
} }
findAllByUser(userId: string, query: CrawlJobQueryDto) { findAllByUser(userId: string, query: CrawlJobQueryDto = {}) {
return this.find(query, userId); return this.find(query, userId);
} }
...@@ -37,7 +38,7 @@ export class CrawlJobRepository { ...@@ -37,7 +38,7 @@ export class CrawlJobRepository {
} }
private async find(query: CrawlJobQueryDto, userId?: string) { private async find(query: CrawlJobQueryDto, userId?: string) {
const where: any = {}; const where: Prisma.CrawlJobWhereInput = {};
if (userId) { if (userId) {
where.userId = userId; where.userId = userId;
} }
...@@ -55,7 +56,7 @@ export class CrawlJobRepository { ...@@ -55,7 +56,7 @@ export class CrawlJobRepository {
} }
const sortBy = query.sortBy || 'createdAt'; const sortBy = query.sortBy || 'createdAt';
const order = query.order || 'desc'; const order = (query.order || 'desc') as Prisma.SortOrder;
const allowedSortFields = [ const allowedSortFields = [
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
...@@ -65,7 +66,7 @@ export class CrawlJobRepository { ...@@ -65,7 +66,7 @@ export class CrawlJobRepository {
'successPages', 'successPages',
'failedPages', 'failedPages',
]; ];
const orderBy: any = allowedSortFields.includes(sortBy) const orderBy: Prisma.CrawlJobOrderByWithRelationInput = allowedSortFields.includes(sortBy)
? { [sortBy]: order } ? { [sortBy]: order }
: { createdAt: 'desc' }; : { createdAt: 'desc' };
...@@ -134,7 +135,21 @@ export class CrawlJobRepository { ...@@ -134,7 +135,21 @@ export class CrawlJobRepository {
findByIdWithPages(id: string) { findByIdWithPages(id: string) {
return prisma.crawlJob.findUnique({ return prisma.crawlJob.findUnique({
where: { id }, where: { id },
include: { pages: true }, include: {
pages: {
select: {
id: true,
url: true,
normalizedUrl: true,
contentHash: true,
wordCount: true,
status: true,
statusCode: true,
title: true,
crawledAt: true,
},
},
},
}); });
} }
...@@ -197,7 +212,21 @@ export class CrawlJobRepository { ...@@ -197,7 +212,21 @@ export class CrawlJobRepository {
status: 'COMPLETED', status: 'COMPLETED',
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { pages: true }, include: {
pages: {
select: {
id: true,
url: true,
normalizedUrl: true,
contentHash: true,
wordCount: true,
status: true,
statusCode: true,
title: true,
crawledAt: true,
},
},
},
}); });
} }
...@@ -213,11 +242,25 @@ export class CrawlJobRepository { ...@@ -213,11 +242,25 @@ export class CrawlJobRepository {
], ],
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { pages: true }, include: {
pages: {
select: {
id: true,
url: true,
normalizedUrl: true,
contentHash: true,
wordCount: true,
status: true,
statusCode: true,
title: true,
crawledAt: true,
},
},
},
}); });
} }
updateDiffReport(id: string, diffReportPath: string, diffSummary: any) { updateDiffReport(id: string, diffReportPath: string, diffSummary: Prisma.InputJsonValue) {
return prisma.crawlJob.update({ return prisma.crawlJob.update({
where: { id }, where: { id },
data: { data: {
......
...@@ -2,7 +2,7 @@ import { Router } from 'express'; ...@@ -2,7 +2,7 @@ import { Router } from 'express';
import { CrawlJobController } from './crawl-job.controller'; import { CrawlJobController } from './crawl-job.controller';
import { apiKeyOrAuthMiddleware } from '../../middlewares/api-key.middleware'; import { apiKeyOrAuthMiddleware } from '../../middlewares/api-key.middleware';
import { validate, validateQuery } from '../../middlewares/validate.middleware'; import { validate, validateQuery } from '../../middlewares/validate.middleware';
import { createCrawlJobSchema, createExportSchema } from './crawl-job.validation'; import { createCrawlJobSchema, createExportSchema, listCrawlJobsQuerySchema } from './crawl-job.validation';
import { crawlPageQuerySchema } from '../crawl-pages/crawl-page.validation'; import { crawlPageQuerySchema } from '../crawl-pages/crawl-page.validation';
import { requireRole } from '../../middlewares/role.middleware'; import { requireRole } from '../../middlewares/role.middleware';
import { ROLES } from '../../common/constants/role.constant'; import { ROLES } from '../../common/constants/role.constant';
...@@ -13,8 +13,9 @@ const controller = new CrawlJobController(); ...@@ -13,8 +13,9 @@ const controller = new CrawlJobController();
router.post('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), validate(createCrawlJobSchema), (req, res, next) => { router.post('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), validate(createCrawlJobSchema), (req, res, next) => {
controller.create(req, res, next); controller.create(req, res, next);
}); });
router.get('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.findAll); router.get('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), validateQuery(listCrawlJobsQuerySchema), controller.findAll);
router.get('/:id', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.findById); router.get('/:id', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.findById);
router.get('/:id/events', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.streamEvents);
router.post('/:id/cancel', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), controller.cancel); router.post('/:id/cancel', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), controller.cancel);
router.get('/:id/pages', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), validateQuery(crawlPageQuerySchema), controller.getPages); router.get('/:id/pages', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), validateQuery(crawlPageQuerySchema), controller.getPages);
router.get('/:id/pages/preview', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), validateQuery(crawlPageQuerySchema), controller.getPagesPreview); router.get('/:id/pages/preview', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), validateQuery(crawlPageQuerySchema), controller.getPagesPreview);
......
...@@ -13,6 +13,7 @@ import { ROLES } from '../../common/constants/role.constant'; ...@@ -13,6 +13,7 @@ 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 { CreateCrawlJobDto, CrawlJobQueryDto } from './crawl-job.dto'; import { CreateCrawlJobDto, CrawlJobQueryDto } from './crawl-job.dto';
import { StorageFactory } from '../../common/storage/storage.factory'; import { StorageFactory } from '../../common/storage/storage.factory';
import { getErrorMessage } from '../../common/helpers/error-mapping.helper';
export class CrawlJobService { export class CrawlJobService {
private readonly repository = new CrawlJobRepository(); private readonly repository = new CrawlJobRepository();
...@@ -48,9 +49,9 @@ export class CrawlJobService { ...@@ -48,9 +49,9 @@ export class CrawlJobService {
chunk.map(async (url) => { chunk.map(async (url) => {
try { try {
await validateUrlAsync(url); await validateUrlAsync(url);
} catch (err: any) { } catch (err: unknown) {
throw new AppError( throw new AppError(
`Invalid or blocked URL in list: ${url}${err?.message}`, `Invalid or blocked URL in list: ${url}${getErrorMessage(err)}`,
400, 400,
ERROR_CODE.INVALID_URL, ERROR_CODE.INVALID_URL,
); );
......
...@@ -39,4 +39,14 @@ export const createCrawlJobSchema = z.object({ ...@@ -39,4 +39,14 @@ export const createCrawlJobSchema = z.object({
export const createExportSchema = z.object({ export const createExportSchema = z.object({
exportType: z.enum(['JSON', 'CSV', 'XLSX', 'MARKDOWN', 'ZIP']), exportType: z.enum(['JSON', 'CSV', 'XLSX', 'MARKDOWN', 'ZIP']),
});
export const listCrawlJobsQuerySchema = z.object({
status: z.enum(['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT', 'COMPLETED', 'FAILED', 'CANCELED']).optional(),
mode: z.enum(['SCRAPE', 'CRAWL', 'SITEMAP', 'URL_LIST']).optional(),
search: z.string().trim().optional(),
sortBy: z.enum(['createdAt', 'updatedAt', 'startUrl', 'status', 'mode', 'totalPages']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
}); });
\ No newline at end of file
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { CrawlPageStatus } from '@prisma/client'; import { CrawlPageStatus, Prisma } from '@prisma/client';
import { CrawlPageQueryDto } from './crawl-page.dto'; import { CrawlPageQueryDto } from './crawl-page.dto';
export class CrawlPageRepository { export class CrawlPageRepository {
...@@ -25,8 +25,8 @@ export class CrawlPageRepository { ...@@ -25,8 +25,8 @@ export class CrawlPageRepository {
} }
async findByJobId(jobId: string, query: CrawlPageQueryDto = {}) { async findByJobId(jobId: string, query: CrawlPageQueryDto = {}) {
const where: any = { jobId }; const where: Prisma.CrawlPageWhereInput = { jobId };
const andConditions: any[] = []; const andConditions: Prisma.CrawlPageWhereInput[] = [];
if (query.status) { if (query.status) {
where.status = query.status; where.status = query.status;
...@@ -51,17 +51,15 @@ export class CrawlPageRepository { ...@@ -51,17 +51,15 @@ export class CrawlPageRepository {
if (exactScore !== undefined && exactScore !== '') { if (exactScore !== undefined && exactScore !== '') {
where.dataQualityScore = Number(exactScore); where.dataQualityScore = Number(exactScore);
} else { } else {
const scoreFilter: Prisma.IntNullableFilter = {};
if (minScore !== undefined && minScore !== '') { if (minScore !== undefined && minScore !== '') {
where.dataQualityScore = { scoreFilter.gte = Number(minScore);
...(where.dataQualityScore || {}),
gte: Number(minScore),
};
} }
if (maxScore !== undefined && maxScore !== '') { if (maxScore !== undefined && maxScore !== '') {
where.dataQualityScore = { scoreFilter.lte = Number(maxScore);
...(where.dataQualityScore || {}), }
lte: Number(maxScore), if (Object.keys(scoreFilter).length > 0) {
}; where.dataQualityScore = scoreFilter;
} }
} }
...@@ -85,19 +83,20 @@ export class CrawlPageRepository { ...@@ -85,19 +83,20 @@ export class CrawlPageRepository {
if (query.hasTables !== undefined && query.hasTables !== '') { if (query.hasTables !== undefined && query.hasTables !== '') {
const isTrue = query.hasTables === true || query.hasTables === 'true' || query.hasTables === '1'; const isTrue = query.hasTables === true || query.hasTables === 'true' || query.hasTables === '1';
const tableConditions = [ const insensitiveMode = Prisma.QueryMode.insensitive;
{ markdownContent: { contains: '<table', mode: 'insensitive' } }, const tableConditions: Prisma.CrawlPageWhereInput[] = [
{ content: { contains: '<table', mode: 'insensitive' } }, { markdownContent: { contains: '<table', mode: insensitiveMode } },
{ markdownContent: { contains: '|', mode: 'insensitive' } }, { content: { contains: '<table', mode: insensitiveMode } },
{ markdownContent: { contains: '|', mode: insensitiveMode } },
]; ];
if (isTrue) { if (isTrue) {
andConditions.push({ OR: tableConditions }); andConditions.push({ OR: tableConditions });
} else { } else {
andConditions.push({ andConditions.push({
AND: [ AND: [
{ markdownContent: { not: { contains: '<table', mode: 'insensitive' } } }, { markdownContent: { not: { contains: '<table' } } },
{ content: { not: { contains: '<table', mode: 'insensitive' } } }, { content: { not: { contains: '<table' } } },
{ markdownContent: { not: { contains: '|', mode: 'insensitive' } } }, { markdownContent: { not: { contains: '|' } } },
], ],
}); });
} }
...@@ -110,22 +109,21 @@ export class CrawlPageRepository { ...@@ -110,22 +109,21 @@ export class CrawlPageRepository {
if (exactLength !== undefined && exactLength !== '') { if (exactLength !== undefined && exactLength !== '') {
where.wordCount = Number(exactLength); where.wordCount = Number(exactLength);
} else { } else {
const countFilter: Prisma.IntFilter = {};
if (minLength !== undefined && minLength !== '') { if (minLength !== undefined && minLength !== '') {
where.wordCount = { countFilter.gte = Number(minLength);
...(where.wordCount || {}),
gte: Number(minLength),
};
} }
if (maxLength !== undefined && maxLength !== '') { if (maxLength !== undefined && maxLength !== '') {
where.wordCount = { countFilter.lte = Number(maxLength);
...(where.wordCount || {}), }
lte: Number(maxLength), if (Object.keys(countFilter).length > 0) {
}; where.wordCount = countFilter;
} }
} }
if (andConditions.length > 0) { if (andConditions.length > 0) {
where.AND = [...(where.AND || []), ...andConditions]; const existingAnd = Array.isArray(where.AND) ? where.AND : (where.AND ? [where.AND] : []);
where.AND = [...existingAnd, ...andConditions];
} }
const sortBy = query.sortBy || 'createdAt'; const sortBy = query.sortBy || 'createdAt';
...@@ -141,15 +139,15 @@ export class CrawlPageRepository { ...@@ -141,15 +139,15 @@ export class CrawlPageRepository {
'dataQualityScore', 'dataQualityScore',
'wordCount', 'wordCount',
]; ];
const orderBy: any = allowedSortFields.includes(sortBy) const orderBy: Prisma.CrawlPageOrderByWithRelationInput = allowedSortFields.includes(sortBy)
? { [sortBy]: order } ? { [sortBy]: order as Prisma.SortOrder }
: { createdAt: 'asc' }; : { createdAt: 'asc' };
const page = Math.max(1, Number(query.page) || 1); const page = Math.max(1, Number(query.page) || 1);
const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100); const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100);
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const select: any = { const select: Prisma.CrawlPageSelect = {
id: true, id: true,
jobId: true, jobId: true,
url: true, url: true,
...@@ -219,6 +217,7 @@ export class CrawlPageRepository { ...@@ -219,6 +217,7 @@ export class CrawlPageRepository {
contentHash?: string | null; contentHash?: string | null;
dataQualityScore?: number | null; dataQualityScore?: number | null;
warnings?: string[]; warnings?: string[];
extractedData?: Prisma.InputJsonValue;
}) { }) {
return prisma.crawlPage.update({ return prisma.crawlPage.update({
where: { id }, where: { id },
......
...@@ -11,8 +11,8 @@ export class CrawlPageService { ...@@ -11,8 +11,8 @@ export class CrawlPageService {
const isPreview = query?.preview === true || query?.preview === 'true'; const isPreview = query?.preview === true || query?.preview === 'true';
if (isPreview) { if (isPreview) {
result.items = result.items.map((item: any) => { result.items = result.items.map((item) => {
const rawMarkdown = item.markdownContent ?? null; const rawMarkdown = (item as { markdownContent?: string | null }).markdownContent ?? null;
const mainContent = extractMainContent(rawMarkdown) || null; const mainContent = extractMainContent(rawMarkdown) || null;
const cleanText = mainContent ? stripMarkdown(mainContent) : null; const cleanText = mainContent ? stripMarkdown(mainContent) : null;
return { return {
......
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { CrawlScheduleService } from './crawl-schedule.service'; import { CrawlScheduleService } from './crawl-schedule.service';
import { CrawlScheduleQueryDto } from './crawl-schedule.dto';
export class CrawlScheduleController { export class CrawlScheduleController {
private readonly service = new CrawlScheduleService(); private readonly service = new CrawlScheduleService();
...@@ -25,7 +26,7 @@ export class CrawlScheduleController { ...@@ -25,7 +26,7 @@ export class CrawlScheduleController {
const result = await this.service.findAllByUser( const result = await this.service.findAllByUser(
req.user!.id, req.user!.id,
req.user!.role, req.user!.role,
req.query as any, req.query as unknown as CrawlScheduleQueryDto,
); );
res.json(result); res.json(result);
} catch (error) { } catch (error) {
......
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { CrawlMode, ScheduleFrequency } from '@prisma/client'; import { CrawlMode, ScheduleFrequency, Prisma } from '@prisma/client';
import { CrawlScheduleQueryDto } from './crawl-schedule.dto'; import { CrawlScheduleQueryDto } from './crawl-schedule.dto';
export class CrawlScheduleRepository { export class CrawlScheduleRepository {
...@@ -66,8 +66,8 @@ export class CrawlScheduleRepository { ...@@ -66,8 +66,8 @@ export class CrawlScheduleRepository {
urls?: string[]; urls?: string[];
isActive?: boolean; isActive?: boolean;
autoDiff?: boolean; autoDiff?: boolean;
nextRunAt?: Date; nextRunAt?: Date | null;
lastRunAt?: Date; lastRunAt?: Date | null;
}, },
) { ) {
return prisma.crawlSchedule.update({ return prisma.crawlSchedule.update({
...@@ -88,7 +88,7 @@ export class CrawlScheduleRepository { ...@@ -88,7 +88,7 @@ export class CrawlScheduleRepository {
}); });
} }
findAllByUser(userId: string, query: CrawlScheduleQueryDto) { findAllByUser(userId: string, query: CrawlScheduleQueryDto = {}) {
return this.find(query, userId); return this.find(query, userId);
} }
...@@ -97,7 +97,7 @@ export class CrawlScheduleRepository { ...@@ -97,7 +97,7 @@ export class CrawlScheduleRepository {
} }
private async find(query: CrawlScheduleQueryDto, userId?: string) { private async find(query: CrawlScheduleQueryDto, userId?: string) {
const where: any = {}; const where: Prisma.CrawlScheduleWhereInput = {};
if (userId) { if (userId) {
where.userId = userId; where.userId = userId;
} }
...@@ -116,7 +116,7 @@ export class CrawlScheduleRepository { ...@@ -116,7 +116,7 @@ export class CrawlScheduleRepository {
} }
const sortBy = query.sortBy || 'createdAt'; const sortBy = query.sortBy || 'createdAt';
const order = query.order || 'desc'; const order = (query.order || 'desc') as Prisma.SortOrder;
const allowedSortFields = [ const allowedSortFields = [
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
...@@ -126,7 +126,7 @@ export class CrawlScheduleRepository { ...@@ -126,7 +126,7 @@ export class CrawlScheduleRepository {
'lastRunAt', 'lastRunAt',
'isActive', 'isActive',
]; ];
const orderBy: any = allowedSortFields.includes(sortBy) const orderBy: Prisma.CrawlScheduleOrderByWithRelationInput = allowedSortFields.includes(sortBy)
? { [sortBy]: order } ? { [sortBy]: order }
: { createdAt: 'desc' }; : { createdAt: 'desc' };
......
...@@ -11,6 +11,7 @@ import { AppError } from '../../common/errors/app-error'; ...@@ -11,6 +11,7 @@ import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
import { ROLES } from '../../common/constants/role.constant'; import { ROLES } from '../../common/constants/role.constant';
import { crawlQueue } from '../../queues/crawl.queue'; import { crawlQueue } from '../../queues/crawl.queue';
import { getErrorMessage } from '../../common/helpers/error-mapping.helper';
export class CrawlScheduleService { export class CrawlScheduleService {
private readonly repository = new CrawlScheduleRepository(); private readonly repository = new CrawlScheduleRepository();
...@@ -32,9 +33,9 @@ export class CrawlScheduleService { ...@@ -32,9 +33,9 @@ export class CrawlScheduleService {
for (const url of deduplicatedUrls) { for (const url of deduplicatedUrls) {
try { try {
await validateUrlAsync(url); await validateUrlAsync(url);
} catch (err: any) { } catch (err: unknown) {
throw new AppError( throw new AppError(
`Invalid or blocked URL in schedule: ${url}${err?.message}`, `Invalid or blocked URL in schedule: ${url}${getErrorMessage(err)}`,
400, 400,
ERROR_CODE.INVALID_URL, ERROR_CODE.INVALID_URL,
); );
...@@ -63,8 +64,8 @@ export class CrawlScheduleService { ...@@ -63,8 +64,8 @@ export class CrawlScheduleService {
cronExpression: payload.cronExpression, cronExpression: payload.cronExpression,
timezone, timezone,
}); });
} catch (err: any) { } catch (err: unknown) {
throw new AppError(err.message || 'Failed to calculate next run date', 400, ERROR_CODE.VALIDATION_ERROR); throw new AppError(getErrorMessage(err) || 'Failed to calculate next run date', 400, ERROR_CODE.VALIDATION_ERROR);
} }
} }
...@@ -154,7 +155,7 @@ export class CrawlScheduleService { ...@@ -154,7 +155,7 @@ export class CrawlScheduleService {
timezone, timezone,
}); });
} else { } else {
nextRunAt = null as any; nextRunAt = null;
} }
return this.repository.update(scheduleId, { return this.repository.update(scheduleId, {
...@@ -256,7 +257,7 @@ export class CrawlScheduleService { ...@@ -256,7 +257,7 @@ export class CrawlScheduleService {
for (const schedule of dueSchedules) { for (const schedule of dueSchedules) {
try { try {
// Skip if user is inactive or deleted // Skip if user is inactive or deleted
const user = (schedule as any).user; const user = schedule.user;
if (user && (!user.isActive || user.deletedAt)) { if (user && (!user.isActive || user.deletedAt)) {
console.warn(`[Schedule Service] Skipping schedule ${schedule.id}: user is inactive or deleted`); console.warn(`[Schedule Service] Skipping schedule ${schedule.id}: user is inactive or deleted`);
continue; continue;
...@@ -293,8 +294,8 @@ export class CrawlScheduleService { ...@@ -293,8 +294,8 @@ export class CrawlScheduleService {
await crawlQueue.add('crawl-job', { jobId: job.id }); await crawlQueue.add('crawl-job', { jobId: job.id });
triggeredCount++; triggeredCount++;
} catch (err: any) { } catch (err: unknown) {
console.error(`[Schedule Service] Failed to trigger due schedule ${schedule.id}: ${err.message}`); console.error(`[Schedule Service] Failed to trigger due schedule ${schedule.id}: ${getErrorMessage(err)}`);
} }
} }
......
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import archiver from 'archiver'; import archiver from 'archiver';
import { CrawlJob, CrawlPage } from '@prisma/client'; import { CrawlJob, CrawlPage } from '@prisma/client';
import { JOB_EXPORT_SUBDIRS } from '../../common/constants/storage-path.constant'; import { JOB_EXPORT_SUBDIRS } from '../../common/constants/storage-path.constant';
......
...@@ -386,7 +386,9 @@ export class XlsxExportService extends BaseExportService { ...@@ -386,7 +386,9 @@ export class XlsxExportService extends BaseExportService {
if (value) { if (value) {
let str = ''; let str = '';
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
str = (value as any).text || ''; str = ('text' in value && typeof (value as { text: unknown }).text === 'string')
? (value as { text: string }).text
: JSON.stringify(value);
} else { } else {
str = String(value); str = String(value);
} }
......
...@@ -58,7 +58,9 @@ export async function runExtractionIfTemplate( ...@@ -58,7 +58,9 @@ export async function runExtractionIfTemplate(
// Extraction requires raw HTML — Firecrawl returns it via the html field // Extraction requires raw HTML — Firecrawl returns it via the html field
// which is not currently surfaced in FirecrawlPageResult. We fall back to // which is not currently surfaced in FirecrawlPageResult. We fall back to
// markdownContent if html is unavailable. // markdownContent if html is unavailable.
const html = (item as any).html ?? item.markdown ?? ''; 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;
const domain = extractDomainFromUrl(pageUrl); const domain = extractDomainFromUrl(pageUrl);
...@@ -76,7 +78,7 @@ export async function runExtractionIfTemplate( ...@@ -76,7 +78,7 @@ export async function runExtractionIfTemplate(
const result = runSelectors(html, fields); const result = runSelectors(html, fields);
await getPageRepository().update(pageId, { await getPageRepository().update(pageId, {
structuredData: { extractedData: {
templateId: template.id, templateId: template.id,
templateName: template.name, templateName: template.name,
success: result.success, success: result.success,
...@@ -84,5 +86,5 @@ export async function runExtractionIfTemplate( ...@@ -84,5 +86,5 @@ export async function runExtractionIfTemplate(
data: result.data, data: result.data,
extractedAt: new Date().toISOString(), extractedAt: new Date().toISOString(),
}, },
} as any); });
} }
\ No newline at end of file
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { Prisma } from '@prisma/client';
import { CreateExtractionTemplateDto, UpdateExtractionTemplateDto } from './extraction-template.dto'; import { CreateExtractionTemplateDto, UpdateExtractionTemplateDto } from './extraction-template.dto';
export class ExtractionTemplateRepository { export class ExtractionTemplateRepository {
...@@ -8,7 +9,7 @@ export class ExtractionTemplateRepository { ...@@ -8,7 +9,7 @@ export class ExtractionTemplateRepository {
userId, userId,
name: data.name, name: data.name,
domain: data.domain, domain: data.domain,
fields: data.fields as any, fields: data.fields as unknown as Prisma.InputJsonValue,
}, },
}); });
} }
...@@ -47,7 +48,7 @@ export class ExtractionTemplateRepository { ...@@ -47,7 +48,7 @@ export class ExtractionTemplateRepository {
where: { id }, where: { id },
data: { data: {
...(data.name !== undefined && { name: data.name }), ...(data.name !== undefined && { name: data.name }),
...(data.fields !== undefined && { fields: data.fields as any }), ...(data.fields !== undefined && { fields: data.fields as unknown as Prisma.InputJsonValue }),
}, },
}); });
} }
......
...@@ -9,8 +9,8 @@ export class ExtractionTemplateService { ...@@ -9,8 +9,8 @@ export class ExtractionTemplateService {
async create(userId: string, payload: CreateExtractionTemplateDto) { async create(userId: string, payload: CreateExtractionTemplateDto) {
try { try {
return await this.repository.create(userId, payload); return await this.repository.create(userId, payload);
} catch (err: any) { } catch (err: unknown) {
if (err?.code === 'P2002') { if (err && typeof err === 'object' && 'code' in err && (err as { code: string }).code === 'P2002') {
throw new AppError( throw new AppError(
`A template for domain "${payload.domain}" already exists`, `A template for domain "${payload.domain}" already exists`,
409, 409,
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
CrawlErrorItem, CrawlErrorItem,
} from './firecrawl.dto'; } from './firecrawl.dto';
import type { FirecrawlDocument } from '@mendable/firecrawl-js'; import type { FirecrawlDocument } from '@mendable/firecrawl-js';
import { getErrorMessage } from '../../common/helpers/error-mapping.helper';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
...@@ -203,10 +204,10 @@ export class FirecrawlService { ...@@ -203,10 +204,10 @@ export class FirecrawlService {
firecrawlConfig.requestTimeoutMs, firecrawlConfig.requestTimeoutMs,
`cancelCrawl(${firecrawlJobId})`, `cancelCrawl(${firecrawlJobId})`,
); );
} catch (err: any) { } catch (err: unknown) {
// Log but don't throw — DB status is already CANCELED, provider cancel // Log but don't throw — DB status is already CANCELED, provider cancel
// is best-effort. A failed cancel doesn't break the user-facing operation. // is best-effort. A failed cancel doesn't break the user-facing operation.
console.warn(`[FirecrawlService] cancelCrawl(${firecrawlJobId}) failed (best-effort): ${err?.message}`); console.warn(`[FirecrawlService] cancelCrawl(${firecrawlJobId}) failed (best-effort): ${getErrorMessage(err)}`);
} }
} }
...@@ -225,8 +226,8 @@ export class FirecrawlService { ...@@ -225,8 +226,8 @@ export class FirecrawlService {
`fetchSitemap(${sitemapUrl})`, `fetchSitemap(${sitemapUrl})`,
); );
xml = response.data; xml = response.data;
} catch (err: any) { } catch (err: unknown) {
throw new Error(`Failed to fetch sitemap at ${sitemapUrl}: ${err?.message ?? 'unknown error'}`); throw new Error(`Failed to fetch sitemap at ${sitemapUrl}: ${getErrorMessage(err)}`);
} }
// cheerio works on HTML by default; force xml mode so <loc> tags parse correctly // cheerio works on HTML by default; force xml mode so <loc> tags parse correctly
...@@ -256,15 +257,18 @@ export class FirecrawlService { ...@@ -256,15 +257,18 @@ export class FirecrawlService {
const client = getFirecrawlClient(); const client = getFirecrawlClient();
const start = await withTimeout( const start = await withTimeout(
client.asyncBatchScrapeUrls(urls, { formats: ['markdown', 'html'] } as any), client.asyncBatchScrapeUrls(urls, { formats: ['markdown', 'html'] } as unknown as { formats: ('markdown' | 'html')[] }),
firecrawlConfig.requestTimeoutMs, firecrawlConfig.requestTimeoutMs,
`asyncBatchScrapeUrls(${urls.length} URLs)`, `asyncBatchScrapeUrls(${urls.length} URLs)`,
); );
if (!start.success || !start.id) { if (!start.success || !start.id) {
const startError = (start && typeof start === 'object' && 'error' in start && typeof (start as { error: unknown }).error === 'string')
? (start as { error: string }).error
: 'Failed to start batch scrape';
return { return {
status: 'failed', completed: 0, total: 0, pages: [], success: false, status: 'failed', completed: 0, total: 0, pages: [], success: false,
error: (start as any).error ?? 'Failed to start batch scrape', error: startError,
}; };
} }
...@@ -285,9 +289,12 @@ export class FirecrawlService { ...@@ -285,9 +289,12 @@ export class FirecrawlService {
); );
if (!status.success) { if (!status.success) {
const statusError = (status && typeof status === 'object' && 'error' in status && typeof (status as { error: unknown }).error === 'string')
? (status as { error: string }).error
: 'Failed to check batch scrape status';
return { return {
status: 'failed', completed: 0, total: 0, pages: [], success: false, status: 'failed', completed: 0, total: 0, pages: [], success: false,
error: (status as any).error ?? 'Failed to check batch scrape status', error: statusError,
}; };
} }
...@@ -304,8 +311,11 @@ export class FirecrawlService { ...@@ -304,8 +311,11 @@ export class FirecrawlService {
firecrawlConfig.requestTimeoutMs, firecrawlConfig.requestTimeoutMs,
`checkBatchScrapeErrors(${batchId})`, `checkBatchScrapeErrors(${batchId})`,
); );
if ('errors' in errorsResult) { if ('errors' in errorsResult && Array.isArray(errorsResult.errors)) {
failedUrls = errorsResult.errors.map((e: any) => ({ url: e.url, error: e.error })); failedUrls = errorsResult.errors.map((e: { url: string; error?: string }) => ({
url: e.url,
error: e.error || 'Unknown error',
}));
robotsBlockedUrls = errorsResult.robotsBlocked; robotsBlockedUrls = errorsResult.robotsBlocked;
} }
} catch { } catch {
......
import { HealthService } from '../health.service';
import { prisma } from '../../../database/prisma.client';
jest.mock('../../../database/prisma.client', () => ({
prisma: {
$queryRaw: jest.fn(),
},
}));
jest.mock('../../../queues/crawl.queue', () => ({
crawlQueue: {
client: Promise.resolve({ ping: jest.fn().mockResolvedValue('PONG') }),
getWaitingCount: jest.fn().mockResolvedValue(2),
getActiveCount: jest.fn().mockResolvedValue(1),
getCompletedCount: jest.fn().mockResolvedValue(50),
getFailedCount: jest.fn().mockResolvedValue(0),
},
}));
jest.mock('../../../queues/webhook.queue', () => ({
webhookQueue: {
getWaitingCount: jest.fn().mockResolvedValue(0),
getActiveCount: jest.fn().mockResolvedValue(0),
getCompletedCount: jest.fn().mockResolvedValue(10),
getFailedCount: jest.fn().mockResolvedValue(0),
},
}));
describe('HealthService', () => {
let service: HealthService;
beforeEach(() => {
jest.clearAllMocks();
service = new HealthService();
});
describe('getLiveness', () => {
it('returns ok status and uptime', () => {
const result = service.getLiveness();
expect(result.status).toBe('ok');
expect(typeof result.uptimeSeconds).toBe('number');
expect(result.timestamp).toBeDefined();
});
});
describe('getReadiness', () => {
it('returns ready status when database is up', async () => {
(prisma.$queryRaw as jest.Mock).mockResolvedValue([{ 1: 1 }]);
const result = await service.getReadiness();
expect(result.status).toBe('ready');
expect(result.checks.database.status).toBe('up');
expect(result.checks.redis.status).toBe('up');
});
it('returns unhealthy status when database query fails', async () => {
(prisma.$queryRaw as jest.Mock).mockRejectedValue(new Error('Connection timeout'));
const result = await service.getReadiness();
expect(result.status).toBe('unhealthy');
expect(result.checks.database.status).toBe('down');
});
});
describe('getMetrics', () => {
it('returns process and queue metrics', async () => {
const result = await service.getMetrics();
expect(result.process.memory).toBeDefined();
expect(result.queues.crawl).toEqual({
waiting: 2,
active: 1,
completed: 50,
failed: 0,
});
});
});
});
import { Request, Response, NextFunction } from 'express';
import { HealthService } from './health.service';
export class HealthController {
private readonly service = new HealthService();
getLiveness = (req: Request, res: Response): void => {
const result = this.service.getLiveness();
res.json(result);
};
getReadiness = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.service.getReadiness();
const statusCode = result.status === 'ready' ? 200 : 503;
res.status(statusCode).json(result);
} catch (error) {
next(error);
}
};
getMetrics = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const result = await this.service.getMetrics();
res.json(result);
} catch (error) {
next(error);
}
};
}
import { Router } from 'express';
import { HealthController } from './health.controller';
const router = Router();
const controller = new HealthController();
router.get('/liveness', controller.getLiveness);
router.get('/readiness', controller.getReadiness);
router.get('/metrics', controller.getMetrics);
export default router;
import { prisma } from '../../database/prisma.client';
import { crawlQueue } from '../../queues/crawl.queue';
import { webhookQueue } from '../../queues/webhook.queue';
import { getErrorMessage } from '../../common/helpers/error-mapping.helper';
export interface QueueCountMetrics {
waiting: number;
active: number;
completed: number;
failed: number;
}
export type QueueMetricsResult = QueueCountMetrics | 'unavailable' | null;
export class HealthService {
getLiveness() {
return {
status: 'ok',
uptimeSeconds: Math.floor(process.uptime()),
timestamp: new Date().toISOString(),
nodeVersion: process.version,
};
}
async getReadiness() {
const checks: Record<string, { status: string; latencyMs?: number; error?: string }> = {};
let isReady = true;
// 1. Check Database
const dbStart = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
checks.database = {
status: 'up',
latencyMs: Date.now() - dbStart,
};
} catch (err: unknown) {
isReady = false;
checks.database = {
status: 'down',
error: getErrorMessage(err),
};
}
// 2. Check Queue / Redis
if (crawlQueue) {
const redisStart = Date.now();
try {
const client = await crawlQueue.client;
if ('ping' in client && typeof (client as { ping: () => Promise<string> }).ping === 'function') {
await (client as { ping: () => Promise<string> }).ping();
}
checks.redis = {
status: 'up',
latencyMs: Date.now() - redisStart,
};
} catch (err: unknown) {
checks.redis = {
status: 'degraded',
error: getErrorMessage(err),
};
}
} else {
checks.redis = {
status: 'skipped',
};
}
return {
status: isReady ? 'ready' : 'unhealthy',
timestamp: new Date().toISOString(),
checks,
};
}
async getMetrics() {
const mem = process.memoryUsage();
let crawlQueueMetrics: QueueMetricsResult = null;
let webhookQueueMetrics: QueueMetricsResult = null;
if (crawlQueue) {
try {
const [waiting, active, completed, failed] = await Promise.all([
crawlQueue.getWaitingCount(),
crawlQueue.getActiveCount(),
crawlQueue.getCompletedCount(),
crawlQueue.getFailedCount(),
]);
crawlQueueMetrics = { waiting, active, completed, failed };
} catch {
crawlQueueMetrics = 'unavailable';
}
}
if (webhookQueue) {
try {
const [waiting, active, completed, failed] = await Promise.all([
webhookQueue.getWaitingCount(),
webhookQueue.getActiveCount(),
webhookQueue.getCompletedCount(),
webhookQueue.getFailedCount(),
]);
webhookQueueMetrics = { waiting, active, completed, failed };
} catch {
webhookQueueMetrics = 'unavailable';
}
}
return {
timestamp: new Date().toISOString(),
process: {
uptimeSeconds: Math.floor(process.uptime()),
pid: process.pid,
memory: {
rssMb: Math.round(mem.rss / 1024 / 1024),
heapTotalMb: Math.round(mem.heapTotal / 1024 / 1024),
heapUsedMb: Math.round(mem.heapUsed / 1024 / 1024),
},
},
queues: {
crawl: crawlQueueMetrics,
webhook: webhookQueueMetrics,
},
};
}
}
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { UserRole } from '@prisma/client'; import { UserRole, Prisma, User } from '@prisma/client';
import { UserQueryDto } from './user.dto'; import { UserQueryDto } from './user.dto';
import { envConfig } from '../../config/env.config'; import { envConfig } from '../../config/env.config';
export class UserRepository { export class UserRepository {
async findAll(query: UserQueryDto = {}) { async findAll(query: UserQueryDto = {}) {
const where: any = { deletedAt: null }; const where: Prisma.UserWhereInput = { deletedAt: null };
if (query.role) { if (query.role) {
where.role = query.role; where.role = query.role;
} }
...@@ -26,12 +26,12 @@ export class UserRepository { ...@@ -26,12 +26,12 @@ export class UserRepository {
const sortBy = query.sortBy || 'createdAt'; const sortBy = query.sortBy || 'createdAt';
const order = query.order || 'desc'; const order = query.order || 'desc';
const allowedSortFields = ['createdAt', 'updatedAt', 'email', 'fullName', 'role', 'isActive']; const allowedSortFields = ['createdAt', 'updatedAt', 'email', 'fullName', 'role', 'isActive'];
const orderBy: any = allowedSortFields.includes(sortBy) const orderBy: Prisma.UserOrderByWithRelationInput = allowedSortFields.includes(sortBy)
? { [sortBy]: order } ? { [sortBy]: order }
: { createdAt: 'desc' }; : { createdAt: 'desc' };
const page = Math.max(1, Number(query.page) || 1); const page = Math.max(1, Number(query.page) || 1);
const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100) const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100);
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
...@@ -52,13 +52,13 @@ export class UserRepository { ...@@ -52,13 +52,13 @@ export class UserRepository {
}; };
} }
findById(id: string) { findById(id: string): Promise<User | null> {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { id, deletedAt: null }, where: { id, deletedAt: null },
}); });
} }
findByEmail(email: string) { findByEmail(email: string): Promise<User | null> {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { email, deletedAt: null }, where: { email, deletedAt: null },
}); });
...@@ -72,7 +72,7 @@ export class UserRepository { ...@@ -72,7 +72,7 @@ export class UserRepository {
maxPagesLimit?: number; maxPagesLimit?: number;
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number; maxConcurrentJobsLimit?: number;
}) { }): Promise<User> {
return prisma.user.create({ return prisma.user.create({
data: { data: {
email: data.email, email: data.email,
...@@ -96,14 +96,14 @@ export class UserRepository { ...@@ -96,14 +96,14 @@ export class UserRepository {
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
maxConcurrentJobsLimit?: number; maxConcurrentJobsLimit?: number;
}, },
) { ): Promise<User> {
return prisma.user.update({ return prisma.user.update({
where: { id }, where: { id },
data, data,
}); });
} }
async delete(id: string, deletedBy: string) { async delete(id: string, deletedBy: string): Promise<User> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const user = await tx.user.update({ const user = await tx.user.update({
where: { id }, where: { id },
......
...@@ -2,14 +2,14 @@ import { Router } from 'express'; ...@@ -2,14 +2,14 @@ import { Router } from 'express';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware'; import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate, validateQuery } from '../../middlewares/validate.middleware';
import { createUserSchema, updateUserSchema } from './user.validation'; import { createUserSchema, updateUserSchema, listUsersQuerySchema } from './user.validation';
import { ROLES } from '../../common/constants/role.constant'; import { ROLES } from '../../common/constants/role.constant';
const router = Router(); const router = Router();
const controller = new UserController(); const controller = new UserController();
router.get('/', authMiddleware, requireRole(ROLES.ADMIN), controller.findAll); router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validateQuery(listUsersQuerySchema), controller.findAll);
router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.findById); router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), controller.findById);
router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), (req, res, next) => { router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/CreateUserRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/CreateUserRequest' } }
......
...@@ -2,13 +2,13 @@ import bcrypt from 'bcryptjs'; ...@@ -2,13 +2,13 @@ import bcrypt from 'bcryptjs';
import { UserRepository } from './user.repository'; import { UserRepository } from './user.repository';
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 { UserRole } from '@prisma/client'; import { UserRole, User } from '@prisma/client';
import { CreateUserDto, UpdateUserDto, UserResponseDto, UserQueryDto } from './user.dto'; import { CreateUserDto, UpdateUserDto, UserResponseDto, UserQueryDto } from './user.dto';
export class UserService { export class UserService {
private readonly repository = new UserRepository(); private readonly repository = new UserRepository();
private formatUser(user: any): UserResponseDto { private formatUser(user: User): UserResponseDto {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
......
...@@ -18,3 +18,11 @@ export const updateUserSchema = z.object({ ...@@ -18,3 +18,11 @@ export const updateUserSchema = z.object({
maxJobsPerDayLimit: z.number().int().min(1).optional(), maxJobsPerDayLimit: z.number().int().min(1).optional(),
maxConcurrentJobsLimit: z.number().int().min(1).optional(), maxConcurrentJobsLimit: z.number().int().min(1).optional(),
}); });
export const listUsersQuerySchema = z.object({
search: z.string().trim().optional(),
role: z.enum(['ADMIN', 'CRAWLER_USER', 'VIEWER']).optional(),
isActive: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
});
...@@ -149,5 +149,50 @@ describe('Webhook Services', () => { ...@@ -149,5 +149,50 @@ describe('Webhook Services', () => {
}), }),
); );
}); });
it('redelivers a failed webhook and enqueues to queue', async () => {
mockWebhookRepo.findDeliveryById.mockResolvedValue({
id: 'delivery-1',
event: 'job.completed',
webhookConfig: {
userId: 'user-1',
},
} as any);
mockWebhookRepo.updateDelivery.mockResolvedValue({
id: 'delivery-1',
status: 'PENDING',
} as any);
const result = await deliveryService.redeliver('delivery-1', 'user-1');
expect(mockWebhookRepo.updateDelivery).toHaveBeenCalledWith(
'delivery-1',
expect.objectContaining({
status: 'PENDING',
attempt: 1,
errorMessage: null,
}),
);
expect(webhookQueue?.add).toHaveBeenCalledWith(
'send-webhook',
{ deliveryId: 'delivery-1' },
expect.any(Object),
);
expect(result.status).toBe('PENDING');
});
it('throws 404 when redelivering delivery of another user', async () => {
mockWebhookRepo.findDeliveryById.mockResolvedValue({
id: 'delivery-1',
webhookConfig: {
userId: 'other-user',
},
} as any);
await expect(
deliveryService.redeliver('delivery-1', 'user-1'),
).rejects.toThrow('Webhook delivery not found');
});
}); });
}); });
...@@ -51,6 +51,10 @@ export function decrypt(encryptedText: string): string { ...@@ -51,6 +51,10 @@ export function decrypt(encryptedText: string): string {
const iv = Buffer.from(parts[0], 'hex'); const iv = Buffer.from(parts[0], 'hex');
const authTag = Buffer.from(parts[1], 'hex'); const authTag = Buffer.from(parts[1], 'hex');
const ciphertext = Buffer.from(parts[2], 'hex'); const ciphertext = Buffer.from(parts[2], 'hex');
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error('Invalid authentication tag length');
}
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag); decipher.setAuthTag(authTag);
......
import { WebhookDelivery } from '@prisma/client'; import { WebhookDelivery, Prisma } from '@prisma/client';
import { WebhookRepository } from './webhook.repository'; import { WebhookRepository } from './webhook.repository';
import { decrypt, signPayload } from './webhook-crypto.helper'; import { decrypt, signPayload } from './webhook-crypto.helper';
import { webhookQueue } from '../../queues/webhook.queue'; import { webhookQueue } from '../../queues/webhook.queue';
import { getSecureAxios } from '../../common/helpers/url.helper'; import { getSecureAxios } from '../../common/helpers/url.helper';
import { getErrorMessage } from '../../common/helpers/error-mapping.helper';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
export class WebhookDeliveryService { export class WebhookDeliveryService {
private readonly repository = new WebhookRepository(); private readonly repository = new WebhookRepository();
async dispatch(crawlJobId: string, userId: string, event: string, jobData: any): Promise<void> { async dispatch(crawlJobId: string, userId: string, event: string, jobData: Record<string, unknown>): Promise<void> {
try { try {
const configs = await this.repository.findActiveConfigsByEvent(userId, event); const configs = await this.repository.findActiveConfigsByEvent(userId, event);
...@@ -16,10 +20,10 @@ export class WebhookDeliveryService { ...@@ -16,10 +20,10 @@ export class WebhookDeliveryService {
} }
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const payload = { const payload: Prisma.InputJsonObject = {
event, event,
timestamp, timestamp,
data: jobData, data: jobData as Prisma.InputJsonValue,
}; };
for (const config of configs) { for (const config of configs) {
...@@ -27,7 +31,7 @@ export class WebhookDeliveryService { ...@@ -27,7 +31,7 @@ export class WebhookDeliveryService {
webhookConfigId: config.id, webhookConfigId: config.id,
crawlJobId, crawlJobId,
event, event,
payload: payload as any, payload,
status: 'PENDING', status: 'PENDING',
attempt: 1, attempt: 1,
}); });
...@@ -48,8 +52,8 @@ export class WebhookDeliveryService { ...@@ -48,8 +52,8 @@ export class WebhookDeliveryService {
console.error('[Webhook] Redis/BullMQ is not initialized. Webhook could not be enqueued.'); console.error('[Webhook] Redis/BullMQ is not initialized. Webhook could not be enqueued.');
} }
} }
} catch (error) { } catch (error: unknown) {
console.error('[Webhook Dispatch Error]:', error); console.error('[Webhook Dispatch Error]:', getErrorMessage(error));
} }
} }
...@@ -92,16 +96,19 @@ export class WebhookDeliveryService { ...@@ -92,16 +96,19 @@ export class WebhookDeliveryService {
errorMessage: null, errorMessage: null,
}); });
} catch (error: any) { } catch (error: unknown) {
let statusCode: number | null = null; let statusCode: number | null = null;
let responseBody: string | null = null; let responseBody: string | null = null;
const errorMessage = error.message || 'Unknown network error'; const errorMessage = getErrorMessage(error);
if (error.response) { if (error && typeof error === 'object' && 'response' in error) {
statusCode = error.response.status; const resp = (error as { response?: { status?: number; data?: unknown } }).response;
responseBody = typeof error.response.data === 'string' if (resp) {
? error.response.data statusCode = resp.status ?? null;
: JSON.stringify(error.response.data); responseBody = typeof resp.data === 'string'
? resp.data
: JSON.stringify(resp.data);
}
} }
await this.repository.updateDelivery(deliveryId, { await this.repository.updateDelivery(deliveryId, {
...@@ -126,10 +133,40 @@ export class WebhookDeliveryService { ...@@ -126,10 +133,40 @@ export class WebhookDeliveryService {
}); });
} }
async redeliver(deliveryId: string, userId: string): Promise<WebhookDelivery> {
const delivery = await this.repository.findDeliveryById(deliveryId);
if (!delivery || delivery.webhookConfig.userId !== userId) {
throw new AppError('Webhook delivery not found', 404, ERROR_CODE.NOT_FOUND);
}
const updated = await this.repository.updateDelivery(deliveryId, {
status: 'PENDING',
attempt: 1,
errorMessage: null,
});
if (webhookQueue) {
await webhookQueue.add(
'send-webhook',
{ deliveryId: delivery.id },
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
},
);
}
return updated;
}
async listDeliveries( async listDeliveries(
userId: string, userId: string,
query: { jobId?: string; status?: string }, query: { jobId?: string; status?: string; page?: number; limit?: number },
): Promise<WebhookDelivery[]> { ) {
return this.repository.listDeliveries(userId, query); return this.repository.listDeliveries(userId, query);
} }
} }
...@@ -76,8 +76,10 @@ export class WebhookController { ...@@ -76,8 +76,10 @@ export class WebhookController {
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 string | undefined;
const page = req.query.page ? Number(req.query.page) : undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const result = await this.deliveryService.listDeliveries(userId, { jobId, status }); const result = await this.deliveryService.listDeliveries(userId, { jobId, status, page, limit });
res.json({ res.json({
success: true, success: true,
...@@ -87,4 +89,29 @@ export class WebhookController { ...@@ -87,4 +89,29 @@ export class WebhookController {
next(error); next(error);
} }
}; };
redeliver = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const userId = req.user.id;
const deliveryId = req.params.id;
const result = await this.deliveryService.redeliver(deliveryId, userId);
await this.auditLogService.log({
userId,
action: AUDIT_ACTIONS.REDELIVER_WEBHOOK,
ipAddress: req.ip,
userAgent: req.headers['user-agent'] as string,
details: { deliveryId: result.id, event: result.event },
});
res.json({
success: true,
message: 'Webhook redelivery enqueued successfully',
data: result,
});
} catch (error) {
next(error);
}
};
} }
...@@ -53,7 +53,7 @@ export class WebhookRepository { ...@@ -53,7 +53,7 @@ export class WebhookRepository {
webhookConfigId: string; webhookConfigId: string;
crawlJobId: string; crawlJobId: string;
event: string; event: string;
payload: any; payload: Prisma.InputJsonValue;
status: string; status: string;
attempt: number; attempt: number;
}): Promise<WebhookDelivery> { }): Promise<WebhookDelivery> {
...@@ -83,9 +83,9 @@ export class WebhookRepository { ...@@ -83,9 +83,9 @@ export class WebhookRepository {
}); });
} }
listDeliveries( async listDeliveries(
userId: string, userId: string,
query: { jobId?: string; status?: string }, query: { jobId?: string; status?: string; page?: number; limit?: number },
) { ) {
const where: Prisma.WebhookDeliveryWhereInput = { const where: Prisma.WebhookDeliveryWhereInput = {
webhookConfig: { webhookConfig: {
...@@ -101,18 +101,37 @@ export class WebhookRepository { ...@@ -101,18 +101,37 @@ export class WebhookRepository {
where.status = query.status; where.status = query.status;
} }
return prisma.webhookDelivery.findMany({ const page = Math.max(1, Number(query.page) || 1);
where, const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100);
include: { const skip = (page - 1) * limit;
webhookConfig: {
select: { const [items, total] = await Promise.all([
url: true, prisma.webhookDelivery.findMany({
where,
include: {
webhookConfig: {
select: {
url: true,
},
}, },
}, },
orderBy: {
createdAt: 'desc',
},
skip,
take: limit,
}),
prisma.webhookDelivery.count({ where }),
]);
return {
items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
}, },
orderBy: { };
createdAt: 'desc',
},
});
} }
} }
import { Router } from 'express'; import { Router } from 'express';
import { WebhookController } from './webhook.controller'; import { WebhookController } from './webhook.controller';
import { authMiddleware } from '../../middlewares/auth.middleware'; import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware'; import { validate, validateQuery } from '../../middlewares/validate.middleware';
import { createWebhookConfigSchema } from './webhook.validation'; import { createWebhookConfigSchema, listWebhookDeliveriesQuerySchema } from './webhook.validation';
const router = Router(); const router = Router();
const controller = new WebhookController(); const controller = new WebhookController();
...@@ -10,6 +10,7 @@ const controller = new WebhookController(); ...@@ -10,6 +10,7 @@ const controller = new WebhookController();
router.post('/configs', authMiddleware, validate(createWebhookConfigSchema), controller.createConfig); router.post('/configs', authMiddleware, validate(createWebhookConfigSchema), controller.createConfig);
router.get('/configs', authMiddleware, controller.listConfigs); router.get('/configs', authMiddleware, controller.listConfigs);
router.delete('/configs/:id', authMiddleware, controller.deleteConfig); router.delete('/configs/:id', authMiddleware, controller.deleteConfig);
router.get('/deliveries', authMiddleware, controller.listDeliveries); router.get('/deliveries', authMiddleware, validateQuery(listWebhookDeliveriesQuerySchema), controller.listDeliveries);
router.post('/deliveries/:id/redeliver', authMiddleware, controller.redeliver);
export default router; export default router;
...@@ -12,3 +12,10 @@ export const createWebhookConfigSchema = z.object({ ...@@ -12,3 +12,10 @@ export const createWebhookConfigSchema = z.object({
z.enum(['job.completed', 'job.failed']) z.enum(['job.completed', 'job.failed'])
).min(1, 'At least one event must be selected for notifications'), ).min(1, 'At least one event must be selected for notifications'),
}); });
export const listWebhookDeliveriesQuerySchema = z.object({
jobId: z.string().uuid().optional(),
status: z.enum(['PENDING', 'SUCCESS', 'FAILED']).optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
});
...@@ -6,7 +6,7 @@ import { CrawlAssetRepository } from '../modules/crawl-assets/crawl-asset.reposi ...@@ -6,7 +6,7 @@ import { CrawlAssetRepository } from '../modules/crawl-assets/crawl-asset.reposi
import { FirecrawlService } from '../modules/firecrawl/firecrawl.service'; import { FirecrawlService } from '../modules/firecrawl/firecrawl.service';
import { CrawlPageProcessorService } from '../modules/crawl-pages/crawl-page-processor.service'; import { CrawlPageProcessorService } from '../modules/crawl-pages/crawl-page-processor.service';
import { SensitiveScanService } from '../modules/crawl-pages/sensitive-scan.service'; import { SensitiveScanService } from '../modules/crawl-pages/sensitive-scan.service';
import { mapCrawlError } from '../common/helpers/error-mapping.helper'; import { mapCrawlError, getErrorMessage } from '../common/helpers/error-mapping.helper';
import { validateUrlAsync } from '../common/helpers/url.helper'; import { validateUrlAsync } from '../common/helpers/url.helper';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { FirecrawlPageResult, CrawlStatusResult } from '../modules/firecrawl/firecrawl.dto'; import { FirecrawlPageResult, CrawlStatusResult } from '../modules/firecrawl/firecrawl.dto';
...@@ -127,8 +127,8 @@ export async function persistBatchResults( ...@@ -127,8 +127,8 @@ export async function persistBatchResults(
await runExtractionIfTemplate(jobId, page.id, item.url, item, userId); await runExtractionIfTemplate(jobId, page.id, item.url, item, userId);
if (item.success) successCount++; if (item.success) successCount++;
else failedCount++; else failedCount++;
} catch (err: any) { } catch (err: unknown) {
console.error(`[Worker] Failed to save page ${item.url}: ${err?.message}`); console.error(`[Worker] Failed to save page ${item.url}: ${getErrorMessage(err)}`);
saveErrors++; saveErrors++;
} }
...@@ -150,8 +150,8 @@ export async function persistBatchResults( ...@@ -150,8 +150,8 @@ export async function persistBatchResults(
const normalized = getPageProcessor().normalizeFailedPage(failed, jobId); const normalized = getPageProcessor().normalizeFailedPage(failed, jobId);
await getPageRepository().upsert(normalized); await getPageRepository().upsert(normalized);
failedCount++; failedCount++;
} catch (err: any) { } catch (err: unknown) {
console.error(`[Worker] Failed to save error page ${failed.url}: ${err?.message}`); console.error(`[Worker] Failed to save error page ${failed.url}: ${getErrorMessage(err)}`);
saveErrors++; saveErrors++;
} }
} }
...@@ -167,8 +167,8 @@ export async function persistBatchResults( ...@@ -167,8 +167,8 @@ export async function persistBatchResults(
); );
await getPageRepository().upsert(normalized); await getPageRepository().upsert(normalized);
failedCount++; failedCount++;
} catch (err: any) { } catch (err: unknown) {
console.error(`[Worker] Failed to save robots-blocked page ${blockedUrl}: ${err?.message}`); console.error(`[Worker] Failed to save robots-blocked page ${blockedUrl}: ${getErrorMessage(err)}`);
saveErrors++; saveErrors++;
} }
} }
...@@ -249,15 +249,16 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -249,15 +249,16 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
let sitemapUrls: string[]; let sitemapUrls: string[];
try { try {
sitemapUrls = await getFirecrawlService().parseSitemapUrls(crawlJob.startUrl, crawlJob.maxPages); sitemapUrls = await getFirecrawlService().parseSitemapUrls(crawlJob.startUrl, crawlJob.maxPages);
} catch (err: any) { } catch (err: unknown) {
const errorMessage = getErrorMessage(err);
await getJobRepository().updateStatus(jobId, 'FAILED', { await getJobRepository().updateStatus(jobId, 'FAILED', {
finishedAt: new Date(), finishedAt: new Date(),
errorMessage: mapCrawlError(err?.message ?? 'Failed to parse sitemap'), errorMessage: mapCrawlError(errorMessage || 'Failed to parse sitemap'),
totalPages: 0, totalPages: 0,
successPages: 0, successPages: 0,
failedPages: 0, failedPages: 0,
}); });
console.error(`[Worker] Job ${jobId} sitemap parse failed: ${err?.message}`); console.error(`[Worker] Job ${jobId} sitemap parse failed: ${errorMessage}`);
return; return;
} }
...@@ -443,13 +444,13 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -443,13 +444,13 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
console.log( console.log(
`[Worker] Diff report generated for job ${jobId}: ${diffReport.summary.newPagesCount} new, ${diffReport.summary.modifiedPagesCount} modified, ${diffReport.summary.deletedPagesCount} deleted, ${diffReport.summary.unchangedPagesCount} unchanged`, `[Worker] Diff report generated for job ${jobId}: ${diffReport.summary.newPagesCount} new, ${diffReport.summary.modifiedPagesCount} modified, ${diffReport.summary.deletedPagesCount} deleted, ${diffReport.summary.unchangedPagesCount} unchanged`,
); );
} catch (diffErr: any) { } catch (diffErr: unknown) {
console.error(`[Worker] Failed to generate diff report for job ${jobId}:`, diffErr?.message); console.error(`[Worker] Failed to generate diff report for job ${jobId}:`, getErrorMessage(diffErr));
} }
} catch (error: any) { } catch (error: unknown) {
await getJobRepository().updateStatus(jobId, 'FAILED', { await getJobRepository().updateStatus(jobId, 'FAILED', {
finishedAt: new Date(), finishedAt: new Date(),
errorMessage: mapCrawlError(error?.message ?? 'Unknown error'), errorMessage: mapCrawlError(getErrorMessage(error)),
}); });
throw error; throw error;
} }
...@@ -461,6 +462,9 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -461,6 +462,9 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
const webhookDeliveryService = new WebhookDeliveryService(); const webhookDeliveryService = new WebhookDeliveryService();
const event = updatedJob.status === 'COMPLETED' ? 'job.completed' : 'job.failed'; const event = updatedJob.status === 'COMPLETED' ? 'job.completed' : 'job.failed';
const diffSummary = (updatedJob && typeof updatedJob === 'object' && 'diffSummary' in updatedJob)
? (updatedJob as { diffSummary: unknown }).diffSummary ?? null
: null;
const payload = { const payload = {
jobId: updatedJob.id, jobId: updatedJob.id,
status: updatedJob.status, status: updatedJob.status,
...@@ -470,7 +474,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -470,7 +474,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
successPages: updatedJob.successPages, successPages: updatedJob.successPages,
failedPages: updatedJob.failedPages, failedPages: updatedJob.failedPages,
errorMessage: updatedJob.errorMessage, errorMessage: updatedJob.errorMessage,
diffSummary: (updatedJob as any).diffSummary ?? null, diffSummary,
startedAt: updatedJob.startedAt, startedAt: updatedJob.startedAt,
finishedAt: updatedJob.finishedAt, finishedAt: updatedJob.finishedAt,
}; };
......
...@@ -2,6 +2,8 @@ import 'dotenv/config'; ...@@ -2,6 +2,8 @@ import 'dotenv/config';
import { envConfig } from '../config/env.config'; import { envConfig } from '../config/env.config';
import { CrawlScheduleService } from '../modules/crawl-schedules/crawl-schedule.service'; import { CrawlScheduleService } from '../modules/crawl-schedules/crawl-schedule.service';
import { getErrorMessage } from '../common/helpers/error-mapping.helper';
if (!envConfig.redis.enabled) { if (!envConfig.redis.enabled) {
console.log('[Schedule Worker] REDIS_ENABLED is not set to true. Schedule Worker will not start.'); console.log('[Schedule Worker] REDIS_ENABLED is not set to true. Schedule Worker will not start.');
process.exit(0); process.exit(0);
...@@ -19,8 +21,8 @@ export async function checkAndProcessDueSchedules() { ...@@ -19,8 +21,8 @@ export async function checkAndProcessDueSchedules() {
if (triggered > 0) { if (triggered > 0) {
console.log(`[Schedule Worker] Triggered ${triggered} due scheduled crawl jobs`); console.log(`[Schedule Worker] Triggered ${triggered} due scheduled crawl jobs`);
} }
} catch (err: any) { } catch (err: unknown) {
console.error(`[Schedule Worker] Error processing due schedules: ${err?.message}`); console.error(`[Schedule Worker] Error processing due schedules: ${getErrorMessage(err)}`);
} finally { } finally {
isProcessing = false; isProcessing = false;
} }
......
...@@ -3,6 +3,8 @@ import { Worker } from 'bullmq'; ...@@ -3,6 +3,8 @@ import { Worker } from 'bullmq';
import { envConfig } from '../config/env.config'; import { envConfig } from '../config/env.config';
import { WebhookDeliveryService } from '../modules/webhooks/webhook-delivery.service'; import { WebhookDeliveryService } from '../modules/webhooks/webhook-delivery.service';
import { getErrorMessage } from '../common/helpers/error-mapping.helper';
if (!envConfig.redis.enabled) { if (!envConfig.redis.enabled) {
console.log('[Webhook Worker] REDIS_ENABLED is not set to true. Webhook Worker will not start.'); console.log('[Webhook Worker] REDIS_ENABLED is not set to true. Webhook Worker will not start.');
process.exit(0); process.exit(0);
...@@ -21,13 +23,14 @@ export const webhookWorker = new Worker( ...@@ -21,13 +23,14 @@ export const webhookWorker = new Worker(
try { try {
await deliveryService.send(deliveryId, currentAttempt); await deliveryService.send(deliveryId, currentAttempt);
console.log(`[Webhook Worker] Delivery ${deliveryId} succeeded`); console.log(`[Webhook Worker] Delivery ${deliveryId} succeeded`);
} catch (err: any) { } catch (err: unknown) {
const maxAttempts = job.opts.attempts || 3; const maxAttempts = job.opts.attempts || 3;
console.error(`[Webhook Worker] Delivery ${deliveryId} failed on attempt ${currentAttempt}/${maxAttempts}: ${err.message}`); const errorMessage = getErrorMessage(err);
console.error(`[Webhook Worker] Delivery ${deliveryId} failed on attempt ${currentAttempt}/${maxAttempts}: ${errorMessage}`);
if (currentAttempt >= maxAttempts) { if (currentAttempt >= maxAttempts) {
// Mark as permanently failed in DB when attempts are exhausted // Mark as permanently failed in DB when attempts are exhausted
await deliveryService.markFailed(deliveryId, err.message || 'Attempts exhausted'); await deliveryService.markFailed(deliveryId, errorMessage || 'Attempts exhausted');
console.log(`[Webhook Worker] Delivery ${deliveryId} marked as permanently FAILED`); console.log(`[Webhook Worker] Delivery ${deliveryId} marked as permanently FAILED`);
} }
......
...@@ -8,12 +8,11 @@ import apiKeyRoute from '../modules/api-keys/api-key.route'; ...@@ -8,12 +8,11 @@ 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';
const router = Router(); import healthRoute from '../modules/health/health.route';
router.get('/health', (req, res) => { const router = Router();
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
router.use('/health', healthRoute);
router.use('/auth', authRoute); router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/crawl-jobs', crawlJobRoute); router.use('/crawl-jobs', crawlJobRoute);
......
...@@ -23,7 +23,7 @@ async function bootstrap() { ...@@ -23,7 +23,7 @@ async function bootstrap() {
await redis.ping(); await redis.ping();
await redis.quit(); await redis.quit();
console.log('[Server] Redis connection confirmed.'); console.log('[Server] Redis connection confirmed.');
} catch (err) { } catch {
console.error('[Server] Cannot connect to Redis. Is Docker running?'); console.error('[Server] Cannot connect to Redis. Is Docker running?');
console.error('[Server] Run: docker compose up -d'); console.error('[Server] Run: docker compose up -d');
process.exit(1); process.exit(1);
...@@ -35,14 +35,12 @@ async function bootstrap() { ...@@ -35,14 +35,12 @@ async function bootstrap() {
initLocalStorage(); initLocalStorage();
if (envConfig.redis.enabled) { 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.');
const { startScheduleWorker } = await import('./queues/schedule.worker'); const { startScheduleWorker } = await import('./queues/schedule.worker');
startScheduleWorker(); startScheduleWorker();
console.log('[Server] Schedule worker initialized in background.'); console.log('[Server] Schedule worker initialized in background.');
}
app.listen(envConfig.port, () => { app.listen(envConfig.port, () => {
console.log(`Server running on port ${envConfig.port} in ${envConfig.nodeEnv} mode`); console.log(`Server running on port ${envConfig.port} in ${envConfig.nodeEnv} mode`);
......
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