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
Show 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
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
ConflictException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateCardDto
}
from
'./dto/create-card.dto'
;
import
{
UpdateCardDto
}
from
'./dto/update-card.dto'
;
import
{
CardFilterDto
}
from
'./dto/card-filter.dto'
;
import
{
DuplicateCardDto
}
from
'./dto/duplicate-card.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
,
}
from
'../../common/utils/pagination.util'
;
@
Injectable
()
export
class
CardsService
{
private
readonly
logger
=
new
Logger
(
CardsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateCardDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
dto
.
boardId
,
deletedAt
:
null
},
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
// Permission: Contractors can only create in Backlog if board allows
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
if
(
!
board
.
allowContractorCreation
)
{
throw
new
ForbiddenException
(
'Contractors cannot create cards on this board'
);
}
}
// Determine target column
let
columnId
=
dto
.
columnId
;
if
(
!
columnId
)
{
const
backlogColumn
=
await
this
.
prisma
.
column
.
findFirst
({
where
:
{
boardId
:
dto
.
boardId
,
type
:
'BACKLOG'
},
});
if
(
!
backlogColumn
)
{
throw
new
BadRequestException
(
'No Backlog column found on this board'
);
}
columnId
=
backlogColumn
.
id
;
}
// Contractors can ONLY create in Backlog
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
const
targetColumn
=
await
this
.
prisma
.
column
.
findUnique
({
where
:
{
id
:
columnId
}
});
if
(
!
targetColumn
||
targetColumn
.
type
!==
'BACKLOG'
)
{
throw
new
ForbiddenException
(
'Contractors can only create cards in the Backlog column'
);
}
}
// Verify column belongs to this board
const
column
=
await
this
.
prisma
.
column
.
findFirst
({
where
:
{
id
:
columnId
,
boardId
:
dto
.
boardId
},
});
if
(
!
column
)
{
throw
new
BadRequestException
(
'Column does not belong to this board'
);
}
// Generate card number
const
nextNumber
=
board
.
nextCardNumber
;
await
this
.
prisma
.
board
.
update
({
where
:
{
id
:
dto
.
boardId
},
data
:
{
nextCardNumber
:
nextNumber
+
1
},
});
const
cardNumber
=
`
${
board
.
key
}
-
${
nextNumber
}
`
;
// Calculate position (bottom of column)
const
maxPos
=
await
this
.
prisma
.
card
.
aggregate
({
where
:
{
columnId
,
deletedAt
:
null
},
_max
:
{
position
:
true
},
});
const
position
=
(
maxPos
.
_max
?.
position
||
0
)
+
1
;
// Create card
const
card
=
await
this
.
prisma
.
card
.
create
({
data
:
{
title
:
dto
.
title
,
description
:
dto
.
description
||
null
,
cardNumber
,
columnId
,
position
,
priority
:
dto
.
priority
||
'NONE'
,
dueDate
:
dto
.
dueDate
?
new
Date
(
dto
.
dueDate
)
:
null
,
estimatedHours
:
dto
.
estimatedHours
||
null
,
bountyPiasters
:
dto
.
bountyPiasters
||
0
,
createdById
:
currentUser
.
id
,
version
:
1
,
},
});
// Connect labels
if
(
dto
.
labelIds
&&
dto
.
labelIds
.
length
>
0
)
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
card
.
id
},
data
:
{
labels
:
{
connect
:
dto
.
labelIds
.
map
((
id
)
=>
({
id
}))
},
},
});
}
// Connect assignees (only SA, Admin, TL can assign)
if
(
dto
.
assigneeIds
&&
dto
.
assigneeIds
.
length
>
0
&&
currentUser
.
role
!==
'CONTRACTOR'
)
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
card
.
id
},
data
:
{
assignees
:
{
connect
:
dto
.
assigneeIds
.
map
((
id
)
=>
({
id
}))
},
},
});
}
// Log activity
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
:
card
.
id
,
userId
:
currentUser
.
id
,
action
:
'CREATED'
,
metadata
:
{
title
:
card
.
title
,
cardNumber
},
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log card creation activity:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Card
${
cardNumber
}
created on board
${
board
.
key
}
by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
card
.
id
,
currentUser
);
}
async
findAll
(
filter
:
CardFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
50
;
const
where
:
any
=
{
deletedAt
:
null
};
if
(
filter
.
isArchived
!==
undefined
)
{
where
.
isArchived
=
filter
.
isArchived
;
}
else
{
where
.
isArchived
=
false
;
}
if
(
filter
.
boardId
)
{
where
.
column
=
{
boardId
:
filter
.
boardId
};
}
if
(
filter
.
columnId
)
{
where
.
columnId
=
filter
.
columnId
;
}
if
(
filter
.
assigneeId
)
{
where
.
assignees
=
{
some
:
{
id
:
filter
.
assigneeId
}
};
}
if
(
filter
.
priority
)
{
where
.
priority
=
filter
.
priority
;
}
if
(
filter
.
labelIds
)
{
const
ids
=
filter
.
labelIds
.
split
(
','
).
filter
(
Boolean
);
if
(
ids
.
length
>
0
)
{
where
.
labels
=
{
some
:
{
id
:
{
in
:
ids
}
}
};
}
}
if
(
filter
.
hasBounty
===
true
)
{
where
.
bountyPiasters
=
{
gt
:
0
};
}
else
if
(
filter
.
hasBounty
===
false
)
{
where
.
bountyPiasters
=
0
;
}
if
(
filter
.
isOverdue
===
true
)
{
where
.
dueDate
=
{
lt
:
new
Date
()
};
where
.
completedAt
=
null
;
}
if
(
filter
.
dueDateFrom
||
filter
.
dueDateTo
)
{
where
.
dueDate
=
where
.
dueDate
||
{};
if
(
filter
.
dueDateFrom
)
where
.
dueDate
.
gte
=
new
Date
(
filter
.
dueDateFrom
);
if
(
filter
.
dueDateTo
)
where
.
dueDate
.
lte
=
new
Date
(
filter
.
dueDateTo
);
}
if
(
filter
.
createdById
)
{
where
.
createdById
=
filter
.
createdById
;
}
if
(
filter
.
search
)
{
where
.
OR
=
[
{
title
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
description
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
cardNumber
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
];
}
// Contractors only see cards on their boards
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
where
.
column
=
{
...
where
.
column
,
board
:
{
members
:
{
some
:
{
userId
:
currentUser
.
id
}
}
},
};
}
const
[
cards
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
card
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
[
filter
.
sortBy
||
'position'
]:
filter
.
sortOrder
||
'asc'
},
include
:
{
labels
:
{
select
:
{
id
:
true
,
name
:
true
,
color
:
true
,
textColor
:
true
}
},
assignees
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
}
},
column
:
{
select
:
{
id
:
true
,
name
:
true
,
boardId
:
true
,
type
:
true
}
},
_count
:
{
select
:
{
comments
:
true
,
attachments
:
true
}
},
},
}),
this
.
prisma
.
card
.
count
({
where
}),
]);
const
enriched
=
cards
.
map
((
card
:
any
)
=>
this
.
formatCardSummary
(
card
));
return
buildPaginatedResponse
(
enriched
,
total
,
{
page
,
limit
,
sortOrder
:
filter
.
sortOrder
||
'asc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
},
include
:
{
labels
:
{
select
:
{
id
:
true
,
name
:
true
,
color
:
true
,
textColor
:
true
}
},
assignees
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
},
},
watchers
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
},
},
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
},
},
column
:
{
select
:
{
id
:
true
,
name
:
true
,
type
:
true
,
boardId
:
true
,
board
:
{
select
:
{
name
:
true
,
key
:
true
}
}
},
},
_count
:
{
select
:
{
comments
:
true
,
attachments
:
true
}
},
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
// Checklist progress
let
checklistProgress
:
{
completed
:
number
;
total
:
number
}
|
null
=
null
;
try
{
const
checklists
=
await
this
.
prisma
.
checklist
.
findMany
({
where
:
{
cardId
:
id
},
include
:
{
items
:
true
},
});
if
(
checklists
.
length
>
0
)
{
let
total
=
0
;
let
completed
=
0
;
for
(
const
cl
of
checklists
)
{
for
(
const
item
of
cl
.
items
)
{
total
++
;
if
(
item
.
isCompleted
)
completed
++
;
}
}
checklistProgress
=
{
completed
,
total
};
}
}
catch
{
// Checklist tables may not exist yet
}
return
{
id
:
card
.
id
,
cardNumber
:
card
.
cardNumber
,
title
:
card
.
title
,
description
:
card
.
description
,
boardId
:
card
.
column
.
boardId
,
boardName
:
(
card
.
column
as
any
).
board
?.
name
,
boardKey
:
(
card
.
column
as
any
).
board
?.
key
,
columnId
:
card
.
columnId
,
columnName
:
card
.
column
.
name
,
position
:
card
.
position
,
priority
:
card
.
priority
,
dueDate
:
card
.
dueDate
,
isOverdue
:
card
.
dueDate
?
new
Date
(
card
.
dueDate
)
<
new
Date
()
&&
!
card
.
completedAt
:
false
,
estimatedHours
:
card
.
estimatedHours
,
actualHours
:
card
.
actualHours
,
bountyPiasters
:
card
.
bountyPiasters
,
bountySplit
:
card
.
bountySplit
,
coverImage
:
card
.
coverImage
,
frozenReason
:
card
.
frozenReason
,
isArchived
:
card
.
isArchived
,
version
:
card
.
version
,
startedAt
:
card
.
startedAt
,
completedAt
:
card
.
completedAt
,
leadTimeHours
:
card
.
leadTimeHours
,
cycleTimeHours
:
card
.
cycleTimeHours
,
frozenTimeHours
:
card
.
frozenTimeHours
,
commentCount
:
card
.
_count
.
comments
,
attachmentCount
:
card
.
_count
.
attachments
,
assigneeCount
:
card
.
assignees
.
length
,
checklistProgress
,
labels
:
card
.
labels
,
assignees
:
card
.
assignees
,
watchers
:
card
.
watchers
,
createdBy
:
card
.
createdBy
,
createdAt
:
card
.
createdAt
,
updatedAt
:
card
.
updatedAt
,
};
}
async
update
(
id
:
string
,
dto
:
UpdateCardDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
},
include
:
{
column
:
{
select
:
{
boardId
:
true
}
},
assignees
:
{
select
:
{
id
:
true
}
},
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
// Optimistic locking
if
(
dto
.
version
!==
undefined
&&
dto
.
version
!==
card
.
version
)
{
throw
new
ConflictException
(
'This card was modified by another user. Please refresh and try again.'
,
);
}
// Permission: Contractors can only edit their assigned cards (limited fields)
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
const
isAssigned
=
card
.
assignees
.
some
((
a
)
=>
a
.
id
===
currentUser
.
id
);
if
(
!
isAssigned
)
{
throw
new
ForbiddenException
(
'You can only edit cards assigned to you'
);
}
// Contractors can only update: description, estimatedHours, actualHours
const
allowedFields
=
[
'description'
,
'estimatedHours'
,
'actualHours'
,
'version'
];
for
(
const
key
of
Object
.
keys
(
dto
))
{
if
(
!
allowedFields
.
includes
(
key
)
&&
(
dto
as
any
)[
key
]
!==
undefined
)
{
throw
new
ForbiddenException
(
`Contractors cannot change the field:
${
key
}
`
);
}
}
}
const
updateData
:
any
=
{
version
:
{
increment
:
1
}
};
const
changes
:
any
=
{};
if
(
dto
.
title
!==
undefined
)
{
changes
.
title
=
{
from
:
card
.
title
,
to
:
dto
.
title
};
updateData
.
title
=
dto
.
title
;
}
if
(
dto
.
description
!==
undefined
)
updateData
.
description
=
dto
.
description
;
if
(
dto
.
priority
!==
undefined
)
{
changes
.
priority
=
{
from
:
card
.
priority
,
to
:
dto
.
priority
};
updateData
.
priority
=
dto
.
priority
;
}
if
(
dto
.
dueDate
!==
undefined
)
{
changes
.
dueDate
=
{
from
:
card
.
dueDate
,
to
:
dto
.
dueDate
};
updateData
.
dueDate
=
dto
.
dueDate
?
new
Date
(
dto
.
dueDate
)
:
null
;
}
if
(
dto
.
estimatedHours
!==
undefined
)
updateData
.
estimatedHours
=
dto
.
estimatedHours
;
if
(
dto
.
actualHours
!==
undefined
)
updateData
.
actualHours
=
dto
.
actualHours
;
if
(
dto
.
coverImage
!==
undefined
)
updateData
.
coverImage
=
dto
.
coverImage
;
await
this
.
prisma
.
card
.
update
({
where
:
{
id
},
data
:
updateData
,
});
// Log significant changes
if
(
Object
.
keys
(
changes
).
length
>
0
)
{
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
:
id
,
userId
:
currentUser
.
id
,
action
:
'UPDATED'
,
metadata
:
changes
,
},
});
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to log card update activity:
${
err
.
message
}
`
);
}
}
return
this
.
findById
(
id
,
currentUser
);
}
async
archive
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
throw
new
ForbiddenException
(
'Contractors cannot archive cards'
);
}
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
,
isArchived
:
false
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found or already archived'
);
}
await
this
.
prisma
.
card
.
update
({
where
:
{
id
},
data
:
{
isArchived
:
true
,
archivedAt
:
new
Date
()
},
});
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
:
id
,
userId
:
currentUser
.
id
,
action
:
'ARCHIVED'
,
metadata
:
{},
},
});
}
catch
{
// Activity table may not exist
}
this
.
logger
.
log
(
`Card
${
id
}
archived by
${
currentUser
.
email
}
`
);
}
async
restore
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can restore archived cards'
);
}
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
,
isArchived
:
true
},
include
:
{
column
:
{
select
:
{
boardId
:
true
}
}
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Archived card not found'
);
}
// Restore to backlog
const
backlog
=
await
this
.
prisma
.
column
.
findFirst
({
where
:
{
boardId
:
card
.
column
.
boardId
,
type
:
'BACKLOG'
},
});
if
(
!
backlog
)
{
throw
new
BadRequestException
(
'No Backlog column found'
);
}
const
maxPos
=
await
this
.
prisma
.
card
.
aggregate
({
where
:
{
columnId
:
backlog
.
id
,
deletedAt
:
null
},
_max
:
{
position
:
true
},
});
await
this
.
prisma
.
card
.
update
({
where
:
{
id
},
data
:
{
isArchived
:
false
,
archivedAt
:
null
,
columnId
:
backlog
.
id
,
position
:
(
maxPos
.
_max
?.
position
||
0
)
+
1
,
},
});
this
.
logger
.
log
(
`Card
${
id
}
restored by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
id
,
currentUser
);
}
async
permanentDelete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can permanently delete cards'
);
}
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
await
this
.
prisma
.
card
.
update
({
where
:
{
id
},
data
:
{
deletedAt
:
new
Date
(),
isArchived
:
true
},
});
this
.
logger
.
log
(
`Card
${
card
.
cardNumber
}
permanently deleted by
${
currentUser
.
email
}
`
);
}
async
duplicate
(
id
:
string
,
dto
:
DuplicateCardDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
throw
new
ForbiddenException
(
'Contractors cannot duplicate cards'
);
}
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
},
include
:
{
labels
:
{
select
:
{
id
:
true
}
},
column
:
{
select
:
{
boardId
:
true
}
},
},
});
if
(
!
card
)
{
throw
new
NotFoundException
(
'Card not found'
);
}
const
targetBoardId
=
dto
.
targetBoardId
||
card
.
column
.
boardId
;
const
targetBoard
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
targetBoardId
,
deletedAt
:
null
},
});
if
(
!
targetBoard
)
{
throw
new
NotFoundException
(
'Target board not found'
);
}
// Find backlog of target board
const
backlogColumn
=
await
this
.
prisma
.
column
.
findFirst
({
where
:
{
boardId
:
targetBoardId
,
type
:
'BACKLOG'
},
});
if
(
!
backlogColumn
)
{
throw
new
BadRequestException
(
'Target board has no Backlog column'
);
}
// Generate card number on target board
const
nextNumber
=
targetBoard
.
nextCardNumber
;
await
this
.
prisma
.
board
.
update
({
where
:
{
id
:
targetBoardId
},
data
:
{
nextCardNumber
:
nextNumber
+
1
},
});
const
maxPos
=
await
this
.
prisma
.
card
.
aggregate
({
where
:
{
columnId
:
backlogColumn
.
id
,
deletedAt
:
null
},
_max
:
{
position
:
true
},
});
const
newCard
=
await
this
.
prisma
.
card
.
create
({
data
:
{
title
:
`Copy of
${
card
.
title
}
`
,
description
:
card
.
description
,
cardNumber
:
`
${
targetBoard
.
key
}
-
${
nextNumber
}
`
,
columnId
:
backlogColumn
.
id
,
position
:
(
maxPos
.
_max
?.
position
||
0
)
+
1
,
priority
:
card
.
priority
,
dueDate
:
dto
.
keepDeadline
?
card
.
dueDate
:
null
,
estimatedHours
:
card
.
estimatedHours
,
bountyPiasters
:
0
,
// Bounty is NOT copied
createdById
:
currentUser
.
id
,
version
:
1
,
},
});
// Copy labels (if on same board, or org-level labels)
if
(
card
.
labels
.
length
>
0
)
{
const
labelIds
=
card
.
labels
.
map
((
l
:
any
)
=>
l
.
id
);
// Filter: only connect labels valid on target board
const
validLabels
=
await
this
.
prisma
.
label
.
findMany
({
where
:
{
id
:
{
in
:
labelIds
},
OR
:
[
{
scope
:
'ORGANIZATION'
},
{
scope
:
'BOARD'
,
boardId
:
targetBoardId
},
],
},
});
if
(
validLabels
.
length
>
0
)
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
newCard
.
id
},
data
:
{
labels
:
{
connect
:
validLabels
.
map
((
l
)
=>
({
id
:
l
.
id
}))
}
},
});
}
}
// Copy checklists
try
{
const
checklists
=
await
this
.
prisma
.
checklist
.
findMany
({
where
:
{
cardId
:
id
},
include
:
{
items
:
{
orderBy
:
{
position
:
'asc'
}
}
},
});
for
(
const
cl
of
checklists
)
{
const
newChecklist
=
await
this
.
prisma
.
checklist
.
create
({
data
:
{
cardId
:
newCard
.
id
,
title
:
cl
.
title
,
position
:
cl
.
position
,
},
});
for
(
const
item
of
cl
.
items
)
{
await
this
.
prisma
.
checklistItem
.
create
({
data
:
{
checklistId
:
newChecklist
.
id
,
title
:
item
.
title
,
position
:
item
.
position
,
isCompleted
:
false
,
// Always unchecked in copy
},
});
}
}
}
catch
{
// Checklist tables may not exist yet
}
this
.
logger
.
log
(
`Card
${
card
.
cardNumber
}
duplicated as
${
newCard
.
cardNumber
}
by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
newCard
.
id
,
currentUser
);
}
async
setBounty
(
cardId
:
string
,
bountyPiasters
:
number
,
bountySplitJson
:
string
|
undefined
,
currentUser
:
RequestUser
,
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can set bounties'
);
}
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
});
if
(
!
card
)
throw
new
NotFoundException
(
'Card not found'
);
if
(
card
.
completedAt
)
{
throw
new
BadRequestException
(
'Cannot modify bounty on a completed card'
);
}
const
updateData
:
any
=
{
bountyPiasters
};
if
(
bountySplitJson
)
{
try
{
updateData
.
bountySplit
=
JSON
.
parse
(
bountySplitJson
);
}
catch
{
throw
new
BadRequestException
(
'Invalid bounty split JSON'
);
}
}
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
updateData
,
});
try
{
await
this
.
prisma
.
cardActivity
.
create
({
data
:
{
cardId
,
userId
:
currentUser
.
id
,
action
:
'BOUNTY_SET'
,
metadata
:
{
bountyPiasters
,
previousBounty
:
card
.
bountyPiasters
,
},
},
});
}
catch
{
// Activity table might not exist
}
return
this
.
findById
(
cardId
,
currentUser
);
}
async
getCardActivity
(
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'
);
try
{
return
await
this
.
prisma
.
cardActivity
.
findMany
({
where
:
{
cardId
},
orderBy
:
{
createdAt
:
'desc'
},
take
:
200
,
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
}
},
},
});
}
catch
{
return
[];
}
}
async
watchCard
(
cardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
}
});
if
(
!
card
)
throw
new
NotFoundException
(
'Card not found'
);
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
watchers
:
{
connect
:
{
id
:
currentUser
.
id
}
}
},
});
}
async
unwatchCard
(
cardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
watchers
:
{
disconnect
:
{
id
:
currentUser
.
id
}
}
},
});
}
private
formatCardSummary
(
card
:
any
):
any
{
let
checklistProgress
:
{
completed
:
number
;
total
:
number
}
|
null
=
null
;
return
{
id
:
card
.
id
,
cardNumber
:
card
.
cardNumber
,
title
:
card
.
title
,
boardId
:
card
.
column
?.
boardId
,
columnId
:
card
.
columnId
,
columnName
:
card
.
column
?.
name
,
columnType
:
card
.
column
?.
type
,
position
:
card
.
position
,
priority
:
card
.
priority
,
dueDate
:
card
.
dueDate
,
isOverdue
:
card
.
dueDate
?
new
Date
(
card
.
dueDate
)
<
new
Date
()
&&
!
card
.
completedAt
:
false
,
bountyPiasters
:
card
.
bountyPiasters
,
coverImage
:
card
.
coverImage
,
isArchived
:
card
.
isArchived
,
frozenReason
:
card
.
frozenReason
,
assigneeCount
:
card
.
assignees
?.
length
||
0
,
commentCount
:
card
.
_count
?.
comments
||
0
,
attachmentCount
:
card
.
_count
?.
attachments
||
0
,
checklistProgress
,
labels
:
card
.
labels
||
[],
assignees
:
card
.
assignees
||
[],
completedAt
:
card
.
completedAt
,
createdAt
:
card
.
createdAt
,
};
}
}
\ No newline at end of file
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
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
ConflictException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateLabelDto
}
from
'./dto/create-label.dto'
;
import
{
UpdateLabelDto
}
from
'./dto/update-label.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
LabelsService
{
private
readonly
logger
=
new
Logger
(
LabelsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateLabelDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
scope
=
dto
.
boardId
?
'BOARD'
:
'ORGANIZATION'
;
// Permission check
if
(
scope
===
'ORGANIZATION'
)
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can create organization-level labels'
);
}
}
else
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
&&
currentUser
.
role
!==
'TEAM_LEAD'
)
{
throw
new
ForbiddenException
(
'Only Super Admin, Admin, and Project Leaders can create board labels'
);
}
// Verify board exists
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
dto
.
boardId
,
deletedAt
:
null
},
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
// If TEAM_LEAD, verify they are a member of this board
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
:
dto
.
boardId
!
,
userId
:
currentUser
.
id
}
},
});
if
(
!
membership
)
{
throw
new
ForbiddenException
(
'You are not a member of this board'
);
}
}
}
// Check for duplicate name within scope
const
boardIdForUnique
=
dto
.
boardId
||
'org-level'
;
const
existing
=
await
this
.
prisma
.
label
.
findUnique
({
where
:
{
name_boardId
:
{
name
:
dto
.
name
,
boardId
:
boardIdForUnique
}
},
});
if
(
existing
)
{
throw
new
ConflictException
(
`Label "
${
dto
.
name
}
" already exists
${
scope
===
'BOARD'
?
'on this board'
:
'at organization level'
}
`
,
);
}
// Auto-calculate text color for contrast if not provided
const
textColor
=
dto
.
textColor
||
this
.
calculateContrastColor
(
dto
.
color
);
const
label
=
await
this
.
prisma
.
label
.
create
({
data
:
{
name
:
dto
.
name
,
color
:
dto
.
color
,
textColor
,
scope
,
boardId
:
dto
.
boardId
||
null
,
createdById
:
currentUser
.
id
,
},
});
this
.
logger
.
log
(
`Label "
${
dto
.
name
}
" (
${
scope
}
) created by
${
currentUser
.
email
}${
dto
.
boardId
?
` on board
${
dto
.
boardId
}
`
:
''
}
`
,
);
return
this
.
formatLabel
(
label
);
}
async
findOrgLabels
():
Promise
<
any
[]
>
{
const
labels
=
await
this
.
prisma
.
label
.
findMany
({
where
:
{
scope
:
'ORGANIZATION'
},
orderBy
:
{
name
:
'asc'
},
});
return
Promise
.
all
(
labels
.
map
((
l
)
=>
this
.
formatLabelWithCount
(
l
)));
}
async
findBoardLabels
(
boardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
boardId
,
deletedAt
:
null
},
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
// Get both org-level and board-level labels
const
labels
=
await
this
.
prisma
.
label
.
findMany
({
where
:
{
OR
:
[
{
scope
:
'ORGANIZATION'
},
{
scope
:
'BOARD'
,
boardId
},
],
},
orderBy
:
[{
scope
:
'asc'
},
{
name
:
'asc'
}],
});
return
Promise
.
all
(
labels
.
map
((
l
)
=>
this
.
formatLabelWithCount
(
l
,
boardId
)));
}
async
findById
(
id
:
string
):
Promise
<
any
>
{
const
label
=
await
this
.
prisma
.
label
.
findUnique
({
where
:
{
id
}
});
if
(
!
label
)
{
throw
new
NotFoundException
(
'Label not found'
);
}
return
this
.
formatLabelWithCount
(
label
);
}
async
update
(
id
:
string
,
dto
:
UpdateLabelDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
label
=
await
this
.
prisma
.
label
.
findUnique
({
where
:
{
id
}
});
if
(
!
label
)
{
throw
new
NotFoundException
(
'Label not found'
);
}
// Permission check
if
(
label
.
scope
===
'ORGANIZATION'
)
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can edit organization-level labels'
);
}
}
else
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
if
(
currentUser
.
role
===
'TEAM_LEAD'
&&
label
.
boardId
)
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
:
label
.
boardId
,
userId
:
currentUser
.
id
}
},
});
if
(
!
membership
)
{
throw
new
ForbiddenException
(
'You are not a member of this board'
);
}
}
else
{
throw
new
ForbiddenException
(
'You do not have permission to edit this label'
);
}
}
}
// Check name uniqueness if name is changing
if
(
dto
.
name
&&
dto
.
name
!==
label
.
name
)
{
const
boardIdForUnique
=
label
.
boardId
||
'org-level'
;
const
existing
=
await
this
.
prisma
.
label
.
findUnique
({
where
:
{
name_boardId
:
{
name
:
dto
.
name
,
boardId
:
boardIdForUnique
}
},
});
if
(
existing
&&
existing
.
id
!==
id
)
{
throw
new
ConflictException
(
`Label "
${
dto
.
name
}
" already exists in this scope`
);
}
}
const
updateData
:
any
=
{};
if
(
dto
.
name
!==
undefined
)
updateData
.
name
=
dto
.
name
;
if
(
dto
.
color
!==
undefined
)
{
updateData
.
color
=
dto
.
color
;
if
(
!
dto
.
textColor
)
{
updateData
.
textColor
=
this
.
calculateContrastColor
(
dto
.
color
);
}
}
if
(
dto
.
textColor
!==
undefined
)
updateData
.
textColor
=
dto
.
textColor
;
const
updated
=
await
this
.
prisma
.
label
.
update
({
where
:
{
id
},
data
:
updateData
,
});
return
this
.
formatLabel
(
updated
);
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
{
affectedCards
:
number
}
>
{
const
label
=
await
this
.
prisma
.
label
.
findUnique
({
where
:
{
id
}
});
if
(
!
label
)
{
throw
new
NotFoundException
(
'Label not found'
);
}
// Permission check
if
(
label
.
scope
===
'ORGANIZATION'
)
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can delete organization-level labels'
);
}
}
else
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
if
(
currentUser
.
role
===
'TEAM_LEAD'
&&
label
.
boardId
)
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
:
label
.
boardId
,
userId
:
currentUser
.
id
}
},
});
if
(
!
membership
)
{
throw
new
ForbiddenException
(
'You are not a member of this board'
);
}
}
else
{
throw
new
ForbiddenException
(
'You do not have permission to delete this label'
);
}
}
}
// Count affected cards
let
affectedCards
=
0
;
try
{
affectedCards
=
await
this
.
prisma
.
card
.
count
({
where
:
{
labels
:
{
some
:
{
id
}
},
deletedAt
:
null
,
},
});
}
catch
{
// Cards table may not be fully set up yet
}
// Delete label (Prisma will handle the many-to-many disconnection)
await
this
.
prisma
.
label
.
delete
({
where
:
{
id
}
});
this
.
logger
.
log
(
`Label "
${
label
.
name
}
" deleted by
${
currentUser
.
email
}
.
${
affectedCards
}
cards affected.`
,
);
return
{
affectedCards
};
}
async
applyToCard
(
labelId
:
string
,
cardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
const
label
=
await
this
.
prisma
.
label
.
findUnique
({
where
:
{
id
:
labelId
}
});
if
(
!
label
)
throw
new
NotFoundException
(
'Label not found'
);
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
include
:
{
column
:
{
select
:
{
boardId
:
true
}
}
},
});
if
(
!
card
)
throw
new
NotFoundException
(
'Card not found'
);
// Verify the label is accessible on this board
if
(
label
.
scope
===
'BOARD'
&&
label
.
boardId
!==
card
.
column
.
boardId
)
{
throw
new
BadRequestException
(
'This label is not available on this board'
);
}
// Permission: Contractors can apply labels to their own assigned cards
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
const
isAssigned
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
assignees
:
{
some
:
{
id
:
currentUser
.
id
}
},
},
});
if
(
!
isAssigned
)
{
throw
new
ForbiddenException
(
'You can only apply labels to cards assigned to you'
);
}
}
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
labels
:
{
connect
:
{
id
:
labelId
}
},
},
});
}
async
removeFromCard
(
labelId
:
string
,
cardId
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
});
if
(
!
card
)
throw
new
NotFoundException
(
'Card not found'
);
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
const
isAssigned
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
assignees
:
{
some
:
{
id
:
currentUser
.
id
}
},
},
});
if
(
!
isAssigned
)
{
throw
new
ForbiddenException
(
'You can only manage labels on cards assigned to you'
);
}
}
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
cardId
},
data
:
{
labels
:
{
disconnect
:
{
id
:
labelId
}
},
},
});
}
private
formatLabel
(
label
:
any
):
any
{
return
{
id
:
label
.
id
,
name
:
label
.
name
,
color
:
label
.
color
,
textColor
:
label
.
textColor
,
scope
:
label
.
scope
,
boardId
:
label
.
boardId
,
createdById
:
label
.
createdById
,
createdAt
:
label
.
createdAt
,
updatedAt
:
label
.
updatedAt
,
};
}
private
async
formatLabelWithCount
(
label
:
any
,
boardId
?:
string
):
Promise
<
any
>
{
let
cardCount
=
0
;
try
{
const
where
:
any
=
{
labels
:
{
some
:
{
id
:
label
.
id
}
},
deletedAt
:
null
,
};
if
(
boardId
)
{
where
.
column
=
{
boardId
};
}
cardCount
=
await
this
.
prisma
.
card
.
count
({
where
});
}
catch
{
// Cards table may not exist yet
}
return
{
...
this
.
formatLabel
(
label
),
cardCount
,
};
}
private
calculateContrastColor
(
hexColor
:
string
):
string
{
const
hex
=
hexColor
.
replace
(
'#'
,
''
);
const
r
=
parseInt
(
hex
.
substring
(
0
,
2
),
16
);
const
g
=
parseInt
(
hex
.
substring
(
2
,
4
),
16
);
const
b
=
parseInt
(
hex
.
substring
(
4
,
6
),
16
);
// W3C luminance formula
const
luminance
=
(
0.299
*
r
+
0.587
*
g
+
0.114
*
b
)
/
255
;
return
luminance
>
0.5
?
'#000000'
:
'#FFFFFF'
;
}
}
\ 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