Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
H
hrsystem
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
Administrator
hrsystem
Commits
0b0931ce
Commit
0b0931ce
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 18 files via Son of Anton
parent
f3429de4
Changes
18
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
1678 additions
and
0 deletions
+1678
-0
app.module.ts
backend/src/app.module.ts
+9
-0
api-keys.controller.ts
backend/src/modules/api-keys/api-keys.controller.ts
+65
-0
api-keys.module.ts
backend/src/modules/api-keys/api-keys.module.ts
+10
-0
api-keys.service.ts
backend/src/modules/api-keys/api-keys.service.ts
+247
-0
create-api-key.dto.ts
backend/src/modules/api-keys/dto/create-api-key.dto.ts
+28
-0
update-api-key.dto.ts
backend/src/modules/api-keys/dto/update-api-key.dto.ts
+29
-0
search-query.dto.ts
backend/src/modules/search/dto/search-query.dto.ts
+20
-0
search-response.dto.ts
backend/src/modules/search/dto/search-response.dto.ts
+16
-0
search.controller.ts
backend/src/modules/search/search.controller.ts
+22
-0
search.module.ts
backend/src/modules/search/search.module.ts
+10
-0
search.service.ts
backend/src/modules/search/search.service.ts
+453
-0
create-webhook.dto.ts
backend/src/modules/webhooks/dto/create-webhook.dto.ts
+24
-0
update-webhook.dto.ts
backend/src/modules/webhooks/dto/update-webhook.dto.ts
+26
-0
webhook-filter.dto.ts
backend/src/modules/webhooks/dto/webhook-filter.dto.ts
+10
-0
webhooks.controller.ts
backend/src/modules/webhooks/webhooks.controller.ts
+101
-0
webhooks.module.ts
backend/src/modules/webhooks/webhooks.module.ts
+10
-0
webhooks.service.ts
backend/src/modules/webhooks/webhooks.service.ts
+553
-0
schema-api-integration.prisma
prisma/schema-api-integration.prisma
+45
-0
No files found.
backend/src/app.module.ts
View file @
0b0931ce
...
...
@@ -58,6 +58,11 @@ import { ReportsModule } from './modules/reports/reports.module';
// ─── Phase 3A: Admin & Intelligence ─────────────────────────
import
{
AnalyticsModule
}
from
'./modules/analytics/analytics.module'
;
// ─── Phase 3B: API & Integration ────────────────────────────
import
{
ApiKeysModule
}
from
'./modules/api-keys/api-keys.module'
;
import
{
WebhooksModule
}
from
'./modules/webhooks/webhooks.module'
;
import
{
SearchModule
}
from
'./modules/search/search.module'
;
import
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
import
{
TransformInterceptor
}
from
'./common/interceptors/transform.interceptor'
;
...
...
@@ -113,6 +118,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
ReportsModule
,
// Phase 3A
AnalyticsModule
,
// Phase 3B
ApiKeysModule
,
WebhooksModule
,
SearchModule
,
],
providers
:
[
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
...
...
backend/src/modules/api-keys/api-keys.controller.ts
0 → 100644
View file @
0b0931ce
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
ApiKeysService
}
from
'./api-keys.service'
;
import
{
CreateApiKeyDto
}
from
'./dto/create-api-key.dto'
;
import
{
UpdateApiKeyDto
}
from
'./dto/update-api-key.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'api-keys'
)
@
Roles
(
'SUPER_ADMIN'
)
export
class
ApiKeysController
{
constructor
(
private
readonly
apiKeysService
:
ApiKeysService
)
{}
@
Post
()
async
create
(@
Body
()
dto
:
CreateApiKeyDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
apiKeysService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
apiKeysService
.
findAll
(
user
);
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
apiKeysService
.
findById
(
id
,
user
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateApiKeyDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
apiKeysService
.
update
(
id
,
dto
,
user
);
}
@
Post
(
':id/revoke'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
revoke
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
apiKeysService
.
revoke
(
id
,
user
);
}
@
Post
(
':id/reactivate'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
reactivate
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
apiKeysService
.
reactivate
(
id
,
user
);
}
@
Delete
(
':id'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
apiKeysService
.
delete
(
id
,
user
);
return
{
message
:
'API key permanently deleted'
};
}
}
\ No newline at end of file
backend/src/modules/api-keys/api-keys.module.ts
0 → 100644
View file @
0b0931ce
import
{
Module
}
from
'@nestjs/common'
;
import
{
ApiKeysController
}
from
'./api-keys.controller'
;
import
{
ApiKeysService
}
from
'./api-keys.service'
;
@
Module
({
controllers
:
[
ApiKeysController
],
providers
:
[
ApiKeysService
],
exports
:
[
ApiKeysService
],
})
export
class
ApiKeysModule
{}
\ No newline at end of file
backend/src/modules/api-keys/api-keys.service.ts
0 → 100644
View file @
0b0931ce
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateApiKeyDto
}
from
'./dto/create-api-key.dto'
;
import
{
UpdateApiKeyDto
}
from
'./dto/update-api-key.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
*
as
crypto
from
'crypto'
;
@
Injectable
()
export
class
ApiKeysService
{
private
readonly
logger
=
new
Logger
(
ApiKeysService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateApiKeyDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
validScopes
=
[
'READ_ONLY'
,
'READ_WRITE'
,
'ADMIN'
];
if
(
!
validScopes
.
includes
(
dto
.
scope
))
{
throw
new
BadRequestException
(
`Scope must be one of:
${
validScopes
.
join
(
', '
)}
`
);
}
// Generate a cryptographically secure API key
const
rawKey
=
`grind_
${
dto
.
scope
.
toLowerCase
()}
_
${
crypto
.
randomBytes
(
32
).
toString
(
'hex'
)}
`
;
const
keyHash
=
crypto
.
createHash
(
'sha256'
).
update
(
rawKey
).
digest
(
'hex'
);
// Store only the first 8 chars as prefix for identification
const
keyPrefix
=
rawKey
.
substring
(
0
,
16
);
const
expiresAt
=
dto
.
expiresInDays
?
new
Date
(
Date
.
now
()
+
dto
.
expiresInDays
*
24
*
60
*
60
*
1000
)
:
null
;
const
apiKey
=
await
this
.
prisma
.
apiKey
.
create
({
data
:
{
name
:
dto
.
name
,
keyHash
,
keyPrefix
,
scope
:
dto
.
scope
,
description
:
dto
.
description
||
null
,
expiresAt
,
isActive
:
true
,
createdById
:
currentUser
.
id
,
rateLimit
:
dto
.
rateLimit
||
1000
,
// requests per hour
},
});
this
.
logger
.
log
(
`API key "
${
dto
.
name
}
" created by
${
currentUser
.
email
}
— scope:
${
dto
.
scope
}
`
,
);
// Return the raw key ONCE — it can never be retrieved again
return
{
id
:
apiKey
.
id
,
name
:
apiKey
.
name
,
key
:
rawKey
,
// ONLY time the full key is shown
keyPrefix
:
apiKey
.
keyPrefix
,
scope
:
apiKey
.
scope
,
description
:
apiKey
.
description
,
expiresAt
:
apiKey
.
expiresAt
,
rateLimit
:
apiKey
.
rateLimit
,
isActive
:
apiKey
.
isActive
,
createdAt
:
apiKey
.
createdAt
,
warning
:
'Store this key securely. It will NOT be shown again.'
,
};
}
async
findAll
(
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
keys
=
await
this
.
prisma
.
apiKey
.
findMany
({
orderBy
:
{
createdAt
:
'desc'
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
return
keys
.
map
((
k
)
=>
({
id
:
k
.
id
,
name
:
k
.
name
,
keyPrefix
:
k
.
keyPrefix
,
scope
:
k
.
scope
,
description
:
k
.
description
,
isActive
:
k
.
isActive
,
expiresAt
:
k
.
expiresAt
,
isExpired
:
k
.
expiresAt
?
new
Date
()
>
k
.
expiresAt
:
false
,
rateLimit
:
k
.
rateLimit
,
lastUsedAt
:
k
.
lastUsedAt
,
createdBy
:
k
.
createdBy
,
createdAt
:
k
.
createdAt
,
}));
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
key
=
await
this
.
prisma
.
apiKey
.
findUnique
({
where
:
{
id
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
if
(
!
key
)
throw
new
NotFoundException
(
'API key not found'
);
return
{
id
:
key
.
id
,
name
:
key
.
name
,
keyPrefix
:
key
.
keyPrefix
,
scope
:
key
.
scope
,
description
:
key
.
description
,
isActive
:
key
.
isActive
,
expiresAt
:
key
.
expiresAt
,
isExpired
:
key
.
expiresAt
?
new
Date
()
>
key
.
expiresAt
:
false
,
rateLimit
:
key
.
rateLimit
,
lastUsedAt
:
key
.
lastUsedAt
,
createdBy
:
key
.
createdBy
,
createdAt
:
key
.
createdAt
,
updatedAt
:
key
.
updatedAt
,
};
}
async
update
(
id
:
string
,
dto
:
UpdateApiKeyDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
key
=
await
this
.
prisma
.
apiKey
.
findUnique
({
where
:
{
id
}
});
if
(
!
key
)
throw
new
NotFoundException
(
'API key not found'
);
const
updateData
:
any
=
{};
if
(
dto
.
name
!==
undefined
)
updateData
.
name
=
dto
.
name
;
if
(
dto
.
description
!==
undefined
)
updateData
.
description
=
dto
.
description
;
if
(
dto
.
rateLimit
!==
undefined
)
updateData
.
rateLimit
=
dto
.
rateLimit
;
if
(
dto
.
scope
!==
undefined
)
{
const
validScopes
=
[
'READ_ONLY'
,
'READ_WRITE'
,
'ADMIN'
];
if
(
!
validScopes
.
includes
(
dto
.
scope
))
{
throw
new
BadRequestException
(
`Scope must be one of:
${
validScopes
.
join
(
', '
)}
`
);
}
updateData
.
scope
=
dto
.
scope
;
}
if
(
dto
.
expiresInDays
!==
undefined
)
{
updateData
.
expiresAt
=
dto
.
expiresInDays
?
new
Date
(
Date
.
now
()
+
dto
.
expiresInDays
*
24
*
60
*
60
*
1000
)
:
null
;
}
const
updated
=
await
this
.
prisma
.
apiKey
.
update
({
where
:
{
id
},
data
:
updateData
,
});
this
.
logger
.
log
(
`API key "
${
updated
.
name
}
" updated by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
id
,
currentUser
);
}
async
revoke
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
key
=
await
this
.
prisma
.
apiKey
.
findUnique
({
where
:
{
id
}
});
if
(
!
key
)
throw
new
NotFoundException
(
'API key not found'
);
if
(
!
key
.
isActive
)
{
throw
new
BadRequestException
(
'API key is already revoked'
);
}
const
updated
=
await
this
.
prisma
.
apiKey
.
update
({
where
:
{
id
},
data
:
{
isActive
:
false
},
});
this
.
logger
.
log
(
`API key "
${
updated
.
name
}
" revoked by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
id
,
currentUser
);
}
async
reactivate
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
key
=
await
this
.
prisma
.
apiKey
.
findUnique
({
where
:
{
id
}
});
if
(
!
key
)
throw
new
NotFoundException
(
'API key not found'
);
if
(
key
.
isActive
)
{
throw
new
BadRequestException
(
'API key is already active'
);
}
const
updated
=
await
this
.
prisma
.
apiKey
.
update
({
where
:
{
id
},
data
:
{
isActive
:
true
},
});
this
.
logger
.
log
(
`API key "
${
updated
.
name
}
" reactivated by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
id
,
currentUser
);
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage API keys'
);
}
const
key
=
await
this
.
prisma
.
apiKey
.
findUnique
({
where
:
{
id
}
});
if
(
!
key
)
throw
new
NotFoundException
(
'API key not found'
);
await
this
.
prisma
.
apiKey
.
delete
({
where
:
{
id
}
});
this
.
logger
.
log
(
`API key "
${
key
.
name
}
" permanently deleted by
${
currentUser
.
email
}
`
);
}
async
validateKey
(
rawKey
:
string
):
Promise
<
{
valid
:
boolean
;
key
?:
any
}
>
{
const
keyHash
=
crypto
.
createHash
(
'sha256'
).
update
(
rawKey
).
digest
(
'hex'
);
const
key
=
await
this
.
prisma
.
apiKey
.
findFirst
({
where
:
{
keyHash
,
isActive
:
true
,
OR
:
[{
expiresAt
:
null
},
{
expiresAt
:
{
gt
:
new
Date
()
}
}],
},
});
if
(
!
key
)
return
{
valid
:
false
};
// Update last used timestamp
await
this
.
prisma
.
apiKey
.
update
({
where
:
{
id
:
key
.
id
},
data
:
{
lastUsedAt
:
new
Date
()
},
});
return
{
valid
:
true
,
key
};
}
}
\ No newline at end of file
backend/src/modules/api-keys/dto/create-api-key.dto.ts
0 → 100644
View file @
0b0931ce
import
{
IsString
,
IsOptional
,
IsInt
,
Min
,
Max
,
MinLength
,
MaxLength
}
from
'class-validator'
;
export
class
CreateApiKeyDto
{
@
IsString
()
@
MinLength
(
2
)
@
MaxLength
(
100
)
name
:
string
;
@
IsString
()
scope
:
string
;
// READ_ONLY, READ_WRITE, ADMIN
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
@
IsOptional
()
@
IsInt
()
@
Min
(
1
)
@
Max
(
365
)
expiresInDays
?:
number
;
@
IsOptional
()
@
IsInt
()
@
Min
(
10
)
@
Max
(
100000
)
rateLimit
?:
number
;
// requests per hour
}
\ No newline at end of file
backend/src/modules/api-keys/dto/update-api-key.dto.ts
0 → 100644
View file @
0b0931ce
import
{
IsString
,
IsOptional
,
IsInt
,
Min
,
Max
,
MaxLength
}
from
'class-validator'
;
export
class
UpdateApiKeyDto
{
@
IsOptional
()
@
IsString
()
@
MaxLength
(
100
)
name
?:
string
;
@
IsOptional
()
@
IsString
()
scope
?:
string
;
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
@
IsOptional
()
@
IsInt
()
@
Min
(
1
)
@
Max
(
365
)
expiresInDays
?:
number
;
@
IsOptional
()
@
IsInt
()
@
Min
(
10
)
@
Max
(
100000
)
rateLimit
?:
number
;
}
\ No newline at end of file
backend/src/modules/search/dto/search-query.dto.ts
0 → 100644
View file @
0b0931ce
import
{
IsString
,
IsOptional
,
IsInt
,
Min
,
Max
,
MinLength
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
export
class
SearchQueryDto
{
@
IsString
()
@
MinLength
(
2
,
{
message
:
'Search query must be at least 2 characters'
})
q
:
string
;
@
IsOptional
()
@
IsString
()
entityTypes
?:
string
;
// Comma-separated: CARDS,USERS,BOARDS,DEDUCTIONS,LABELS,MESSAGES,NOTIFICATIONS
@
IsOptional
()
@
Type
(()
=>
Number
)
@
IsInt
()
@
Min
(
1
)
@
Max
(
100
)
limit
?:
number
;
}
\ No newline at end of file
backend/src/modules/search/dto/search-response.dto.ts
0 → 100644
View file @
0b0931ce
export
class
SearchResultDto
{
type
:
string
;
id
:
string
;
title
:
string
;
subtitle
:
string
|
null
;
context
:
string
|
null
;
url
:
string
;
score
:
number
;
}
export
class
SearchResponseDto
{
query
:
string
;
totalResults
:
number
;
results
:
SearchResultDto
[];
groupedResults
:
Record
<
string
,
SearchResultDto
[]
>
;
}
\ No newline at end of file
backend/src/modules/search/search.controller.ts
0 → 100644
View file @
0b0931ce
import
{
Controller
,
Get
,
Query
}
from
'@nestjs/common'
;
import
{
SearchService
}
from
'./search.service'
;
import
{
SearchQueryDto
}
from
'./dto/search-query.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Controller
(
'search'
)
export
class
SearchController
{
constructor
(
private
readonly
searchService
:
SearchService
)
{}
@
Get
()
async
search
(@
Query
()
query
:
SearchQueryDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
searchService
.
search
(
query
,
user
);
}
@
Get
(
'quick'
)
async
quickSearch
(@
Query
(
'q'
)
q
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
if
(
!
q
||
q
.
trim
().
length
<
2
)
{
return
{
results
:
[]
};
}
return
this
.
searchService
.
quickSearch
(
q
.
trim
(),
user
);
}
}
\ No newline at end of file
backend/src/modules/search/search.module.ts
0 → 100644
View file @
0b0931ce
import
{
Module
}
from
'@nestjs/common'
;
import
{
SearchController
}
from
'./search.controller'
;
import
{
SearchService
}
from
'./search.service'
;
@
Module
({
controllers
:
[
SearchController
],
providers
:
[
SearchService
],
exports
:
[
SearchService
],
})
export
class
SearchModule
{}
\ No newline at end of file
backend/src/modules/search/search.service.ts
0 → 100644
View file @
0b0931ce
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
SearchQueryDto
}
from
'./dto/search-query.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
interface
SearchResult
{
type
:
string
;
id
:
string
;
title
:
string
;
subtitle
:
string
|
null
;
context
:
string
|
null
;
url
:
string
;
score
:
number
;
}
interface
SearchResponse
{
query
:
string
;
totalResults
:
number
;
results
:
SearchResult
[];
groupedResults
:
Record
<
string
,
SearchResult
[]
>
;
}
@
Injectable
()
export
class
SearchService
{
private
readonly
logger
=
new
Logger
(
SearchService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
search
(
query
:
SearchQueryDto
,
currentUser
:
RequestUser
):
Promise
<
SearchResponse
>
{
const
q
=
query
.
q
?.
trim
();
if
(
!
q
||
q
.
length
<
2
)
{
return
{
query
:
q
||
''
,
totalResults
:
0
,
results
:
[],
groupedResults
:
{}
};
}
const
limit
=
query
.
limit
||
50
;
const
entityTypes
=
query
.
entityTypes
?
query
.
entityTypes
.
split
(
','
).
map
((
t
)
=>
t
.
trim
().
toUpperCase
())
:
null
;
// null means search all
const
allResults
:
SearchResult
[]
=
[];
// ─── CARDS ──────────────────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'CARDS'
))
{
const
cards
=
await
this
.
searchCards
(
q
,
currentUser
,
20
);
allResults
.
push
(...
cards
);
}
// ─── USERS / CONTRACTORS ────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'USERS'
)
||
entityTypes
.
includes
(
'CONTRACTORS'
))
{
const
users
=
await
this
.
searchUsers
(
q
,
currentUser
,
10
);
allResults
.
push
(...
users
);
}
// ─── BOARDS ─────────────────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'BOARDS'
))
{
const
boards
=
await
this
.
searchBoards
(
q
,
currentUser
,
10
);
allResults
.
push
(...
boards
);
}
// ─── DEDUCTIONS ─────────────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'DEDUCTIONS'
))
{
const
deductions
=
await
this
.
searchDeductions
(
q
,
currentUser
,
10
);
allResults
.
push
(...
deductions
);
}
// ─── LABELS ─────────────────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'LABELS'
))
{
const
labels
=
await
this
.
searchLabels
(
q
,
currentUser
,
10
);
allResults
.
push
(...
labels
);
}
// ─── MESSAGES ───────────────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'MESSAGES'
))
{
const
messages
=
await
this
.
searchMessages
(
q
,
currentUser
,
10
);
allResults
.
push
(...
messages
);
}
// ─── NOTIFICATIONS ──────────────────────────────────────
if
(
!
entityTypes
||
entityTypes
.
includes
(
'NOTIFICATIONS'
))
{
const
notifications
=
await
this
.
searchNotifications
(
q
,
currentUser
,
10
);
allResults
.
push
(...
notifications
);
}
// Sort by score descending, then slice to limit
allResults
.
sort
((
a
,
b
)
=>
b
.
score
-
a
.
score
);
const
limitedResults
=
allResults
.
slice
(
0
,
limit
);
// Group by type
const
groupedResults
:
Record
<
string
,
SearchResult
[]
>
=
{};
for
(
const
result
of
limitedResults
)
{
if
(
!
groupedResults
[
result
.
type
])
{
groupedResults
[
result
.
type
]
=
[];
}
groupedResults
[
result
.
type
].
push
(
result
);
}
return
{
query
:
q
,
totalResults
:
limitedResults
.
length
,
results
:
limitedResults
,
groupedResults
,
};
}
async
quickSearch
(
q
:
string
,
currentUser
:
RequestUser
):
Promise
<
{
results
:
SearchResult
[]
}
>
{
const
allResults
:
SearchResult
[]
=
[];
// Quick search only hits the most common entities with lower limits
const
[
cards
,
users
,
boards
]
=
await
Promise
.
all
([
this
.
searchCards
(
q
,
currentUser
,
5
),
this
.
searchUsers
(
q
,
currentUser
,
3
),
this
.
searchBoards
(
q
,
currentUser
,
3
),
]);
allResults
.
push
(...
cards
,
...
users
,
...
boards
);
allResults
.
sort
((
a
,
b
)
=>
b
.
score
-
a
.
score
);
return
{
results
:
allResults
.
slice
(
0
,
10
)
};
}
// ─── ENTITY-SPECIFIC SEARCH METHODS ────────────────────────
private
async
searchCards
(
q
:
string
,
user
:
RequestUser
,
limit
:
number
):
Promise
<
SearchResult
[]
>
{
const
where
:
any
=
{
deletedAt
:
null
,
OR
:
[
{
title
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
description
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
cardNumber
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
],
};
// Permission: contractors only see cards on their boards
if
(
user
.
role
===
'CONTRACTOR'
)
{
where
.
column
=
{
board
:
{
members
:
{
some
:
{
userId
:
user
.
id
}
}
},
};
}
else
if
(
user
.
role
===
'TEAM_LEAD'
)
{
where
.
column
=
{
board
:
{
members
:
{
some
:
{
userId
:
user
.
id
}
}
},
};
}
const
cards
=
await
this
.
prisma
.
card
.
findMany
({
where
,
take
:
limit
,
orderBy
:
{
updatedAt
:
'desc'
},
select
:
{
id
:
true
,
cardNumber
:
true
,
title
:
true
,
description
:
true
,
isArchived
:
true
,
column
:
{
select
:
{
name
:
true
,
board
:
{
select
:
{
id
:
true
,
name
:
true
,
key
:
true
}
},
},
},
},
});
return
cards
.
map
((
c
)
=>
{
const
isExactCardNumber
=
c
.
cardNumber
.
toLowerCase
()
===
q
.
toLowerCase
();
const
isTitleMatch
=
c
.
title
.
toLowerCase
().
includes
(
q
.
toLowerCase
());
return
{
type
:
'CARD'
,
id
:
c
.
id
,
title
:
`
${
c
.
cardNumber
}
:
${
c
.
title
}
`
,
subtitle
:
`
${
c
.
column
.
board
.
name
}
→
${
c
.
column
.
name
}${
c
.
isArchived
?
' (Archived)'
:
''
}
`
,
context
:
c
.
description
?
this
.
extractContext
(
c
.
description
,
q
,
100
)
:
null
,
url
:
`/boards/
${
c
.
column
.
board
.
id
}
?card=
${
c
.
id
}
`
,
score
:
isExactCardNumber
?
100
:
isTitleMatch
?
80
:
50
,
};
});
}
private
async
searchUsers
(
q
:
string
,
user
:
RequestUser
,
limit
:
number
):
Promise
<
SearchResult
[]
>
{
const
where
:
any
=
{
deletedAt
:
null
,
OR
:
[
{
firstName
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
lastName
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
username
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
displayName
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
],
};
// Contractors can see the directory but with limited data
const
users
=
await
this
.
prisma
.
user
.
findMany
({
where
,
take
:
limit
,
orderBy
:
{
firstName
:
'asc'
},
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
username
:
true
,
role
:
true
,
status
:
true
,
contractorType
:
true
,
},
});
return
users
.
map
((
u
)
=>
{
const
isExactUsername
=
u
.
username
.
toLowerCase
()
===
q
.
toLowerCase
();
return
{
type
:
'USER'
,
id
:
u
.
id
,
title
:
`
${
u
.
firstName
}
${
u
.
lastName
}
`
,
subtitle
:
`@
${
u
.
username
}
·
${
u
.
role
}
·
${
u
.
status
}
`
,
context
:
u
.
contractorType
||
null
,
url
:
user
.
role
===
'CONTRACTOR'
?
`/directory`
:
`/admin/contractors/
${
u
.
id
}
`
,
score
:
isExactUsername
?
90
:
60
,
};
});
}
private
async
searchBoards
(
q
:
string
,
user
:
RequestUser
,
limit
:
number
):
Promise
<
SearchResult
[]
>
{
const
where
:
any
=
{
deletedAt
:
null
,
isArchived
:
false
,
OR
:
[
{
name
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
description
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
key
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
],
};
// Non-admins only see their boards
if
(
user
.
role
!==
'SUPER_ADMIN'
&&
user
.
role
!==
'ADMIN'
)
{
where
.
members
=
{
some
:
{
userId
:
user
.
id
}
};
}
const
boards
=
await
this
.
prisma
.
board
.
findMany
({
where
,
take
:
limit
,
orderBy
:
{
name
:
'asc'
},
select
:
{
id
:
true
,
name
:
true
,
key
:
true
,
description
:
true
,
_count
:
{
select
:
{
members
:
true
}
},
},
});
return
boards
.
map
((
b
:
any
)
=>
({
type
:
'BOARD'
,
id
:
b
.
id
,
title
:
`
${
b
.
name
}
(
${
b
.
key
}
)`
,
subtitle
:
`
${
b
.
_count
.
members
}
members`
,
context
:
b
.
description
?
this
.
extractContext
(
b
.
description
,
q
,
80
)
:
null
,
url
:
`/boards/
${
b
.
id
}
`
,
score
:
b
.
key
.
toLowerCase
()
===
q
.
toLowerCase
()
?
95
:
70
,
}));
}
private
async
searchDeductions
(
q
:
string
,
user
:
RequestUser
,
limit
:
number
):
Promise
<
SearchResult
[]
>
{
// Contractors only see their own deductions
const
where
:
any
=
{
OR
:
[
{
description
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
category
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
{
subCategory
:
{
contains
:
q
,
mode
:
'insensitive'
}
},
],
};
if
(
user
.
role
===
'CONTRACTOR'
)
{
where
.
userId
=
user
.
id
;
}
else
if
(
user
.
role
===
'TEAM_LEAD'
)
{
// PLs see deductions for their team (counts only, but search should return)
where
.
OR2
=
[
{
initiatedById
:
user
.
id
},
{
user
:
{
assignedProjectLeaderId
:
user
.
id
}
},
];
// Merge conditions
where
.
AND
=
[
{
OR
:
where
.
OR
},
{
OR
:
[{
initiatedById
:
user
.
id
},
{
user
:
{
assignedProjectLeaderId
:
user
.
id
}
}]
},
];
delete
where
.
OR
;
delete
where
.
OR2
;
}
const
deductions
=
await
this
.
prisma
.
deduction
.
findMany
({
where
,
take
:
limit
,
orderBy
:
{
createdAt
:
'desc'
},
select
:
{
id
:
true
,
category
:
true
,
subCategory
:
true
,
description
:
true
,
status
:
true
,
amountPiasters
:
true
,
violationDate
:
true
,
user
:
{
select
:
{
firstName
:
true
,
lastName
:
true
}
},
},
});
return
deductions
.
map
((
d
)
=>
({
type
:
'DEDUCTION'
,
id
:
d
.
id
,
title
:
`Deduction
${
d
.
subCategory
}
:
${
d
.
user
.
firstName
}
${
d
.
user
.
lastName
}
`
,
subtitle
:
`
${
d
.
status
}
·
${
d
.
violationDate
?.
toISOString
().
split
(
'T'
)[
0
]
||
'N/A'
}
`,
context: d.description
? this.extractContext(d.description, q, 100)
: null,
url: `
/
salary
`,
score: 40,
}));
}
private async searchLabels(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const labels = await this.prisma.label.findMany({
where: {
name: { contains: q, mode: 'insensitive' },
},
take: limit,
select: {
id: true,
name: true,
color: true,
scope: true,
boardId: true,
board: { select: { name: true } },
},
});
return labels.map((l: any) => ({
type: 'LABEL',
id: l.id,
title: l.name,
subtitle: l.scope === 'ORGANIZATION' ? 'Organization label' : `
Board
:
$
{
l
.
board
?.
name
||
'Unknown'
}
`,
context: null,
url: l.boardId ? `
/
boards
/
$
{
l
.
boardId
}
` : '/admin/templates',
score: 30,
}));
}
private async searchMessages(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
const where: any = {
content: { contains: q, mode: 'insensitive' },
deletedAt: null,
};
// Only search own conversations (SA can search all)
if (user.role !== 'SUPER_ADMIN') {
where.conversation = {
participants: { some: { userId: user.id } },
};
}
const messages = await this.prisma.message.findMany({
where,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
content: true,
conversationId: true,
sender: { select: { firstName: true, lastName: true } },
createdAt: true,
},
});
return messages.map((m) => ({
type: 'MESSAGE',
id: m.id,
title: `
Message
from
$
{
m
.
sender
.
firstName
}
$
{
m
.
sender
.
lastName
}
`,
subtitle: m.createdAt.toISOString().split('T')[0],
context: this.extractContext(m.content, q, 100),
url: `
/
messages
/
$
{
m
.
conversationId
}
`,
score: 35,
}));
}
private async searchNotifications(q: string, user: RequestUser, limit: number): Promise<SearchResult[]> {
// Everyone only searches their own notifications
const notifications = await this.prisma.notification.findMany({
where: {
userId: user.id,
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ message: { contains: q, mode: 'insensitive' } },
],
},
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
message: true,
actionUrl: true,
isRead: true,
createdAt: true,
},
});
return notifications.map((n) => ({
type: 'NOTIFICATION',
id: n.id,
title: n.title,
subtitle: `
$
{
n
.
isRead
?
'Read'
:
'Unread'
}
·
$
{
n
.
createdAt
.
toISOString
().
split
(
'T'
)[
0
]}
`,
context: n.message
? this.extractContext(n.message, q, 80)
: null,
url: n.actionUrl || '/notifications',
score: 20,
}));
}
// ─── HELPERS ────────────────────────────────────────────────
/**
* Extract a snippet of text around the search query for context display.
*/
private extractContext(text: string, query: string, maxLength: number): string | null {
if (!text) return null;
// Strip HTML tags if any
const plainText = text.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const lowerText = plainText.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) {
// Query not found in plain text — return start of text
return plainText.length > maxLength
? plainText.substring(0, maxLength) + '…'
: plainText;
}
// Center the context around the match
const contextStart = Math.max(0, index - Math.floor(maxLength / 3));
const contextEnd = Math.min(plainText.length, contextStart + maxLength);
let context = plainText.substring(contextStart, contextEnd);
if (contextStart > 0) context = '…' + context;
if (contextEnd < plainText.length) context = context + '…';
return context;
}
}
\ No newline at end of file
backend/src/modules/webhooks/dto/create-webhook.dto.ts
0 → 100644
View file @
0b0931ce
import
{
IsString
,
IsOptional
,
IsArray
,
MinLength
,
MaxLength
,
IsUrl
}
from
'class-validator'
;
export
class
CreateWebhookDto
{
@
IsString
()
@
MinLength
(
2
)
@
MaxLength
(
100
)
name
:
string
;
@
IsString
()
url
:
string
;
@
IsOptional
()
@
IsString
()
secret
?:
string
;
@
IsArray
()
@
IsString
({
each
:
true
})
events
:
string
[];
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
}
\ No newline at end of file
backend/src/modules/webhooks/dto/update-webhook.dto.ts
0 → 100644
View file @
0b0931ce
import
{
IsString
,
IsOptional
,
IsArray
,
MaxLength
}
from
'class-validator'
;
export
class
UpdateWebhookDto
{
@
IsOptional
()
@
IsString
()
@
MaxLength
(
100
)
name
?:
string
;
@
IsOptional
()
@
IsString
()
url
?:
string
;
@
IsOptional
()
@
IsString
()
secret
?:
string
;
@
IsOptional
()
@
IsArray
()
@
IsString
({
each
:
true
})
events
?:
string
[];
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
}
\ No newline at end of file
backend/src/modules/webhooks/dto/webhook-filter.dto.ts
0 → 100644
View file @
0b0931ce
import
{
IsOptional
,
IsBoolean
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
WebhookFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
Type
(()
=>
Boolean
)
@
IsBoolean
()
isActive
?:
boolean
;
}
\ No newline at end of file
backend/src/modules/webhooks/webhooks.controller.ts
0 → 100644
View file @
0b0931ce
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
WebhooksService
}
from
'./webhooks.service'
;
import
{
CreateWebhookDto
}
from
'./dto/create-webhook.dto'
;
import
{
UpdateWebhookDto
}
from
'./dto/update-webhook.dto'
;
import
{
WebhookFilterDto
}
from
'./dto/webhook-filter.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'webhooks'
)
@
Roles
(
'SUPER_ADMIN'
)
export
class
WebhooksController
{
constructor
(
private
readonly
webhooksService
:
WebhooksService
)
{}
@
Post
()
async
create
(@
Body
()
dto
:
CreateWebhookDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
webhooksService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
Query
()
filter
:
WebhookFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
webhooksService
.
findAll
(
filter
,
user
);
}
@
Get
(
'events'
)
async
getAvailableEvents
()
{
return
this
.
webhooksService
.
getAvailableEvents
();
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
webhooksService
.
findById
(
id
,
user
);
}
@
Get
(
':id/deliveries'
)
async
getDeliveries
(
@
Param
(
'id'
)
id
:
string
,
@
Query
(
'page'
)
page
?:
string
,
@
Query
(
'limit'
)
limit
?:
string
,
@
CurrentUser
()
user
?:
RequestUser
,
)
{
return
this
.
webhooksService
.
getDeliveries
(
id
,
page
?
parseInt
(
page
,
10
)
:
1
,
limit
?
parseInt
(
limit
,
10
)
:
50
,
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateWebhookDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
webhooksService
.
update
(
id
,
dto
,
user
);
}
@
Post
(
':id/test'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
testWebhook
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
webhooksService
.
sendTestEvent
(
id
,
user
);
}
@
Post
(
':id/activate'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
activate
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
webhooksService
.
setActive
(
id
,
true
,
user
);
}
@
Post
(
':id/deactivate'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
deactivate
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
webhooksService
.
setActive
(
id
,
false
,
user
);
}
@
Delete
(
':id'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
webhooksService
.
delete
(
id
,
user
);
return
{
message
:
'Webhook deleted'
};
}
@
Post
(
'deliveries/:deliveryId/retry'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
retryDelivery
(
@
Param
(
'deliveryId'
)
deliveryId
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
webhooksService
.
retryDelivery
(
deliveryId
,
user
);
}
}
\ No newline at end of file
backend/src/modules/webhooks/webhooks.module.ts
0 → 100644
View file @
0b0931ce
import
{
Module
}
from
'@nestjs/common'
;
import
{
WebhooksController
}
from
'./webhooks.controller'
;
import
{
WebhooksService
}
from
'./webhooks.service'
;
@
Module
({
controllers
:
[
WebhooksController
],
providers
:
[
WebhooksService
],
exports
:
[
WebhooksService
],
})
export
class
WebhooksModule
{}
\ No newline at end of file
backend/src/modules/webhooks/webhooks.service.ts
0 → 100644
View file @
0b0931ce
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateWebhookDto
}
from
'./dto/create-webhook.dto'
;
import
{
UpdateWebhookDto
}
from
'./dto/update-webhook.dto'
;
import
{
WebhookFilterDto
}
from
'./dto/webhook-filter.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
}
from
'../../common/utils/pagination.util'
;
import
*
as
crypto
from
'crypto'
;
const
AVAILABLE_EVENTS
=
[
'card.created'
,
'card.moved'
,
'card.assigned'
,
'card.done'
,
'card.overdue'
,
'card.updated'
,
'card.deleted'
,
'report.submitted'
,
'report.missed'
,
'deduction.created'
,
'deduction.applied'
,
'deduction.dismissed'
,
'bounty.paid'
,
'contractor.activated'
,
'contractor.terminated'
,
'payroll.approved'
,
'payroll.paid'
,
'evaluation.compiled'
,
'pip.created'
,
'pip.completed'
,
'meeting.created'
,
'meeting.cancelled'
,
'adjustment.approved'
,
'notice.created'
,
];
@
Injectable
()
export
class
WebhooksService
{
private
readonly
logger
=
new
Logger
(
WebhooksService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
getAvailableEvents
():
{
events
:
string
[];
descriptions
:
Record
<
string
,
string
>
}
{
const
descriptions
:
Record
<
string
,
string
>
=
{
'card.created'
:
'A new card was created on any board'
,
'card.moved'
:
'A card was moved between columns'
,
'card.assigned'
:
'A card was assigned or unassigned'
,
'card.done'
:
'A card was moved to Done'
,
'card.overdue'
:
'A card passed its deadline without completion'
,
'card.updated'
:
'A card
\'
s fields were updated'
,
'card.deleted'
:
'A card was archived or deleted'
,
'report.submitted'
:
'A daily report was submitted'
,
'report.missed'
:
'A contractor missed their report deadline'
,
'deduction.created'
:
'A new deduction was initiated'
,
'deduction.applied'
:
'A deduction was applied to a contractor
\'
s salary'
,
'deduction.dismissed'
:
'A deduction was dismissed'
,
'bounty.paid'
:
'A bounty was paid out to contractor(s)'
,
'contractor.activated'
:
'A new contractor was activated'
,
'contractor.terminated'
:
'A contractor was terminated'
,
'payroll.approved'
:
'Monthly payroll was approved'
,
'payroll.paid'
:
'Monthly payroll was marked as paid'
,
'evaluation.compiled'
:
'A monthly evaluation was compiled'
,
'pip.created'
:
'A Performance Improvement Plan was created'
,
'pip.completed'
:
'A PIP was completed (passed or failed)'
,
'meeting.created'
:
'A new meeting was scheduled'
,
'meeting.cancelled'
:
'A meeting was cancelled'
,
'adjustment.approved'
:
'A salary adjustment was approved'
,
'notice.created'
:
'A notice/announcement was created'
,
};
return
{
events
:
AVAILABLE_EVENTS
,
descriptions
};
}
async
create
(
dto
:
CreateWebhookDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage webhooks'
);
}
// Validate URL format
try
{
new
URL
(
dto
.
url
);
}
catch
{
throw
new
BadRequestException
(
'Invalid webhook URL'
);
}
// Validate events
const
invalidEvents
=
dto
.
events
.
filter
((
e
)
=>
!
AVAILABLE_EVENTS
.
includes
(
e
));
if
(
invalidEvents
.
length
>
0
)
{
throw
new
BadRequestException
(
`Invalid event(s):
${
invalidEvents
.
join
(
', '
)}
`
);
}
if
(
dto
.
events
.
length
===
0
)
{
throw
new
BadRequestException
(
'At least one event must be subscribed'
);
}
// Generate a secret for signature verification
const
secret
=
dto
.
secret
||
crypto
.
randomBytes
(
32
).
toString
(
'hex'
);
const
webhook
=
await
this
.
prisma
.
webhook
.
create
({
data
:
{
name
:
dto
.
name
,
url
:
dto
.
url
,
secret
,
events
:
dto
.
events
,
description
:
dto
.
description
||
null
,
isActive
:
true
,
createdById
:
currentUser
.
id
,
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
this
.
logger
.
log
(
`Webhook "
${
dto
.
name
}
" created by
${
currentUser
.
email
}
→
${
dto
.
url
}
(
${
dto
.
events
.
length
}
events)`
,
);
return
{
...
webhook
,
secret
,
// Show secret only on creation
secretWarning
:
'Store this secret securely. It will be used to verify webhook signatures.'
,
};
}
async
findAll
(
filter
:
WebhookFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage webhooks'
);
}
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
20
;
const
where
:
any
=
{};
if
(
filter
.
isActive
!==
undefined
)
{
where
.
isActive
=
filter
.
isActive
;
}
if
(
filter
.
search
)
{
where
.
OR
=
[
{
name
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
url
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
];
}
const
[
data
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
webhook
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
createdAt
:
'desc'
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
_count
:
{
select
:
{
deliveries
:
true
}
},
},
}),
this
.
prisma
.
webhook
.
count
({
where
}),
]);
// Get last delivery stats for each webhook
const
enriched
=
await
Promise
.
all
(
data
.
map
(
async
(
w
:
any
)
=>
{
const
lastDelivery
=
await
this
.
prisma
.
webhookDelivery
.
findFirst
({
where
:
{
webhookId
:
w
.
id
},
orderBy
:
{
createdAt
:
'desc'
},
select
:
{
status
:
true
,
createdAt
:
true
,
responseCode
:
true
},
});
const
failedCount
=
await
this
.
prisma
.
webhookDelivery
.
count
({
where
:
{
webhookId
:
w
.
id
,
status
:
{
in
:
[
'FAILED'
,
'PERMANENTLY_FAILED'
]
},
createdAt
:
{
gte
:
new
Date
(
Date
.
now
()
-
24
*
60
*
60
*
1000
)
},
},
});
return
{
id
:
w
.
id
,
name
:
w
.
name
,
url
:
w
.
url
,
events
:
w
.
events
,
description
:
w
.
description
,
isActive
:
w
.
isActive
,
totalDeliveries
:
w
.
_count
.
deliveries
,
failedLast24h
:
failedCount
,
lastDelivery
:
lastDelivery
?
{
status
:
lastDelivery
.
status
,
responseCode
:
lastDelivery
.
responseCode
,
at
:
lastDelivery
.
createdAt
,
}
:
null
,
createdBy
:
w
.
createdBy
,
createdAt
:
w
.
createdAt
,
};
}),
);
return
buildPaginatedResponse
(
enriched
,
total
,
{
page
,
limit
,
sortOrder
:
'desc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage webhooks'
);
}
const
webhook
=
await
this
.
prisma
.
webhook
.
findUnique
({
where
:
{
id
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
_count
:
{
select
:
{
deliveries
:
true
}
},
},
});
if
(
!
webhook
)
throw
new
NotFoundException
(
'Webhook not found'
);
// Recent delivery stats
const
deliveryStats
=
await
this
.
prisma
.
webhookDelivery
.
groupBy
({
by
:
[
'status'
],
where
:
{
webhookId
:
id
},
_count
:
true
,
});
return
{
id
:
webhook
.
id
,
name
:
webhook
.
name
,
url
:
webhook
.
url
,
events
:
webhook
.
events
,
description
:
webhook
.
description
,
isActive
:
webhook
.
isActive
,
// Secret is NOT returned after creation — security
hasSecret
:
!!
webhook
.
secret
,
totalDeliveries
:
webhook
.
_count
.
deliveries
,
deliveryStats
:
deliveryStats
.
map
((
s
)
=>
({
status
:
s
.
status
,
count
:
s
.
_count
,
})),
createdBy
:
webhook
.
createdBy
,
createdAt
:
webhook
.
createdAt
,
updatedAt
:
webhook
.
updatedAt
,
};
}
async
getDeliveries
(
webhookId
:
string
,
page
:
number
=
1
,
limit
:
number
=
50
,
):
Promise
<
PaginatedResult
<
any
>>
{
const
webhook
=
await
this
.
prisma
.
webhook
.
findUnique
({
where
:
{
id
:
webhookId
}
});
if
(
!
webhook
)
throw
new
NotFoundException
(
'Webhook not found'
);
const
where
=
{
webhookId
};
const
[
data
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
webhookDelivery
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
createdAt
:
'desc'
},
}),
this
.
prisma
.
webhookDelivery
.
count
({
where
}),
]);
return
buildPaginatedResponse
(
data
,
total
,
{
page
,
limit
,
sortOrder
:
'desc'
});
}
async
update
(
id
:
string
,
dto
:
UpdateWebhookDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage webhooks'
);
}
const
webhook
=
await
this
.
prisma
.
webhook
.
findUnique
({
where
:
{
id
}
});
if
(
!
webhook
)
throw
new
NotFoundException
(
'Webhook not found'
);
const
updateData
:
any
=
{};
if
(
dto
.
name
!==
undefined
)
updateData
.
name
=
dto
.
name
;
if
(
dto
.
description
!==
undefined
)
updateData
.
description
=
dto
.
description
;
if
(
dto
.
url
!==
undefined
)
{
try
{
new
URL
(
dto
.
url
);
}
catch
{
throw
new
BadRequestException
(
'Invalid webhook URL'
);
}
updateData
.
url
=
dto
.
url
;
}
if
(
dto
.
events
!==
undefined
)
{
const
invalidEvents
=
dto
.
events
.
filter
((
e
)
=>
!
AVAILABLE_EVENTS
.
includes
(
e
));
if
(
invalidEvents
.
length
>
0
)
{
throw
new
BadRequestException
(
`Invalid event(s):
${
invalidEvents
.
join
(
', '
)}
`
);
}
if
(
dto
.
events
.
length
===
0
)
{
throw
new
BadRequestException
(
'At least one event must be subscribed'
);
}
updateData
.
events
=
dto
.
events
;
}
if
(
dto
.
secret
!==
undefined
)
{
updateData
.
secret
=
dto
.
secret
||
crypto
.
randomBytes
(
32
).
toString
(
'hex'
);
}
await
this
.
prisma
.
webhook
.
update
({
where
:
{
id
},
data
:
updateData
});
this
.
logger
.
log
(
`Webhook "
${
webhook
.
name
}
" updated by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
id
,
currentUser
);
}
async
setActive
(
id
:
string
,
isActive
:
boolean
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage webhooks'
);
}
const
webhook
=
await
this
.
prisma
.
webhook
.
findUnique
({
where
:
{
id
}
});
if
(
!
webhook
)
throw
new
NotFoundException
(
'Webhook not found'
);
await
this
.
prisma
.
webhook
.
update
({
where
:
{
id
},
data
:
{
isActive
}
});
this
.
logger
.
log
(
`Webhook "
${
webhook
.
name
}
"
${
isActive
?
'activated'
:
'deactivated'
}
by
${
currentUser
.
email
}
`
,
);
return
this
.
findById
(
id
,
currentUser
);
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can manage webhooks'
);
}
const
webhook
=
await
this
.
prisma
.
webhook
.
findUnique
({
where
:
{
id
}
});
if
(
!
webhook
)
throw
new
NotFoundException
(
'Webhook not found'
);
// Delete deliveries first (cascade should handle it but let's be explicit)
await
this
.
prisma
.
webhookDelivery
.
deleteMany
({
where
:
{
webhookId
:
id
}
});
await
this
.
prisma
.
webhook
.
delete
({
where
:
{
id
}
});
this
.
logger
.
log
(
`Webhook "
${
webhook
.
name
}
" deleted by
${
currentUser
.
email
}
`
);
}
async
sendTestEvent
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can test webhooks'
);
}
const
webhook
=
await
this
.
prisma
.
webhook
.
findUnique
({
where
:
{
id
}
});
if
(
!
webhook
)
throw
new
NotFoundException
(
'Webhook not found'
);
if
(
!
webhook
.
isActive
)
{
throw
new
BadRequestException
(
'Cannot test an inactive webhook. Activate it first.'
);
}
const
testPayload
=
{
event
:
'webhook.test'
,
timestamp
:
new
Date
().
toISOString
(),
data
:
{
message
:
'This is a test webhook delivery from The Grind.'
,
webhookId
:
webhook
.
id
,
webhookName
:
webhook
.
name
,
triggeredBy
:
currentUser
.
id
,
},
};
// Create delivery record
const
delivery
=
await
this
.
prisma
.
webhookDelivery
.
create
({
data
:
{
webhookId
:
webhook
.
id
,
event
:
'webhook.test'
,
payload
:
testPayload
,
status
:
'PENDING'
,
attempts
:
0
,
},
});
// Attempt delivery
try
{
const
signature
=
this
.
computeSignature
(
testPayload
,
webhook
.
secret
||
''
);
const
response
=
await
fetch
(
webhook
.
url
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
'X-Webhook-Signature'
:
signature
,
'X-Webhook-Event'
:
'webhook.test'
,
'X-Webhook-Delivery'
:
delivery
.
id
,
'X-Webhook-Attempt'
:
'1'
,
},
body
:
JSON
.
stringify
(
testPayload
),
signal
:
AbortSignal
.
timeout
(
10000
),
});
const
updated
=
await
this
.
prisma
.
webhookDelivery
.
update
({
where
:
{
id
:
delivery
.
id
},
data
:
{
status
:
response
.
ok
?
'DELIVERED'
:
'FAILED'
,
responseCode
:
response
.
status
,
deliveredAt
:
response
.
ok
?
new
Date
()
:
null
,
lastError
:
response
.
ok
?
null
:
`HTTP
${
response
.
status
}
:
${
response
.
statusText
}
`
,
attempts
:
1
,
},
});
return
{
success
:
response
.
ok
,
delivery
:
updated
,
responseCode
:
response
.
status
,
message
:
response
.
ok
?
'Test webhook delivered successfully!'
:
`Test delivery failed with HTTP
${
response
.
status
}
`
,
};
}
catch
(
err
)
{
const
updated
=
await
this
.
prisma
.
webhookDelivery
.
update
({
where
:
{
id
:
delivery
.
id
},
data
:
{
status
:
'FAILED'
,
lastError
:
err
.
message
,
attempts
:
1
,
},
});
return
{
success
:
false
,
delivery
:
updated
,
message
:
`Test delivery failed:
${
err
.
message
}
`
,
};
}
}
async
retryDelivery
(
deliveryId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can retry webhook deliveries'
);
}
const
delivery
=
await
this
.
prisma
.
webhookDelivery
.
findUnique
({
where
:
{
id
:
deliveryId
},
include
:
{
webhook
:
true
},
});
if
(
!
delivery
)
throw
new
NotFoundException
(
'Delivery not found'
);
if
(
delivery
.
status
===
'DELIVERED'
)
{
throw
new
BadRequestException
(
'This delivery was already successful'
);
}
if
(
!
delivery
.
webhook
.
isActive
)
{
throw
new
BadRequestException
(
'Cannot retry — webhook is inactive'
);
}
// Reset for retry
await
this
.
prisma
.
webhookDelivery
.
update
({
where
:
{
id
:
deliveryId
},
data
:
{
status
:
'PENDING'
,
attempts
:
0
,
nextRetryAt
:
new
Date
(),
lastError
:
null
,
},
});
return
{
message
:
'Delivery queued for retry. It will be processed within 15 minutes.'
};
}
/**
* Dispatch a webhook event. Called by other services when events occur.
*/
async
dispatch
(
event
:
string
,
payload
:
any
):
Promise
<
void
>
{
try
{
const
webhooks
=
await
this
.
prisma
.
webhook
.
findMany
({
where
:
{
isActive
:
true
,
events
:
{
has
:
event
},
},
});
for
(
const
webhook
of
webhooks
)
{
const
fullPayload
=
{
event
,
timestamp
:
new
Date
().
toISOString
(),
data
:
payload
,
};
const
delivery
=
await
this
.
prisma
.
webhookDelivery
.
create
({
data
:
{
webhookId
:
webhook
.
id
,
event
,
payload
:
fullPayload
,
status
:
'PENDING'
,
attempts
:
0
,
},
});
// Attempt immediate delivery
try
{
const
signature
=
this
.
computeSignature
(
fullPayload
,
webhook
.
secret
||
''
);
const
response
=
await
fetch
(
webhook
.
url
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
'X-Webhook-Signature'
:
signature
,
'X-Webhook-Event'
:
event
,
'X-Webhook-Delivery'
:
delivery
.
id
,
'X-Webhook-Attempt'
:
'1'
,
},
body
:
JSON
.
stringify
(
fullPayload
),
signal
:
AbortSignal
.
timeout
(
10000
),
});
if
(
response
.
ok
)
{
await
this
.
prisma
.
webhookDelivery
.
update
({
where
:
{
id
:
delivery
.
id
},
data
:
{
status
:
'DELIVERED'
,
responseCode
:
response
.
status
,
deliveredAt
:
new
Date
(),
attempts
:
1
,
},
});
}
else
{
throw
new
Error
(
`HTTP
${
response
.
status
}
`
);
}
}
catch
(
err
)
{
await
this
.
prisma
.
webhookDelivery
.
update
({
where
:
{
id
:
delivery
.
id
},
data
:
{
status
:
'FAILED'
,
lastError
:
err
.
message
,
attempts
:
1
,
nextRetryAt
:
new
Date
(
Date
.
now
()
+
60
*
1000
),
// retry in 1 min
},
});
}
}
}
catch
(
err
)
{
this
.
logger
.
error
(
`Webhook dispatch error for event "
${
event
}
":
${
err
.
message
}
`
);
}
}
private
computeSignature
(
payload
:
any
,
secret
:
string
):
string
{
if
(
!
secret
)
return
''
;
return
crypto
.
createHmac
(
'sha256'
,
secret
)
.
update
(
JSON
.
stringify
(
payload
))
.
digest
(
'hex'
);
}
}
\ No newline at end of file
prisma/schema-api-integration.prisma
0 → 100644
View file @
0b0931ce
//
───
API
INTEGRATION
MODELS
─────────────────────────────────
//
Phase
3
B
:
API
Keys
,
Webhooks
,
Search
model
Webhook
{
id
String
@
id
@
default
(
uuid
())
name
String
url
String
secret
String
?
events
String
[]
//
Array
of
event
names
subscribed
to
isActive
Boolean
@
default
(
true
)
description
String
?
createdById
String
createdBy
User
@
relation
(
"WebhookCreator"
,
fields
:
[
createdById
],
references
:
[
id
],
onDelete
:
RESTRICT
)
deliveries
WebhookDelivery
[]
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
isActive
])
@@
index
([
createdById
])
}
model
WebhookDelivery
{
id
String
@
id
@
default
(
uuid
())
webhookId
String
webhook
Webhook
@
relation
(
fields
:
[
webhookId
],
references
:
[
id
],
onDelete
:
CASCADE
)
event
String
payload
Json
status
String
@
default
(
"PENDING"
)
//
PENDING
,
DELIVERED
,
FAILED
,
PERMANENTLY_FAILED
,
CANCELLED
responseCode
Int
?
lastError
String
?
attempts
Int
@
default
(
0
)
nextRetryAt
DateTime
?
deliveredAt
DateTime
?
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
webhookId
])
@@
index
([
status
])
@@
index
([
nextRetryAt
])
@@
index
([
createdAt
])
}
\ No newline at end of file
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