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
22a7a58a
Commit
22a7a58a
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 19 files via Son of Anton
parent
fbb29954
Changes
19
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
2019 additions
and
0 deletions
+2019
-0
app.module.ts
backend/src/app.module.ts
+4
-0
card-assignment.service.ts
backend/src/modules/cards/card-assignment.service.ts
+156
-0
card-movement.service.ts
backend/src/modules/cards/card-movement.service.ts
+244
-0
cards.controller.ts
backend/src/modules/cards/cards.controller.ts
+146
-0
cards.module.ts
backend/src/modules/cards/cards.module.ts
+12
-0
cards.service.ts
backend/src/modules/cards/cards.service.ts
+723
-0
assign-card.dto.ts
backend/src/modules/cards/dto/assign-card.dto.ts
+23
-0
card-filter.dto.ts
backend/src/modules/cards/dto/card-filter.dto.ts
+53
-0
card-response.dto.ts
backend/src/modules/cards/dto/card-response.dto.ts
+41
-0
create-card.dto.ts
backend/src/modules/cards/dto/create-card.dto.ts
+59
-0
duplicate-card.dto.ts
backend/src/modules/cards/dto/duplicate-card.dto.ts
+12
-0
move-card.dto.ts
backend/src/modules/cards/dto/move-card.dto.ts
+16
-0
update-card.dto.ts
backend/src/modules/cards/dto/update-card.dto.ts
+51
-0
create-label.dto.ts
backend/src/modules/labels/dto/create-label.dto.ts
+22
-0
label-response.dto.ts
backend/src/modules/labels/dto/label-response.dto.ts
+12
-0
update-label.dto.ts
backend/src/modules/labels/dto/update-label.dto.ts
+19
-0
labels.controller.ts
backend/src/modules/labels/labels.controller.ts
+79
-0
labels.module.ts
backend/src/modules/labels/labels.module.ts
+10
-0
labels.service.ts
backend/src/modules/labels/labels.service.ts
+337
-0
No files found.
backend/src/app.module.ts
View file @
22a7a58a
...
...
@@ -19,6 +19,8 @@ import { UsersModule } from './modules/users/users.module';
import
{
OnboardingModule
}
from
'./modules/onboarding/onboarding.module'
;
import
{
BoardsModule
}
from
'./modules/boards/boards.module'
;
import
{
ColumnsModule
}
from
'./modules/columns/columns.module'
;
import
{
LabelsModule
}
from
'./modules/labels/labels.module'
;
import
{
CardsModule
}
from
'./modules/cards/cards.module'
;
import
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
...
...
@@ -44,6 +46,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
OnboardingModule
,
BoardsModule
,
ColumnsModule
,
LabelsModule
,
CardsModule
,
],
providers
:
[
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
...
...
backend/src/modules/cards/card-assignment.service.ts
0 → 100644
View file @
22a7a58a
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
CardAssignmentService
{
private
readonly
logger
=
new
Logger
(
CardAssignmentService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
assign
(
cardId
:
string
,
assigneeIds
:
string
[],
currentUser
:
RequestUser
):
Promise
<
any
>
{
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'
);
}
// Contractors cannot assign anyone
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
throw
new
ForbiddenException
(
'Contractors cannot assign cards'
);
}
// TEAM_LEAD: can only assign members of their boards
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 assign cards on boards you manage'
);
}
}
// Verify all assignees are board members
for
(
const
userId
of
assigneeIds
)
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
},
});
if
(
!
user
)
{
this
.
logger
.
warn
(
`User
${
userId
}
not found, skipping assignment`
);
continue
;
}
const
isMember
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
:
card
.
column
.
boardId
,
userId
}
},
});
if
(
!
isMember
)
{
this
.
logger
.
warn
(
`User
${
userId
}
is not a member of board
${
card
.
column
.
boardId
}
, skipping`
);
continue
;
}
// Connect the assignee
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
assignees
:
{
connect
:
{
id
:
userId
}
}
},
});
// Log activity
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
,
userId
:
currentUser
.
id
,
action
:
'ASSIGNED'
,
metadata
:
{
assigneeId
:
userId
,
assigneeName
:
`
${
user
.
firstName
}
${
user
.
lastName
}
`
,
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log assignment activity:
${
err
.
message
}
`
);
}
}
this
.
logger
.
log
(
`Card
${
cardId
}
: assigned
${
assigneeIds
.
length
}
user(s) by
${
currentUser
.
email
}
`
);
return
this
.
getCardAssignees
(
cardId
);
}
async
unassign
(
cardId
:
string
,
userIds
:
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'
);
}
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
throw
new
ForbiddenException
(
'Contractors cannot unassign cards'
);
}
for
(
const
userId
of
userIds
)
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
assignees
:
{
disconnect
:
{
id
:
userId
}
}
},
});
try
{
const
user
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
id
:
userId
},
select
:
{
firstName
:
true
,
lastName
:
true
},
});
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
,
userId
:
currentUser
.
id
,
action
:
'UNASSIGNED'
,
metadata
:
{
unassignedUserId
:
userId
,
unassignedUserName
:
user
?
`
${
user
.
firstName
}
${
user
.
lastName
}
`
:
userId
,
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log unassignment activity:
${
err
.
message
}
`
);
}
}
this
.
logger
.
log
(
`Card
${
cardId
}
: unassigned
${
userIds
.
length
}
user(s) by
${
currentUser
.
email
}
`
);
return
this
.
getCardAssignees
(
cardId
);
}
async
getCardAssignees
(
cardId
:
string
):
Promise
<
any
[]
>
{
const
card
=
await
this
.
prisma
.
card
.
findUnique
({
where
:
{
id
:
cardId
},
select
:
{
assignees
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
,
},
},
},
});
return
card
?.
assignees
||
[];
}
}
\ No newline at end of file
backend/src/modules/cards/card-movement.service.ts
0 → 100644
View file @
22a7a58a
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
ConflictException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
MoveCardDto
}
from
'./dto/move-card.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
CardMovementService
{
private
readonly
logger
=
new
Logger
(
CardMovementService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
moveCard
(
cardId
:
string
,
dto
:
MoveCardDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
include
:
{
column
:
{
select
:
{
id
:
true
,
boardId
:
true
,
type
:
true
,
name
:
true
}
},
assignees
:
{
select
:
{
id
:
true
}
},
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
const
targetColumn
=
await
this
.
prisma
.
column
.
findUnique
({
where
:
{
id
:
dto
.
columnId
},
});
if
(
!
targetColumn
)
{
throw
new
NotFoundException
(
'Target column not found'
);
}
if
(
targetColumn
.
boardId
!==
card
.
column
.
boardId
)
{
throw
new
BadRequestException
(
'Cannot move card to a column on a different board'
);
}
const
sourceColumn
=
card
.
column
;
// If same column, just reorder
if
(
sourceColumn
.
id
===
targetColumn
.
id
)
{
return
this
.
reorderInColumn
(
card
,
dto
.
position
??
0
);
}
// ─── PERMISSION ENFORCEMENT ─────────────────────────────────
this
.
enforceMovementPermissions
(
currentUser
,
sourceColumn
,
targetColumn
,
card
);
// ─── FROZEN COLUMN LOGIC ─────────────────────────────────────
if
(
targetColumn
.
type
===
'FROZEN'
)
{
if
(
!
dto
.
frozenReason
||
dto
.
frozenReason
.
length
<
20
)
{
throw
new
BadRequestException
(
'Moving to Frozen requires a reason of at least 20 characters'
);
}
}
// ─── WIP LIMIT CHECK ─────────────────────────────────────────
await
this
.
checkWipLimits
(
targetColumn
,
card
,
currentUser
);
// ─── CALCULATE POSITION ──────────────────────────────────────
const
newPosition
=
await
this
.
calculatePosition
(
targetColumn
.
id
,
dto
.
position
);
// ─── TRACK TIMING ────────────────────────────────────────────
const
now
=
new
Date
();
const
updateData
:
any
=
{
columnId
:
targetColumn
.
id
,
position
:
newPosition
,
};
// Entering Frozen
if
(
targetColumn
.
type
===
'FROZEN'
)
{
updateData
.
frozenReason
=
dto
.
frozenReason
;
updateData
.
frozenAt
=
now
;
}
// Leaving Frozen — calculate frozen time
if
(
sourceColumn
.
type
===
'FROZEN'
&&
targetColumn
.
type
!==
'FROZEN'
)
{
updateData
.
frozenReason
=
null
;
if
(
card
.
frozenAt
)
{
const
frozenMs
=
now
.
getTime
()
-
new
Date
(
card
.
frozenAt
).
getTime
();
const
existingFrozenMs
=
(
card
.
frozenTimeHours
||
0
)
*
3600000
;
updateData
.
frozenTimeHours
=
(
existingFrozenMs
+
frozenMs
)
/
3600000
;
}
updateData
.
frozenAt
=
null
;
}
// First move to DOING — track cycle time start
if
(
targetColumn
.
type
===
'DOING'
&&
!
card
.
startedAt
)
{
updateData
.
startedAt
=
now
;
}
// Moving to DONE
if
(
targetColumn
.
type
===
'DONE'
)
{
updateData
.
completedAt
=
now
;
// Calculate lead time (creation → done)
const
leadMs
=
now
.
getTime
()
-
new
Date
(
card
.
createdAt
).
getTime
();
updateData
.
leadTimeHours
=
leadMs
/
3600000
;
// Calculate cycle time (first doing → done, minus frozen time)
if
(
card
.
startedAt
)
{
const
cycleMs
=
now
.
getTime
()
-
new
Date
(
card
.
startedAt
).
getTime
();
const
frozenMs
=
(
updateData
.
frozenTimeHours
||
card
.
frozenTimeHours
||
0
)
*
3600000
;
updateData
.
cycleTimeHours
=
(
cycleMs
-
frozenMs
)
/
3600000
;
}
}
// Moving OUT of Done (reopening)
if
(
sourceColumn
.
type
===
'DONE'
&&
targetColumn
.
type
!==
'DONE'
)
{
updateData
.
completedAt
=
null
;
updateData
.
leadTimeHours
=
null
;
updateData
.
cycleTimeHours
=
null
;
}
const
updated
=
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
updateData
,
});
// ─── LOG ACTIVITY ────────────────────────────────────────────
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
,
userId
:
currentUser
.
id
,
action
:
'MOVED'
,
metadata
:
{
fromColumn
:
sourceColumn
.
name
,
fromColumnId
:
sourceColumn
.
id
,
toColumn
:
targetColumn
.
name
,
toColumnId
:
targetColumn
.
id
,
frozenReason
:
dto
.
frozenReason
||
null
,
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log card activity:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Card
${
cardId
}
moved from "
${
sourceColumn
.
name
}
" to "
${
targetColumn
.
name
}
" by
${
currentUser
.
email
}
`
,
);
return
updated
;
}
private
enforceMovementPermissions
(
currentUser
:
RequestUser
,
sourceColumn
:
any
,
targetColumn
:
any
,
card
:
any
,
):
void
{
const
isSA
=
currentUser
.
role
===
'SUPER_ADMIN'
;
const
isAdmin
=
currentUser
.
role
===
'ADMIN'
;
const
isTL
=
currentUser
.
role
===
'TEAM_LEAD'
;
const
isContractor
=
currentUser
.
role
===
'CONTRACTOR'
;
// Moving TO Done — only SA, Admin, TL
if
(
targetColumn
.
type
===
'DONE'
)
{
if
(
isContractor
)
{
throw
new
ForbiddenException
(
'Only Project Leaders and Admins can mark tasks as Done'
,
);
}
}
// Moving FROM Done — only SA
if
(
sourceColumn
.
type
===
'DONE'
)
{
if
(
!
isSA
)
{
throw
new
ForbiddenException
(
'Only Super Admin can reopen cards from Done'
);
}
}
// Contractors can only move their own assigned cards (except to Done)
if
(
isContractor
)
{
const
isAssigned
=
card
.
assignees
?.
some
((
a
:
any
)
=>
a
.
id
===
currentUser
.
id
);
if
(
!
isAssigned
)
{
throw
new
ForbiddenException
(
'You can only move cards assigned to you'
);
}
}
}
private
async
checkWipLimits
(
targetColumn
:
any
,
card
:
any
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
// Per-user WIP limit
if
(
targetColumn
.
wipLimit
&&
targetColumn
.
wipLimit
>
0
)
{
const
assigneeIds
=
card
.
assignees
?.
map
((
a
:
any
)
=>
a
.
id
)
||
[];
if
(
assigneeIds
.
length
>
0
)
{
for
(
const
assigneeId
of
assigneeIds
)
{
const
userCardCount
=
await
this
.
prisma
.
card
.
count
({
where
:
{
columnId
:
targetColumn
.
id
,
deletedAt
:
null
,
assignees
:
{
some
:
{
id
:
assigneeId
}
},
},
});
if
(
userCardCount
>=
targetColumn
.
wipLimit
)
{
throw
new
BadRequestException
(
`WIP limit reached in "
${
targetColumn
.
name
}
". Move a card out first.`
,
);
}
}
}
}
// Total WIP limit
if
(
targetColumn
.
wipLimitTotal
&&
targetColumn
.
wipLimitTotal
>
0
)
{
const
totalCount
=
await
this
.
prisma
.
card
.
count
({
where
:
{
columnId
:
targetColumn
.
id
,
deletedAt
:
null
},
});
if
(
totalCount
>=
targetColumn
.
wipLimitTotal
)
{
throw
new
BadRequestException
(
`Total WIP limit reached in "
${
targetColumn
.
name
}
" (
${
totalCount
}
/
${
targetColumn
.
wipLimitTotal
}
). Move a card out first.`
,
);
}
}
}
private
async
calculatePosition
(
columnId
:
string
,
requestedPosition
?:
number
):
Promise
<
number
>
{
const
maxResult
=
await
this
.
prisma
.
card
.
aggregate
({
where
:
{
columnId
,
deletedAt
:
null
},
_max
:
{
position
:
true
},
});
const
maxPosition
=
maxResult
.
_max
?.
position
||
0
;
if
(
requestedPosition
!==
undefined
&&
requestedPosition
!==
null
)
{
return
requestedPosition
;
}
// Place at the end
return
maxPosition
+
1
;
}
private
async
reorderInColumn
(
card
:
any
,
newPosition
:
number
):
Promise
<
any
>
{
return
this
.
prisma
.
card
.
update
({
where
:
{
id
:
card
.
id
},
data
:
{
position
:
newPosition
},
});
}
}
\ No newline at end of file
backend/src/modules/cards/cards.controller.ts
0 → 100644
View file @
22a7a58a
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
CardsService
}
from
'./cards.service'
;
import
{
CardMovementService
}
from
'./card-movement.service'
;
import
{
CardAssignmentService
}
from
'./card-assignment.service'
;
import
{
CreateCardDto
}
from
'./dto/create-card.dto'
;
import
{
UpdateCardDto
}
from
'./dto/update-card.dto'
;
import
{
MoveCardDto
}
from
'./dto/move-card.dto'
;
import
{
CardFilterDto
}
from
'./dto/card-filter.dto'
;
import
{
DuplicateCardDto
}
from
'./dto/duplicate-card.dto'
;
import
{
AssignCardDto
,
UnassignCardDto
,
SetBountyDto
}
from
'./dto/assign-card.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'cards'
)
export
class
CardsController
{
constructor
(
private
readonly
cardsService
:
CardsService
,
private
readonly
cardMovementService
:
CardMovementService
,
private
readonly
cardAssignmentService
:
CardAssignmentService
,
)
{}
@
Post
()
async
create
(@
Body
()
dto
:
CreateCardDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
cardsService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
Query
()
filter
:
CardFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
cardsService
.
findAll
(
filter
,
user
);
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
cardsService
.
findById
(
id
,
user
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateCardDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
cardsService
.
update
(
id
,
dto
,
user
);
}
@
Put
(
':id/move'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
moveCard
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
MoveCardDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
cardMovementService
.
moveCard
(
id
,
dto
,
user
);
return
this
.
cardsService
.
findById
(
id
,
user
);
}
@
Put
(
':id/assign'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
assignCard
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
AssignCardDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
cardAssignmentService
.
assign
(
id
,
dto
.
assigneeIds
,
user
);
}
@
Put
(
':id/unassign'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
unassignCard
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UnassignCardDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
cardAssignmentService
.
unassign
(
id
,
dto
.
userIds
,
user
);
}
@
Put
(
':id/bounty'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
setBounty
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
SetBountyDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
cardsService
.
setBounty
(
id
,
dto
.
bountyPiasters
,
dto
.
bountySplitJson
,
user
);
}
@
Post
(
':id/duplicate'
)
async
duplicateCard
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
DuplicateCardDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
cardsService
.
duplicate
(
id
,
dto
,
user
);
}
@
Post
(
':id/archive'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
archiveCard
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
cardsService
.
archive
(
id
,
user
);
return
{
message
:
'Card archived'
};
}
@
Post
(
':id/restore'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
restoreCard
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
cardsService
.
restore
(
id
,
user
);
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
permanentDelete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
cardsService
.
permanentDelete
(
id
,
user
);
return
{
message
:
'Card permanently deleted'
};
}
@
Get
(
':id/activity'
)
async
getActivity
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
cardsService
.
getCardActivity
(
id
,
user
);
}
@
Post
(
':id/watch'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
watchCard
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
cardsService
.
watchCard
(
id
,
user
);
return
{
message
:
'Now watching this card'
};
}
@
Delete
(
':id/watch'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
unwatchCard
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
cardsService
.
unwatchCard
(
id
,
user
);
return
{
message
:
'Stopped watching this card'
};
}
}
\ No newline at end of file
backend/src/modules/cards/cards.module.ts
0 → 100644
View file @
22a7a58a
import
{
Module
}
from
'@nestjs/common'
;
import
{
CardsController
}
from
'./cards.controller'
;
import
{
CardsService
}
from
'./cards.service'
;
import
{
CardMovementService
}
from
'./card-movement.service'
;
import
{
CardAssignmentService
}
from
'./card-assignment.service'
;
@
Module
({
controllers
:
[
CardsController
],
providers
:
[
CardsService
,
CardMovementService
,
CardAssignmentService
],
exports
:
[
CardsService
,
CardMovementService
,
CardAssignmentService
],
})
export
class
CardsModule
{}
\ No newline at end of file
backend/src/modules/cards/cards.service.ts
0 → 100644
View file @
22a7a58a
This diff is collapsed.
Click to expand it.
backend/src/modules/cards/dto/assign-card.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsArray
,
IsOptional
}
from
'class-validator'
;
export
class
AssignCardDto
{
@
IsArray
()
@
IsString
({
each
:
true
})
assigneeIds
:
string
[];
}
export
class
UnassignCardDto
{
@
IsArray
()
@
IsString
({
each
:
true
})
userIds
:
string
[];
}
export
class
SetBountyDto
{
@
IsOptional
()
bountyPiasters
:
number
;
@
IsOptional
()
@
IsString
()
bountySplitJson
?:
string
;
// JSON string of { userId: percentage } for multi-assignee split
}
\ No newline at end of file
backend/src/modules/cards/dto/card-filter.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsOptional
,
IsString
,
IsBoolean
,
IsArray
,
IsEnum
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
CardFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
IsString
()
boardId
?:
string
;
@
IsOptional
()
@
IsString
()
columnId
?:
string
;
@
IsOptional
()
@
IsString
()
assigneeId
?:
string
;
@
IsOptional
()
@
IsString
()
priority
?:
string
;
@
IsOptional
()
@
IsString
({
each
:
true
})
labelIds
?:
string
;
// Comma-separated label IDs
@
IsOptional
()
@
Type
(()
=>
Boolean
)
@
IsBoolean
()
hasBounty
?:
boolean
;
@
IsOptional
()
@
Type
(()
=>
Boolean
)
@
IsBoolean
()
isOverdue
?:
boolean
;
@
IsOptional
()
@
IsString
()
dueDateFrom
?:
string
;
@
IsOptional
()
@
IsString
()
dueDateTo
?:
string
;
@
IsOptional
()
@
Type
(()
=>
Boolean
)
@
IsBoolean
()
isArchived
?:
boolean
;
@
IsOptional
()
@
IsString
()
createdById
?:
string
;
}
\ No newline at end of file
backend/src/modules/cards/dto/card-response.dto.ts
0 → 100644
View file @
22a7a58a
export
class
CardSummaryResponseDto
{
id
:
string
;
cardNumber
:
string
;
title
:
string
;
boardId
:
string
;
columnId
:
string
;
position
:
number
;
priority
:
string
;
dueDate
:
string
|
null
;
isOverdue
:
boolean
;
bountyPiasters
:
number
;
coverImage
:
string
|
null
;
isArchived
:
boolean
;
assigneeCount
:
number
;
commentCount
:
number
;
attachmentCount
:
number
;
checklistProgress
:
{
completed
:
number
;
total
:
number
}
|
null
;
labels
:
Array
<
{
id
:
string
;
name
:
string
;
color
:
string
;
textColor
:
string
|
null
}
>
;
assignees
:
Array
<
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
avatar
:
string
|
null
}
>
;
createdAt
:
string
;
}
export
class
CardDetailResponseDto
extends
CardSummaryResponseDto
{
description
:
string
|
null
;
estimatedHours
:
number
|
null
;
actualHours
:
number
|
null
;
frozenReason
:
string
|
null
;
bountySplit
:
any
;
version
:
number
;
startedAt
:
string
|
null
;
completedAt
:
string
|
null
;
leadTimeHours
:
number
|
null
;
cycleTimeHours
:
number
|
null
;
frozenTimeHours
:
number
|
null
;
createdBy
:
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
avatar
:
string
|
null
};
watchers
:
Array
<
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
avatar
:
string
|
null
}
>
;
columnName
:
string
;
boardName
:
string
;
boardKey
:
string
;
updatedAt
:
string
;
}
\ No newline at end of file
backend/src/modules/cards/dto/create-card.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsOptional
,
IsArray
,
IsNumber
,
IsInt
,
IsBoolean
,
Min
,
Max
,
MinLength
,
MaxLength
,
}
from
'class-validator'
;
export
class
CreateCardDto
{
@
IsString
()
boardId
:
string
;
@
IsOptional
()
@
IsString
()
columnId
?:
string
;
// If not provided, defaults to Backlog column of the board
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
200
)
title
:
string
;
@
IsOptional
()
@
IsString
()
description
?:
string
;
@
IsOptional
()
@
IsString
()
priority
?:
string
;
@
IsOptional
()
@
IsString
()
dueDate
?:
string
;
@
IsOptional
()
@
IsNumber
()
@
Min
(
0
)
estimatedHours
?:
number
;
@
IsOptional
()
@
IsArray
()
@
IsString
({
each
:
true
})
assigneeIds
?:
string
[];
@
IsOptional
()
@
IsArray
()
@
IsString
({
each
:
true
})
labelIds
?:
string
[];
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
bountyPiasters
?:
number
;
}
\ No newline at end of file
backend/src/modules/cards/dto/duplicate-card.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsOptional
,
IsBoolean
}
from
'class-validator'
;
export
class
DuplicateCardDto
{
@
IsOptional
()
@
IsString
()
targetBoardId
?:
string
;
// If not provided, duplicates to the same board
@
IsOptional
()
@
IsBoolean
()
keepDeadline
?:
boolean
;
}
\ No newline at end of file
backend/src/modules/cards/dto/move-card.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsOptional
,
IsNumber
,
MinLength
}
from
'class-validator'
;
export
class
MoveCardDto
{
@
IsString
()
columnId
:
string
;
@
IsOptional
()
@
IsNumber
()
position
?:
number
;
@
IsOptional
()
@
IsString
()
@
MinLength
(
20
,
{
message
:
'Frozen reason must be at least 20 characters'
})
frozenReason
?:
string
;
// Required when moving to Frozen column
}
\ No newline at end of file
backend/src/modules/cards/dto/update-card.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsOptional
,
IsNumber
,
IsInt
,
Min
,
Max
,
MinLength
,
MaxLength
,
IsArray
,
}
from
'class-validator'
;
export
class
UpdateCardDto
{
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
200
)
title
?:
string
;
@
IsOptional
()
@
IsString
()
description
?:
string
;
@
IsOptional
()
@
IsString
()
priority
?:
string
;
@
IsOptional
()
@
IsString
()
dueDate
?:
string
;
@
IsOptional
()
@
IsNumber
()
@
Min
(
0
)
estimatedHours
?:
number
;
@
IsOptional
()
@
IsNumber
()
@
Min
(
0
)
actualHours
?:
number
;
@
IsOptional
()
@
IsString
()
coverImage
?:
string
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
version
?:
number
;
// For optimistic locking
}
\ No newline at end of file
backend/src/modules/labels/dto/create-label.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsOptional
,
MinLength
,
MaxLength
,
Matches
}
from
'class-validator'
;
export
class
CreateLabelDto
{
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
20
)
name
:
string
;
@
IsString
()
@
Matches
(
/^#
[
0-9A-Fa-f
]{6}
$/
,
{
message
:
'Color must be a valid hex color (e.g., #EF4444)'
})
color
:
string
;
@
IsOptional
()
@
IsString
()
@
Matches
(
/^#
[
0-9A-Fa-f
]{6}
$/
,
{
message
:
'Text color must be a valid hex color'
})
textColor
?:
string
;
@
IsOptional
()
@
IsString
()
boardId
?:
string
;
// If boardId is provided → BOARD scope. If not → ORGANIZATION scope.
}
\ No newline at end of file
backend/src/modules/labels/dto/label-response.dto.ts
0 → 100644
View file @
22a7a58a
export
class
LabelResponseDto
{
id
:
string
;
name
:
string
;
color
:
string
;
textColor
:
string
|
null
;
scope
:
string
;
boardId
:
string
|
null
;
createdById
:
string
|
null
;
cardCount
?:
number
;
createdAt
:
string
;
updatedAt
:
string
;
}
\ No newline at end of file
backend/src/modules/labels/dto/update-label.dto.ts
0 → 100644
View file @
22a7a58a
import
{
IsString
,
IsOptional
,
MinLength
,
MaxLength
,
Matches
}
from
'class-validator'
;
export
class
UpdateLabelDto
{
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
20
)
name
?:
string
;
@
IsOptional
()
@
IsString
()
@
Matches
(
/^#
[
0-9A-Fa-f
]{6}
$/
,
{
message
:
'Color must be a valid hex color'
})
color
?:
string
;
@
IsOptional
()
@
IsString
()
@
Matches
(
/^#
[
0-9A-Fa-f
]{6}
$/
,
{
message
:
'Text color must be a valid hex color'
})
textColor
?:
string
;
}
\ No newline at end of file
backend/src/modules/labels/labels.controller.ts
0 → 100644
View file @
22a7a58a
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
LabelsService
}
from
'./labels.service'
;
import
{
CreateLabelDto
}
from
'./dto/create-label.dto'
;
import
{
UpdateLabelDto
}
from
'./dto/update-label.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'labels'
)
export
class
LabelsController
{
constructor
(
private
readonly
labelsService
:
LabelsService
)
{}
@
Post
()
async
create
(@
Body
()
dto
:
CreateLabelDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
labelsService
.
create
(
dto
,
user
);
}
@
Get
(
'organization'
)
async
findOrgLabels
()
{
return
this
.
labelsService
.
findOrgLabels
();
}
@
Get
(
'board/:boardId'
)
async
findBoardLabels
(@
Param
(
'boardId'
)
boardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
labelsService
.
findBoardLabels
(
boardId
,
user
);
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
)
{
return
this
.
labelsService
.
findById
(
id
);
}
@
Put
(
':id'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateLabelDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
labelsService
.
update
(
id
,
dto
,
user
);
}
@
Delete
(
':id'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
const
result
=
await
this
.
labelsService
.
delete
(
id
,
user
);
return
{
message
:
`Label deleted.
${
result
.
affectedCards
}
cards affected.`
,
...
result
};
}
@
Post
(
':labelId/cards/:cardId'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
applyToCard
(
@
Param
(
'labelId'
)
labelId
:
string
,
@
Param
(
'cardId'
)
cardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
labelsService
.
applyToCard
(
labelId
,
cardId
,
user
);
return
{
message
:
'Label applied to card'
};
}
@
Delete
(
':labelId/cards/:cardId'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
removeFromCard
(
@
Param
(
'labelId'
)
labelId
:
string
,
@
Param
(
'cardId'
)
cardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
labelsService
.
removeFromCard
(
labelId
,
cardId
,
user
);
return
{
message
:
'Label removed from card'
};
}
}
\ No newline at end of file
backend/src/modules/labels/labels.module.ts
0 → 100644
View file @
22a7a58a
import
{
Module
}
from
'@nestjs/common'
;
import
{
LabelsController
}
from
'./labels.controller'
;
import
{
LabelsService
}
from
'./labels.service'
;
@
Module
({
controllers
:
[
LabelsController
],
providers
:
[
LabelsService
],
exports
:
[
LabelsService
],
})
export
class
LabelsModule
{}
\ No newline at end of file
backend/src/modules/labels/labels.service.ts
0 → 100644
View file @
22a7a58a
This diff is collapsed.
Click to expand it.
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