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