Commit fc8cce16 authored by ThinhNC's avatar ThinhNC

fix(core): resolve P0/P1 audit findings across security, tenant isolation, and scheduler'

parent 069b960b
This diff is collapsed.
This diff is collapsed.
...@@ -209,6 +209,7 @@ model CrawlAsset { ...@@ -209,6 +209,7 @@ model CrawlAsset {
@@index([pageId]) @@index([pageId])
@@index([assetType]) @@index([assetType])
@@index([crawlJobId])
@@map("crawl_assets") @@map("crawl_assets")
} }
...@@ -275,6 +276,9 @@ model AuditLog { ...@@ -275,6 +276,9 @@ model AuditLog {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId, createdAt])
@@index([action])
@@index([createdAt])
@@map("audit_logs") @@map("audit_logs")
} }
......
...@@ -3615,9 +3615,6 @@ ...@@ -3615,9 +3615,6 @@
}, },
"CreateCrawlJobRequest": { "CreateCrawlJobRequest": {
"type": "object", "type": "object",
"required": [
"startUrl"
],
"properties": { "properties": {
"startUrl": { "startUrl": {
"type": "string", "type": "string",
...@@ -3645,6 +3642,18 @@ ...@@ -3645,6 +3642,18 @@
"minimum": 1, "minimum": 1,
"maximum": 10, "maximum": 10,
"example": 3 "example": 3
},
"urls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"example": [
"https://example.com/1",
"https://example.com/2"
],
"description": "Bắt buộc khi mode là URL_LIST"
} }
} }
}, },
......
...@@ -251,12 +251,17 @@ const rawSchemas = { ...@@ -251,12 +251,17 @@ const rawSchemas = {
}, },
CreateCrawlJobRequest: { CreateCrawlJobRequest: {
type: 'object', type: 'object',
required: ['startUrl'],
properties: { properties: {
startUrl: { type: 'string', format: 'uri', example: 'https://example.com' }, startUrl: { type: 'string', format: 'uri', example: 'https://example.com' },
mode: { type: 'string', enum: ['SCRAPE', 'CRAWL', 'SITEMAP', 'URL_LIST'], example: 'CRAWL' }, mode: { type: 'string', enum: ['SCRAPE', 'CRAWL', 'SITEMAP', 'URL_LIST'], example: 'CRAWL' },
maxPages: { type: 'integer', minimum: 1, maximum: 1000, example: 100 }, maxPages: { type: 'integer', minimum: 1, maximum: 1000, example: 100 },
maxDepth: { type: 'integer', minimum: 1, maximum: 10, example: 3 } maxDepth: { type: 'integer', minimum: 1, maximum: 10, example: 3 },
urls: {
type: 'array',
items: { type: 'string', format: 'uri' },
example: ['https://example.com/1', 'https://example.com/2'],
description: 'Bắt buộc khi mode là URL_LIST'
}
} }
}, },
CreateExportRequest: { CreateExportRequest: {
......
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 { prisma } from '../database/prisma.client'; 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,10 +15,7 @@ export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next: ...@@ -14,10 +15,7 @@ export async function apiKeyOrAuthMiddleware(req: Request, res: Response, next:
try { try {
const validKeyRecord = await apiKeyService.validate(apiKey); const validKeyRecord = await apiKeyService.validate(apiKey);
const user = await prisma.user.findFirst({ const user = await userRepository.findById(validKeyRecord.userId);
where: { id: validKeyRecord.userId, deletedAt: null },
select: { id: true, email: true, role: true, isActive: true },
});
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));
......
...@@ -3,9 +3,11 @@ import jwt from 'jsonwebtoken'; ...@@ -3,9 +3,11 @@ import jwt from 'jsonwebtoken';
import { jwtConfig } from '../config/jwt.config'; import { jwtConfig } from '../config/jwt.config';
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 { prisma } from '../database/prisma.client'; import { UserRepository } from '../modules/users/user.repository';
import { UserRole } from '@prisma/client'; import { UserRole } from '@prisma/client';
const userRepository = new UserRepository();
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> { export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
let token: string | undefined = req.cookies?.accessToken; let token: string | undefined = req.cookies?.accessToken;
...@@ -28,10 +30,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc ...@@ -28,10 +30,7 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
role: string; role: string;
}; };
const user = await prisma.user.findFirst({ const user = await userRepository.findById(payload.id);
where: { id: payload.id, deletedAt: null },
select: { isActive: true },
});
if (!user) { if (!user) {
next(new AppError("User not found", 401, ERROR_CODE.UNAUTHORIZED)); next(new AppError("User not found", 401, ERROR_CODE.UNAUTHORIZED));
......
jest.mock('../../../database/prisma.client', () => ({
prisma: {},
}));
jest.mock('../crawl-job.repository');
jest.mock('../../users/user.repository');
jest.mock('../../crawl-exports/crawl-export.repository');
jest.mock('../../../common/helpers/url.helper');
jest.mock('../../../queues/crawl.queue', () => ({
crawlQueue: {
add: jest.fn().mockResolvedValue({ id: 'bull-job-1' }),
},
}));
import { CrawlJobService } from '../crawl-job.service';
import { CrawlJobRepository } from '../crawl-job.repository';
import { UserRepository } from '../../users/user.repository';
import * as urlHelper from '../../../common/helpers/url.helper';
describe('CrawlJobService', () => {
let service: CrawlJobService;
let mockJobRepo: jest.Mocked<CrawlJobRepository>;
let mockUserRepo: jest.Mocked<UserRepository>;
beforeEach(() => {
jest.clearAllMocks();
mockJobRepo = {
create: jest.fn().mockResolvedValue({ id: 'job-1', status: 'PENDING' }),
countJobsSince: jest.fn().mockResolvedValue(0),
countConcurrentJobs: jest.fn().mockResolvedValue(0),
findById: jest.fn(),
} as any;
mockUserRepo = {
findById: jest.fn().mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
role: 'CRAWLER_USER',
maxPagesLimit: 50,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
isActive: true,
}),
} as any;
(CrawlJobRepository as jest.Mock).mockReturnValue(mockJobRepo);
(UserRepository as jest.Mock).mockReturnValue(mockUserRepo);
(urlHelper.validateUrl as jest.Mock).mockImplementation((url: string) => new URL(url));
(urlHelper.extractDomain as jest.Mock).mockReturnValue('example.com');
(urlHelper.validateUrlAsync as jest.Mock).mockResolvedValue(undefined);
service = new CrawlJobService();
});
describe('create', () => {
it('creates a job when within quota and valid startUrl', async () => {
const result = await service.create('user-1', {
startUrl: 'https://example.com',
mode: 'SCRAPE',
maxPages: 10,
});
expect(mockUserRepo.findById).toHaveBeenCalledWith('user-1');
expect(mockJobRepo.countJobsSince).toHaveBeenCalled();
expect(mockJobRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
startUrl: 'https://example.com/',
mode: 'SCRAPE',
}),
);
expect(result.id).toBe('job-1');
});
it('throws error when requested pages exceed user maxPagesLimit', async () => {
await expect(
service.create('user-1', {
startUrl: 'https://example.com',
mode: 'CRAWL',
maxPages: 100,
}),
).rejects.toThrow('exceeds quota limit');
});
it('throws error when daily job quota is reached', async () => {
mockJobRepo.countJobsSince.mockResolvedValue(10);
await expect(
service.create('user-1', {
startUrl: 'https://example.com',
mode: 'SCRAPE',
maxPages: 5,
}),
).rejects.toThrow('Daily job quota of 10 exceeded');
});
it('throws error when concurrent job quota is reached', async () => {
mockJobRepo.countConcurrentJobs.mockResolvedValue(3);
await expect(
service.create('user-1', {
startUrl: 'https://example.com',
mode: 'SCRAPE',
maxPages: 5,
}),
).rejects.toThrow('Concurrent jobs quota of 3 exceeded');
});
it('deduplicates URLs in URL_LIST mode and validates them', async () => {
const urls = [
'https://example.com/1',
'https://example.com/2',
'https://example.com/1',
];
await service.create('user-1', {
mode: 'URL_LIST',
urls,
});
expect(urlHelper.validateUrlAsync).toHaveBeenCalledTimes(2);
expect(mockJobRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'URL_LIST',
startUrl: 'https://example.com/1',
urls: ['https://example.com/1', 'https://example.com/2'],
}),
);
});
});
});
import { CrawlJobStatus, CrawlMode } from '@prisma/client'; import { CrawlJobStatus, CrawlMode } from '@prisma/client';
export interface CreateCrawlJobDto { export interface CreateCrawlJobDto {
startUrl: string; startUrl?: string;
mode?: CrawlMode; mode?: CrawlMode;
maxPages?: number; maxPages?: number;
maxDepth?: number; maxDepth?: number;
......
...@@ -239,4 +239,23 @@ export class CrawlJobRepository { ...@@ -239,4 +239,23 @@ export class CrawlJobRepository {
prisma.crawlJob.count({ where: { scheduleId } }), prisma.crawlJob.count({ where: { scheduleId } }),
]); ]);
} }
countJobsSince(userId: string, sinceDate: Date): Promise<number> {
return prisma.crawlJob.count({
where: {
userId,
createdAt: { gte: sinceDate },
},
});
}
countConcurrentJobs(userId: string, activeStatuses: CrawlJobStatus[], sinceDate?: Date): Promise<number> {
return prisma.crawlJob.count({
where: {
userId,
status: { in: activeStatuses },
...(sinceDate ? { createdAt: { gte: sinceDate } } : {}),
},
});
}
} }
\ No newline at end of file
import { prisma } from '../../database/prisma.client';
import { CrawlJobRepository } from './crawl-job.repository'; import { CrawlJobRepository } from './crawl-job.repository';
import { CrawlExportRepository } from '../crawl-exports/crawl-export.repository'; import { CrawlExportRepository } from '../crawl-exports/crawl-export.repository';
import { UserRepository } from '../users/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 { validateUrl, extractDomain } from '../../common/helpers/url.helper'; import { validateUrl, extractDomain } from '../../common/helpers/url.helper';
import {
getZonedDateParts,
createUtcDateFromZonedParts,
} from '../../common/helpers/schedule-calculator.helper';
import { crawlQueue } from '../../queues/crawl.queue'; import { crawlQueue } from '../../queues/crawl.queue';
import { ROLES } from '../../common/constants/role.constant'; import { ROLES } from '../../common/constants/role.constant';
import { JOB_STATUS } from '../../common/constants/job-status.constant'; import { JOB_STATUS } from '../../common/constants/job-status.constant';
...@@ -12,11 +16,12 @@ import { StorageFactory } from '../../common/storage/storage.factory'; ...@@ -12,11 +16,12 @@ import { StorageFactory } from '../../common/storage/storage.factory';
export class CrawlJobService { export class CrawlJobService {
private readonly repository = new CrawlJobRepository(); private readonly repository = new CrawlJobRepository();
private readonly userRepository = new UserRepository();
async create(userId: string, payload: CreateCrawlJobDto) { async create(userId: string, payload: CreateCrawlJobDto) {
const isUrlList = payload.mode === 'URL_LIST'; const isUrlList = payload.mode === 'URL_LIST';
// Fix #3: Deduplicate URLs before anything else // Deduplicate URLs before anything else
const deduplicatedUrls = isUrlList const deduplicatedUrls = isUrlList
? [...new Set(payload.urls!.map((u) => u.trim()))] ? [...new Set(payload.urls!.map((u) => u.trim()))]
: []; : [];
...@@ -26,31 +31,36 @@ export class CrawlJobService { ...@@ -26,31 +31,36 @@ export class CrawlJobService {
? new URL(deduplicatedUrls[0]).hostname ? new URL(deduplicatedUrls[0]).hostname
: extractDomain(payload.startUrl!); : extractDomain(payload.startUrl!);
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await this.userRepository.findById(userId);
if (!user) { if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND); throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
} }
// Fix #1: SSRF validation for ALL roles for URL_LIST // SSRF validation with bounded concurrency for URL_LIST
if (isUrlList) { if (isUrlList) {
const { validateUrlAsync } = const { validateUrlAsync } =
await import('../../common/helpers/url.helper'); await import('../../common/helpers/url.helper');
for (const url of deduplicatedUrls) { const chunkSize = 10;
try { for (let i = 0; i < deduplicatedUrls.length; i += chunkSize) {
await validateUrlAsync(url); const chunk = deduplicatedUrls.slice(i, i + chunkSize);
} catch (err: any) { await Promise.all(
throw new AppError( chunk.map(async (url) => {
`Invalid or blocked URL in list: ${url}${err?.message}`, try {
400, await validateUrlAsync(url);
ERROR_CODE.INVALID_URL, } catch (err: any) {
); throw new AppError(
} `Invalid or blocked URL in list: ${url}${err?.message}`,
400,
ERROR_CODE.INVALID_URL,
);
}
}),
);
} }
} }
if (user.role !== ROLES.ADMIN) { if (user.role !== ROLES.ADMIN) {
// Fix #2: For URL_LIST, quota check uses deduplicated urls.length
const requestedPages = isUrlList const requestedPages = isUrlList
? deduplicatedUrls.length ? deduplicatedUrls.length
: (payload.maxPages ?? 20); : (payload.maxPages ?? 20);
...@@ -63,11 +73,18 @@ export class CrawlJobService { ...@@ -63,11 +73,18 @@ export class CrawlJobService {
); );
} }
const startOfDay = new Date(); // Timezone UTC+7 start of day calculation
startOfDay.setHours(0, 0, 0, 0); const nowZoned = getZonedDateParts(new Date(), 'Asia/Ho_Chi_Minh');
const jobsTodayCount = await prisma.crawlJob.count({ const startOfDay = createUtcDateFromZonedParts(
where: { userId, createdAt: { gte: startOfDay } }, nowZoned.year,
}); nowZoned.month,
nowZoned.day,
0,
0,
'Asia/Ho_Chi_Minh',
);
const jobsTodayCount = await this.repository.countJobsSince(userId, startOfDay);
if (jobsTodayCount >= user.maxJobsPerDayLimit) { if (jobsTodayCount >= user.maxJobsPerDayLimit) {
throw new AppError( throw new AppError(
...@@ -79,20 +96,17 @@ export class CrawlJobService { ...@@ -79,20 +96,17 @@ export class CrawlJobService {
const twoHoursAgo = new Date(); const twoHoursAgo = new Date();
twoHoursAgo.setHours(twoHoursAgo.getHours() - 2); twoHoursAgo.setHours(twoHoursAgo.getHours() - 2);
const concurrentJobsCount = await prisma.crawlJob.count({ const activeStatuses = [
where: { JOB_STATUS.PENDING,
userId, JOB_STATUS.QUEUED,
status: { JOB_STATUS.RUNNING,
in: [ JOB_STATUS.PROCESSING_EXPORT,
JOB_STATUS.PENDING, ];
JOB_STATUS.QUEUED, const concurrentJobsCount = await this.repository.countConcurrentJobs(
JOB_STATUS.RUNNING, userId,
JOB_STATUS.PROCESSING_EXPORT, activeStatuses,
], twoHoursAgo,
}, );
createdAt: { gte: twoHoursAgo },
},
});
if (concurrentJobsCount >= user.maxConcurrentJobsLimit) { if (concurrentJobsCount >= user.maxConcurrentJobsLimit) {
throw new AppError( throw new AppError(
......
...@@ -33,6 +33,7 @@ describe('CrawlScheduleService', () => { ...@@ -33,6 +33,7 @@ describe('CrawlScheduleService', () => {
findAll: jest.fn(), findAll: jest.fn(),
findDueSchedules: jest.fn(), findDueSchedules: jest.fn(),
updateNextRun: jest.fn(), updateNextRun: jest.fn(),
claimDueSchedule: jest.fn().mockResolvedValue(true),
} as any; } as any;
mockJobRepo = { mockJobRepo = {
...@@ -203,20 +204,46 @@ describe('CrawlScheduleService', () => { ...@@ -203,20 +204,46 @@ describe('CrawlScheduleService', () => {
describe('processDueSchedules', () => { describe('processDueSchedules', () => {
it('finds and triggers all due active schedules', async () => { it('finds and triggers all due active schedules', async () => {
mockScheduleRepo.findDueSchedules.mockResolvedValue([mockSchedule] as any); mockScheduleRepo.findDueSchedules.mockResolvedValue([
{ ...mockSchedule, user: { isActive: true, deletedAt: null } },
] as any);
mockJobRepo.create.mockResolvedValue({ id: 'job-due-1' } as any); mockJobRepo.create.mockResolvedValue({ id: 'job-due-1' } as any);
mockScheduleRepo.updateNextRun.mockResolvedValue({} as any);
const count = await service.processDueSchedules(); const count = await service.processDueSchedules();
expect(count).toBe(1); expect(count).toBe(1);
expect(mockScheduleRepo.claimDueSchedule).toHaveBeenCalled();
expect(mockJobRepo.create).toHaveBeenCalledWith( expect(mockJobRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
scheduleId: 'schedule-1', scheduleId: 'schedule-1',
}), }),
); );
expect(crawlQueue?.add).toHaveBeenCalledWith('crawl-job', { jobId: 'job-due-1' }); expect(crawlQueue?.add).toHaveBeenCalledWith('crawl-job', { jobId: 'job-due-1' });
expect(mockScheduleRepo.updateNextRun).toHaveBeenCalled(); });
it('skips schedule when user is inactive or deleted', async () => {
mockScheduleRepo.findDueSchedules.mockResolvedValue([
{ ...mockSchedule, user: { isActive: false, deletedAt: null } },
{ ...mockSchedule, id: 'schedule-2', user: { isActive: true, deletedAt: new Date() } },
] as any);
const count = await service.processDueSchedules();
expect(count).toBe(0);
expect(mockScheduleRepo.claimDueSchedule).not.toHaveBeenCalled();
expect(mockJobRepo.create).not.toHaveBeenCalled();
});
it('skips schedule when another worker instance already claimed it', async () => {
mockScheduleRepo.findDueSchedules.mockResolvedValue([
{ ...mockSchedule, user: { isActive: true, deletedAt: null } },
] as any);
mockScheduleRepo.claimDueSchedule.mockResolvedValue(false);
const count = await service.processDueSchedules();
expect(count).toBe(0);
expect(mockJobRepo.create).not.toHaveBeenCalled();
}); });
}); });
}); });
...@@ -176,4 +176,19 @@ export class CrawlScheduleRepository { ...@@ -176,4 +176,19 @@ export class CrawlScheduleRepository {
}, },
}); });
} }
async claimDueSchedule(id: string, now: Date, nextRunAt: Date): Promise<boolean> {
const result = await prisma.crawlSchedule.updateMany({
where: {
id,
isActive: true,
nextRunAt: { lte: now },
},
data: {
lastRunAt: now,
nextRunAt,
},
});
return result.count > 0;
}
} }
...@@ -255,18 +255,12 @@ export class CrawlScheduleService { ...@@ -255,18 +255,12 @@ export class CrawlScheduleService {
for (const schedule of dueSchedules) { for (const schedule of dueSchedules) {
try { try {
const job = await this.jobRepository.create({ // Skip if user is inactive or deleted
userId: schedule.userId, const user = (schedule as any).user;
startUrl: schedule.startUrl, if (user && (!user.isActive || user.deletedAt)) {
domain: schedule.domain ?? undefined, console.warn(`[Schedule Service] Skipping schedule ${schedule.id}: user is inactive or deleted`);
mode: schedule.mode, continue;
maxPages: schedule.maxPages, }
maxDepth: schedule.maxDepth,
urls: schedule.urls,
scheduleId: schedule.id,
});
await crawlQueue.add('crawl-job', { jobId: job.id });
const nextRunAt = calculateNextRun({ const nextRunAt = calculateNextRun({
frequency: schedule.frequency, frequency: schedule.frequency,
...@@ -279,7 +273,25 @@ export class CrawlScheduleService { ...@@ -279,7 +273,25 @@ export class CrawlScheduleService {
fromDate: now, fromDate: now,
}); });
await this.repository.updateNextRun(schedule.id, now, nextRunAt); // Atomic claim: only proceed if this instance successfully updated nextRunAt
const claimed = await this.repository.claimDueSchedule(schedule.id, now, nextRunAt);
if (!claimed) {
// Another worker instance already claimed and triggered this schedule
continue;
}
const job = await this.jobRepository.create({
userId: schedule.userId,
startUrl: schedule.startUrl,
domain: schedule.domain ?? undefined,
mode: schedule.mode,
maxPages: schedule.maxPages,
maxDepth: schedule.maxDepth,
urls: schedule.urls,
scheduleId: schedule.id,
});
await crawlQueue.add('crawl-job', { jobId: job.id });
triggeredCount++; triggeredCount++;
} catch (err: any) { } catch (err: any) {
console.error(`[Schedule Service] Failed to trigger due schedule ${schedule.id}: ${err.message}`); console.error(`[Schedule Service] Failed to trigger due schedule ${schedule.id}: ${err.message}`);
......
import fs from 'fs';
import { CsvExportService } from '../csv-export.service';
jest.mock('../../../database/prisma.client', () => ({
prisma: {},
}));
jest.mock('../../crawl-assets/crawl-asset.repository', () => ({
CrawlAssetRepository: jest.fn().mockImplementation(() => ({
findByJobId: jest.fn().mockResolvedValue([]),
})),
}));
jest.mock('../../../common/helpers/file.helper', () => ({
buildJobDataFilePath: jest.fn((jobId: string, fileName: string) => ({
fileName,
filePath: `test/${fileName}`,
})),
ensureJobExportStructure: jest.fn(),
}));
jest.mock('fs');
describe('CsvExportService', () => {
let service: CsvExportService;
beforeEach(() => {
jest.clearAllMocks();
service = new CsvExportService();
});
it('neutralizes formula injection characters (=, +, -, @) in CSV export', async () => {
const writtenFiles: Record<string, string> = {};
(fs.writeFileSync as jest.Mock).mockImplementation((filePath, content) => {
writtenFiles[filePath] = content;
});
const mockJob: any = {
id: 'job-1',
startUrl: 'https://example.com',
domain: 'example.com',
pages: [
{
id: 'page-1',
url: 'https://example.com/test',
title: '=cmd|\'/C calc\'!A0',
description: '@SUM(1,2)',
status: 'COMPLETED',
statusCode: 200,
markdownContent: '+12345',
crawledAt: new Date('2026-09-02T12:00:00Z'),
},
],
};
await (service as any).executeExport(mockJob);
const pagesCsv = writtenFiles['test/pages.csv'];
expect(pagesCsv).toBeDefined();
expect(pagesCsv).toContain("'=cmd|'/C calc'!A0");
expect(pagesCsv).toContain("'@SUM(1,2)");
expect(pagesCsv).toContain("'+12345");
});
});
...@@ -151,9 +151,18 @@ export class CsvExportService extends BaseExportService { ...@@ -151,9 +151,18 @@ export class CsvExportService extends BaseExportService {
} }
private escapeCsv(value: string): string { private escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) { let sanitized = value;
return `"${value.replace(/"/g, '""')}"`; if (/^[=+\-@\t\r]/.test(sanitized)) {
sanitized = `'${sanitized}`;
} }
return value; if (
sanitized.includes(',') ||
sanitized.includes('"') ||
sanitized.includes('\n') ||
sanitized.includes('\r')
) {
return `"${sanitized.replace(/"/g, '""')}"`;
}
return sanitized;
} }
} }
\ No newline at end of file
...@@ -53,18 +53,21 @@ export async function runExtractionIfTemplate( ...@@ -53,18 +53,21 @@ export async function runExtractionIfTemplate(
pageId: string, pageId: string,
pageUrl: string, pageUrl: string,
item: FirecrawlPageResult, item: FirecrawlPageResult,
userId?: string,
): Promise<void> { ): Promise<void> {
// 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. A future task should add html // markdownContent if html is unavailable.
// to FirecrawlPageResult and pass it through normalizePage().
const html = (item as any).html ?? item.markdown ?? ''; const html = (item as any).html ?? item.markdown ?? '';
if (!html) return; if (!html) return;
const domain = extractDomainFromUrl(pageUrl); const domain = extractDomainFromUrl(pageUrl);
if (!domain) return; if (!domain) return;
const template = await getTemplateRepository().findByDomain(domain); const repository = getTemplateRepository();
const template = userId
? await repository.findByUserAndDomain(userId, domain)
: await repository.findByDomain(domain);
if (!template) return; if (!template) return;
const fields = template.fields as unknown as ExtractionFieldDto[]; const fields = template.fields as unknown as ExtractionFieldDto[];
......
...@@ -28,6 +28,20 @@ export class ExtractionTemplateRepository { ...@@ -28,6 +28,20 @@ export class ExtractionTemplateRepository {
return prisma.extractionTemplate.findFirst({ where: { domain } }); return prisma.extractionTemplate.findFirst({ where: { domain } });
} }
findByUserAndDomain(userId: string, domain: string) {
const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(userId);
if (!isUuid) return null;
return prisma.extractionTemplate.findUnique({
where: {
userId_domain: {
userId,
domain,
},
},
});
}
update(id: string, data: UpdateExtractionTemplateDto) { update(id: string, data: UpdateExtractionTemplateDto) {
return prisma.extractionTemplate.update({ return prisma.extractionTemplate.update({
where: { id }, where: { id },
......
jest.mock('../../../database/prisma.client', () => ({
prisma: {},
}));
jest.mock('../webhook.repository');
jest.mock('../../../common/helpers/url.helper');
jest.mock('../../../queues/webhook.queue', () => ({
webhookQueue: {
add: jest.fn().mockResolvedValue({ id: 'webhook-job-1' }),
},
}));
import { WebhookConfigService } from '../webhook-config.service';
import { WebhookDeliveryService } from '../webhook-delivery.service';
import { WebhookRepository } from '../webhook.repository';
import * as urlHelper from '../../../common/helpers/url.helper';
import { webhookQueue } from '../../../queues/webhook.queue';
import { encrypt } from '../webhook-crypto.helper';
describe('Webhook Services', () => {
let configService: WebhookConfigService;
let deliveryService: WebhookDeliveryService;
let mockWebhookRepo: jest.Mocked<WebhookRepository>;
let mockSecureAxiosPost: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockWebhookRepo = {
createConfig: jest.fn(),
listConfigsByUser: jest.fn(),
findConfigById: jest.fn(),
deleteConfig: jest.fn(),
findActiveConfigsByEvent: jest.fn(),
createDelivery: jest.fn(),
findDeliveryById: jest.fn(),
updateDelivery: jest.fn(),
listDeliveries: jest.fn(),
} as any;
(WebhookRepository as jest.Mock).mockReturnValue(mockWebhookRepo);
mockSecureAxiosPost = jest.fn().mockResolvedValue({ status: 200, data: 'OK' });
(urlHelper.getSecureAxios as jest.Mock).mockReturnValue({
post: mockSecureAxiosPost,
});
configService = new WebhookConfigService();
deliveryService = new WebhookDeliveryService();
});
describe('WebhookConfigService', () => {
it('creates webhook config and strips encryptedSecret from return value', async () => {
mockWebhookRepo.createConfig.mockResolvedValue({
id: 'config-1',
userId: 'user-1',
url: 'https://webhook.site/test',
encryptedSecret: 'enc:secret',
events: ['job.completed'],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await configService.create(
'user-1',
'https://webhook.site/test',
'plain-secret-123',
['job.completed'],
);
expect(mockWebhookRepo.createConfig).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
url: 'https://webhook.site/test',
events: ['job.completed'],
}),
);
expect((result as any).encryptedSecret).toBeUndefined();
expect(result.id).toBe('config-1');
});
it('throws 404 when deleting a non-existent or other user config', async () => {
mockWebhookRepo.findConfigById.mockResolvedValue({
id: 'config-1',
userId: 'other-user',
} as any);
await expect(
configService.delete('config-1', 'user-1'),
).rejects.toThrow('Webhook configuration not found');
});
});
describe('WebhookDeliveryService', () => {
it('dispatches deliveries and enqueues to webhook queue', async () => {
mockWebhookRepo.findActiveConfigsByEvent.mockResolvedValue([
{ id: 'config-1', userId: 'user-1' } as any,
]);
mockWebhookRepo.createDelivery.mockResolvedValue({
id: 'delivery-1',
} as any);
await deliveryService.dispatch('job-1', 'user-1', 'job.completed', { pages: 10 });
expect(mockWebhookRepo.createDelivery).toHaveBeenCalledWith(
expect.objectContaining({
webhookConfigId: 'config-1',
crawlJobId: 'job-1',
event: 'job.completed',
}),
);
expect(webhookQueue?.add).toHaveBeenCalledWith(
'send-webhook',
{ deliveryId: 'delivery-1' },
expect.any(Object),
);
});
it('sends delivery using getSecureAxios to prevent SSRF', async () => {
mockWebhookRepo.findDeliveryById.mockResolvedValue({
id: 'delivery-1',
event: 'job.completed',
payload: { test: true },
webhookConfig: {
url: 'https://webhook.site/callback',
encryptedSecret: encrypt('my-secret-123'),
},
} as any);
await deliveryService.send('delivery-1', 1);
expect(urlHelper.getSecureAxios).toHaveBeenCalled();
expect(mockSecureAxiosPost).toHaveBeenCalledWith(
'https://webhook.site/callback',
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'X-Webhook-Event': 'job.completed',
}),
}),
);
expect(mockWebhookRepo.updateDelivery).toHaveBeenCalledWith(
'delivery-1',
expect.objectContaining({
status: 'SUCCESS',
statusCode: 200,
}),
);
});
});
});
import { prisma } from '../../database/prisma.client';
import { WebhookConfig } from '@prisma/client'; import { WebhookConfig } from '@prisma/client';
import { WebhookRepository } from './webhook.repository';
import { encrypt } from './webhook-crypto.helper'; import { encrypt } from './webhook-crypto.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';
export class WebhookConfigService { export class WebhookConfigService {
private readonly repository = new WebhookRepository();
async create( async create(
userId: string, userId: string,
url: string, url: string,
plainSecret: string, plainSecret: string,
events: string[] events: string[],
): Promise<Omit<WebhookConfig, 'encryptedSecret'>> { ): Promise<Omit<WebhookConfig, 'encryptedSecret'>> {
const encryptedSecret = encrypt(plainSecret); const encryptedSecret = encrypt(plainSecret);
const config = await prisma.webhookConfig.create({ const config = await this.repository.createConfig({
data: { userId,
userId, url,
url, encryptedSecret,
encryptedSecret, events,
events,
},
}); });
const { encryptedSecret: _, ...rest } = config; const { encryptedSecret: _, ...rest } = config;
...@@ -27,31 +27,18 @@ export class WebhookConfigService { ...@@ -27,31 +27,18 @@ export class WebhookConfigService {
} }
async list(userId: string): Promise<Omit<WebhookConfig, 'encryptedSecret'>[]> { async list(userId: string): Promise<Omit<WebhookConfig, 'encryptedSecret'>[]> {
const configs = await prisma.webhookConfig.findMany({ const configs = await this.repository.listConfigsByUser(userId);
where: { return configs.map(({ encryptedSecret: _, ...rest }) => rest);
userId,
},
orderBy: {
createdAt: 'desc',
},
});
return configs.map(({ encryptedSecret, ...rest }) => rest);
} }
async delete(configId: string, userId: string): Promise<Omit<WebhookConfig, 'encryptedSecret'>> { async delete(configId: string, userId: string): Promise<Omit<WebhookConfig, 'encryptedSecret'>> {
const config = await prisma.webhookConfig.findUnique({ const config = await this.repository.findConfigById(configId);
where: { id: configId },
});
if (!config || config.userId !== userId) { if (!config || config.userId !== userId) {
throw new AppError('Webhook configuration not found', 404, ERROR_CODE.WEBHOOK_CONFIG_NOT_FOUND); throw new AppError('Webhook configuration not found', 404, ERROR_CODE.WEBHOOK_CONFIG_NOT_FOUND);
} }
const deleted = await prisma.webhookConfig.delete({ const deleted = await this.repository.deleteConfig(configId);
where: { id: configId },
});
const { encryptedSecret: _, ...rest } = deleted; const { encryptedSecret: _, ...rest } = deleted;
return rest; return rest;
} }
......
import { prisma } from '../../database/prisma.client'; import { WebhookDelivery } from '@prisma/client';
import { WebhookDelivery, WebhookConfig } from '@prisma/client'; 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 axios from 'axios'; import { getSecureAxios } from '../../common/helpers/url.helper';
export class WebhookDeliveryService { export class WebhookDeliveryService {
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: any): Promise<void> {
try { try {
const configs = await prisma.webhookConfig.findMany({ const configs = await this.repository.findActiveConfigsByEvent(userId, event);
where: {
userId,
isActive: true,
events: {
has: event,
},
},
});
if (configs.length === 0) { if (configs.length === 0) {
return; return;
...@@ -29,15 +23,13 @@ export class WebhookDeliveryService { ...@@ -29,15 +23,13 @@ export class WebhookDeliveryService {
}; };
for (const config of configs) { for (const config of configs) {
const delivery = await prisma.webhookDelivery.create({ const delivery = await this.repository.createDelivery({
data: { webhookConfigId: config.id,
webhookConfigId: config.id, crawlJobId,
crawlJobId, event,
event, payload: payload as any,
payload: payload as any, status: 'PENDING',
status: 'PENDING', attempt: 1,
attempt: 1,
},
}); });
if (webhookQueue) { if (webhookQueue) {
...@@ -50,7 +42,7 @@ export class WebhookDeliveryService { ...@@ -50,7 +42,7 @@ export class WebhookDeliveryService {
type: 'exponential', type: 'exponential',
delay: 5000, // 5s, 25s, 125s delay: 5000, // 5s, 25s, 125s
}, },
} },
); );
} else { } else {
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.');
...@@ -62,18 +54,14 @@ export class WebhookDeliveryService { ...@@ -62,18 +54,14 @@ export class WebhookDeliveryService {
} }
async send(deliveryId: string, attemptNumber: number): Promise<void> { async send(deliveryId: string, attemptNumber: number): Promise<void> {
const delivery = await prisma.webhookDelivery.findUnique({ const delivery = await this.repository.findDeliveryById(deliveryId);
where: { id: deliveryId },
include: { webhookConfig: true },
});
if (!delivery) { if (!delivery) {
throw new Error(`WebhookDelivery ${deliveryId} not found`); throw new Error(`WebhookDelivery ${deliveryId} not found`);
} }
await prisma.webhookDelivery.update({ await this.repository.updateDelivery(deliveryId, {
where: { id: deliveryId }, attempt: attemptNumber,
data: { attempt: attemptNumber },
}); });
const config = delivery.webhookConfig; const config = delivery.webhookConfig;
...@@ -82,7 +70,7 @@ export class WebhookDeliveryService { ...@@ -82,7 +70,7 @@ export class WebhookDeliveryService {
const signature = signPayload(secret, payloadStr); const signature = signPayload(secret, payloadStr);
try { try {
const response = await axios.post(config.url, payloadStr, { const response = await getSecureAxios().post(config.url, payloadStr, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Webhook-Signature': `sha256=${signature}`, 'X-Webhook-Signature': `sha256=${signature}`,
...@@ -92,25 +80,22 @@ export class WebhookDeliveryService { ...@@ -92,25 +80,22 @@ export class WebhookDeliveryService {
timeout: 10000, // 10s timeout timeout: 10000, // 10s timeout
}); });
const responseBody = typeof response.data === 'string' const responseBody = typeof response.data === 'string'
? response.data ? response.data
: JSON.stringify(response.data); : JSON.stringify(response.data);
await prisma.webhookDelivery.update({ await this.repository.updateDelivery(deliveryId, {
where: { id: deliveryId }, status: 'SUCCESS',
data: { statusCode: response.status,
status: 'SUCCESS', responseBody: responseBody.substring(0, 2000), // Limit size stored in DB
statusCode: response.status, deliveredAt: new Date(),
responseBody: responseBody.substring(0, 2000), // Limit size stored in DB errorMessage: null,
deliveredAt: new Date(),
errorMessage: null,
},
}); });
} catch (error: any) { } catch (error: any) {
let statusCode: number | null = null; let statusCode: number | null = null;
let responseBody: string | null = null; let responseBody: string | null = null;
let errorMessage = error.message || 'Unknown network error'; const errorMessage = error.message || 'Unknown network error';
if (error.response) { if (error.response) {
statusCode = error.response.status; statusCode = error.response.status;
...@@ -119,13 +104,10 @@ export class WebhookDeliveryService { ...@@ -119,13 +104,10 @@ export class WebhookDeliveryService {
: JSON.stringify(error.response.data); : JSON.stringify(error.response.data);
} }
await prisma.webhookDelivery.update({ await this.repository.updateDelivery(deliveryId, {
where: { id: deliveryId }, statusCode,
data: { responseBody: responseBody ? responseBody.substring(0, 2000) : null,
statusCode, errorMessage: errorMessage.substring(0, 1000),
responseBody: responseBody ? responseBody.substring(0, 2000) : null,
errorMessage: errorMessage.substring(0, 1000),
},
}); });
// Throw error to trigger BullMQ retry // Throw error to trigger BullMQ retry
...@@ -138,45 +120,16 @@ export class WebhookDeliveryService { ...@@ -138,45 +120,16 @@ export class WebhookDeliveryService {
* Called by BullMQ worker when job fails after max attempts. * Called by BullMQ worker when job fails after max attempts.
*/ */
async markFailed(deliveryId: string, errorReason: string): Promise<void> { async markFailed(deliveryId: string, errorReason: string): Promise<void> {
await prisma.webhookDelivery.update({ await this.repository.updateDelivery(deliveryId, {
where: { id: deliveryId }, status: 'FAILED',
data: { errorMessage: `Max attempts exhausted. Last error: ${errorReason}`.substring(0, 1000),
status: 'FAILED',
errorMessage: `Max attempts exhausted. Last error: ${errorReason}`.substring(0, 1000),
},
}); });
} }
async listDeliveries( async listDeliveries(
userId: string, userId: string,
query: { jobId?: string; status?: string } query: { jobId?: string; status?: string },
): Promise<WebhookDelivery[]> { ): Promise<WebhookDelivery[]> {
const where: any = { return this.repository.listDeliveries(userId, query);
webhookConfig: {
userId,
},
};
if (query.jobId) {
where.crawlJobId = query.jobId;
}
if (query.status) {
where.status = query.status;
}
return prisma.webhookDelivery.findMany({
where,
include: {
webhookConfig: {
select: {
url: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
} }
} }
import { prisma } from '../../database/prisma.client';
import { WebhookConfig, WebhookDelivery, Prisma } from '@prisma/client';
export class WebhookRepository {
createConfig(data: {
userId: string;
url: string;
encryptedSecret: string;
events: string[];
}): Promise<WebhookConfig> {
return prisma.webhookConfig.create({
data: {
userId: data.userId,
url: data.url,
encryptedSecret: data.encryptedSecret,
events: data.events,
},
});
}
listConfigsByUser(userId: string): Promise<WebhookConfig[]> {
return prisma.webhookConfig.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
findConfigById(id: string): Promise<WebhookConfig | null> {
return prisma.webhookConfig.findUnique({
where: { id },
});
}
deleteConfig(id: string): Promise<WebhookConfig> {
return prisma.webhookConfig.delete({
where: { id },
});
}
findActiveConfigsByEvent(userId: string, event: string): Promise<WebhookConfig[]> {
return prisma.webhookConfig.findMany({
where: {
userId,
isActive: true,
events: {
has: event,
},
},
});
}
createDelivery(data: {
webhookConfigId: string;
crawlJobId: string;
event: string;
payload: any;
status: string;
attempt: number;
}): Promise<WebhookDelivery> {
return prisma.webhookDelivery.create({
data: {
webhookConfigId: data.webhookConfigId,
crawlJobId: data.crawlJobId,
event: data.event,
payload: data.payload,
status: data.status,
attempt: data.attempt,
},
});
}
findDeliveryById(id: string) {
return prisma.webhookDelivery.findUnique({
where: { id },
include: { webhookConfig: true },
});
}
updateDelivery(id: string, data: Prisma.WebhookDeliveryUpdateInput): Promise<WebhookDelivery> {
return prisma.webhookDelivery.update({
where: { id },
data,
});
}
listDeliveries(
userId: string,
query: { jobId?: string; status?: string },
) {
const where: Prisma.WebhookDeliveryWhereInput = {
webhookConfig: {
userId,
},
};
if (query.jobId) {
where.crawlJobId = query.jobId;
}
if (query.status) {
where.status = query.status;
}
return prisma.webhookDelivery.findMany({
where,
include: {
webhookConfig: {
select: {
url: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
}
}
...@@ -108,6 +108,7 @@ export async function scanAndFlagPage(pageId: string, ...texts: (string | undefi ...@@ -108,6 +108,7 @@ export async function scanAndFlagPage(pageId: string, ...texts: (string | undefi
export async function persistBatchResults( export async function persistBatchResults(
jobId: string, jobId: string,
result: CrawlStatusResult, result: CrawlStatusResult,
userId?: string,
): Promise<{ successCount: number; failedCount: number; saveErrors: number; totalPages: number }> { ): Promise<{ successCount: number; failedCount: number; saveErrors: number; totalPages: number }> {
let successCount = 0; let successCount = 0;
let failedCount = 0; let failedCount = 0;
...@@ -123,7 +124,7 @@ export async function persistBatchResults( ...@@ -123,7 +124,7 @@ export async function persistBatchResults(
const page = await getPageRepository().upsert(normalized); const page = await getPageRepository().upsert(normalized);
await savePageAssets(jobId, page.id, item); await savePageAssets(jobId, page.id, item);
await scanAndFlagPage(page.id, normalized.markdownContent, normalized.title, normalized.description); await scanAndFlagPage(page.id, normalized.markdownContent, normalized.title, normalized.description);
await runExtractionIfTemplate(jobId, page.id, item.url, item); 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: any) {
...@@ -233,7 +234,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -233,7 +234,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
const page = await getPageRepository().upsert(normalized); const page = await getPageRepository().upsert(normalized);
await savePageAssets(jobId, page.id, result); await savePageAssets(jobId, page.id, result);
await scanAndFlagPage(page.id, normalized.markdownContent, normalized.title, normalized.description); await scanAndFlagPage(page.id, normalized.markdownContent, normalized.title, normalized.description);
await runExtractionIfTemplate(jobId, page.id, crawlJob.startUrl, result); await runExtractionIfTemplate(jobId, page.id, crawlJob.startUrl, result, crawlJob.userId);
await getJobRepository().updateStatus(jobId, 'COMPLETED', { await getJobRepository().updateStatus(jobId, 'COMPLETED', {
finishedAt: new Date(), finishedAt: new Date(),
totalPages: 1, totalPages: 1,
...@@ -307,7 +308,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -307,7 +308,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
return; return;
} }
const { successCount, failedCount, saveErrors, totalPages } = await persistBatchResults(jobId, result); const { successCount, failedCount, saveErrors, totalPages } = await persistBatchResults(jobId, result, crawlJob.userId);
console.log(`[Worker] Job ${jobId} completed: ${successCount} success, ${failedCount} failed, ${saveErrors} save errors, ${totalPages} total`); console.log(`[Worker] Job ${jobId} completed: ${successCount} success, ${failedCount} failed, ${saveErrors} save errors, ${totalPages} total`);
await getJobRepository().updateStatus(jobId, 'COMPLETED', { await getJobRepository().updateStatus(jobId, 'COMPLETED', {
finishedAt: new Date(), finishedAt: new Date(),
...@@ -362,7 +363,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -362,7 +363,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
return; return;
} }
const { successCount, failedCount, saveErrors, totalPages } = await persistBatchResults(jobId, result); const { successCount, failedCount, saveErrors, totalPages } = await persistBatchResults(jobId, result, crawlJob.userId);
console.log(`[Worker] Job ${jobId} completed: ${successCount} success, ${failedCount} failed, ${saveErrors} save errors, ${totalPages} total`); console.log(`[Worker] Job ${jobId} completed: ${successCount} success, ${failedCount} failed, ${saveErrors} save errors, ${totalPages} total`);
await getJobRepository().updateStatus(jobId, 'COMPLETED', { await getJobRepository().updateStatus(jobId, 'COMPLETED', {
finishedAt: new Date(), finishedAt: new Date(),
...@@ -424,7 +425,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -424,7 +425,7 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) {
return; return;
} }
const { successCount, failedCount, saveErrors, totalPages } = await persistBatchResults(jobId, result); const { successCount, failedCount, saveErrors, totalPages } = await persistBatchResults(jobId, result, crawlJob.userId);
console.log(`[Worker] Job ${jobId} completed: ${successCount} success, ${failedCount} failed, ${saveErrors} save errors, ${totalPages} total`); console.log(`[Worker] Job ${jobId} completed: ${successCount} success, ${failedCount} failed, ${saveErrors} save errors, ${totalPages} total`);
await getJobRepository().updateStatus(jobId, 'COMPLETED', { await getJobRepository().updateStatus(jobId, 'COMPLETED', {
finishedAt: new Date(), finishedAt: new Date(),
......
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