Commit 3db9f8fa authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/core-saas-crawler-features' into 'develop'

feat(core): implement essential saas and crawler lifecycle features

See merge request !3
parents be22f9e8 65fc55f1
# Quy Tắc Thiết Kế Kiến Trúc (design.md)
> Chuẩn mực kiến trúc phân lớp, nguyên tắc xác thực và xử lý lỗi cho Antigravity trong `data-crawler-be`.
---
## 1. Kiến Trúc 5 Lớp Đơn Hướng (Unidirectional 5-Layer Pattern)
```
Route → Controller → Service → Repository → Prisma → PostgreSQL
```
- **Route:** Chỉ làm nhiệm vụ cấu hình endpoint HTTP, middleware chuỗi (auth, role, validation, rateLimit). Không viết logic xử lý.
- **Controller:** Nhận và chuyển đổi kiểu dữ liệu HTTP, gọi Service tương ứng, định dạng kết quả response `res.status(...).json(...)`.
- **Service:** Xử lý nghiệp vụ chính (Business Logic), điều phối nhiều Repository, tích hợp hàng đợi BullMQ. Service không xử lý response HTTP và **không bao giờ gọi Prisma trực tiếp**.
- **Repository:** Nơi **duy nhất** được phép import và gọi Prisma Client. Đảm bảo toàn bộ câu truy vấn (query, include, select, transaction) được cô lập tại đây.
---
## 2. Xác Thực Đầu Vào Tại Biên (Boundary Validation with Zod)
- Mọi endpoint nhận dữ liệu từ client (`body`, `query`, `params`) phải có schema kiểm thực tương ứng bằng Zod.
- Sử dụng middleware dùng chung:
```typescript
import { validate } from '../../middlewares/validate.middleware';
import { mySchema } from './my.validation';
router.post('/', validate(mySchema), myController.create);
```
- DTO type được suy diễn trực tiếp từ schema: `type MyDto = z.infer<typeof mySchema>;`.
---
## 3. Quản Lý Lỗi Tập Trung (Centralized Error Handling)
- Bắt buộc dùng `AppError` kèm HTTP status code và mã `ERROR_CODE`:
```typescript
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
throw new AppError('Resource not found', 404, ERROR_CODE.NOT_FOUND);
```
- Định dạng response lỗi chuẩn:
```json
{
"success": false,
"message": "Thông điệp lỗi",
"code": "ERROR_CODE",
"errors": []
}
```
---
## 4. Hợp Đồng Dữ Liệu Cào (Data Contract V1)
Bảo toàn hợp đồng dữ liệu quy định tại `docs/DATA_CONTRACT_V1.md`:
- Dữ liệu thô (`raw`): Nguyên bản HTML từ Firecrawl.
- Dữ liệu sạch (`clean`): Markdown chuẩn hóa qua Turndown, lọc bỏ script/ads/styles, tính toán `dataQualityScore``contentHash`.
# Công Nghệ Mặc Định & Cấu Hình Chuẩn (tech-defaults.md)
> Bảng quy ước phiên bản và danh mục công nghệ cốt lõi trong `data-crawler-be`.
---
## 1. Môi Trường Thực Thi & Ngôn Ngữ
- **Node.js:** `>= 20.x`
- **Package Manager:** `pnpm@9.15.0` (chỉ dùng `pnpm`, không dùng `npm` hay `yarn`)
- **TypeScript:** `5.7` (`strict: true`, không dùng `any` bừa bãi)
- **Web Framework:** `Express.js 4.21`
---
## 2. Lưu Trữ & Hàng Đợi
- **Cơ sở dữ liệu:** PostgreSQL 15+ (chạy Docker local qua `docker-compose.yml`)
- **ORM:** Prisma `5.22.x` (quản lý qua `scripts/prisma-run.js`)
- **Queue / In-Memory Store:** Redis 7 + `bullmq ^5.34.0`
- **Storage Driver:** Hỗ trợ song song Local (`storage/exports/`) và S3/MinIO (`@aws-sdk/client-s3`)
---
## 3. Crawl & Làm Sạch Dữ Liệu
- **Crawl Engine:** `@mendable/firecrawl-js ^1.19.0` (SCRAPE, CRAWL, SITEMAP, URL_LIST)
- **DOM Parser:** `cheerio ^1.2.0` & `node-html-parser ^8.0.4`
- **Markdown Conversion:** `turndown ^7.2.0`
- **Export formats:** `exceljs ^4.4.0` (XLSX), `json2csv ^6.0.0-alpha.2` (CSV), `archiver ^7.0.1` (ZIP)
---
## 4. Bảo Mật & Xác Thực
- **Auth:** JWT (`jsonwebtoken ^9.0.2`, Access Token + Refresh Token trong database)
- **Hash:** `bcryptjs ^2.4.3`
- **Rate Limit:** `express-rate-limit ^8.5.2`
- **HTTP Headers:** `helmet ^8.0.0` (tắt CSP cho Swagger UI)
- **Phân quyền (RBAC):** `ADMIN`, `CRAWLER_USER`, `VIEWER`
# Quy Tắc Quy Trình Phát Triển (workflow.md)
> Hướng dẫn quy trình phát triển tính năng, kiểm thử, migration cơ sở dữ liệu và quy chuẩn Git cho Antigravity.
---
## 1. Vòng Đời Triển Khai Tính Năng (Feature Lifecycle)
Khi xây dựng hoặc sửa đổi tính năng trong `data-crawler-be`, thực hiện tuần tự:
1. **Khảo sát & Thiết kế:** Kiểm tra quan hệ trong `prisma/schema.prisma` và tài liệu `docs/DATA_CONTRACT_V1.md`.
2. **Tầng Repository:** Viết hàm truy vấn Prisma trong `src/modules/<feature>/<feature>.repository.ts`.
3. **Tầng Service:** Viết business logic, kiểm tra quota, tích hợp BullMQ queue trong `<feature>.service.ts`.
4. **Tầng Controller & DTO:** Định nghĩa Zod schema trong `<feature>.validation.ts`, kiểu DTO trong `<feature>.dto.ts`, xử lý HTTP request trong `<feature>.controller.ts`.
5. **Gắn Route & Swagger:** Mount route tại `<feature>.route.ts`, đăng ký vào `src/routes/index.ts`, chạy `pnpm swagger`.
6. **Kiểm thử & QA:** Thêm unit test vào `__tests__/`, chạy `pnpm lint``pnpm test -- --runInBand`.
---
## 2. Quy Chuẩn Commit Git
Áp dụng chuẩn Conventional Commits:
- `feat(<module>):` Thêm chức năng mới
- `fix(<module>):` Sửa lỗi nghiệp vụ hoặc kỹ thuật
- `refactor(<module>):` Tối ưu hóa code mà không thay đổi tính năng
- `test(<module>):` Bổ sung hoặc sửa đổi unit test
- `docs(<module>):` Cập nhật tài liệu, Swagger, README
- `chore:` Nâng cấp gói, cấu hình môi trường, script
---
## 3. Quy Trình Làm Việc Với Prisma Database
- Mọi thay đổi cấu trúc bảng thực hiện tại `prisma/schema.prisma`.
- Luôn chạy migration thông qua script runner:
```bash
# Tạo và áp dụng migration development
pnpm db:migrate
# Sinh lại Prisma Client
pnpm prisma:generate
# Kiểm tra trạng thái
pnpm db:migrate:status
```
- **Nghiêm cấm:** Không chạy `pnpm db:migrate:reset` khi chưa được sự xác nhận rõ ràng của người dùng.
---
name: shop-amazon
description: "Quy trình chuẩn để thu thập, bóc tách dữ liệu sản phẩm cấu trúc từ sàn Amazon sử dụng Firecrawl template extraction"
---
# Skill: Thu Thập & Bóc Tách Sản Phẩm Amazon (shop-amazon)
Quy trình chuẩn dành cho Antigravity để tự động cấu hình và kích hoạt tác vụ cào dữ liệu sản phẩm từ sàn thương mại điện tử Amazon trong `data-crawler-be`.
---
## 1. Mục Đích & Phạm Vi Áp Dụng
- Dùng khi cần cào danh sách sản phẩm hoặc thông tin chi tiết một sản phẩm trên Amazon (`amazon.com`, `amazon.co.jp`, v.v.).
- Bóc tách các trường: Tiêu đề (`title`), Giá bán (`price`), Đánh giá (`rating`), Số lượng đánh giá (`reviewCount`), Ảnh sản phẩm (`mainImage`), Tình trạng (`availability`).
---
## 2. Cấu Hình Job Đề Xuất (Payload Mẫu)
Gửi yêu cầu tới `POST /api/v1/crawl-jobs`:
```json
{
"startUrl": "https://www.amazon.com/s?k=laptop",
"mode": "CRAWL",
"maxPages": 25,
"maxDepth": 2,
"delayMs": 2500,
"timeoutMs": 45000,
"retryCount": 3,
"respectRobotsTxt": true,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
}
```
> **Quy tắc chống chặn (Anti-bot):**
> - Amazon áp dụng cơ chế phát hiện bot rất mạnh. Luôn đặt `delayMs >= 2000`ms.
> - Đảm bảo worker bắt cờ `CAPTCHA_DETECTED` hoặc `BLOCKED` trong bảng `crawl_pages` để cảnh báo kịp thời.
---
## 3. Extraction Template Cho Amazon
Định nghĩa template trích xuất có cấu trúc cho domain `amazon.com`:
```json
{
"domain": "amazon.com",
"name": "Amazon Product Standard Template",
"fields": [
{ "name": "title", "selector": "#productTitle, h2 a.a-link-normal span", "type": "text", "required": true },
{ "name": "price", "selector": ".a-price .a-offscreen, span.a-price-whole", "type": "text", "required": false },
{ "name": "rating", "selector": "span[data-hook='rating-out-of-text'], span.a-icon-alt", "type": "text", "required": false },
{ "name": "reviewCount", "selector": "#acrCustomerReviewText, span[data-hook='total-review-count']", "type": "number", "required": false },
{ "name": "mainImage", "selector": "#landingImage, .s-image", "type": "attribute", "attributeName": "src", "required": false },
{ "name": "availability", "selector": "#availability span", "type": "text", "required": false }
]
}
```
---
## 4. Xuất Dữ Liệu Sau Khi Cào
Sau khi Job đạt trạng thái `COMPLETED`:
- Kích hoạt export sang Excel qua `POST /api/v1/exports` với `exportType: "XLSX"`.
- Báo cáo kết quả và đường dẫn tải file xuất cho người dùng.
# CLAUDE.md - Bộ Não Dự Án (data-crawler-be)
> Tài liệu hướng dẫn trung tâm dành cho Claude Agent khi làm việc trên mã nguồn **data-crawler-be**.
---
## 1. Tổng quan Dự Án (Project Overview)
- **Tên dự án:** `data-crawler-be`
- **Mục đích:** Dịch vụ Backend API cho hệ thống thu thập dữ liệu web (Data Crawler & Scraper). Hỗ trợ nhận URL, crawl website tự động (qua Firecrawl API hoặc scraper engine nội bộ), chuẩn hóa nội dung (HTML -> Markdown/Text), trích xuất structured data (sử dụng Extraction Templates), lưu trữ tài nguyên (assets) và xuất dữ liệu sang các định dạng `JSON`, `CSV`, `XLSX`, `MARKDOWN`, `ZIP`.
- **Cơ chế xử lý:** Bất đồng bộ dựa trên hàng đợi **BullMQ + Redis**, worker phân luồng xử lý riêng biệt.
- **Package Manager:** `pnpm@9.15.0` (tuân thủ nghiêm ngặt, không dùng `npm` hay `yarn`).
---
## 2. Kiến Trúc Cốt Lõi (Architectural Golden Rules)
Mọi luồng xử lý dữ liệu nghiệp vụ bắt buộc phải tuân theo thứ tự phân tầng 5 lớp (Strict Layered Architecture):
```
Route → Controller → Service → Repository → Prisma Client → PostgreSQL
```
### Quy tắc bất di bất dịch:
1. **Chỉ Repository được gọi Prisma:** Tuyệt đối **chỉ có** các file `*.repository.ts` được import `prisma` hoặc `PrismaClient`. Service, Controller, Worker hay Helper **không bao giờ** được gọi Prisma trực tiếp.
2. **Cấu trúc Module chuẩn:** Mọi tính năng nghiệp vụ đặt tại `src/modules/<feature>/` với đầy đủ các file quy chuẩn:
- `<feature>.route.ts`: Định nghĩa endpoint, gắn middleware (auth, validate, rate-limit).
- `<feature>.controller.ts`: Xử lý HTTP request/response, bắt lỗi chuyển cho next hoặc dùng AppError.
- `<feature>.service.ts`: Xử lý logic nghiệp vụ thuần túy, gọi một hoặc nhiều repository.
- `<feature>.repository.ts`: Thao tác trực tiếp với cơ sở dữ liệu qua Prisma.
- `<feature>.dto.ts` & `<feature>.validation.ts`: Định nghĩa types và schema xác thực Zod.
- `__tests__/`: Unit/Integration tests sử dụng Jest.
3. **Routing tập trung:** Tất cả route của module phải được đăng ký vào `src/routes/index.ts` và gắn prefix `/api/v1`.
4. **Mã dùng chung:** Chỉ đưa vào `src/common/` khi code thực sự được tái sử dụng ở từ 2 module trở lên (errors, helpers, constants, types, storage drivers).
---
## 3. Quản Lý Cơ Sở Dữ Liệu & Prisma
- **File nguồn chân lý:** `prisma/schema.prisma`.
- **Cơ chế chạy Prisma:** Dự án dùng script bọc `scripts/prisma-run.js` để tự động tổng hợp `DATABASE_URL` từ các biến môi trường rời rạc (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_SSL`).
- **Lệnh migration an toàn:**
- `pnpm db:migrate`: Tạo và chạy migration trong môi trường development.
- `pnpm db:migrate:deploy`: Chạy migration trong môi trường production/CI.
- `pnpm prisma:generate`: Sinh lại Prisma Client sau khi sửa schema.
- `pnpm prisma:studio`: Mở giao diện xem dữ liệu Prisma.
- **CẤM:** Tuyệt đối **không** tự ý chạy `pnpm db:migrate:reset` trừ khi người dùng yêu cầu rõ ràng việc xóa trắng dữ liệu local.
---
## 4. Hàng Đợi & Worker (BullMQ + Redis)
- Các tác vụ nặng (crawl dữ liệu, chạy lịch biểu schedule, gửi webhook) đều đưa vào hàng đợi:
- `crawl.queue.ts` & `crawl.worker.processor.ts` (Worker: `crawl.worker.ts`)
- `webhook.queue.ts` & `webhook.worker.ts`
- `schedule.worker.ts`
- **Quy tắc Worker:**
- Xử lý phải có tính **Idempotent** (chạy lại không sinh lỗi trùng lặp dữ liệu).
- Duy trì đúng vòng đời trạng thái của Job: `PENDING` -> `QUEUED` -> `RUNNING` -> `PROCESSING_EXPORT` -> `COMPLETED` / `FAILED` / `CANCELED`.
- Cập nhật log chi tiết vào bảng `crawl_job_logs` theo từng step.
---
## 5. Lưu Trữ & Hợp Đồng Dữ Liệu (Storage & Data Contract)
- Hỗ trợ 2 driver lưu trữ linh hoạt qua `src/common/storage/`: `local` (thư mục `storage/exports/`) hoặc `s3` (AWS S3 / MinIO).
- Tuân thủ hợp đồng dữ liệu chuẩn tại `docs/DATA_CONTRACT_V1.md`:
- Dữ liệu thô (raw): bảo toàn cấu trúc trả về từ Firecrawl / HTML ban đầu.
- Dữ liệu sạch (clean): Markdown chuẩn hóa, lọc thẻ rác, bóc tách metadata, tính toán `contentHash``dataQualityScore`.
- **Không chỉnh sửa thủ công:**
- `dist/` (build output)
- `storage/exports/*` (runtime files)
- `src/docs/swagger.json` (sinh tự động qua `pnpm swagger`)
---
## 6. Lệnh Thường Dùng (Cheatsheet Commands)
Chạy tất cả lệnh từ thư mục `data-crawler-be`:
```bash
# Development
pnpm dev # Chạy API server với ts-node-dev và tự động build swagger
pnpm worker # Chạy Crawl Worker riêng biệt
pnpm swagger # Sinh lại file docs Swagger từ route và controller
# Quality & Testing
pnpm lint # Kiểm tra lỗi cú pháp ESLint
pnpm format # Format toàn bộ code bằng Prettier
pnpm test # Chạy toàn bộ test suite Jest (--runInBand)
pnpm test -- <path> # Chạy test cho một file/thư mục cụ thể
# Build & Production
pnpm build # Build Swagger và compile TypeScript sang dist/
pnpm start # Chạy server production từ dist/server.js
```
---
## 7. Quy Tắc Ứng Xử Của Agent (Do's & Don'ts)
- **DO:** Luôn đọc kỹ code hiện có trước khi sửa đổi, kiểm tra tính tương thích type trong TypeScript.
- **DO:** Viết Jest test tương ứng khi thêm logic mới vào Service, Worker, Repository hoặc Export pipeline.
- **DO:** Dùng `AppError` kèm mã lỗi chuẩn từ `src/common/errors/error-code.ts`.
- **DON'T:** Không bypass tầng Repository để query trực tiếp từ Service/Controller.
- **DON'T:** Không commit file `.env`, `CLAUDE.local.md` hay `settings.local.json`.
- **DON'T:** Không tự ý cài đặt thêm dependency nặng nếu thư viện sẵn có đã hỗ trợ.
---
name: crawler-specialist
description: "Chuyên gia kỹ thuật thu thập dữ liệu web, Firecrawl API, BullMQ worker xử dữ liệu cào"
tools:
- view_file
- replace_file_content
- multi_replace_file_content
- run_command
---
# Sub-Agent: Chuyên Gia Thu Thập Dữ Liệu Web (crawler-specialist.md)
Bạn là **Crawler Specialist Sub-Agent** am hiểu sâu sắc về kiến trúc thu thập dữ liệu web, xử lý worker bất đồng bộ và chuẩn hóa dữ liệu trong **data-crawler-be**.
---
## 1. Phạm Vi Chuyên Môn
1. **Firecrawl API Integration (`src/modules/firecrawl/`):**
- Cấu hình các mode: `SCRAPE`, `CRAWL`, `SITEMAP`, `URL_LIST`.
- Xử lý options: `includeTags`, `excludeTags`, `waitFor`, `mobile`, `skipTlsVerification`.
- Cơ chế phòng ngừa bị chặn (Anti-bot): thiết lập delay hợp lý giữa các request, tùy biến User-Agent.
2. **Hàng Đợi & Phân Luồng Worker (`src/queues/`):**
- Tối ưu hóa xử lý đồng thời trong BullMQ worker (`concurrency`, `limiter`).
- Xử lý Retry với Exponential Backoff khi gặp sự cố mạng hoặc rate-limit từ website đích.
- Đảm bảo tính Idempotent của worker: nếu worker khởi động lại giữa chừng, không lưu trùng trang đã cào.
3. **Chuẩn Hóa & Làm Sạch Dữ Liệu (`src/modules/crawl-pages/`):**
- Loại bỏ thẻ script, style, quảng cáo, iframe không cần thiết qua Cheerio / Node-HTML-Parser.
- Chuyển đổi mã nguồn HTML sang định dạng Markdown tối ưu cho LLM qua Turndown.
- Tính toán chỉ số chất lượng `dataQualityScore` và băm nội dung `contentHash` để phục vụ Change Detection.
4. **Trích Xuất Định Dạng Cấu Trúc (`src/modules/extraction-templates/`):**
- Áp dụng các quy tắc CSS Selector và regex để bóc tách các trường cụ thể (tiêu đề, giá bán, mô tả, ảnh, thông số kỹ thuật).
5. **Hệ Thống Xuất Dữ Liệu Đa Định Dạng (`src/modules/crawl-exports/`):**
- Quản lý pipeline xuất file `JSON`, `CSV`, `XLSX`, `MARKDOWN`, `ZIP`.
- Kết hợp lưu trữ cục bộ hoặc Cloud S3/MinIO qua Storage Driver.
---
name: researcher
description: "Chuyên gia nghiên cứu, phân tích kiến trúc truy vết nguồn cho data-crawler-be"
tools:
- view_file
- list_dir
- grep_search
- search_web
---
# Sub-Agent: Chuyên Gia Nghiên Cứu & Khảo Sát (researcher.md)
Bạn là **Researcher Sub-Agent** chuyên trách việc điều tra, đọc hiểu kiến trúc và phân tích mã nguồn cho dự án **data-crawler-be**.
---
## 1. Mục Tiêu & Trách Nhiệm
1. **Khảo sát hệ thống:** Đọc và hiểu cặn kẽ luồng dữ liệu hiện tại trước khi bất kỳ dòng code nào được thay đổi.
2. **Truy vết luồng dữ liệu 5 lớp:**
- Theo dõi từ `Route` -> `Controller` -> `Service` -> `Repository` -> `Prisma Model`.
- Xác định rõ quan hệ cha-con, khóa ngoại, các index và rằng buộc trong `prisma/schema.prisma`.
3. **Phân tích tác động (Impact Analysis):**
- Đánh giá xem việc thay đổi một bảng trong DB có ảnh hưởng đến các Worker nền (`crawl.worker.ts`, `schedule.worker.ts`, `webhook.worker.ts`) hay không.
- Kiểm tra tính tương thích của API đối với các client bên ngoài hoặc frontend (`data-crawler-fe`).
4. **Tham chiếu hợp đồng dữ liệu:** Luôn đối chiếu với `docs/DATA_CONTRACT_V1.md` khi xem xét các thay đổi liên quan đến cấu trúc `CrawlPage``CrawlExport`.
---
## 2. Nguyên Tắc Hoạt Động (Rules of Engagement)
- **Read-Only First:** Không thực hiện sửa đổi file hoặc chạy các lệnh làm thay đổi trạng thái hệ thống trong quá trình nghiên cứu.
- **Dẫn chứng cụ thể:** Khi báo cáo phát hiện, luôn cung cấp đường dẫn file chính xác kèm số dòng liên quan.
- **Đánh giá rủi ro:** Chỉ ra cụ thể các rủi ro tiềm ẩn (ví dụ: N+1 query trong Prisma, race condition trong worker concurrency, rò rỉ bộ nhớ khi export file lớn).
---
## 3. Mẫu Báo Cáo Phân Tích (Deliverable Template)
Khi hoàn thành nghiên cứu, cung cấp kết quả theo định dạng:
```markdown
### Báo Cáo Khảo Sát Kỹ Thuật
1. **Hiện trạng mã nguồn:** (Tóm tắt module và các file liên quan)
2. **Luồng dữ liệu thực tế:** (Sơ đồ Route -> Service -> Repo -> DB)
3. **Các điểm nghẽn / Rủi ro phát hiện:** (Chỉ rõ vị trí code cụ thể)
4. **Đề xuất phương án thực hiện:** (Các bước cụ thể để triển khai an toàn)
```
---
name: reviewer
description: "Chuyên gia đánh giá chất lượng nguồn, kiến trúc an toàn bảo mật cho data-crawler-be"
tools:
- view_file
- grep_search
- run_command
---
# Sub-Agent: Chuyên Gia Đánh Giá Mã Nguồn (reviewer.md)
Bạn là **Reviewer Sub-Agent** chịu trách nhiệm kiểm duyệt mọi thay đổi mã nguồn trước khi tích hợp vào nhánh chính của **data-crawler-be**.
---
## 1. Danh Sách Kiểm Tra Bắt Buộc (Review Checklist)
### A. Tính Tuân Thủ Kiến Trúc (Architectural Compliance)
- [ ] **Quy tắc Prisma độc quyền:** Chỉ duy nhất các file `*.repository.ts` được import `prisma` hoặc `PrismaClient`. Tuyệt đối không chấp nhận Prisma query trong Controller, Service, Worker hay Middleware.
- [ ] **Phân tách tầng rõ ràng:** Controller không chứa logic tính toán nghiệp vụ; Service không can thiệp vào định dạng response HTTP (`res.status()`).
- [ ] **Đăng ký Route:** Route mới đã được đăng ký vào `src/routes/index.ts` và có prefix hợp lệ `/api/v1/...`.
### B. Tính Toàn Vẹn Dữ Liệu & Xác Thực (Validation & Type Safety)
- [ ] **Xác thực Zod:** Toàn bộ Body, Query và Params phải đi qua `validate(schema)` middleware.
- [ ] **Kiểu dữ liệu TypeScript:** Không dùng `any` bừa bãi. Sử dụng `z.infer<typeof schema>` cho các DTO.
- [ ] **Xử lý lỗi:** Lỗi phải được ném ra qua `AppError` với `statusCode``ERROR_CODE` chuẩn mực, không dùng `throw new Error()`.
### C. Hiệu Năng & Cơ Sở Dữ Liệu (Performance & Database)
- [ ] **Tránh N+1 Query:** Khi truy vấn dữ liệu liên kết, phải sử dụng `include` hoặc `select` hợp lý thay vì gọi lặp lại trong vòng lặp `for`/`forEach`.
- [ ] **Đúng chỉ mục (Index):** Các trường thường xuyên filter, sort (`status`, `createdAt`, `userId`, `domain`) phải có index trong `schema.prisma`.
- [ ] **Xử lý Stream:** Các tác vụ export file dung lượng lớn bắt buộc phải dùng luồng (Stream) thay vì dồn toàn bộ vào RAM.
### D. Kiểm Thử & Kiểm Định (Testing & Verification)
- [ ] Đã bổ sung unit test tương ứng trong thư mục `__tests__/` liền kề.
- [ ] Lệnh `pnpm lint` chạy không có cảnh báo nghiêm trọng hoặc lỗi cú pháp.
- [ ] Lệnh `pnpm test -- --runInBand` chạy thành công 100%.
---
## 2. Tiêu Chuẩn Phản Hồi Khi Review
Khi đưa ra nhận xét, Reviewer phải phân loại theo 3 mức độ:
1. 🔴 **[BLOCKER]**: Vi phạm nghiêm trọng kiến trúc (ví dụ: Service gọi Prisma), lỗ hổng bảo mật, làm gãy test. Yêu cầu sửa ngay lập tức.
2. 🟡 **[WARNING]**: Chưa tối ưu hiệu năng, thiếu test case biên hoặc chưa cập nhật Swagger. Cần cân nhắc xử lý.
3. 🟢 **[SUGGESTION]**: Góp ý làm gọn code, đặt tên biến rõ nghĩa hơn hoặc cải thiện comment.
# memory.md - Bộ Nhớ Bền Vững Của Claude (Project Persistent Memory)
> File lưu trữ ngữ cảnh kiến trúc, các quyết định kỹ thuật quan trọng và lưu ý đặc thù của dự án `data-crawler-be`.
---
## 1. Trạng Thái Hiện Tại Của Hệ Thống
- **Các module đã hoàn thiện trong `src/modules/`:**
- `auth`: Đăng ký, đăng nhập JWT (access token trong cookie/header, refresh token lưu database), đổi mật khẩu.
- `users`: Quản lý người dùng và quota (giới hạn số trang, số job mỗi ngày, số job đồng thời).
- `crawl-jobs`: Tạo job, xem danh sách, chi tiết tiến độ, hủy job, retry job.
- `crawl-pages`: Lưu trữ các trang đã cào được (`CrawlPage`), kiểm tra chất lượng nội dung, bóc tách `structuredData`, cảnh báo dữ liệu nhạy cảm.
- `crawl-assets`: Lưu hình ảnh, liên kết, PDF, file đính kèm tìm thấy trong các trang.
- `crawl-schedules`: Lập lịch cào định kỳ với cron expressions và tần suất (DAILY, WEEKLY, MONTHLY, CUSTOM), hỗ trợ so sánh khác biệt (`autoDiff`).
- `change-detection`: So sánh diff giữa các phiên bản cào để phát hiện nội dung trang bị thay đổi.
- `extraction-templates`: Quản lý template trích xuất theo domain (CSS selector/JSON field mapping).
- `crawl-exports`: Xuất kết quả cào sang `JSON`, `CSV`, `XLSX`, `MARKDOWN`, `ZIP`.
- `firecrawl`: Wrapper tích hợp Firecrawl API (hỗ trợ scrape single page, crawl whole site, map sitemap, url list).
- `webhooks`: Đăng ký URL webhook và phân phối kết quả (HMAC signature, retry attempts).
- `audit-logs`: Ghi nhận lịch sử thao tác của người dùng.
- `api-keys`: Quản lý API Key cho client bên thứ 3.
- `health`: Kiểm tra sức khỏe dịch vụ, kết nối DB và Redis.
---
## 2. Các Quyết Định Kỹ Thuật Quan Trọng (Key Architectural Decisions)
1. **Prisma Connection Pooling & DATABASE_URL:**
- Script `scripts/prisma-run.js` chịu trách nhiệm tạo chuỗi `DATABASE_URL` động nếu chưa có trong biến môi trường. Hỗ trợ tự động nhận diện Supabase pooler và SSL.
2. **Strict Layering Constraint:**
- Controller chỉ nhận request, gọi Service.
- Service chỉ chứa business logic, điều phối các Repository.
- Chỉ `*.repository.ts` được dùng Prisma. Tuyệt đối không query Prisma trong Service/Controller.
3. **Queue Processing Workflow:**
- Khi API nhận request cào -> Tạo record `CrawlJob` (status `PENDING` -> `QUEUED`) -> Đẩy vào `crawl.queue.ts`.
- `crawl.worker.processor.ts` lắng nghe:
- Chuyển status `RUNNING`.
- Gọi Firecrawl SDK để lấy nội dung.
- Phân tích HTML/Markdown, bóc tách link/assets, đánh giá quality score.
- Lưu các bản ghi `CrawlPage``CrawlAsset`.
- Tự động kích hoạt export nếu người dùng cấu hình export tự động.
- Chuyển status sang `COMPLETED` (hoặc `FAILED` nếu có lỗi không thể phục hồi).
4. **Export Stream:**
- Dùng stream với `archiver``exceljs` để tránh tràn bộ nhớ Node.js (out-of-memory) khi dữ liệu cào lên tới hàng trăm nghìn trang.
---
## 3. Các "Bẫy Kỹ Thuật" Cần Tránh (Gotchas & Warnings)
- **Helmet CSP và Swagger:** Helmet mặc định bật CSP khiến giao diện Swagger UI bị chặn load CSS. Trong `app.ts` đã tắt CSP riêng cho Swagger (`contentSecurityPolicy: false`).
- **Prisma Cascade Delete:** Quan hệ giữa `CrawlJob` với `CrawlPage`, `CrawlExport`, `CrawlJobLog``Cascade`. Xóa job sẽ xóa hết các trang liên quan.
- **Trust Proxy:** Cần gọi qua helper `parseTrustProxy` để rate limiting và lấy IP người dùng chính xác khi chạy sau Reverse Proxy/Nginx/Cloudflare.
# Quy Tắc Thiết Kế Kiến Trúc (design.md)
> Quy chuẩn thiết kế phần mềm, cấu trúc các tầng và chuẩn mực mã nguồn của hệ thống `data-crawler-be`.
---
## 1. Kiến Trúc 5 Lớp (5-Layer Pattern)
Hệ thống áp dụng kiến trúc phân lớp hướng dịch vụ (Layered Clean Architecture):
```
┌────────────────────────────────────────┐
│ 1. Route (src/modules/<feature>/*.route.ts)
│ - Gắn URI, HTTP Method, Middleware (Auth, Validate, Rate-limit)
└───────────────────┬────────────────────┘
┌───────────────────▼────────────────────┐
│ 2. Controller (src/modules/<feature>/*.controller.ts)
│ - Tiếp nhận Request, trích xuất Params/Body/Query
│ - Gọi Service tương ứng
│ - Định dạng Response chuẩn HTTP (200, 201, 204...)
└───────────────────┬────────────────────┘
┌───────────────────▼────────────────────┐
│ 3. Service (src/modules/<feature>/*.service.ts)
│ - Chứa Business Logic, kiểm tra Quota, nghiệp vụ cào dữ liệu
│ - Gọi một hoặc nhiều Repository
│ - Đẩy job vào BullMQ nếu là tác vụ bất đồng bộ
└───────────────────┬────────────────────┘
┌───────────────────▼────────────────────┐
│ 4. Repository (src/modules/<feature>/*.repository.ts)
│ - Chịu trách nhiệm DUY NHẤT về việc truy vấn cơ sở dữ liệu
│ - Định nghĩa Prisma select, include, pagination, filter
└───────────────────┬────────────────────┘
┌───────────────────▼────────────────────┐
│ 5. Database (PostgreSQL via Prisma Client)
└────────────────────────────────────────┘
```
---
## 2. Xác Thực Dữ Liệu Tại Biên (Boundary Validation with Zod)
- Mọi dữ liệu đầu vào từ người dùng (Body, Query, Params) **bắt buộc** phải được định nghĩa Schema bằng **Zod** trong `<feature>.validation.ts`.
- Sử dụng middleware dùng chung `validateMiddleware`:
```typescript
import { validate } from '../../middlewares/validate.middleware';
import { createCrawlJobSchema } from './crawl-job.validation';
router.post('/', validate(createCrawlJobSchema), crawlJobController.create);
```
- Định nghĩa kiểu TypeScript tương ứng (`DTO`) bằng `z.infer<typeof schema>` trong `<feature>.dto.ts`.
---
## 3. Xử Lý Lỗi Tập Trung (Centralized Error Handling)
- Không dùng `throw new Error("...")` một cách tùy tiện.
- Bắt buộc kế thừa từ `AppError`:
```typescript
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
if (!job) {
throw new AppError('Crawl job not found', 404, ERROR_CODE.NOT_FOUND);
}
```
- Cấu trúc response trả về cho client luôn thống nhất:
```json
{
"success": false,
"message": "Chi tiết lỗi",
"code": "ERROR_CODE_ENUM",
"errors": [] // (nếu là lỗi validate form)
}
```
---
## 4. Hợp Đồng Dữ Liệu Cào (Data Contract V1)
Bảo toàn hợp đồng dữ liệu quy định tại `docs/DATA_CONTRACT_V1.md`:
- **Trang Cào (`CrawlPage`):**
- `url`, `normalizedUrl`: URL chuẩn hóa (loại bỏ tracking query params không cần thiết).
- `markdownContent`: Nội dung chính sau khi dọn sạch thẻ rác HTML.
- `structuredData`: Dữ liệu bóc tách dựa trên Extraction Template.
- `dataQualityScore`: Điểm chất lượng nội dung (tính dựa trên tỷ lệ text/tag, mật độ từ).
- `hasSensitiveData`: Cờ phát hiện email/số điện thoại/khóa API nhạy cảm.
---
## 5. Trừu Tượng Hóa Tầng Lưu Trữ (Storage Abstraction)
- Mọi thao tác ghi file kết quả export và lưu trữ asset phải đi qua interface lưu trữ chung tại `src/common/storage/`:
- `LocalStorageProvider`: Lưu file tại ổ đĩa server (`storage/exports/`).
- `S3StorageProvider`: Tải file trực tiếp lên AWS S3 hoặc MinIO tương thích S3.
- Không hardcode đường dẫn file vật lý trong tầng Controller hoặc Service.
# Công Nghệ Mặc Định & Cấu Hình Chuẩn (tech-defaults.md)
> Danh sách công nghệ cốt lõi, phiên bản và các quy ước mặc định được áp dụng trong dự án `data-crawler-be`.
---
## 1. Môi Trường & Phiên Bản Chuẩn (Core Runtime)
| Thành phần | Phiên bản / Thư viện | Ghi chú quy ước |
| :--- | :--- | :--- |
| **Node.js** | `>= 20.x` | Sử dụng cú pháp ES2022+ hiện đại |
| **Package Manager** | `pnpm@9.15.0` | Không dùng npm hay yarn để tránh lệch pnpm-lock |
| **Ngôn ngữ** | `TypeScript 5.7` | `strict: true`, không dùng kiểu `any` vô căn cứ |
| **Web Framework** | `Express.js 4.21` | Tách rời App configuration và Server listener |
---
## 2. Cơ Sở Dữ Liệu & Bộ Nhớ Đệm (Database & Caching)
- **Database:** PostgreSQL (chạy Docker local qua `docker-compose.yml` trên port `5432`).
- **ORM:** Prisma `5.22.x`
- Đặt tên model dạng `PascalCase`, ánh xạ bảng dạng `snake_case` thông qua `@@map("table_name")`.
- Khóa chính luôn là UUID v4 (`@id @default(uuid()) @db.Uuid`).
- Luôn định nghĩa `createdAt` (`@default(now())`) và `updatedAt` (`@updatedAt`).
- **Cache & Queue Broker:** Redis `7.x` (chạy trên port `6379`)
- Kết nối thông qua thư viện `ioredis``bullmq`.
---
## 3. Crawl Engine & Phân Tích Nội Dung (Crawl Stack)
- **Engine thu thập:** `@mendable/firecrawl-js ^1.19.0`
- Chế độ cào linh hoạt: `SCRAPE` (trang đơn), `CRAWL` (toàn bộ website theo độ sâu `maxDepth`), `SITEMAP` (dựa trên sơ đồ trang), `URL_LIST` (danh sách URL rời).
- **Phân tích DOM & Làm sạch nội dung:**
- `cheerio ^1.2.0` & `node-html-parser ^8.0.4`: Bóc tách DOM, tìm kiếm selector nhanh.
- `turndown ^7.2.0`: Chuyển đổi mã nguồn HTML đã làm sạch thành Markdown dễ đọc cho LLM.
---
## 4. Xuất Dữ Liệu & Lưu Trữ (Export & Storage)
- **Các định dạng hỗ trợ:**
- `JSON`: JSON mảng các trang thu thập được.
- `CSV`: Tạo bằng `json2csv ^6.0.0-alpha.2`.
- `XLSX`: Tạo bằng `exceljs ^4.4.0` (hỗ trợ phân tab, tự động tính độ rộng cột).
- `MARKDOWN`: Xuất file `.md` từng trang.
- `ZIP`: Đóng gói toàn bộ file xuất kèm thư mục assets bằng `archiver ^7.0.1`.
- **Cloud Storage:** `@aws-sdk/client-s3``@aws-sdk/lib-storage` (tương thích AWS S3 và MinIO).
---
## 5. Bảo Mật & Xác Thực (Security & Auth)
- **Mã hóa mật khẩu:** `bcryptjs ^2.4.3` với salt round chuẩn = 10.
- **Token:** `jsonwebtoken ^9.0.2` (Access token thời hạn 1 ngày, Refresh token 7 ngày lưu database).
- **Bảo vệ HTTP:** `helmet ^8.0.0` (tắt CSP cho Swagger), `cors ^2.8.5`, `express-rate-limit ^8.5.2`.
- **Phân quyền (RBAC):**
- `ADMIN`: Toàn quyền hệ thống, quản lý người dùng, xem toàn bộ log.
- `CRAWLER_USER`: Người dùng tiêu chuẩn, có thể tạo job cào và xuất file theo quota.
- `VIEWER`: Chỉ xem danh sách job và tải dữ liệu đã cào sẵn.
# Quy Tắc Quy Trình Phát Triển (workflow.md)
> Quy định về các bước làm việc, quy chuẩn Git, cơ sở dữ liệu và kiểm thử chất lượng mã nguồn trong `data-crawler-be`.
---
## 1. Quy Trình Phát Triển Tính Năng Mới (Feature Lifecycle)
Khi xây dựng một module hoặc tính năng mới, Agent cần thực hiện tuần tự theo 6 bước:
```
[1. Khảo sát Schema & Yêu Cầu]
[2. Viết Repository (Prisma Query)]
[3. Viết Service (Business Logic & Validation)]
[4. Viết Controller & Định nghĩa Route]
[5. Đăng ký Route vào index.ts & Viết Swagger]
[6. Viết Jest Test & Chạy Lint]
```
---
## 2. Quy Chuẩn Git & Commit Message
Tuân thủ chuẩn **Conventional Commits**:
- `feat(module):` Thêm tính năng mới (ví dụ: `feat(crawl-jobs): add priority queue option`)
- `fix(module):` Sửa lỗi (ví dụ: `fix(auth): handle expired refresh token race condition`)
- `refactor(module):` Tái cấu trúc mã mà không đổi hành vi nghiệp vụ
- `test(module):` Thêm hoặc cập nhật unit tests
- `docs(module):` Cập nhật tài liệu, Swagger, README
- `chore:` Thay đổi cấu hình build, dependencies, tooling
---
## 3. Quy Trình Làm Việc Với Cơ Sở Dữ Liệu (Prisma Migration Workflow)
Khi cần thay đổi cấu trúc bảng hoặc thêm trường mới:
1. Chỉnh sửa schema tại `prisma/schema.prisma`.
2. Tạo và áp dụng migration:
```bash
pnpm db:migrate
# Hoặc nếu là lần đầu: pnpm db:migrate:init
```
3. Sinh lại Prisma Client để cập nhật kiểu dữ liệu TypeScript:
```bash
pnpm prisma:generate
```
4. Kiểm tra trạng thái migration:
```bash
pnpm db:migrate:status
```
5. **QUAN TRỌNG:** Commit cả file `schema.prisma` lẫn thư mục migration mới được sinh ra trong `prisma/migrations/`.
---
## 4. Quy Trình Kiểm Thử & Kiểm Tra Chất Lượng (QA & Validation)
Trước khi coi một tác vụ là hoàn thành, Agent bắt buộc phải chạy các bước kiểm tra sau:
```bash
# 1. Kiểm tra định dạng và quy chuẩn cú pháp:
pnpm lint
# 2. Cập nhật tài liệu API:
pnpm swagger
# 3. Chạy test suite:
pnpm test -- --runInBand
# 4. Kiểm tra khả năng build production:
pnpm build
```
---
## 5. Quy Trình Bổ Sung Queue Worker
Khi tạo thêm Worker xử lý tác vụ nền:
1. Tạo Queue tại `src/queues/<job-name>.queue.ts`.
2. Tạo Processor xử lý logic tại `src/queues/<job-name>.worker.processor.ts`.
3. Khởi tạo Worker file tại `src/queues/<job-name>.worker.ts`.
4. Đảm bảo cấu hình retry với exponential backoff và cơ chế log lỗi vào cơ sở dữ liệu.
{
"$schema": "https://json.schemastore.org/partial-claude-code-settings.json",
"version": "1.0",
"permissions": {
"allowAutoExecution": [
"pnpm lint",
"pnpm format",
"pnpm swagger",
"pnpm test",
"pnpm build",
"pnpm prisma:generate",
"node scripts/prisma-run.js migrate status"
],
"denyExecution": [
"pnpm db:migrate:reset",
"rm -rf *",
"git push --force",
"git reset --hard"
],
"fileProtection": {
"readOnly": [
"pnpm-lock.yaml",
"dist/**",
"storage/exports/**",
"src/docs/swagger.json"
]
}
},
"hooks": {
"postEdit": [
{
"pattern": "src/modules/**/*.route.ts",
"command": "pnpm swagger",
"description": "Tự động sinh lại Swagger JSON khi có route thay đổi"
},
{
"pattern": "prisma/schema.prisma",
"command": "pnpm prisma:generate",
"description": "Tự động sinh lại Prisma Client khi schema thay đổi"
}
],
"preCommit": [
{
"command": "pnpm lint",
"description": "Kiểm tra cú pháp và quy chuẩn code trước khi commit"
},
{
"command": "pnpm test -- --runInBand",
"description": "Chạy unit test để đảm bảo không làm gãy tính năng"
}
]
},
"environment": {
"NODE_ENV": "development",
"PAGER": "cat"
}
}
# Tác Vụ Tái Sử Dụng: Cào & Bóc Tách Dữ Liệu Sản Phẩm Amazon (shop-amazon.md)
> Skill workflow định nghĩa quy trình chuẩn để thu thập và trích xuất dữ liệu sản phẩm có cấu trúc từ sàn thương mại điện tử Amazon sử dụng hệ thống `data-crawler-be`.
---
## 1. Thông Tin Tác Vụ (Task Metadata)
- **Mục tiêu:** Thu thập thông tin danh sách sản phẩm hoặc chi tiết sản phẩm Amazon (Tiêu đề, Giá bán, Đánh giá sao, Số lượng review, Ảnh đại diện, ASIN, Tình trạng còn hàng).
- **Target Domain:** `amazon.com`, `amazon.co.jp`, `amazon.de`, ...
- **Chế độ khuyến nghị:** `CRAWL` (cho trang danh mục) hoặc `SCRAPE` (cho từng sản phẩm cụ thể).
---
## 2. Cấu Hình Job Đề Xuất (Job Parameters)
Khi khởi tạo `CrawlJob` qua API `POST /api/v1/crawl-jobs`, sử dụng payload mẫu sau:
```json
{
"startUrl": "https://www.amazon.com/s?k=laptop",
"mode": "CRAWL",
"maxPages": 25,
"maxDepth": 2,
"delayMs": 2500,
"timeoutMs": 45000,
"retryCount": 3,
"respectRobotsTxt": true,
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
}
```
> **Lưu ý chống chặn (Anti-bot):**
> - Luôn thiết lập `delayMs` tối thiểu `2000`ms để tránh bị hệ thống Amazon chặn IP hoặc hiển thị CAPTCHA.
> - Đảm bảo worker bắt cờ `CAPTCHA_DETECTED` và `BLOCKED` trong bảng `crawl_pages` để cảnh báo người dùng.
---
## 3. Extraction Template Cho Amazon (Bóc Tách Dữ Liệu)
Áp dụng template trích xuất có cấu trúc tương ứng với domain `amazon.com`:
```json
{
"domain": "amazon.com",
"name": "Amazon Product Standard Template",
"fields": [
{
"name": "title",
"selector": "#productTitle, h2 a.a-link-normal span",
"type": "text",
"required": true
},
{
"name": "price",
"selector": ".a-price .a-offscreen, span.a-price-whole",
"type": "text",
"required": false
},
{
"name": "rating",
"selector": "span[data-hook='rating-out-of-text'], span.a-icon-alt",
"type": "text",
"required": false
},
{
"name": "reviewCount",
"selector": "#acrCustomerReviewText, span[data-hook='total-review-count']",
"type": "number",
"required": false
},
{
"name": "mainImage",
"selector": "#landingImage, .s-image",
"type": "attribute",
"attributeName": "src",
"required": false
},
{
"name": "availability",
"selector": "#availability span",
"type": "text",
"required": false
}
]
}
```
---
## 4. Quy Trình Xuất Báo Cáo Kết Quả
1. Sau khi Job chuyển trạng thái `COMPLETED`:
2. Gọi API `POST /api/v1/exports` với:
```json
{
"jobId": "<crawl-job-id>",
"exportType": "XLSX"
}
```
3. File Excel sinh ra sẽ có cột phân tách rõ ràng cho từng thuộc tính sản phẩm, sẵn sàng phân tích hoặc nhập liệu.
...@@ -7,4 +7,8 @@ storage/exports/* ...@@ -7,4 +7,8 @@ storage/exports/*
.DS_Store .DS_Store
nopush/ nopush/
coverage/ coverage/
frontend-data-crawler-be/
\ No newline at end of file # Claude local overrides
.claude/*.local.*
.claude/CLAUDE.local.md
.claude/settings.local.json
\ No newline at end of file
# Backend guidance # Backend Guidance & Architecture Rules (data-crawler-be)
## Architecture > Tài liệu quy tắc chuẩn cho **Antigravity** khi phát triển và bảo trì mã nguồn trong workspace `data-crawler-be`.
- Keep the request path `Route -> Controller -> Service -> Repository -> Prisma`. ---
- Only `*.repository.ts` files may import or call the Prisma client.
- Keep module code under `src/modules/<feature>/`; use the existing route, controller, service, repository, DTO, validation, and test conventions in the nearest module.
- Put shared errors, helpers, constants, storage code, and types under `src/common/` only when they are genuinely reused.
- Mount module routes through `src/routes/index.ts`.
## Data and integrations ## 1. Kiến Trúc Phân Tầng Tuyệt Đối (Strict 5-Layer Pattern)
- Treat `prisma/schema.prisma` as the database schema source of truth. Mọi luồng dữ liệu nghiệp vụ bắt buộc tuân theo thứ tự phân tầng đơn hướng:
- Make schema changes through Prisma migrations and commit the schema and generated migration together.
- Never run `pnpm db:migrate:reset` unless the user explicitly requests destructive local reset.
- Keep queue processors idempotent where retries are possible, and preserve valid crawl-job state transitions.
- Parse and validate external input at boundaries. Reuse Zod validation and the existing application error shape.
- Keep configuration access in `src/config/`; document new variables in `.env.example` without real values.
- Preserve the clean/raw output contract described in `docs/DATA_CONTRACT_V1.md`.
## Generated and runtime files ```
Route → Controller → Service → Repository → Prisma Client → PostgreSQL
```
- Do not hand-edit `dist/`, `coverage/`, `storage/exports/`, or generated Swagger JSON. ### Quy tắc bất biến:
- When API annotations or routes change, regenerate Swagger with `pnpm swagger`. - **Độc quyền Prisma:** Chỉ duy nhất các file `*.repository.ts` được phép import và gọi `prisma` hoặc `PrismaClient`. Service, Controller, Worker, Helper và Middleware **tuyệt đối không** được gọi Prisma trực tiếp.
- **Tổ chức Module chuẩn (`src/modules/<feature>/`):**
- `<feature>.route.ts`: Khai báo endpoints, gắn middleware (`auth`, `role`, `validate`, `rateLimit`).
- `<feature>.controller.ts`: Nhận HTTP request, trích xuất parameters, gọi Service, trả response HTTP chuẩn.
- `<feature>.service.ts`: Chứa toàn bộ Business Logic, điều phối các Repository và đẩy job vào BullMQ.
- `<feature>.repository.ts`: Chịu trách nhiệm duy nhất về tương tác dữ liệu với Prisma (select, filter, transaction).
- `<feature>.dto.ts` & `<feature>.validation.ts`: Định nghĩa kiểu TypeScript và schema kiểm thực Zod.
- `__tests__/`: Chứa colocated unit/integration tests cho module.
- **Routing:** Mọi router module mới phải được mount tập trung trong `src/routes/index.ts` với prefix `/api/v1/`.
- **Mã dùng chung (`src/common/`):** Chỉ đặt vào `src/common/` (errors, helpers, constants, types, storage) khi code thực sự được tái sử dụng qua ít nhất 2 modules.
## Validation ---
Run commands from `data-crawler-be/`. ## 2. Dữ Liệu & Tích Hợp (Data, Prisma & Workers)
- Focused test: `pnpm test -- <path-to-test> --runInBand` ### A. Cơ sở dữ liệu & Prisma Migrations
- Test suite: `pnpm test -- --runInBand` - `prisma/schema.prisma` là nguồn chân lý duy nhất (Single Source of Truth) của database schema.
- Lint: `pnpm lint` - Thao tác Prisma thông qua script runner: `node scripts/prisma-run.js <cmd>`. Runner tự động tổng hợp `DATABASE_URL` từ các biến môi trường cấu hình (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_SSL`).
- Production compile: `pnpm build` - Mọi thay đổi schema phải sinh migration tương ứng bằng `pnpm db:migrate` và commit đồng thời cả `schema.prisma` lẫn thư mục migration.
- **CẤM:** Không bao giờ chạy `pnpm db:migrate:reset` trừ khi người dùng yêu cầu rõ ràng việc xóa trắng dữ liệu.
Add colocated Jest tests under `__tests__/` for service, worker, repository-boundary, export, or contract behavior that changes. ### B. Hàng đợi bất đồng bộ & Worker (BullMQ + Redis)
- Các tác vụ nặng (thu thập web, gửi webhook, chạy lịch cron) phải chuyển qua hàng đợi BullMQ:
- `crawl.queue.ts` / `crawl.worker.ts` / `crawl.worker.processor.ts`
- `webhook.queue.ts` / `webhook.worker.ts`
- `schedule.worker.ts`
- **Idempotency:** Worker processor phải có khả năng chạy lại mà không tạo trùng dữ liệu (dựa trên `url`, `jobId`, `contentHash`).
- **State Lifecycle:** Bảo toàn chuyển đổi trạng thái hợp lệ của `CrawlJob`:
$$\text{PENDING} \longrightarrow \text{QUEUED} \longrightarrow \text{RUNNING} \longrightarrow \text{PROCESSING\_EXPORT} \longrightarrow \text{COMPLETED} \ / \ \text{FAILED} \ / \ \text{CANCELED}$$
- Ghi log chi tiết theo từng step vào `crawl_job_logs`.
---
## 3. Xác Thực, Xử Lý Lỗi & Hợp Đồng Dữ Liệu
- **Xác thực tại tầng biên:** Toàn bộ Body, Query và Params phải được định nghĩa bằng **Zod** và gắn middleware `validate(schema)`.
- **Chuẩn hóa lỗi:** Sử dụng `AppError` kèm mã định danh từ `src/common/errors/error-code.ts` (`ERROR_CODE.*`), không dùng `throw new Error()`.
- **Data Contract V1:** Tuân thủ cấu trúc dữ liệu theo `docs/DATA_CONTRACT_V1.md`:
- Phân định rõ ràng giữa dữ liệu thô (raw HTML từ Firecrawl) và dữ liệu đã làm sạch (clean Markdown, structuredData, dataQualityScore).
- **Lưu trữ tệp:** Sử dụng abstraction `src/common/storage/` (hỗ trợ `local` disk và `s3` / MinIO).
---
## 4. Các Tệp Sinh Tự Động & Runtime (Do Not Hand-Edit)
- **Không chỉnh sửa thủ công:**
- `dist/` (build artifacts)
- `coverage/` (test reports)
- `storage/exports/*` (runtime export files)
- `src/docs/swagger.json` (sinh tự động qua Swagger Autogen)
- Khi thay đổi router, query params, request body hoặc Swagger tags, chạy lại:
```bash
pnpm swagger
```
---
## 5. Quy Trình Kiểm Thử & Kiểm Định Chất Lượng (QA Commands)
Thực hiện tất cả các lệnh từ thư mục `data-crawler-be/`:
```bash
# 1. Chạy test đơn lẻ hoặc theo thư mục
pnpm test -- <path-to-test> --runInBand
# 2. Chạy toàn bộ test suite
pnpm test -- --runInBand
# 3. Kiểm tra cú pháp và quy chuẩn mã nguồn
pnpm lint
# 4. Format mã nguồn
pnpm format
# 5. Build kiểm tra biên dịch TypeScript
pnpm build
```
Bắt buộc bổ sung Jest test trong thư mục `__tests__/` cho mọi Service, Worker, Repository logic, Export pipeline hoặc Data Contract mới được thêm vào hoặc chỉnh sửa.
-- AlterTable
ALTER TABLE "users" ADD COLUMN "avatar_url" TEXT;
...@@ -85,6 +85,7 @@ model User { ...@@ -85,6 +85,7 @@ model User {
email String @unique email String @unique
passwordHash String @map("password_hash") passwordHash String @map("password_hash")
fullName String? @map("full_name") fullName String? @map("full_name")
avatarUrl String? @map("avatar_url")
role UserRole @default(CRAWLER_USER) role UserRole @default(CRAWLER_USER)
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
......
...@@ -162,6 +162,53 @@ export const swaggerPaths: Record<string, any> = { ...@@ -162,6 +162,53 @@ export const swaggerPaths: Record<string, any> = {
} }
} }
}, },
'/auth/me/usage': {
get: {
tags: ['Auth'],
summary: 'Xem hạn mức và mức độ sử dụng Quota hiện tại',
description: 'Trả về số job đã chạy hôm nay theo giờ Việt Nam UTC+7, số job đồng thời đang chạy và tổng trang đã crawl.',
responses: {
200: {
description: 'Lấy quota và usage thành công',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
quota: {
type: 'object',
properties: {
maxPagesLimit: { type: 'integer', example: 100 },
maxJobsPerDayLimit: { type: 'integer', example: 10 },
maxConcurrentJobsLimit: { type: 'integer', example: 3 }
}
},
usage: {
type: 'object',
properties: {
jobsUsedToday: { type: 'integer', example: 2 },
jobsRemainingToday: { type: 'integer', example: 8 },
concurrentJobsRunning: { type: 'integer', example: 0 },
concurrentJobsAvailable: { type: 'integer', example: 3 },
totalPagesCrawled: { type: 'integer', example: 45 }
}
},
resetAt: { type: 'string', example: '2026-09-04T00:00:00.000Z' }
}
}
}
}
}
}
},
401: { description: 'Chưa xác thực' }
}
}
},
'/auth/change-password': { '/auth/change-password': {
post: { post: {
tags: ['Auth'], tags: ['Auth'],
......
...@@ -265,6 +265,87 @@ ...@@ -265,6 +265,87 @@
"summary": "Cập nhật thông tin cá nhân" "summary": "Cập nhật thông tin cá nhân"
} }
}, },
"/auth/me/usage": {
"get": {
"description": "Trả về số job đã chạy hôm nay theo giờ Việt Nam UTC+7, số job đồng thời đang chạy và tổng trang đã crawl.",
"responses": {
"200": {
"description": "Lấy quota và usage thành công",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"example": true
},
"data": {
"type": "object",
"properties": {
"quota": {
"type": "object",
"properties": {
"maxPagesLimit": {
"type": "integer",
"example": 100
},
"maxJobsPerDayLimit": {
"type": "integer",
"example": 10
},
"maxConcurrentJobsLimit": {
"type": "integer",
"example": 3
}
}
},
"usage": {
"type": "object",
"properties": {
"jobsUsedToday": {
"type": "integer",
"example": 2
},
"jobsRemainingToday": {
"type": "integer",
"example": 8
},
"concurrentJobsRunning": {
"type": "integer",
"example": 0
},
"concurrentJobsAvailable": {
"type": "integer",
"example": 3
},
"totalPagesCrawled": {
"type": "integer",
"example": 45
}
}
},
"resetAt": {
"type": "string",
"example": "2026-09-04T00:00:00.000Z"
}
}
}
}
}
}
}
},
"401": {
"description": "Chưa xác thực"
}
},
"tags": [
"Auth"
],
"summary": "Xem hạn mức và mức độ sử dụng Quota hiện tại"
}
},
"/auth/change-password": { "/auth/change-password": {
"post": { "post": {
"description": "Thay đổi mật khẩu cho người dùng hiện tại đang đăng nhập. Yêu cầu nhập mật khẩu hiện tại, mật khẩu mới và xác nhận mật khẩu mới.", "description": "Thay đổi mật khẩu cho người dùng hiện tại đang đăng nhập. Yêu cầu nhập mật khẩu hiện tại, mật khẩu mới và xác nhận mật khẩu mới.",
...@@ -848,6 +929,16 @@ ...@@ -848,6 +929,16 @@
"summary": "Xóa người dùng" "summary": "Xóa người dùng"
} }
}, },
"/dashboard/stats": {
"get": {
"description": "",
"responses": {
"default": {
"description": ""
}
}
}
},
"/crawl-jobs": { "/crawl-jobs": {
"post": { "post": {
"description": "Tạo một tác vụ crawl dữ liệu từ URL bắt đầu với các cấu hình về độ sâu và giới hạn trang.", "description": "Tạo một tác vụ crawl dữ liệu từ URL bắt đầu với các cấu hình về độ sâu và giới hạn trang.",
...@@ -1026,6 +1117,85 @@ ...@@ -1026,6 +1117,85 @@
"Crawl Jobs" "Crawl Jobs"
], ],
"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"
},
"delete": {
"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}/rerun": {
"post": {
"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}/logs": {
"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}/events": { "/crawl-jobs/{id}/events": {
...@@ -2496,6 +2666,16 @@ ...@@ -2496,6 +2666,16 @@
"summary": "Xem lịch sử các lần chạy của lịch crawl" "summary": "Xem lịch sử các lần chạy của lịch crawl"
} }
}, },
"/exports": {
"get": {
"description": "",
"responses": {
"default": {
"description": ""
}
}
}
},
"/exports/{exportId}/download": { "/exports/{exportId}/download": {
"get": { "get": {
"description": "Tải xuống tệp dữ liệu đã xuất cụ thể theo ID của tệp.", "description": "Tải xuống tệp dữ liệu đã xuất cụ thể theo ID của tệp.",
...@@ -2530,6 +2710,26 @@ ...@@ -2530,6 +2710,26 @@
"summary": "Tải xuống tệp export theo ID" "summary": "Tải xuống tệp export theo ID"
} }
}, },
"/exports/{exportId}": {
"delete": {
"description": "",
"parameters": [
{
"name": "exportId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"description": ""
}
}
}
},
"/audit-logs": { "/audit-logs": {
"get": { "get": {
"description": "Lấy danh sách phân trang các hành động được ghi nhật ký trong hệ thống. Chỉ có ADMIN mới có quyền truy cập.", "description": "Lấy danh sách phân trang các hành động được ghi nhật ký trong hệ thống. Chỉ có ADMIN mới có quyền truy cập.",
...@@ -2938,6 +3138,24 @@ ...@@ -2938,6 +3138,24 @@
} }
}, },
"/webhooks/configs/{id}": { "/webhooks/configs/{id}": {
"patch": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"description": ""
}
}
},
"delete": { "delete": {
"description": "Xóa hoàn toàn một cấu hình Webhook nhận callback của người dùng.", "description": "Xóa hoàn toàn một cấu hình Webhook nhận callback của người dùng.",
"parameters": [ "parameters": [
...@@ -2984,6 +3202,26 @@ ...@@ -2984,6 +3202,26 @@
"summary": "Xóa cấu hình Webhook" "summary": "Xóa cấu hình Webhook"
} }
}, },
"/webhooks/configs/{id}/test": {
"post": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"default": {
"description": ""
}
}
}
},
"/webhooks/deliveries": { "/webhooks/deliveries": {
"get": { "get": {
"description": "Xem toàn bộ lịch sử gửi webhook (delivery logs) bao gồm các nỗ lực gửi, trạng thái, mã phản hồi và lỗi nếu có.", "description": "Xem toàn bộ lịch sử gửi webhook (delivery logs) bao gồm các nỗ lực gửi, trạng thái, mã phản hồi và lỗi nếu có.",
......
import { AuthService } from '../auth.service';
import { CrawlJobRepository } from '../../crawl-jobs/crawl-job.repository';
jest.mock('../../crawl-jobs/crawl-job.repository');
describe('AuthService getUsage and avatarUrl', () => {
const mockUser = {
id: 'user-123',
email: 'test@example.com',
fullName: 'Test User',
avatarUrl: 'https://example.com/old-avatar.png',
role: 'CRAWLER_USER',
isActive: true,
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
createdAt: new Date(),
};
it('calculates quota usage correctly in UTC+7 timezone', async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(mockUser),
};
(service as any).repository = repository;
(CrawlJobRepository.prototype.countJobsSince as jest.Mock).mockResolvedValue(4);
(CrawlJobRepository.prototype.countConcurrentJobs as jest.Mock).mockResolvedValue(1);
(CrawlJobRepository.prototype.sumPagesCrawledByUser as jest.Mock).mockResolvedValue(125);
const usage = await service.getUsage('user-123');
expect(usage.quota).toEqual({
maxPagesLimit: 100,
maxJobsPerDayLimit: 10,
maxConcurrentJobsLimit: 3,
});
expect(usage.usage).toEqual({
jobsUsedToday: 4,
jobsRemainingToday: 6,
concurrentJobsRunning: 1,
concurrentJobsAvailable: 2,
totalPagesCrawled: 125,
});
expect(usage.resetAt).toBeDefined();
});
it('updates avatarUrl via updateMe', async () => {
const service = new AuthService();
const repository = {
findById: jest.fn().mockResolvedValue(mockUser),
updateUser: jest.fn().mockResolvedValue({
...mockUser,
avatarUrl: 'https://example.com/new-avatar.png',
}),
};
(service as any).repository = repository;
const result = await service.updateMe('user-123', {
avatarUrl: 'https://example.com/new-avatar.png',
});
expect(result.avatarUrl).toBe('https://example.com/new-avatar.png');
expect(repository.updateUser).toHaveBeenCalledWith('user-123', {
avatarUrl: 'https://example.com/new-avatar.png',
});
});
});
...@@ -74,6 +74,19 @@ export class AuthController { ...@@ -74,6 +74,19 @@ export class AuthController {
} }
}; };
usage = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.getUsage(req.user.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
refresh = async (req: Request, res: Response, next: NextFunction) => { refresh = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { refreshToken } = req.body; const { refreshToken } = req.body;
......
...@@ -12,6 +12,7 @@ export interface MeDto { ...@@ -12,6 +12,7 @@ export interface MeDto {
id: string; id: string;
email: string; email: string;
fullName: string | null; fullName: string | null;
avatarUrl: string | null;
role: string; role: string;
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
...@@ -24,6 +25,7 @@ export interface LoginResponseDto { ...@@ -24,6 +25,7 @@ export interface LoginResponseDto {
id: string; id: string;
email: string; email: string;
fullName: string | null; fullName: string | null;
avatarUrl?: string | null;
role: string; role: string;
}; };
} }
...@@ -36,6 +38,23 @@ export interface RegisterDto { ...@@ -36,6 +38,23 @@ export interface RegisterDto {
export interface UpdateMeDto { export interface UpdateMeDto {
fullName?: string; fullName?: string;
avatarUrl?: string | null;
}
export interface UserUsageDto {
quota: {
maxPagesLimit: number;
maxJobsPerDayLimit: number;
maxConcurrentJobsLimit: number;
};
usage: {
jobsUsedToday: number;
jobsRemainingToday: number;
concurrentJobsRunning: number;
concurrentJobsAvailable: number;
totalPagesCrawled: number;
};
resetAt: string;
} }
export interface ChangePasswordDto { export interface ChangePasswordDto {
......
...@@ -25,7 +25,7 @@ export class AuthRepository { ...@@ -25,7 +25,7 @@ export class AuthRepository {
}); });
} }
updateUser(id: string, data: { fullName?: string; passwordHash?: string; isActive?: boolean }) { updateUser(id: string, data: { fullName?: string; avatarUrl?: string | null; passwordHash?: string; isActive?: boolean }) {
return prisma.user.update({ return prisma.user.update({
where: { id }, where: { id },
data, data,
......
...@@ -9,45 +9,36 @@ const router = Router(); ...@@ -9,45 +9,36 @@ const router = Router();
const controller = new AuthController(); const controller = new AuthController();
router.post('/login', authRateLimiter, validate(loginSchema), (req, res, next) => { router.post('/login', authRateLimiter, validate(loginSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/LoginRequest' } }
controller.login(req, res, next); controller.login(req, res, next);
}); });
router.post('/refresh', copyRefreshTokenToBody, validate(refreshSchema), (req, res, next) => { router.post('/refresh', copyRefreshTokenToBody, validate(refreshSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/RefreshRequest' } }
controller.refresh(req, res, next); controller.refresh(req, res, next);
}); });
router.post('/logout', copyRefreshTokenToBody, validate(logoutSchema), (req, res, next) => { router.post('/logout', copyRefreshTokenToBody, validate(logoutSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/LogoutRequest' } }
controller.logout(req, res, next); controller.logout(req, res, next);
}); });
router.get('/me', authMiddleware, controller.me); router.get('/me', authMiddleware, controller.me);
router.get('/me/usage', authMiddleware, controller.usage);
router.put("/me", authMiddleware, validate(updateMeSchema), (req, res, next) => { router.put("/me", authMiddleware, validate(updateMeSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/UpdateMeRequest' } }
controller.updateMe(req, res, next); controller.updateMe(req, res, next);
}); });
router.post('/change-password', authMiddleware, validate(changePasswordSchema), (req, res, next) => { router.post('/change-password', authMiddleware, validate(changePasswordSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ChangePasswordRequest' } }
controller.changePassword(req, res, next); controller.changePassword(req, res, next);
}); });
router.post("/register", authRateLimiter, validate(registerSchema), (req, res, next) => { router.post("/register", authRateLimiter, validate(registerSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/RegisterRequest' } }
controller.register(req, res, next); controller.register(req, res, next);
}); });
router.post("/forgot-password", authRateLimiter, validate(forgotPasswordSchema), (req, res, next) => { router.post("/forgot-password", authRateLimiter, validate(forgotPasswordSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ForgotPasswordRequest' } }
controller.forgotPassword(req, res, next); controller.forgotPassword(req, res, next);
}); });
router.post("/reset-password", validate(resetPasswordSchema), (req, res, next) => { router.post("/reset-password", validate(resetPasswordSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ResetPasswordRequest' } }
controller.resetPassword(req, res, next); controller.resetPassword(req, res, next);
}); });
router.post("/resend-verification", authRateLimiter, validate(resendVerificationSchema), (req, res, next) => { router.post("/resend-verification", authRateLimiter, validate(resendVerificationSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/ResendVerificationRequest' } }
controller.resendVerification(req, res, next); controller.resendVerification(req, res, next);
}); });
router.post("/verify-email", validate(verifyEmailSchema), (req, res, next) => { router.post("/verify-email", validate(verifyEmailSchema), (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/VerifyEmailRequest' } }
controller.verifyEmail(req, res, next); controller.verifyEmail(req, res, next);
}); });
......
...@@ -4,8 +4,14 @@ import { AuthRepository } from './auth.repository'; ...@@ -4,8 +4,14 @@ 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';
import { jwtConfig } from '../../config/jwt.config'; 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, UserUsageDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto } from './auth.dto';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { CrawlJobRepository } from '../crawl-jobs/crawl-job.repository';
import { JOB_STATUS } from '../../common/constants/job-status.constant';
import {
getZonedDateParts,
createUtcDateFromZonedParts,
} from '../../common/helpers/schedule-calculator.helper';
interface AuthJwtPayload { interface AuthJwtPayload {
id: string; id: string;
...@@ -93,6 +99,7 @@ export class AuthService { ...@@ -93,6 +99,7 @@ export class AuthService {
id: user.id, id: user.id,
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role, role: user.role,
}, },
}; };
...@@ -109,6 +116,7 @@ export class AuthService { ...@@ -109,6 +116,7 @@ export class AuthService {
id: user.id, id: user.id,
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
createdAt: user.createdAt, createdAt: user.createdAt,
...@@ -183,6 +191,7 @@ export class AuthService { ...@@ -183,6 +191,7 @@ export class AuthService {
id: existing.id, id: existing.id,
email: existing.email, email: existing.email,
fullName: existing.fullName, fullName: existing.fullName,
avatarUrl: existing.avatarUrl ?? null,
role: existing.role, role: existing.role,
isActive: existing.isActive, isActive: existing.isActive,
createdAt: existing.createdAt, createdAt: existing.createdAt,
...@@ -210,6 +219,7 @@ export class AuthService { ...@@ -210,6 +219,7 @@ export class AuthService {
id: user.id, id: user.id,
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
avatarUrl: user.avatarUrl ?? null,
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
createdAt: user.createdAt, createdAt: user.createdAt,
...@@ -237,10 +247,14 @@ export class AuthService { ...@@ -237,10 +247,14 @@ export class AuthService {
} }
const normalizedFullName = data.fullName?.trim(); const normalizedFullName = data.fullName?.trim();
if ( const avatarUrl = data.avatarUrl;
normalizedFullName !== undefined &&
normalizedFullName === (user.fullName ?? '') const hasNameChange =
) { normalizedFullName !== undefined && normalizedFullName !== (user.fullName ?? '');
const hasAvatarChange =
avatarUrl !== undefined && avatarUrl !== (user.avatarUrl ?? null);
if (!hasNameChange && !hasAvatarChange) {
throw new AppError( throw new AppError(
'Không có thay đổi nào để cập nhật.', 'Không có thay đổi nào để cập nhật.',
400, 400,
...@@ -248,11 +262,14 @@ export class AuthService { ...@@ -248,11 +262,14 @@ export class AuthService {
); );
} }
const updateData: { fullName?: string } = {}; const updateData: { fullName?: string; avatarUrl?: string | null } = {};
if (normalizedFullName !== undefined) { if (hasNameChange) {
updateData.fullName = normalizedFullName; updateData.fullName = normalizedFullName;
} }
if (hasAvatarChange) {
updateData.avatarUrl = avatarUrl;
}
const updatedUser = await this.repository.updateUser(userId, updateData); const updatedUser = await this.repository.updateUser(userId, updateData);
...@@ -260,12 +277,70 @@ export class AuthService { ...@@ -260,12 +277,70 @@ export class AuthService {
id: updatedUser.id, id: updatedUser.id,
email: updatedUser.email, email: updatedUser.email,
fullName: updatedUser.fullName, fullName: updatedUser.fullName,
avatarUrl: updatedUser.avatarUrl,
role: updatedUser.role, role: updatedUser.role,
isActive: updatedUser.isActive, isActive: updatedUser.isActive,
createdAt: updatedUser.createdAt, createdAt: updatedUser.createdAt,
}; };
} }
async getUsage(userId: string): Promise<UserUsageDto> {
const user = await this.repository.findById(userId);
if (!user || !user.isActive) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
const crawlJobRepo = new CrawlJobRepository();
const nowZoned = getZonedDateParts(new Date(), 'Asia/Ho_Chi_Minh');
const startOfDay = createUtcDateFromZonedParts(
nowZoned.year,
nowZoned.month,
nowZoned.day,
0,
0,
'Asia/Ho_Chi_Minh',
);
const nextDay = createUtcDateFromZonedParts(
nowZoned.year,
nowZoned.month,
nowZoned.day + 1,
0,
0,
'Asia/Ho_Chi_Minh',
);
const twoHoursAgo = new Date();
twoHoursAgo.setHours(twoHoursAgo.getHours() - 2);
const activeStatuses = [
JOB_STATUS.PENDING,
JOB_STATUS.QUEUED,
JOB_STATUS.RUNNING,
JOB_STATUS.PROCESSING_EXPORT,
];
const [jobsTodayCount, concurrentJobsCount, totalPages] = await Promise.all([
crawlJobRepo.countJobsSince(userId, startOfDay),
crawlJobRepo.countConcurrentJobs(userId, activeStatuses, twoHoursAgo),
crawlJobRepo.sumPagesCrawledByUser(userId),
]);
return {
quota: {
maxPagesLimit: user.maxPagesLimit,
maxJobsPerDayLimit: user.maxJobsPerDayLimit,
maxConcurrentJobsLimit: user.maxConcurrentJobsLimit,
},
usage: {
jobsUsedToday: jobsTodayCount,
jobsRemainingToday: Math.max(0, user.maxJobsPerDayLimit - jobsTodayCount),
concurrentJobsRunning: concurrentJobsCount,
concurrentJobsAvailable: Math.max(0, user.maxConcurrentJobsLimit - concurrentJobsCount),
totalPagesCrawled: totalPages,
},
resetAt: nextDay.toISOString(),
};
}
async changePassword( async changePassword(
userId: string, userId: string,
data: ChangePasswordDto, data: ChangePasswordDto,
......
...@@ -29,7 +29,8 @@ export const registerSchema = z.object({ ...@@ -29,7 +29,8 @@ export const registerSchema = z.object({
}); });
export const updateMeSchema = z.object({ export const updateMeSchema = z.object({
fullName: z.string().optional(), fullName: z.string().trim().min(1, 'Họ và tên không được để trống.').optional(),
avatarUrl: z.string().url('Avatar URL không đúng định dạng.').or(z.literal('')).nullable().optional(),
}); });
export const changePasswordSchema = z export const changePasswordSchema = z
......
import { CrawlExportService } from '../crawl-export.service';
import { CrawlExportRepository } from '../crawl-export.repository';
import { CrawlJobRepository } from '../../crawl-jobs/crawl-job.repository';
import { StorageFactory } from '../../../common/storage/storage.factory';
jest.mock('../crawl-export.repository');
jest.mock('../../crawl-jobs/crawl-job.repository');
jest.mock('../../../common/storage/storage.factory');
describe('CrawlExportService findAllByUser and delete', () => {
const mockStorage = {
deleteFile: jest.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
jest.clearAllMocks();
(StorageFactory.getStorageService as jest.Mock).mockReturnValue(mockStorage);
});
const mockExport = {
id: 'exp-123',
jobId: 'job-123',
filePath: 'exports/job-123.zip',
fileName: 'export.zip',
};
it('lists exports for authenticated user', async () => {
const service = new CrawlExportService();
(CrawlExportRepository.prototype.findAllByUser as jest.Mock).mockResolvedValue({
items: [mockExport],
total: 1,
page: 1,
limit: 20,
});
const result = await service.findAllByUser('user-1', 1, 20);
expect(result.total).toBe(1);
expect(result.items[0].id).toBe('exp-123');
});
it('deletes export file from storage and database', async () => {
const service = new CrawlExportService();
jest.spyOn(service, 'findById').mockResolvedValue(mockExport as any);
(CrawlExportRepository.prototype.delete as jest.Mock).mockResolvedValue(mockExport);
const result = await service.delete('user-1', 'CRAWLER_USER', 'exp-123');
expect(result.success).toBe(true);
expect(mockStorage.deleteFile).toHaveBeenCalledWith('exports/job-123.zip');
expect(CrawlExportRepository.prototype.delete).toHaveBeenCalledWith('exp-123');
});
});
...@@ -33,4 +33,36 @@ export class CrawlExportController { ...@@ -33,4 +33,36 @@ export class CrawlExportController {
next(error); next(error);
} }
}; };
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 20;
const result = await this.service.findAllByUser(req.user.id, page, limit);
res.json({
success: true,
data: result.items,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
},
});
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.delete(
req.user.id,
req.user.role,
req.params.exportId,
);
res.json(result);
} catch (error) {
next(error);
}
};
} }
...@@ -25,5 +25,45 @@ export class CrawlExportRepository { ...@@ -25,5 +25,45 @@ export class CrawlExportRepository {
data, data,
}); });
} }
async findAllByUser(userId: string, page = 1, limit = 20) {
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), 100);
const skip = (safePage - 1) * safeLimit;
const where = {
job: {
userId,
},
};
const [items, total] = await Promise.all([
prisma.crawlExport.findMany({
where,
include: {
job: {
select: {
id: true,
startUrl: true,
domain: true,
mode: true,
status: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip,
take: safeLimit,
}),
prisma.crawlExport.count({ where }),
]);
return { items, total, page: safePage, limit: safeLimit };
}
delete(id: string) {
return prisma.crawlExport.delete({
where: { id },
});
}
} }
...@@ -7,7 +7,9 @@ import { ROLES } from '../../common/constants/role.constant'; ...@@ -7,7 +7,9 @@ import { ROLES } from '../../common/constants/role.constant';
const router = Router(); const router = Router();
const controller = new CrawlExportController(); const controller = new CrawlExportController();
router.get('/', authMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.findAll);
router.get('/:exportId/download', authMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.download); router.get('/:exportId/download', authMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.download);
router.delete('/:exportId', authMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), controller.delete);
export default router; export default router;
...@@ -52,4 +52,21 @@ export class CrawlExportService { ...@@ -52,4 +52,21 @@ export class CrawlExportService {
return exportService.generate(job, exportType); return exportService.generate(job, exportType);
} }
async findAllByUser(userId: string, page = 1, limit = 20) {
return this.repository.findAllByUser(userId, page, limit);
}
async delete(userId: string, role: string, id: string) {
const exportRecord = await this.findById(userId, role, id);
if (exportRecord.filePath) {
const { StorageFactory } = await import('../../common/storage/storage.factory');
const storage = StorageFactory.getStorageService();
await storage.deleteFile(exportRecord.filePath).catch(() => {});
}
await this.repository.delete(id);
return { success: true, message: 'Export deleted successfully' };
}
} }
import { CrawlJobService } from '../crawl-job.service';
import { CrawlJobRepository } from '../crawl-job.repository';
import { CrawlExportRepository } from '../../crawl-exports/crawl-export.repository';
import { StorageFactory } from '../../../common/storage/storage.factory';
jest.mock('../crawl-job.repository');
jest.mock('../../crawl-exports/crawl-export.repository');
jest.mock('../../../common/storage/storage.factory');
jest.mock('../../../queues/crawl.queue', () => ({
crawlQueue: {
add: jest.fn().mockResolvedValue({ id: 'bull-job-1' }),
},
}));
describe('CrawlJobService delete, rerun, and getLogs', () => {
const mockStorage = {
exists: jest.fn().mockResolvedValue(true),
deleteFile: jest.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
jest.clearAllMocks();
(StorageFactory.getStorageService as jest.Mock).mockReturnValue(mockStorage);
});
const mockJob = {
id: 'job-123',
userId: 'user-1',
startUrl: 'https://example.com',
mode: 'SCRAPE',
status: 'COMPLETED',
maxPages: 20,
maxDepth: 1,
urls: [],
diffReportPath: 'diffs/job-123.json',
};
it('deletes completed job and cleans up storage files', async () => {
const service = new CrawlJobService();
(CrawlJobRepository.prototype.findById as jest.Mock).mockResolvedValue(mockJob);
(CrawlExportRepository.prototype.findByJobId as jest.Mock).mockResolvedValue([
{ id: 'exp-1', filePath: 'exports/exp-1.zip' },
]);
(CrawlJobRepository.prototype.delete as jest.Mock).mockResolvedValue(mockJob);
const result = await service.delete('user-1', 'CRAWLER_USER', 'job-123');
expect(result.success).toBe(true);
expect(mockStorage.deleteFile).toHaveBeenCalledWith('exports/exp-1.zip');
expect(mockStorage.deleteFile).toHaveBeenCalledWith('diffs/job-123.json');
expect(CrawlJobRepository.prototype.delete).toHaveBeenCalledWith('job-123');
});
it('blocks deletion of an actively running job', async () => {
const service = new CrawlJobService();
(CrawlJobRepository.prototype.findById as jest.Mock).mockResolvedValue({
...mockJob,
status: 'RUNNING',
});
await expect(
service.delete('user-1', 'CRAWLER_USER', 'job-123'),
).rejects.toThrow('Cannot delete a job that is currently running');
});
it('reruns an existing job with identical configuration', async () => {
const service = new CrawlJobService();
(CrawlJobRepository.prototype.findById as jest.Mock).mockResolvedValue(mockJob);
const createSpy = jest.spyOn(service, 'create').mockResolvedValue({
...mockJob,
id: 'job-new',
} as any);
const result = await service.rerun('user-1', 'CRAWLER_USER', 'job-123');
expect(result.id).toBe('job-new');
expect(createSpy).toHaveBeenCalledWith('user-1', {
startUrl: mockJob.startUrl,
mode: mockJob.mode,
maxPages: mockJob.maxPages,
maxDepth: mockJob.maxDepth,
urls: mockJob.urls,
});
});
it('fetches logs for job', async () => {
const service = new CrawlJobService();
(CrawlJobRepository.prototype.findById as jest.Mock).mockResolvedValue(mockJob);
(CrawlJobRepository.prototype.findLogsByJobId as jest.Mock).mockResolvedValue({
items: [{ id: 'log-1', step: 'INIT', message: 'Job started' }],
total: 1,
page: 1,
limit: 50,
});
const logs = await service.getLogs('user-1', 'CRAWLER_USER', 'job-123', 1, 50);
expect(logs.total).toBe(1);
expect(logs.items[0].step).toBe('INIT');
});
});
...@@ -341,4 +341,58 @@ export class CrawlJobController { ...@@ -341,4 +341,58 @@ export class CrawlJobController {
next(error); next(error);
} }
}; };
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.delete(
req.user.id,
req.user.role,
req.params.id,
);
res.json(result);
} catch (error) {
next(error);
}
};
rerun = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.rerun(
req.user.id,
req.user.role,
req.params.id,
);
res.status(201).json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
getLogs = async (req: Request, res: Response, next: NextFunction) => {
try {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 50;
const result = await this.service.getLogs(
req.user.id,
req.user.role,
req.params.id,
page,
limit,
);
res.json({
success: true,
data: result.items,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
},
});
} catch (error) {
next(error);
}
};
} }
import { prisma } from '../../database/prisma.client'; import { prisma } from '../../database/prisma.client';
import { CrawlJobStatus, CrawlMode, Prisma } from '@prisma/client'; import { CrawlJobStatus, CrawlMode, LogLevel, 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';
...@@ -301,4 +301,49 @@ export class CrawlJobRepository { ...@@ -301,4 +301,49 @@ export class CrawlJobRepository {
}, },
}); });
} }
async sumPagesCrawledByUser(userId: string): Promise<number> {
const aggregate = await prisma.crawlJob.aggregate({
where: { userId },
_sum: { totalPages: true },
});
return aggregate._sum.totalPages ?? 0;
}
async delete(id: string) {
return prisma.$transaction(async (tx) => {
await tx.crawlAsset.deleteMany({ where: { crawlJobId: id } });
await tx.crawlJobLog.deleteMany({ where: { jobId: id } });
await tx.crawlExport.deleteMany({ where: { jobId: id } });
await tx.crawlPage.deleteMany({ where: { jobId: id } });
return tx.crawlJob.delete({ where: { id } });
});
}
async createJobLog(data: {
jobId: string;
level: LogLevel;
step: string;
message: string;
}) {
return prisma.crawlJobLog.create({
data,
});
}
async findLogsByJobId(jobId: string, page = 1, limit = 50) {
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), 200);
const skip = (safePage - 1) * safeLimit;
const [items, total] = await Promise.all([
prisma.crawlJobLog.findMany({
where: { jobId },
orderBy: { createdAt: 'asc' },
skip,
take: safeLimit,
}),
prisma.crawlJobLog.count({ where: { jobId } }),
]);
return { items, total, page: safePage, limit: safeLimit };
}
} }
\ No newline at end of file
...@@ -15,6 +15,9 @@ router.post('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_ ...@@ -15,6 +15,9 @@ router.post('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_
}); });
router.get('/', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), validateQuery(listCrawlJobsQuerySchema), 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.delete('/:id', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), controller.delete);
router.post('/:id/rerun', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER), controller.rerun);
router.get('/:id/logs', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.getLogs);
router.get('/:id/events', apiKeyOrAuthMiddleware, requireRole(ROLES.ADMIN, ROLES.CRAWLER_USER, ROLES.VIEWER), controller.streamEvents); 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);
......
...@@ -233,4 +233,50 @@ export class CrawlJobService { ...@@ -233,4 +233,50 @@ export class CrawlJobService {
const exportService = new ExportService(); const exportService = new ExportService();
return exportService.generate(job, 'ZIP'); return exportService.generate(job, 'ZIP');
} }
async delete(userId: string, role: string, jobId: string) {
const job = await this.findById(userId, role, jobId);
if (job.status === JOB_STATUS.RUNNING || job.status === JOB_STATUS.PROCESSING_EXPORT) {
throw new AppError(
'Cannot delete a job that is currently running. Cancel it first.',
400,
ERROR_CODE.CRAWL_JOB_NOT_COMPLETED,
);
}
const exportRepository = new CrawlExportRepository();
const exports = await exportRepository.findByJobId(jobId);
const storage = StorageFactory.getStorageService();
for (const exp of exports) {
if (exp.filePath) {
await storage.deleteFile(exp.filePath).catch(() => {});
}
}
if (job.diffReportPath) {
await storage.deleteFile(job.diffReportPath).catch(() => {});
}
await this.repository.delete(jobId);
return { success: true, message: 'Crawl job deleted successfully' };
}
async rerun(userId: string, role: string, jobId: string) {
const existing = await this.findById(userId, role, jobId);
return this.create(userId, {
startUrl: existing.startUrl,
mode: existing.mode,
maxPages: existing.maxPages,
maxDepth: existing.maxDepth,
urls: existing.urls,
});
}
async getLogs(userId: string, role: string, jobId: string, page = 1, limit = 50) {
await this.findById(userId, role, jobId);
return this.repository.findLogsByJobId(jobId, page, limit);
}
} }
import { DashboardService } from '../dashboard.service';
import { DashboardRepository } from '../dashboard.repository';
import { AuthService } from '../../auth/auth.service';
jest.mock('../dashboard.repository');
jest.mock('../../auth/auth.service');
describe('DashboardService getStats', () => {
it('combines dashboard counts and user quota usage', async () => {
const service = new DashboardService();
(DashboardRepository.prototype.getStats as jest.Mock).mockResolvedValue({
jobs: { total: 10, completed: 8, failed: 1, running: 1, pending: 0 },
pages: { total: 120, successful: 115, failed: 5 },
schedules: { total: 2, active: 1 },
exports: { total: 5 },
});
(AuthService.prototype.getUsage as jest.Mock).mockResolvedValue({
quota: { maxPagesLimit: 100, maxJobsPerDayLimit: 10, maxConcurrentJobsLimit: 3 },
usage: { jobsUsedToday: 2, jobsRemainingToday: 8, concurrentJobsRunning: 1, concurrentJobsAvailable: 2, totalPagesCrawled: 120 },
resetAt: '2026-09-04T00:00:00.000Z',
});
const stats = await service.getStats('user-1', 'CRAWLER_USER');
expect(stats.jobs.total).toBe(10);
expect(stats.pages.successful).toBe(115);
expect(stats.quotaAndUsage.usage.jobsUsedToday).toBe(2);
});
});
import { Request, Response, NextFunction } from 'express';
import { DashboardService } from './dashboard.service';
export class DashboardController {
private readonly service = new DashboardService();
getStats = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.getStats(req.user.id, req.user.role);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
}
import { prisma } from '../../database/prisma.client';
import { ROLES } from '../../common/constants/role.constant';
export class DashboardRepository {
async getStats(userId: string, role: string) {
const isGlobal = role === ROLES.ADMIN;
const jobWhere = isGlobal ? {} : { userId };
const pageWhere = isGlobal ? {} : { job: { userId } };
const scheduleWhere = isGlobal ? {} : { userId };
const exportWhere = isGlobal ? {} : { job: { userId } };
const [
totalJobs,
completedJobs,
failedJobs,
runningJobs,
pendingJobs,
totalPagesCrawled,
successfulPages,
failedPages,
activeSchedules,
totalSchedules,
totalExports,
] = await Promise.all([
prisma.crawlJob.count({ where: jobWhere }),
prisma.crawlJob.count({ where: { ...jobWhere, status: 'COMPLETED' } }),
prisma.crawlJob.count({ where: { ...jobWhere, status: 'FAILED' } }),
prisma.crawlJob.count({ where: { ...jobWhere, status: 'RUNNING' } }),
prisma.crawlJob.count({ where: { ...jobWhere, status: { in: ['PENDING', 'QUEUED'] } } }),
prisma.crawlPage.count({ where: pageWhere }),
prisma.crawlPage.count({ where: { ...pageWhere, status: 'SUCCESS' } }),
prisma.crawlPage.count({ where: { ...pageWhere, status: 'FAILED' } }),
prisma.crawlSchedule.count({ where: { ...scheduleWhere, isActive: true } }),
prisma.crawlSchedule.count({ where: scheduleWhere }),
prisma.crawlExport.count({ where: exportWhere }),
]);
return {
jobs: {
total: totalJobs,
completed: completedJobs,
failed: failedJobs,
running: runningJobs,
pending: pendingJobs,
},
pages: {
total: totalPagesCrawled,
successful: successfulPages,
failed: failedPages,
},
schedules: {
total: totalSchedules,
active: activeSchedules,
},
exports: {
total: totalExports,
},
};
}
}
import { Router } from 'express';
import { DashboardController } from './dashboard.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
const router = Router();
const controller = new DashboardController();
router.get('/stats', authMiddleware, controller.getStats);
export default router;
import { DashboardRepository } from './dashboard.repository';
import { AuthService } from '../auth/auth.service';
export class DashboardService {
private readonly repository = new DashboardRepository();
private readonly authService = new AuthService();
async getStats(userId: string, role: string) {
const [counts, usageData] = await Promise.all([
this.repository.getStats(userId, role),
this.authService.getUsage(userId),
]);
return {
...counts,
quotaAndUsage: usageData,
};
}
}
...@@ -4,6 +4,7 @@ export interface CreateUserDto { ...@@ -4,6 +4,7 @@ export interface CreateUserDto {
email: string; email: string;
password: string; password: string;
fullName?: string; fullName?: string;
avatarUrl?: string;
role?: string; role?: string;
maxPagesLimit?: number; maxPagesLimit?: number;
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
...@@ -12,6 +13,7 @@ export interface CreateUserDto { ...@@ -12,6 +13,7 @@ export interface CreateUserDto {
export interface UpdateUserDto { export interface UpdateUserDto {
fullName?: string; fullName?: string;
avatarUrl?: string | null;
isActive?: boolean; isActive?: boolean;
role?: string; role?: string;
maxPagesLimit?: number; maxPagesLimit?: number;
...@@ -23,6 +25,7 @@ export interface UserResponseDto { ...@@ -23,6 +25,7 @@ export interface UserResponseDto {
id: string; id: string;
email: string; email: string;
fullName: string | null; fullName: string | null;
avatarUrl: string | null;
role: string; role: string;
isActive: boolean; isActive: boolean;
maxPagesLimit: number; maxPagesLimit: number;
......
...@@ -68,6 +68,7 @@ export class UserRepository { ...@@ -68,6 +68,7 @@ export class UserRepository {
email: string; email: string;
passwordHash: string; passwordHash: string;
fullName?: string; fullName?: string;
avatarUrl?: string;
role?: UserRole; role?: UserRole;
maxPagesLimit?: number; maxPagesLimit?: number;
maxJobsPerDayLimit?: number; maxJobsPerDayLimit?: number;
...@@ -78,6 +79,7 @@ export class UserRepository { ...@@ -78,6 +79,7 @@ export class UserRepository {
email: data.email, email: data.email,
passwordHash: data.passwordHash, passwordHash: data.passwordHash,
fullName: data.fullName, fullName: data.fullName,
avatarUrl: data.avatarUrl,
role: data.role ?? 'CRAWLER_USER', role: data.role ?? 'CRAWLER_USER',
maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages, maxPagesLimit: data.maxPagesLimit ?? envConfig.quota.defaultMaxPages,
maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay, maxJobsPerDayLimit: data.maxJobsPerDayLimit ?? envConfig.quota.defaultMaxJobsPerDay,
...@@ -90,6 +92,7 @@ export class UserRepository { ...@@ -90,6 +92,7 @@ export class UserRepository {
id: string, id: string,
data: { data: {
fullName?: string; fullName?: string;
avatarUrl?: string | null;
isActive?: boolean; isActive?: boolean;
role?: UserRole; role?: UserRole;
maxPagesLimit?: number; maxPagesLimit?: number;
......
...@@ -13,6 +13,7 @@ export class UserService { ...@@ -13,6 +13,7 @@ export class UserService {
id: user.id, id: user.id,
email: user.email, email: user.email,
fullName: user.fullName, fullName: user.fullName,
avatarUrl: user.avatarUrl ?? null,
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
maxPagesLimit: user.maxPagesLimit, maxPagesLimit: user.maxPagesLimit,
......
...@@ -4,6 +4,7 @@ export const createUserSchema = z.object({ ...@@ -4,6 +4,7 @@ export const createUserSchema = z.object({
email: z.string().email('Email không đúng định dạng.'), email: z.string().email('Email không đúng định dạng.'),
password: z.string().min(8, 'Mật khẩu phải có ít nhất 8 ký tự.'), password: z.string().min(8, 'Mật khẩu phải có ít nhất 8 ký tự.'),
fullName: z.string().optional(), fullName: z.string().optional(),
avatarUrl: z.string().url('Avatar URL không đúng định dạng.').or(z.literal('')).optional(),
role: z.enum(['ADMIN', 'CRAWLER_USER', 'VIEWER']).optional(), role: z.enum(['ADMIN', 'CRAWLER_USER', 'VIEWER']).optional(),
maxPagesLimit: z.number().int().min(1).optional(), maxPagesLimit: z.number().int().min(1).optional(),
maxJobsPerDayLimit: z.number().int().min(1).optional(), maxJobsPerDayLimit: z.number().int().min(1).optional(),
...@@ -12,6 +13,7 @@ export const createUserSchema = z.object({ ...@@ -12,6 +13,7 @@ export const createUserSchema = z.object({
export const updateUserSchema = z.object({ export const updateUserSchema = z.object({
fullName: z.string().optional(), fullName: z.string().optional(),
avatarUrl: z.string().url('Avatar URL không đúng định dạng.').or(z.literal('')).nullable().optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
role: z.enum(['ADMIN', 'CRAWLER_USER', 'VIEWER']).optional(), role: z.enum(['ADMIN', 'CRAWLER_USER', 'VIEWER']).optional(),
maxPagesLimit: z.number().int().min(1).optional(), maxPagesLimit: z.number().int().min(1).optional(),
......
import { WebhookConfigService } from '../webhook-config.service';
import { WebhookRepository } from '../webhook.repository';
import { WebhookDeliveryService } from '../webhook-delivery.service';
jest.mock('../webhook.repository');
jest.mock('../webhook-delivery.service');
jest.mock('../../../queues/webhook.queue', () => ({
webhookQueue: {
add: jest.fn().mockResolvedValue({ id: 'wh-job-1' }),
},
}));
jest.mock('../webhook-crypto.helper', () => ({
encrypt: jest.fn((val) => `enc_${val}`),
decrypt: jest.fn((val) => `dec_${val}`),
signPayload: jest.fn(() => 'mock_signature'),
}));
describe('WebhookConfigService update and test', () => {
const mockConfig = {
id: 'config-1',
userId: 'user-1',
url: 'https://webhook.site/test',
encryptedSecret: 'enc_secret1234567890',
events: ['job.completed'],
isActive: true,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('updates webhook configuration fields', async () => {
const service = new WebhookConfigService();
(WebhookRepository.prototype.findConfigById as jest.Mock).mockResolvedValue(mockConfig);
(WebhookRepository.prototype.updateConfig as jest.Mock).mockResolvedValue({
...mockConfig,
url: 'https://webhook.site/updated',
events: ['job.completed', 'job.failed'],
});
const result = await service.update('config-1', 'user-1', {
url: 'https://webhook.site/updated',
events: ['job.completed', 'job.failed'],
});
expect(result.url).toBe('https://webhook.site/updated');
expect(result.events).toEqual(['job.completed', 'job.failed']);
expect(WebhookRepository.prototype.updateConfig).toHaveBeenCalledWith('config-1', {
url: 'https://webhook.site/updated',
events: ['job.completed', 'job.failed'],
});
});
it('creates delivery and sends ping test payload', async () => {
const service = new WebhookConfigService();
(WebhookRepository.prototype.findConfigById as jest.Mock).mockResolvedValue(mockConfig);
(WebhookRepository.prototype.createDelivery as jest.Mock).mockResolvedValue({
id: 'delivery-test-1',
webhookConfigId: 'config-1',
crawlJobId: '00000000-0000-0000-0000-000000000000',
event: 'test.ping',
status: 'PENDING',
});
(WebhookDeliveryService.prototype.send as jest.Mock).mockResolvedValue(undefined);
(WebhookRepository.prototype.findDeliveryById as jest.Mock).mockResolvedValue({
id: 'delivery-test-1',
event: 'test.ping',
status: 'SUCCESS',
statusCode: 200,
});
const result = await service.test('config-1', 'user-1');
expect(result?.status).toBe('SUCCESS');
expect(result?.statusCode).toBe(200);
expect(WebhookRepository.prototype.createDelivery).toHaveBeenCalledWith(
expect.objectContaining({
webhookConfigId: 'config-1',
event: 'test.ping',
}),
);
});
});
...@@ -42,4 +42,72 @@ export class WebhookConfigService { ...@@ -42,4 +42,72 @@ export class WebhookConfigService {
const { encryptedSecret: _, ...rest } = deleted; const { encryptedSecret: _, ...rest } = deleted;
return rest; return rest;
} }
async update(
configId: string,
userId: string,
data: {
url?: string;
secret?: string;
events?: string[];
isActive?: boolean;
},
): Promise<Omit<WebhookConfig, 'encryptedSecret'>> {
const config = await this.repository.findConfigById(configId);
if (!config || config.userId !== userId) {
throw new AppError('Webhook configuration not found', 404, ERROR_CODE.WEBHOOK_CONFIG_NOT_FOUND);
}
const updatePayload: {
url?: string;
encryptedSecret?: string;
events?: string[];
isActive?: boolean;
} = {};
if (data.url !== undefined) updatePayload.url = data.url;
if (data.secret !== undefined) updatePayload.encryptedSecret = encrypt(data.secret);
if (data.events !== undefined) updatePayload.events = data.events;
if (data.isActive !== undefined) updatePayload.isActive = data.isActive;
const updated = await this.repository.updateConfig(configId, updatePayload);
const { encryptedSecret: _, ...rest } = updated;
return rest;
}
async test(configId: string, userId: string) {
const config = await this.repository.findConfigById(configId);
if (!config || config.userId !== userId) {
throw new AppError('Webhook configuration not found', 404, ERROR_CODE.WEBHOOK_CONFIG_NOT_FOUND);
}
const timestamp = new Date().toISOString();
const payload = {
event: 'test.ping',
timestamp,
message: 'This is a test webhook delivery from DataCrawler.',
};
const delivery = await this.repository.createDelivery({
webhookConfigId: config.id,
crawlJobId: '00000000-0000-0000-0000-000000000000',
event: 'test.ping',
payload,
status: 'PENDING',
attempt: 1,
});
const { WebhookDeliveryService } = await import('./webhook-delivery.service');
const deliveryService = new WebhookDeliveryService();
try {
await deliveryService.send(delivery.id, 1);
} catch {
// delivery status & error recorded in DB by send()
}
return this.repository.findDeliveryById(delivery.id);
}
} }
...@@ -71,6 +71,36 @@ export class WebhookController { ...@@ -71,6 +71,36 @@ export class WebhookController {
} }
}; };
updateConfig = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const userId = req.user.id;
const configId = req.params.id;
const result = await this.configService.update(configId, userId, req.body);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
testConfig = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const userId = req.user.id;
const configId = req.params.id;
const result = await this.configService.test(configId, userId);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
listDeliveries = async (req: Request, res: Response, next: NextFunction): Promise<void> => { listDeliveries = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try { try {
const userId = req.user.id; const userId = req.user.id;
......
...@@ -37,6 +37,21 @@ export class WebhookRepository { ...@@ -37,6 +37,21 @@ export class WebhookRepository {
}); });
} }
updateConfig(
id: string,
data: {
url?: string;
encryptedSecret?: string;
events?: string[];
isActive?: boolean;
},
): Promise<WebhookConfig> {
return prisma.webhookConfig.update({
where: { id },
data,
});
}
findActiveConfigsByEvent(userId: string, event: string): Promise<WebhookConfig[]> { findActiveConfigsByEvent(userId: string, event: string): Promise<WebhookConfig[]> {
return prisma.webhookConfig.findMany({ return prisma.webhookConfig.findMany({
where: { where: {
......
...@@ -2,14 +2,16 @@ import { Router } from 'express'; ...@@ -2,14 +2,16 @@ 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, validateQuery } from '../../middlewares/validate.middleware'; import { validate, validateQuery } from '../../middlewares/validate.middleware';
import { createWebhookConfigSchema, listWebhookDeliveriesQuerySchema } from './webhook.validation'; import { createWebhookConfigSchema, updateWebhookConfigSchema, listWebhookDeliveriesQuerySchema } from './webhook.validation';
const router = Router(); const router = Router();
const controller = new WebhookController(); 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.patch('/configs/:id', authMiddleware, validate(updateWebhookConfigSchema), controller.updateConfig);
router.delete('/configs/:id', authMiddleware, controller.deleteConfig); router.delete('/configs/:id', authMiddleware, controller.deleteConfig);
router.post('/configs/:id/test', authMiddleware, controller.testConfig);
router.get('/deliveries', authMiddleware, validateQuery(listWebhookDeliveriesQuerySchema), controller.listDeliveries); router.get('/deliveries', authMiddleware, validateQuery(listWebhookDeliveriesQuerySchema), controller.listDeliveries);
router.post('/deliveries/:id/redeliver', authMiddleware, controller.redeliver); router.post('/deliveries/:id/redeliver', authMiddleware, controller.redeliver);
......
...@@ -13,6 +13,20 @@ export const createWebhookConfigSchema = z.object({ ...@@ -13,6 +13,20 @@ export const createWebhookConfigSchema = z.object({
).min(1, 'At least one event must be selected for notifications'), ).min(1, 'At least one event must be selected for notifications'),
}); });
export const updateWebhookConfigSchema = z.object({
url: z.string().url('Invalid Webhook URL format').optional(),
secret: z
.string()
.min(16, 'Signing secret must be at least 16 characters long for security')
.max(128, 'Signing secret is too long')
.optional(),
events: z
.array(z.enum(['job.completed', 'job.failed']))
.min(1, 'At least one event must be selected for notifications')
.optional(),
isActive: z.boolean().optional(),
});
export const listWebhookDeliveriesQuerySchema = z.object({ export const listWebhookDeliveriesQuerySchema = z.object({
jobId: z.string().uuid().optional(), jobId: z.string().uuid().optional(),
status: z.enum(['PENDING', 'SUCCESS', 'FAILED']).optional(), status: z.enum(['PENDING', 'SUCCESS', 'FAILED']).optional(),
......
...@@ -176,21 +176,35 @@ export async function persistBatchResults( ...@@ -176,21 +176,35 @@ export async function persistBatchResults(
return { successCount, failedCount, saveErrors, totalPages: seenUrls.size }; return { successCount, failedCount, saveErrors, totalPages: seenUrls.size };
} }
export async function processCrawlJob(job: Job<{ jobId: string }>) { async function logStep(
const { jobId } = job.data; jobId: string,
level: 'INFO' | 'WARNING' | 'ERROR',
step: string,
message: string,
): Promise<void> {
try {
await getJobRepository().createJobLog({ jobId, level, step, message });
} catch {
// Non-fatal if database logging fails
}
}
export async function processCrawlJob(job: Job): Promise<void> {
const { jobId } = job.data;
const crawlJob = await getJobRepository().findById(jobId); const crawlJob = await getJobRepository().findById(jobId);
if (!crawlJob) { if (!crawlJob) {
throw new Error(`Job ${jobId} not found`); throw new Error(`CrawlJob ${jobId} not found`);
} }
try {
if (crawlJob.status === 'CANCELED') { if (crawlJob.status === 'CANCELED') {
console.log(`[Worker] Job ${jobId} was canceled before processing, skipping`);
return; return;
} }
await getJobRepository().updateStatus(jobId, 'RUNNING', { startedAt: new Date() });
void logStep(jobId, 'INFO', 'INITIALIZE', `Job started, mode=${crawlJob.mode}`);
try {
if (crawlJob.mode !== 'URL_LIST') { if (crawlJob.mode !== 'URL_LIST') {
try { try {
await validateUrlAsync(crawlJob.startUrl); await validateUrlAsync(crawlJob.startUrl);
...@@ -462,6 +476,12 @@ export async function processCrawlJob(job: Job<{ jobId: string }>) { ...@@ -462,6 +476,12 @@ 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';
void logStep(
jobId,
updatedJob.status === 'COMPLETED' ? 'INFO' : 'ERROR',
updatedJob.status,
`Job finished with status ${updatedJob.status}${updatedJob.errorMessage ? `: ${updatedJob.errorMessage}` : ''}`,
);
const diffSummary = (updatedJob && typeof updatedJob === 'object' && 'diffSummary' in updatedJob) const diffSummary = (updatedJob && typeof updatedJob === 'object' && 'diffSummary' in updatedJob)
? (updatedJob as { diffSummary: unknown }).diffSummary ?? null ? (updatedJob as { diffSummary: unknown }).diffSummary ?? null
: null; : null;
......
...@@ -9,12 +9,14 @@ import webhookRoute from '../modules/webhooks/webhook.route'; ...@@ -9,12 +9,14 @@ import webhookRoute from '../modules/webhooks/webhook.route';
import extractionTemplateRoute from '../modules/extraction-templates/extraction-template.route'; import extractionTemplateRoute from '../modules/extraction-templates/extraction-template.route';
import crawlScheduleRoute from '../modules/crawl-schedules/crawl-schedule.route'; import crawlScheduleRoute from '../modules/crawl-schedules/crawl-schedule.route';
import healthRoute from '../modules/health/health.route'; import healthRoute from '../modules/health/health.route';
import dashboardRoute from '../modules/dashboard/dashboard.route';
const router = Router(); const router = Router();
router.use('/health', healthRoute); router.use('/health', healthRoute);
router.use('/auth', authRoute); router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/dashboard', dashboardRoute);
router.use('/crawl-jobs', crawlJobRoute); router.use('/crawl-jobs', crawlJobRoute);
router.use('/crawl-schedules', crawlScheduleRoute); router.use('/crawl-schedules', crawlScheduleRoute);
router.use('/exports', crawlExportRoute); router.use('/exports', crawlExportRoute);
......
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