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
130e018c
Commit
130e018c
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 19 files via Son of Anton
parent
fe02219a
Changes
19
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1525 additions
and
0 deletions
+1525
-0
app.module.ts
backend/src/app.module.ts
+6
-0
attachments.controller.ts
backend/src/modules/attachments/attachments.controller.ts
+65
-0
attachments.module.ts
backend/src/modules/attachments/attachments.module.ts
+17
-0
attachments.service.ts
backend/src/modules/attachments/attachments.service.ts
+321
-0
attachment-response.dto.ts
...nd/src/modules/attachments/dto/attachment-response.dto.ts
+16
-0
minio.service.ts
backend/src/modules/attachments/minio.service.ts
+96
-0
checklists.controller.ts
backend/src/modules/checklists/checklists.controller.ts
+92
-0
checklists.module.ts
backend/src/modules/checklists/checklists.module.ts
+10
-0
checklists.service.ts
backend/src/modules/checklists/checklists.service.ts
+402
-0
create-checklist.dto.ts
backend/src/modules/checklists/dto/create-checklist.dto.ts
+34
-0
toggle-item.dto.ts
backend/src/modules/checklists/dto/toggle-item.dto.ts
+6
-0
update-checklist.dto.ts
backend/src/modules/checklists/dto/update-checklist.dto.ts
+31
-0
comments.controller.ts
backend/src/modules/comments/comments.controller.ts
+48
-0
comments.module.ts
backend/src/modules/comments/comments.module.ts
+10
-0
comments.service.ts
backend/src/modules/comments/comments.service.ts
+244
-0
comment-response.dto.ts
backend/src/modules/comments/dto/comment-response.dto.ts
+19
-0
create-comment.dto.ts
backend/src/modules/comments/dto/create-comment.dto.ts
+15
-0
update-comment.dto.ts
backend/src/modules/comments/dto/update-comment.dto.ts
+7
-0
schema-comments-checklists.prisma
prisma/schema-comments-checklists.prisma
+86
-0
No files found.
backend/src/app.module.ts
View file @
130e018c
...
@@ -21,6 +21,9 @@ import { BoardsModule } from './modules/boards/boards.module';
...
@@ -21,6 +21,9 @@ import { BoardsModule } from './modules/boards/boards.module';
import
{
ColumnsModule
}
from
'./modules/columns/columns.module'
;
import
{
ColumnsModule
}
from
'./modules/columns/columns.module'
;
import
{
LabelsModule
}
from
'./modules/labels/labels.module'
;
import
{
LabelsModule
}
from
'./modules/labels/labels.module'
;
import
{
CardsModule
}
from
'./modules/cards/cards.module'
;
import
{
CardsModule
}
from
'./modules/cards/cards.module'
;
import
{
CommentsModule
}
from
'./modules/comments/comments.module'
;
import
{
ChecklistsModule
}
from
'./modules/checklists/checklists.module'
;
import
{
AttachmentsModule
}
from
'./modules/attachments/attachments.module'
;
import
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
...
@@ -48,6 +51,9 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
...
@@ -48,6 +51,9 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
ColumnsModule
,
ColumnsModule
,
LabelsModule
,
LabelsModule
,
CardsModule
,
CardsModule
,
CommentsModule
,
ChecklistsModule
,
AttachmentsModule
,
],
],
providers
:
[
providers
:
[
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
...
...
backend/src/modules/attachments/attachments.controller.ts
0 → 100644
View file @
130e018c
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Param
,
HttpCode
,
HttpStatus
,
UseInterceptors
,
UploadedFile
,
BadRequestException
,
}
from
'@nestjs/common'
;
import
{
FileInterceptor
}
from
'@nestjs/platform-express'
;
import
{
AttachmentsService
}
from
'./attachments.service'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Controller
(
'attachments'
)
export
class
AttachmentsController
{
constructor
(
private
readonly
attachmentsService
:
AttachmentsService
)
{}
@
Post
(
'card/:cardId'
)
@
UseInterceptors
(
FileInterceptor
(
'file'
,
{
limits
:
{
fileSize
:
26214400
},
// 25MB hard limit at multer level
}),
)
async
upload
(
@
Param
(
'cardId'
)
cardId
:
string
,
@
UploadedFile
()
file
:
Express
.
Multer
.
File
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
if
(
!
file
)
{
throw
new
BadRequestException
(
'No file uploaded'
);
}
return
this
.
attachmentsService
.
upload
(
cardId
,
file
,
user
);
}
@
Get
(
'card/:cardId'
)
async
findByCard
(@
Param
(
'cardId'
)
cardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
attachmentsService
.
findByCard
(
cardId
,
user
);
}
@
Get
(
':id/download'
)
async
getDownloadUrl
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
attachmentsService
.
getDownloadUrl
(
id
,
user
);
}
@
Delete
(
':id'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
attachmentsService
.
delete
(
id
,
user
);
return
{
message
:
'Attachment deleted'
};
}
@
Put
(
':id/cover/:cardId'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
setCoverImage
(
@
Param
(
'id'
)
id
:
string
,
@
Param
(
'cardId'
)
cardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
attachmentsService
.
setCoverImage
(
cardId
,
id
,
user
);
}
}
\ No newline at end of file
backend/src/modules/attachments/attachments.module.ts
0 → 100644
View file @
130e018c
import
{
Module
}
from
'@nestjs/common'
;
import
{
MulterModule
}
from
'@nestjs/platform-express'
;
import
{
AttachmentsController
}
from
'./attachments.controller'
;
import
{
AttachmentsService
}
from
'./attachments.service'
;
import
{
MinioService
}
from
'./minio.service'
;
@
Module
({
imports
:
[
MulterModule
.
register
({
storage
:
undefined
,
// Use memory storage (buffer) — we upload to MinIO ourselves
}),
],
controllers
:
[
AttachmentsController
],
providers
:
[
AttachmentsService
,
MinioService
],
exports
:
[
AttachmentsService
,
MinioService
],
})
export
class
AttachmentsModule
{}
\ No newline at end of file
backend/src/modules/attachments/attachments.service.ts
0 → 100644
View file @
130e018c
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
ConfigService
}
from
'@nestjs/config'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
MinioService
}
from
'./minio.service'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
generateStoragePath
,
isAllowedMimeType
,
isWithinSizeLimit
,
isImageMimeType
}
from
'../../common/utils/file.util'
;
@
Injectable
()
export
class
AttachmentsService
{
private
readonly
logger
=
new
Logger
(
AttachmentsService
.
name
);
private
readonly
maxFileSizeBytes
:
number
;
private
readonly
maxAttachmentsPerCard
:
number
;
private
readonly
allowedMimeTypes
:
string
[];
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
minioService
:
MinioService
,
private
readonly
configService
:
ConfigService
,
)
{
this
.
maxFileSizeBytes
=
this
.
configService
.
get
<
number
>
(
'upload.maxFileSizeBytes'
)
||
26214400
;
this
.
maxAttachmentsPerCard
=
this
.
configService
.
get
<
number
>
(
'upload.maxAttachmentsPerCard'
)
||
20
;
this
.
allowedMimeTypes
=
this
.
configService
.
get
<
string
[]
>
(
'upload.allowedFileMimeTypes'
)
||
[];
}
async
upload
(
cardId
:
string
,
file
:
Express
.
Multer
.
File
,
currentUser
:
RequestUser
,
):
Promise
<
any
>
{
if
(
!
file
)
{
throw
new
BadRequestException
(
'No file provided'
);
}
// Validate file size
if
(
!
isWithinSizeLimit
(
file
.
size
,
this
.
maxFileSizeBytes
))
{
throw
new
BadRequestException
(
`File too large. Maximum size:
${
Math
.
round
(
this
.
maxFileSizeBytes
/
1048576
)}
MB`
,
);
}
// Validate MIME type
if
(
this
.
allowedMimeTypes
.
length
>
0
&&
!
isAllowedMimeType
(
file
.
mimetype
,
this
.
allowedMimeTypes
))
{
throw
new
BadRequestException
(
`File type "
${
file
.
mimetype
}
" is not allowed`
,
);
}
// Verify card exists
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
include
:
{
column
:
{
select
:
{
boardId
:
true
}
},
assignees
:
{
select
:
{
id
:
true
}
},
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
// Permission check
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
const
isAssigned
=
card
.
assignees
.
some
((
a
:
any
)
=>
a
.
id
===
currentUser
.
id
);
if
(
!
isAssigned
)
{
throw
new
ForbiddenException
(
'You can only attach files to cards assigned to you'
);
}
}
// Check attachment limit
const
existingCount
=
await
this
.
prisma
.
attachment
.
count
({
where
:
{
cardId
},
});
if
(
existingCount
>=
this
.
maxAttachmentsPerCard
)
{
throw
new
BadRequestException
(
`Maximum
${
this
.
maxAttachmentsPerCard
}
attachments per card. Delete an existing attachment first.`
,
);
}
// Generate storage path
const
storagePath
=
generateStoragePath
(
'cards'
,
cardId
,
file
.
originalname
);
// Upload to MinIO
try
{
await
this
.
minioService
.
upload
(
storagePath
,
file
.
buffer
,
file
.
mimetype
,
file
.
size
);
}
catch
(
err
)
{
this
.
logger
.
error
(
`MinIO upload failed:
${
err
.
message
}
`
);
throw
new
BadRequestException
(
'File upload failed. Please try again.'
);
}
// Create database record
const
attachment
=
await
this
.
prisma
.
attachment
.
create
({
data
:
{
cardId
,
originalName
:
file
.
originalname
,
storagePath
,
mimeType
:
file
.
mimetype
,
sizeBytes
:
file
.
size
,
uploadedById
:
currentUser
.
id
,
},
include
:
{
uploadedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
},
},
},
});
// If this is the first image attachment and card has no cover, set as cover
if
(
isImageMimeType
(
file
.
mimetype
)
&&
!
card
.
coverImage
)
{
try
{
const
url
=
await
this
.
minioService
.
getPresignedUrl
(
storagePath
,
86400
*
7
);
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
coverImage
:
url
},
});
}
catch
{
// Non-critical — cover image is cosmetic
}
}
// Log activity
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
,
userId
:
currentUser
.
id
,
action
:
'ATTACHMENT_ADDED'
,
metadata
:
{
fileName
:
file
.
originalname
,
mimeType
:
file
.
mimetype
,
sizeBytes
:
file
.
size
,
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log attachment activity:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Attachment "
${
file
.
originalname
}
" uploaded to card
${
cardId
}
by
${
currentUser
.
email
}
`
,
);
return
this
.
formatAttachment
(
attachment
);
}
async
findByCard
(
cardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
const
attachments
=
await
this
.
prisma
.
attachment
.
findMany
({
where
:
{
cardId
},
orderBy
:
{
createdAt
:
'desc'
},
include
:
{
uploadedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
},
},
},
});
const
result
=
[];
for
(
const
att
of
attachments
)
{
result
.
push
(
await
this
.
formatAttachment
(
att
));
}
return
result
;
}
async
getDownloadUrl
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
{
url
:
string
;
fileName
:
string
}
>
{
const
attachment
=
await
this
.
prisma
.
attachment
.
findUnique
({
where
:
{
id
}
});
if
(
!
attachment
)
{
throw
new
NotFoundException
(
'Attachment not found'
);
}
try
{
const
url
=
await
this
.
minioService
.
getPresignedUrl
(
attachment
.
storagePath
,
3600
);
return
{
url
,
fileName
:
attachment
.
originalName
};
}
catch
(
err
)
{
this
.
logger
.
error
(
`Failed to generate download URL:
${
err
.
message
}
`
);
throw
new
BadRequestException
(
'Could not generate download link'
);
}
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
const
attachment
=
await
this
.
prisma
.
attachment
.
findUnique
({
where
:
{
id
},
include
:
{
card
:
{
include
:
{
column
:
{
select
:
{
boardId
:
true
}
},
},
},
},
});
if
(
!
attachment
)
{
throw
new
NotFoundException
(
'Attachment not found'
);
}
// Permission: Contractors cannot delete. TL can on own boards. Admin/SA can anywhere.
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
throw
new
ForbiddenException
(
'Contractors cannot delete attachments'
);
}
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
:
attachment
.
card
.
column
.
boardId
,
userId
:
currentUser
.
id
,
},
},
});
if
(
!
membership
)
{
throw
new
ForbiddenException
(
'You can only manage attachments on your boards'
);
}
}
// Delete from MinIO
try
{
await
this
.
minioService
.
delete
(
attachment
.
storagePath
);
}
catch
(
err
)
{
this
.
logger
.
error
(
`Failed to delete from MinIO:
${
err
.
message
}
`
);
// Continue with DB deletion even if MinIO fails
}
// If this was the cover image, clear it
if
(
attachment
.
card
.
coverImage
)
{
try
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
attachment
.
cardId
},
data
:
{
coverImage
:
null
},
});
}
catch
{
// Non-critical
}
}
// Delete DB record
await
this
.
prisma
.
attachment
.
delete
({
where
:
{
id
}
});
// Log activity
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
:
attachment
.
cardId
,
userId
:
currentUser
.
id
,
action
:
'ATTACHMENT_REMOVED'
,
metadata
:
{
fileName
:
attachment
.
originalName
,
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log attachment deletion:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Attachment
${
id
}
deleted by
${
currentUser
.
email
}
`
);
}
async
setCoverImage
(
cardId
:
string
,
attachmentId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
throw
new
ForbiddenException
(
'Contractors cannot change cover images'
);
}
const
attachment
=
await
this
.
prisma
.
attachment
.
findFirst
({
where
:
{
id
:
attachmentId
,
cardId
},
});
if
(
!
attachment
)
{
throw
new
NotFoundException
(
'Attachment not found on this card'
);
}
if
(
!
isImageMimeType
(
attachment
.
mimeType
))
{
throw
new
BadRequestException
(
'Only image attachments can be set as cover'
);
}
try
{
const
url
=
await
this
.
minioService
.
getPresignedUrl
(
attachment
.
storagePath
,
86400
*
30
);
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
coverImage
:
url
},
});
return
{
coverImage
:
url
};
}
catch
(
err
)
{
throw
new
BadRequestException
(
'Failed to set cover image'
);
}
}
private
async
formatAttachment
(
attachment
:
any
):
Promise
<
any
>
{
let
downloadUrl
:
string
|
null
=
null
;
try
{
downloadUrl
=
await
this
.
minioService
.
getPresignedUrl
(
attachment
.
storagePath
,
3600
);
}
catch
{
// MinIO might not be available
}
return
{
id
:
attachment
.
id
,
cardId
:
attachment
.
cardId
,
originalName
:
attachment
.
originalName
,
mimeType
:
attachment
.
mimeType
,
sizeBytes
:
attachment
.
sizeBytes
,
isImage
:
isImageMimeType
(
attachment
.
mimeType
),
downloadUrl
,
uploadedBy
:
attachment
.
uploadedBy
||
null
,
createdAt
:
attachment
.
createdAt
,
};
}
}
\ No newline at end of file
backend/src/modules/attachments/dto/attachment-response.dto.ts
0 → 100644
View file @
130e018c
export
class
AttachmentResponseDto
{
id
:
string
;
cardId
:
string
;
originalName
:
string
;
mimeType
:
string
;
sizeBytes
:
number
;
isImage
:
boolean
;
downloadUrl
:
string
|
null
;
uploadedBy
:
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
avatar
:
string
|
null
;
};
createdAt
:
string
;
}
\ No newline at end of file
backend/src/modules/attachments/minio.service.ts
0 → 100644
View file @
130e018c
import
{
Injectable
,
OnModuleInit
,
Logger
}
from
'@nestjs/common'
;
import
{
ConfigService
}
from
'@nestjs/config'
;
import
*
as
Minio
from
'minio'
;
import
{
Readable
}
from
'stream'
;
@
Injectable
()
export
class
MinioService
implements
OnModuleInit
{
private
readonly
logger
=
new
Logger
(
MinioService
.
name
);
private
client
:
Minio
.
Client
;
private
bucket
:
string
;
constructor
(
private
readonly
configService
:
ConfigService
)
{}
async
onModuleInit
():
Promise
<
void
>
{
const
endPoint
=
this
.
configService
.
get
<
string
>
(
'minio.endPoint'
)
||
'localhost'
;
const
port
=
this
.
configService
.
get
<
number
>
(
'minio.port'
)
||
9000
;
const
useSSL
=
this
.
configService
.
get
<
boolean
>
(
'minio.useSSL'
)
||
false
;
const
accessKey
=
this
.
configService
.
get
<
string
>
(
'minio.accessKey'
)
||
'minioadmin'
;
const
secretKey
=
this
.
configService
.
get
<
string
>
(
'minio.secretKey'
)
||
'minioadmin'
;
this
.
bucket
=
this
.
configService
.
get
<
string
>
(
'minio.bucket'
)
||
'hr-files'
;
this
.
client
=
new
Minio
.
Client
({
endPoint
,
port
,
useSSL
,
accessKey
,
secretKey
,
});
try
{
const
bucketExists
=
await
this
.
client
.
bucketExists
(
this
.
bucket
);
if
(
!
bucketExists
)
{
await
this
.
client
.
makeBucket
(
this
.
bucket
);
this
.
logger
.
log
(
`Bucket "
${
this
.
bucket
}
" created`
);
}
else
{
this
.
logger
.
log
(
`Bucket "
${
this
.
bucket
}
" already exists`
);
}
}
catch
(
err
)
{
this
.
logger
.
error
(
`MinIO initialization failed:
${
err
.
message
}
`
);
this
.
logger
.
warn
(
'File storage will not be available. Ensure MinIO is running.'
);
}
}
async
upload
(
storagePath
:
string
,
buffer
:
Buffer
,
mimeType
:
string
,
size
:
number
,
):
Promise
<
void
>
{
await
this
.
client
.
putObject
(
this
.
bucket
,
storagePath
,
buffer
,
size
,
{
'Content-Type'
:
mimeType
,
});
this
.
logger
.
log
(
`File uploaded to
${
this
.
bucket
}
/
${
storagePath
}
(
${
size
}
bytes)`
);
}
async
uploadStream
(
storagePath
:
string
,
stream
:
Readable
,
mimeType
:
string
,
size
:
number
,
):
Promise
<
void
>
{
await
this
.
client
.
putObject
(
this
.
bucket
,
storagePath
,
stream
,
size
,
{
'Content-Type'
:
mimeType
,
});
}
async
getPresignedUrl
(
storagePath
:
string
,
expirySeconds
:
number
=
3600
):
Promise
<
string
>
{
return
this
.
client
.
presignedGetObject
(
this
.
bucket
,
storagePath
,
expirySeconds
);
}
async
getPresignedUploadUrl
(
storagePath
:
string
,
expirySeconds
:
number
=
600
):
Promise
<
string
>
{
return
this
.
client
.
presignedPutObject
(
this
.
bucket
,
storagePath
,
expirySeconds
);
}
async
delete
(
storagePath
:
string
):
Promise
<
void
>
{
await
this
.
client
.
removeObject
(
this
.
bucket
,
storagePath
);
this
.
logger
.
log
(
`File deleted:
${
this
.
bucket
}
/
${
storagePath
}
`
);
}
async
getFileStream
(
storagePath
:
string
):
Promise
<
Readable
>
{
return
this
.
client
.
getObject
(
this
.
bucket
,
storagePath
);
}
async
getFileStat
(
storagePath
:
string
):
Promise
<
Minio
.
BucketItemStat
>
{
return
this
.
client
.
statObject
(
this
.
bucket
,
storagePath
);
}
async
exists
(
storagePath
:
string
):
Promise
<
boolean
>
{
try
{
await
this
.
client
.
statObject
(
this
.
bucket
,
storagePath
);
return
true
;
}
catch
{
return
false
;
}
}
}
\ No newline at end of file
backend/src/modules/checklists/checklists.controller.ts
0 → 100644
View file @
130e018c
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
ChecklistsService
}
from
'./checklists.service'
;
import
{
CreateChecklistDto
,
CreateChecklistItemDto
}
from
'./dto/create-checklist.dto'
;
import
{
UpdateChecklistDto
,
UpdateChecklistItemDto
,
ReorderChecklistItemsDto
}
from
'./dto/update-checklist.dto'
;
import
{
ToggleChecklistItemDto
}
from
'./dto/toggle-item.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Controller
(
'checklists'
)
export
class
ChecklistsController
{
constructor
(
private
readonly
checklistsService
:
ChecklistsService
)
{}
@
Post
()
async
create
(@
Body
()
dto
:
CreateChecklistDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
checklistsService
.
create
(
dto
,
user
);
}
@
Get
(
'card/:cardId'
)
async
findByCard
(@
Param
(
'cardId'
)
cardId
:
string
)
{
return
this
.
checklistsService
.
findByCard
(
cardId
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateChecklistDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
checklistsService
.
update
(
id
,
dto
,
user
);
}
@
Delete
(
':id'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
checklistsService
.
delete
(
id
,
user
);
return
{
message
:
'Checklist deleted'
};
}
@
Post
(
':id/items'
)
async
addItem
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
CreateChecklistItemDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
checklistsService
.
addItem
(
id
,
dto
,
user
);
}
@
Put
(
'items/:itemId'
)
async
updateItem
(
@
Param
(
'itemId'
)
itemId
:
string
,
@
Body
()
dto
:
UpdateChecklistItemDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
checklistsService
.
updateItem
(
itemId
,
dto
,
user
);
}
@
Put
(
'items/:itemId/toggle'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
toggleItem
(
@
Param
(
'itemId'
)
itemId
:
string
,
@
Body
()
dto
:
ToggleChecklistItemDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
checklistsService
.
toggleItem
(
itemId
,
dto
,
user
);
}
@
Delete
(
'items/:itemId'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
deleteItem
(@
Param
(
'itemId'
)
itemId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
checklistsService
.
deleteItem
(
itemId
,
user
);
return
{
message
:
'Checklist item deleted'
};
}
@
Put
(
':id/reorder'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
reorderItems
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
ReorderChecklistItemsDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
checklistsService
.
reorderItems
(
id
,
dto
,
user
);
}
}
\ No newline at end of file
backend/src/modules/checklists/checklists.module.ts
0 → 100644
View file @
130e018c
import
{
Module
}
from
'@nestjs/common'
;
import
{
ChecklistsController
}
from
'./checklists.controller'
;
import
{
ChecklistsService
}
from
'./checklists.service'
;
@
Module
({
controllers
:
[
ChecklistsController
],
providers
:
[
ChecklistsService
],
exports
:
[
ChecklistsService
],
})
export
class
ChecklistsModule
{}
\ No newline at end of file
backend/src/modules/checklists/checklists.service.ts
0 → 100644
View file @
130e018c
This diff is collapsed.
Click to expand it.
backend/src/modules/checklists/dto/create-checklist.dto.ts
0 → 100644
View file @
130e018c
import
{
IsString
,
IsOptional
,
IsArray
,
ValidateNested
,
MinLength
,
MaxLength
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
export
class
CreateChecklistDto
{
@
IsString
()
cardId
:
string
;
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
100
)
title
:
string
;
}
export
class
CreateChecklistItemDto
{
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
200
)
title
:
string
;
@
IsOptional
()
@
IsString
()
assigneeId
?:
string
;
@
IsOptional
()
@
IsString
()
dueDate
?:
string
;
}
export
class
BulkCreateChecklistItemsDto
{
@
IsArray
()
@
ValidateNested
({
each
:
true
})
@
Type
(()
=>
CreateChecklistItemDto
)
items
:
CreateChecklistItemDto
[];
}
\ No newline at end of file
backend/src/modules/checklists/dto/toggle-item.dto.ts
0 → 100644
View file @
130e018c
import
{
IsBoolean
}
from
'class-validator'
;
export
class
ToggleChecklistItemDto
{
@
IsBoolean
()
isCompleted
:
boolean
;
}
\ No newline at end of file
backend/src/modules/checklists/dto/update-checklist.dto.ts
0 → 100644
View file @
130e018c
import
{
IsString
,
IsOptional
,
IsArray
,
MinLength
,
MaxLength
}
from
'class-validator'
;
export
class
UpdateChecklistDto
{
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
100
)
title
?:
string
;
}
export
class
UpdateChecklistItemDto
{
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
200
)
title
?:
string
;
@
IsOptional
()
@
IsString
()
assigneeId
?:
string
;
@
IsOptional
()
@
IsString
()
dueDate
?:
string
;
}
export
class
ReorderChecklistItemsDto
{
@
IsArray
()
@
IsString
({
each
:
true
})
itemIds
:
string
[];
}
\ No newline at end of file
backend/src/modules/comments/comments.controller.ts
0 → 100644
View file @
130e018c
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
CommentsService
}
from
'./comments.service'
;
import
{
CreateCommentDto
}
from
'./dto/create-comment.dto'
;
import
{
UpdateCommentDto
}
from
'./dto/update-comment.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'comments'
)
export
class
CommentsController
{
constructor
(
private
readonly
commentsService
:
CommentsService
)
{}
@
Post
()
async
create
(@
Body
()
dto
:
CreateCommentDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
commentsService
.
create
(
dto
,
user
);
}
@
Get
(
'card/:cardId'
)
async
findByCard
(@
Param
(
'cardId'
)
cardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
commentsService
.
findByCard
(
cardId
,
user
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateCommentDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
commentsService
.
update
(
id
,
dto
,
user
);
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
commentsService
.
delete
(
id
,
user
);
return
{
message
:
'Comment deleted'
};
}
}
\ No newline at end of file
backend/src/modules/comments/comments.module.ts
0 → 100644
View file @
130e018c
import
{
Module
}
from
'@nestjs/common'
;
import
{
CommentsController
}
from
'./comments.controller'
;
import
{
CommentsService
}
from
'./comments.service'
;
@
Module
({
controllers
:
[
CommentsController
],
providers
:
[
CommentsService
],
exports
:
[
CommentsService
],
})
export
class
CommentsModule
{}
\ No newline at end of file
backend/src/modules/comments/comments.service.ts
0 → 100644
View file @
130e018c
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateCommentDto
}
from
'./dto/create-comment.dto'
;
import
{
UpdateCommentDto
}
from
'./dto/update-comment.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
const
EDIT_WINDOW_MINUTES
=
15
;
@
Injectable
()
export
class
CommentsService
{
private
readonly
logger
=
new
Logger
(
CommentsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateCommentDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
dto
.
cardId
,
deletedAt
:
null
},
include
:
{
column
:
{
select
:
{
boardId
:
true
}
},
assignees
:
{
select
:
{
id
:
true
}
},
watchers
:
{
select
:
{
id
:
true
}
},
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
// Permission: Contractors can only comment on cards they're assigned to or watching
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
const
isAssigned
=
card
.
assignees
.
some
((
a
:
any
)
=>
a
.
id
===
currentUser
.
id
);
const
isWatcher
=
card
.
watchers
.
some
((
w
:
any
)
=>
w
.
id
===
currentUser
.
id
);
if
(
!
isAssigned
&&
!
isWatcher
)
{
throw
new
ForbiddenException
(
'You can only comment on cards you are assigned to or watching'
);
}
}
// TEAM_LEAD: must be a member of the board
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
:
card
.
column
.
boardId
,
userId
:
currentUser
.
id
}
},
});
if
(
!
membership
)
{
throw
new
ForbiddenException
(
'You can only comment on cards in your boards'
);
}
}
const
editableUntil
=
new
Date
(
Date
.
now
()
+
EDIT_WINDOW_MINUTES
*
60
*
1000
);
const
comment
=
await
this
.
prisma
.
comment
.
create
({
data
:
{
cardId
:
dto
.
cardId
,
userId
:
currentUser
.
id
,
content
:
dto
.
content
,
editableUntil
,
mentions
:
dto
.
mentions
||
[],
isEdited
:
false
,
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
},
},
},
});
// Log card activity
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
:
dto
.
cardId
,
userId
:
currentUser
.
id
,
action
:
'COMMENTED'
,
metadata
:
{
commentId
:
comment
.
id
,
preview
:
dto
.
content
.
substring
(
0
,
100
),
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log comment activity:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Comment created on card
${
dto
.
cardId
}
by
${
currentUser
.
email
}
`
);
return
this
.
formatComment
(
comment
,
currentUser
.
id
);
}
async
findByCard
(
cardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
const
comments
=
await
this
.
prisma
.
comment
.
findMany
({
where
:
{
cardId
},
orderBy
:
{
createdAt
:
'asc'
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
},
},
},
});
return
comments
.
map
((
c
:
any
)
=>
this
.
formatComment
(
c
,
currentUser
.
id
));
}
async
update
(
id
:
string
,
dto
:
UpdateCommentDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
comment
=
await
this
.
prisma
.
comment
.
findUnique
({
where
:
{
id
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
},
},
},
});
if
(
!
comment
)
{
throw
new
NotFoundException
(
'Comment not found'
);
}
// Only the author can edit
if
(
comment
.
userId
!==
currentUser
.
id
&&
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'You can only edit your own comments'
);
}
// Check edit window (Super Admin bypasses)
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
const
now
=
new
Date
();
if
(
now
>
comment
.
editableUntil
)
{
throw
new
BadRequestException
(
'Edit window has expired. Comments can only be edited within 15 minutes of posting.'
,
);
}
}
// Store original content on first edit
const
updateData
:
any
=
{
content
:
dto
.
content
,
isEdited
:
true
,
};
if
(
!
comment
.
isEdited
)
{
updateData
.
originalContent
=
comment
.
content
;
}
const
updated
=
await
this
.
prisma
.
comment
.
update
({
where
:
{
id
},
data
:
updateData
,
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
},
},
},
});
this
.
logger
.
log
(
`Comment
${
id
}
edited by
${
currentUser
.
email
}
`
);
return
this
.
formatComment
(
updated
,
currentUser
.
id
);
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can delete comments'
);
}
const
comment
=
await
this
.
prisma
.
comment
.
findUnique
({
where
:
{
id
}
});
if
(
!
comment
)
{
throw
new
NotFoundException
(
'Comment not found'
);
}
await
this
.
prisma
.
comment
.
delete
({
where
:
{
id
}
});
// Log deletion activity
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
:
comment
.
cardId
,
userId
:
currentUser
.
id
,
action
:
'COMMENT_DELETED'
,
metadata
:
{
commentId
:
id
,
deletedContent
:
comment
.
content
.
substring
(
0
,
200
),
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log comment deletion:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Comment
${
id
}
deleted by
${
currentUser
.
email
}
`
);
}
private
formatComment
(
comment
:
any
,
viewerId
:
string
):
any
{
const
now
=
new
Date
();
const
canEdit
=
comment
.
userId
===
viewerId
&&
now
<
new
Date
(
comment
.
editableUntil
);
return
{
id
:
comment
.
id
,
cardId
:
comment
.
cardId
,
content
:
comment
.
content
,
isEdited
:
comment
.
isEdited
,
originalContent
:
comment
.
originalContent
||
null
,
editableUntil
:
comment
.
editableUntil
,
canEdit
,
mentions
:
comment
.
mentions
||
[],
user
:
comment
.
user
,
createdAt
:
comment
.
createdAt
,
updatedAt
:
comment
.
updatedAt
,
};
}
}
\ No newline at end of file
backend/src/modules/comments/dto/comment-response.dto.ts
0 → 100644
View file @
130e018c
export
class
CommentResponseDto
{
id
:
string
;
cardId
:
string
;
content
:
string
;
isEdited
:
boolean
;
originalContent
:
string
|
null
;
editableUntil
:
string
;
canEdit
:
boolean
;
mentions
:
string
[]
|
null
;
user
:
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
displayName
:
string
|
null
;
avatar
:
string
|
null
;
};
createdAt
:
string
;
updatedAt
:
string
;
}
\ No newline at end of file
backend/src/modules/comments/dto/create-comment.dto.ts
0 → 100644
View file @
130e018c
import
{
IsString
,
IsOptional
,
IsArray
,
MinLength
}
from
'class-validator'
;
export
class
CreateCommentDto
{
@
IsString
()
cardId
:
string
;
@
IsString
()
@
MinLength
(
1
,
{
message
:
'Comment cannot be empty'
})
content
:
string
;
@
IsOptional
()
@
IsArray
()
@
IsString
({
each
:
true
})
mentions
?:
string
[];
}
\ No newline at end of file
backend/src/modules/comments/dto/update-comment.dto.ts
0 → 100644
View file @
130e018c
import
{
IsString
,
MinLength
}
from
'class-validator'
;
export
class
UpdateCommentDto
{
@
IsString
()
@
MinLength
(
1
,
{
message
:
'Comment cannot be empty'
})
content
:
string
;
}
\ No newline at end of file
prisma/schema-comments-checklists.prisma
0 → 100644
View file @
130e018c
//
═══════════════════════════════════════════
//
COMMENT
,
ATTACHMENT
,
CHECKLIST
models
//
Merge
into
your
existing
schema
if
missing
//
═══════════════════════════════════════════
model
Comment
{
id
String
@
id
@
default
(
uuid
())
cardId
String
card
Card
@
relation
(
fields
:
[
cardId
],
references
:
[
id
],
onDelete
:
Cascade
)
userId
String
user
User
@
relation
(
"UserComments"
,
fields
:
[
userId
],
references
:
[
id
])
content
String
isEdited
Boolean
@
default
(
false
)
originalContent
String
?
editableUntil
DateTime
mentions
Json
?
@
default
(
"[]"
)
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
cardId
])
@@
index
([
userId
])
@@
index
([
createdAt
])
}
model
Attachment
{
id
String
@
id
@
default
(
uuid
())
cardId
String
card
Card
@
relation
(
fields
:
[
cardId
],
references
:
[
id
],
onDelete
:
Cascade
)
originalName
String
storagePath
String
mimeType
String
sizeBytes
Int
uploadedById
String
uploadedBy
User
@
relation
(
"UserAttachments"
,
fields
:
[
uploadedById
],
references
:
[
id
])
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
cardId
])
@@
index
([
uploadedById
])
}
model
Checklist
{
id
String
@
id
@
default
(
uuid
())
cardId
String
card
Card
@
relation
(
fields
:
[
cardId
],
references
:
[
id
],
onDelete
:
Cascade
)
title
String
position
Int
@
default
(
0
)
items
ChecklistItem
[]
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
cardId
])
}
model
ChecklistItem
{
id
String
@
id
@
default
(
uuid
())
checklistId
String
checklist
Checklist
@
relation
(
fields
:
[
checklistId
],
references
:
[
id
],
onDelete
:
Cascade
)
title
String
position
Int
@
default
(
0
)
isCompleted
Boolean
@
default
(
false
)
completedAt
DateTime
?
assigneeId
String
?
assignee
User
?
@
relation
(
"ChecklistItemAssignee"
,
fields
:
[
assigneeId
],
references
:
[
id
],
onDelete
:
SetNull
)
dueDate
DateTime
?
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
checklistId
])
@@
index
([
assigneeId
])
}
model
CardActivity
{
id
String
@
id
@
default
(
uuid
())
cardId
String
card
Card
@
relation
(
fields
:
[
cardId
],
references
:
[
id
],
onDelete
:
Cascade
)
userId
String
?
user
User
?
@
relation
(
"UserCardActivities"
,
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
SetNull
)
action
String
metadata
Json
?
createdAt
DateTime
@
default
(
now
())
@@
index
([
cardId
])
@@
index
([
userId
])
@@
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