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
ba81ccf6
Commit
ba81ccf6
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 17 files via Son of Anton
parent
f9cd0fe6
Changes
17
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
1807 additions
and
0 deletions
+1807
-0
app.module.ts
backend/src/app.module.ts
+4
-0
board-template.service.ts
backend/src/modules/boards/board-template.service.ts
+157
-0
boards.controller.ts
backend/src/modules/boards/boards.controller.ts
+174
-0
boards.module.ts
backend/src/modules/boards/boards.module.ts
+11
-0
boards.service.ts
backend/src/modules/boards/boards.service.ts
+668
-0
board-filter.dto.ts
backend/src/modules/boards/dto/board-filter.dto.ts
+18
-0
board-member.dto.ts
backend/src/modules/boards/dto/board-member.dto.ts
+30
-0
board-response.dto.ts
backend/src/modules/boards/dto/board-response.dto.ts
+52
-0
board-template.dto.ts
backend/src/modules/boards/dto/board-template.dto.ts
+29
-0
create-board.dto.ts
backend/src/modules/boards/dto/create-board.dto.ts
+66
-0
update-board.dto.ts
backend/src/modules/boards/dto/update-board.dto.ts
+57
-0
columns.controller.ts
backend/src/modules/columns/columns.controller.ts
+62
-0
columns.module.ts
backend/src/modules/columns/columns.module.ts
+10
-0
columns.service.ts
backend/src/modules/columns/columns.service.ts
+318
-0
create-column.dto.ts
backend/src/modules/columns/dto/create-column.dto.ts
+29
-0
update-column.dto.ts
backend/src/modules/columns/dto/update-column.dto.ts
+40
-0
schema-boards.prisma
prisma/schema-boards.prisma
+82
-0
No files found.
backend/src/app.module.ts
View file @
ba81ccf6
...
...
@@ -17,6 +17,8 @@ import { SettingsModule } from './modules/settings/settings.module';
import
{
AuditTrailModule
}
from
'./modules/audit-trail/audit-trail.module'
;
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
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
...
...
@@ -40,6 +42,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
AuditTrailModule
,
UsersModule
,
OnboardingModule
,
BoardsModule
,
ColumnsModule
,
],
providers
:
[
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
...
...
backend/src/modules/boards/board-template.service.ts
0 → 100644
View file @
ba81ccf6
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateBoardTemplateDto
,
UpdateBoardTemplateDto
}
from
'./dto/board-template.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
BoardTemplateService
{
private
readonly
logger
=
new
Logger
(
BoardTemplateService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
saveAsTemplate
(
dto
:
CreateBoardTemplateDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can create board templates'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
dto
.
boardId
,
deletedAt
:
null
},
include
:
{
columns
:
{
orderBy
:
{
position
:
'asc'
}
},
labels
:
{
where
:
{
boardId
:
dto
.
boardId
},
select
:
{
name
:
true
,
color
:
true
,
textColor
:
true
},
},
},
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
const
boardConfig
=
{
visibility
:
board
.
visibility
,
allowContractorCreation
:
board
.
allowContractorCreation
,
autoArchiveDoneCardsDays
:
board
.
autoArchiveDoneCardsDays
,
deadlineExcludesHolidays
:
board
.
deadlineExcludesHolidays
,
columns
:
board
.
columns
.
map
((
col
)
=>
({
name
:
col
.
name
,
icon
:
col
.
icon
,
position
:
col
.
position
,
type
:
col
.
type
,
isDone
:
col
.
isDone
,
isDefault
:
col
.
isDefault
,
wipLimit
:
col
.
wipLimit
,
wipLimitTotal
:
col
.
wipLimitTotal
,
color
:
col
.
color
,
})),
};
const
labelConfig
=
(
board
as
any
).
labels
?.
map
((
l
:
any
)
=>
({
name
:
l
.
name
,
color
:
l
.
color
,
textColor
:
l
.
textColor
,
}))
||
[];
const
template
=
await
this
.
prisma
.
boardTemplate
.
create
({
data
:
{
name
:
dto
.
name
,
description
:
dto
.
description
||
null
,
boardConfig
,
labelConfig
,
createdById
:
currentUser
.
id
,
},
});
this
.
logger
.
log
(
`Board template "
${
dto
.
name
}
" created from board
${
dto
.
boardId
}
by
${
currentUser
.
email
}
`
);
return
template
;
}
async
findAll
(
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can view board templates'
);
}
return
this
.
prisma
.
boardTemplate
.
findMany
({
orderBy
:
{
createdAt
:
'desc'
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
username
:
true
},
},
},
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can view board templates'
);
}
const
template
=
await
this
.
prisma
.
boardTemplate
.
findUnique
({
where
:
{
id
},
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
username
:
true
},
},
},
});
if
(
!
template
)
{
throw
new
NotFoundException
(
'Board template not found'
);
}
return
template
;
}
async
update
(
id
:
string
,
dto
:
UpdateBoardTemplateDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can edit board templates'
);
}
const
template
=
await
this
.
prisma
.
boardTemplate
.
findUnique
({
where
:
{
id
}
});
if
(
!
template
)
{
throw
new
NotFoundException
(
'Board template not found'
);
}
const
updateData
:
any
=
{};
if
(
dto
.
name
!==
undefined
)
updateData
.
name
=
dto
.
name
;
if
(
dto
.
description
!==
undefined
)
updateData
.
description
=
dto
.
description
;
return
this
.
prisma
.
boardTemplate
.
update
({
where
:
{
id
},
data
:
updateData
,
});
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can delete board templates'
);
}
const
template
=
await
this
.
prisma
.
boardTemplate
.
findUnique
({
where
:
{
id
}
});
if
(
!
template
)
{
throw
new
NotFoundException
(
'Board template not found'
);
}
await
this
.
prisma
.
boardTemplate
.
delete
({
where
:
{
id
}
});
this
.
logger
.
log
(
`Board template
${
id
}
deleted by
${
currentUser
.
email
}
`
);
}
async
getTemplateConfig
(
templateId
:
string
):
Promise
<
{
boardConfig
:
any
;
labelConfig
:
any
}
>
{
const
template
=
await
this
.
prisma
.
boardTemplate
.
findUnique
({
where
:
{
id
:
templateId
}
});
if
(
!
template
)
{
throw
new
NotFoundException
(
'Board template not found'
);
}
return
{
boardConfig
:
template
.
boardConfig
as
any
,
labelConfig
:
template
.
labelConfig
as
any
,
};
}
}
\ No newline at end of file
backend/src/modules/boards/boards.controller.ts
0 → 100644
View file @
ba81ccf6
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
BoardsService
}
from
'./boards.service'
;
import
{
BoardTemplateService
}
from
'./board-template.service'
;
import
{
CreateBoardDto
}
from
'./dto/create-board.dto'
;
import
{
UpdateBoardDto
}
from
'./dto/update-board.dto'
;
import
{
BoardFilterDto
}
from
'./dto/board-filter.dto'
;
import
{
AddBoardMemberDto
,
AddBoardMembersBulkDto
,
UpdateBoardMemberRoleDto
,
}
from
'./dto/board-member.dto'
;
import
{
CreateBoardTemplateDto
,
UpdateBoardTemplateDto
}
from
'./dto/board-template.dto'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Controller
(
'boards'
)
export
class
BoardsController
{
constructor
(
private
readonly
boardsService
:
BoardsService
,
private
readonly
boardTemplateService
:
BoardTemplateService
,
)
{}
// ─── BOARD CRUD ──────────────────────────────────────────
@
Post
()
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
create
(@
Body
()
dto
:
CreateBoardDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardsService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
Query
()
filter
:
BoardFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardsService
.
findAll
(
filter
,
user
);
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardsService
.
findById
(
id
,
user
);
}
@
Put
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateBoardDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
boardsService
.
update
(
id
,
dto
,
user
);
}
@
Post
(
':id/archive'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
archive
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
boardsService
.
archive
(
id
,
user
);
return
{
message
:
'Board archived'
};
}
@
Post
(
':id/restore'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
restore
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
boardsService
.
restore
(
id
,
user
);
return
{
message
:
'Board restored'
};
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
permanentDelete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
boardsService
.
permanentDelete
(
id
,
user
);
return
{
message
:
'Board permanently deleted'
};
}
// ─── MEMBER MANAGEMENT ──────────────────────────────────
@
Get
(
':id/members'
)
async
getMembers
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardsService
.
getMembers
(
id
,
user
);
}
@
Post
(
':id/members'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
addMember
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
AddBoardMemberDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
boardsService
.
addMember
(
id
,
dto
,
user
);
}
@
Post
(
':id/members/bulk'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
addMembersBulk
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
AddBoardMembersBulkDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
boardsService
.
addMembersBulk
(
id
,
dto
,
user
);
}
@
Put
(
':id/members/:userId/role'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
updateMemberRole
(
@
Param
(
'id'
)
id
:
string
,
@
Param
(
'userId'
)
userId
:
string
,
@
Body
()
dto
:
UpdateBoardMemberRoleDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
boardsService
.
updateMemberRole
(
id
,
userId
,
dto
,
user
);
}
@
Delete
(
':id/members/:userId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
removeMember
(
@
Param
(
'id'
)
id
:
string
,
@
Param
(
'userId'
)
userId
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
boardsService
.
removeMember
(
id
,
userId
,
user
);
return
{
message
:
'Member removed from board'
};
}
// ─── TEMPLATES ──────────────────────────────────────────
@
Post
(
'templates'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
saveAsTemplate
(@
Body
()
dto
:
CreateBoardTemplateDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardTemplateService
.
saveAsTemplate
(
dto
,
user
);
}
@
Get
(
'templates/list'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
listTemplates
(@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardTemplateService
.
findAll
(
user
);
}
@
Get
(
'templates/:templateId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getTemplate
(@
Param
(
'templateId'
)
templateId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
boardTemplateService
.
findById
(
templateId
,
user
);
}
@
Put
(
'templates/:templateId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
updateTemplate
(
@
Param
(
'templateId'
)
templateId
:
string
,
@
Body
()
dto
:
UpdateBoardTemplateDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
boardTemplateService
.
update
(
templateId
,
dto
,
user
);
}
@
Delete
(
'templates/:templateId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
deleteTemplate
(@
Param
(
'templateId'
)
templateId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
boardTemplateService
.
delete
(
templateId
,
user
);
return
{
message
:
'Board template deleted'
};
}
}
\ No newline at end of file
backend/src/modules/boards/boards.module.ts
0 → 100644
View file @
ba81ccf6
import
{
Module
}
from
'@nestjs/common'
;
import
{
BoardsController
}
from
'./boards.controller'
;
import
{
BoardsService
}
from
'./boards.service'
;
import
{
BoardTemplateService
}
from
'./board-template.service'
;
@
Module
({
controllers
:
[
BoardsController
],
providers
:
[
BoardsService
,
BoardTemplateService
],
exports
:
[
BoardsService
,
BoardTemplateService
],
})
export
class
BoardsModule
{}
\ No newline at end of file
backend/src/modules/boards/boards.service.ts
0 → 100644
View file @
ba81ccf6
import
{
Injectable
,
NotFoundException
,
ConflictException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateBoardDto
}
from
'./dto/create-board.dto'
;
import
{
UpdateBoardDto
}
from
'./dto/update-board.dto'
;
import
{
BoardFilterDto
}
from
'./dto/board-filter.dto'
;
import
{
AddBoardMemberDto
,
AddBoardMembersBulkDto
,
UpdateBoardMemberRoleDto
}
from
'./dto/board-member.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
,
}
from
'../../common/utils/pagination.util'
;
const
DEFAULT_COLUMNS
=
[
{
name
:
'Backlog'
,
icon
:
'📋'
,
position
:
0
,
type
:
'BACKLOG'
,
isDone
:
false
,
isDefault
:
true
},
{
name
:
'Todo'
,
icon
:
'📌'
,
position
:
1
,
type
:
'TODO'
,
isDone
:
false
,
isDefault
:
true
},
{
name
:
'Doing'
,
icon
:
'🔨'
,
position
:
2
,
type
:
'DOING'
,
isDone
:
false
,
isDefault
:
true
},
{
name
:
'Frozen'
,
icon
:
'🧊'
,
position
:
3
,
type
:
'FROZEN'
,
isDone
:
false
,
isDefault
:
true
},
{
name
:
'In Review'
,
icon
:
'🔍'
,
position
:
4
,
type
:
'IN_REVIEW'
,
isDone
:
false
,
isDefault
:
true
},
{
name
:
'Done'
,
icon
:
'✅'
,
position
:
5
,
type
:
'DONE'
,
isDone
:
true
,
isDefault
:
true
},
];
@
Injectable
()
export
class
BoardsService
{
private
readonly
logger
=
new
Logger
(
BoardsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
create
(
dto
:
CreateBoardDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can create boards'
);
}
const
key
=
dto
.
key
||
this
.
generateBoardKey
(
dto
.
name
);
const
existingKey
=
await
this
.
prisma
.
board
.
findUnique
({
where
:
{
key
}
});
if
(
existingKey
)
{
throw
new
ConflictException
(
`Board key "
${
key
}
" is already in use`
);
}
const
board
=
await
this
.
prisma
.
board
.
create
({
data
:
{
name
:
dto
.
name
,
description
:
dto
.
description
||
null
,
key
,
visibility
:
dto
.
visibility
||
'PRIVATE'
,
color
:
dto
.
color
||
null
,
icon
:
dto
.
icon
||
null
,
allowContractorCreation
:
dto
.
allowContractorCreation
??
true
,
autoArchiveDoneCardsDays
:
dto
.
autoArchiveDoneCardsDays
??
30
,
deadlineExcludesHolidays
:
dto
.
deadlineExcludesHolidays
??
false
,
createdById
:
currentUser
.
id
,
},
});
// Create default columns
for
(
const
col
of
DEFAULT_COLUMNS
)
{
await
this
.
prisma
.
column
.
create
({
data
:
{
boardId
:
board
.
id
,
name
:
col
.
name
,
icon
:
col
.
icon
,
position
:
col
.
position
,
type
:
col
.
type
,
isDone
:
col
.
isDone
,
isDefault
:
col
.
isDefault
,
},
});
}
// Add creator as OWNER member
await
this
.
prisma
.
boardMember
.
create
({
data
:
{
boardId
:
board
.
id
,
userId
:
currentUser
.
id
,
role
:
'OWNER'
,
},
});
// Add initial members if specified
if
(
dto
.
memberUserIds
&&
dto
.
memberUserIds
.
length
>
0
)
{
for
(
const
userId
of
dto
.
memberUserIds
)
{
if
(
userId
===
currentUser
.
id
)
continue
;
try
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
},
});
if
(
user
)
{
await
this
.
prisma
.
boardMember
.
create
({
data
:
{
boardId
:
board
.
id
,
userId
,
role
:
'MEMBER'
,
},
});
}
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to add member
${
userId
}
to board
${
board
.
id
}
:
${
err
.
message
}
`
);
}
}
}
this
.
logger
.
log
(
`Board "
${
board
.
name
}
" (
${
board
.
key
}
) created by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
board
.
id
,
currentUser
);
}
async
findAll
(
filter
:
BoardFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
20
;
const
where
:
any
=
{
deletedAt
:
null
};
if
(
filter
.
isArchived
!==
undefined
)
{
where
.
isArchived
=
filter
.
isArchived
;
}
else
{
where
.
isArchived
=
false
;
}
if
(
filter
.
visibility
)
{
where
.
visibility
=
filter
.
visibility
;
}
if
(
filter
.
search
)
{
where
.
OR
=
[
{
name
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
description
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
{
key
:
{
contains
:
filter
.
search
,
mode
:
'insensitive'
}
},
];
}
// Non-admin users only see boards they're members of
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
where
.
members
=
{
some
:
{
userId
:
currentUser
.
id
}
};
}
if
(
filter
.
memberId
)
{
where
.
members
=
{
...
where
.
members
,
some
:
{
...
where
.
members
?.
some
,
userId
:
filter
.
memberId
}
};
}
const
[
boards
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
board
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
[
filter
.
sortBy
||
'createdAt'
]:
filter
.
sortOrder
||
'desc'
},
include
:
{
_count
:
{
select
:
{
members
:
true
,
},
},
members
:
{
where
:
{
userId
:
currentUser
.
id
},
take
:
1
,
},
},
}),
this
.
prisma
.
board
.
count
({
where
}),
]);
const
enriched
=
boards
.
map
((
board
:
any
)
=>
({
id
:
board
.
id
,
name
:
board
.
name
,
description
:
board
.
description
,
key
:
board
.
key
,
visibility
:
board
.
visibility
,
color
:
board
.
color
,
icon
:
board
.
icon
,
isArchived
:
board
.
isArchived
,
allowContractorCreation
:
board
.
allowContractorCreation
,
autoArchiveDoneCardsDays
:
board
.
autoArchiveDoneCardsDays
,
deadlineExcludesHolidays
:
board
.
deadlineExcludesHolidays
,
memberCount
:
board
.
_count
.
members
,
cardCount
:
0
,
// Will be filled when cards module is built
currentUserRole
:
board
.
members
[
0
]?.
role
||
null
,
createdById
:
board
.
createdById
,
createdAt
:
board
.
createdAt
,
updatedAt
:
board
.
updatedAt
,
}));
return
buildPaginatedResponse
(
enriched
,
total
,
{
page
,
limit
,
sortOrder
:
filter
.
sortOrder
||
'desc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
},
include
:
{
columns
:
{
orderBy
:
{
position
:
'asc'
},
},
members
:
{
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
,
},
},
},
orderBy
:
{
joinedAt
:
'asc'
},
},
_count
:
{
select
:
{
members
:
true
},
},
},
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
// Check access
this
.
enforceReadAccess
(
board
,
currentUser
);
// Get card counts per column
const
columnCardCounts
=
await
this
.
prisma
.
column
.
findMany
({
where
:
{
boardId
:
id
},
select
:
{
id
:
true
,
_count
:
{
select
:
{
cards
:
true
,
},
},
},
}).
catch
(()
=>
[]);
const
cardCountMap
:
Record
<
string
,
number
>
=
{};
for
(
const
col
of
columnCardCounts
)
{
cardCountMap
[
col
.
id
]
=
(
col
as
any
).
_count
?.
cards
||
0
;
}
return
{
id
:
board
.
id
,
name
:
board
.
name
,
description
:
board
.
description
,
key
:
board
.
key
,
visibility
:
board
.
visibility
,
color
:
board
.
color
,
icon
:
board
.
icon
,
isArchived
:
board
.
isArchived
,
allowContractorCreation
:
board
.
allowContractorCreation
,
autoArchiveDoneCardsDays
:
board
.
autoArchiveDoneCardsDays
,
deadlineExcludesHolidays
:
board
.
deadlineExcludesHolidays
,
memberCount
:
board
.
_count
.
members
,
createdById
:
board
.
createdById
,
createdAt
:
board
.
createdAt
,
updatedAt
:
board
.
updatedAt
,
columns
:
board
.
columns
.
map
((
col
)
=>
({
id
:
col
.
id
,
name
:
col
.
name
,
icon
:
col
.
icon
,
position
:
col
.
position
,
type
:
col
.
type
,
isDone
:
col
.
isDone
,
isDefault
:
col
.
isDefault
,
wipLimit
:
col
.
wipLimit
,
wipLimitTotal
:
col
.
wipLimitTotal
,
color
:
col
.
color
,
cardCount
:
cardCountMap
[
col
.
id
]
||
0
,
})),
members
:
board
.
members
.
map
((
m
:
any
)
=>
({
id
:
m
.
id
,
userId
:
m
.
userId
,
role
:
m
.
role
,
joinedAt
:
m
.
joinedAt
,
user
:
m
.
user
,
})),
};
}
async
update
(
id
:
string
,
dto
:
UpdateBoardDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can edit board settings'
);
}
if
(
dto
.
key
&&
dto
.
key
!==
board
.
key
)
{
const
existingKey
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
key
:
dto
.
key
,
id
:
{
not
:
id
}
},
});
if
(
existingKey
)
{
throw
new
ConflictException
(
`Board key "
${
dto
.
key
}
" is already in use`
);
}
}
const
updateData
:
any
=
{};
const
fields
=
[
'name'
,
'description'
,
'key'
,
'visibility'
,
'color'
,
'icon'
,
'allowContractorCreation'
,
'autoArchiveDoneCardsDays'
,
'deadlineExcludesHolidays'
,
];
for
(
const
field
of
fields
)
{
if
((
dto
as
any
)[
field
]
!==
undefined
)
{
updateData
[
field
]
=
(
dto
as
any
)[
field
];
}
}
await
this
.
prisma
.
board
.
update
({
where
:
{
id
},
data
:
updateData
,
});
this
.
logger
.
log
(
`Board
${
id
}
updated by
${
currentUser
.
email
}
`
);
return
this
.
findById
(
id
,
currentUser
);
}
async
archive
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can archive boards'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
if
(
board
.
isArchived
)
{
throw
new
BadRequestException
(
'Board is already archived'
);
}
await
this
.
prisma
.
board
.
update
({
where
:
{
id
},
data
:
{
isArchived
:
true
,
archivedAt
:
new
Date
()
},
});
this
.
logger
.
log
(
`Board
${
id
}
archived by
${
currentUser
.
email
}
`
);
}
async
restore
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can restore boards'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
if
(
!
board
.
isArchived
)
{
throw
new
BadRequestException
(
'Board is not archived'
);
}
await
this
.
prisma
.
board
.
update
({
where
:
{
id
},
data
:
{
isArchived
:
false
,
archivedAt
:
null
},
});
this
.
logger
.
log
(
`Board
${
id
}
restored by
${
currentUser
.
email
}
`
);
}
async
permanentDelete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can permanently delete boards'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
,
deletedAt
:
null
},
include
:
{
_count
:
{
select
:
{
columns
:
true
,
members
:
true
},
},
},
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
// Soft delete — we don't actually nuke the data
await
this
.
prisma
.
board
.
update
({
where
:
{
id
},
data
:
{
deletedAt
:
new
Date
(),
isArchived
:
true
},
});
this
.
logger
.
log
(
`Board
${
id
}
permanently deleted by
${
currentUser
.
email
}
`
);
}
// ─── MEMBER MANAGEMENT ─────────────────────────────────────
async
addMember
(
boardId
:
string
,
dto
:
AddBoardMemberDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can manage board members'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
boardId
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
dto
.
userId
,
deletedAt
:
null
}
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'User not found'
);
}
const
existing
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
,
userId
:
dto
.
userId
}
},
});
if
(
existing
)
{
throw
new
ConflictException
(
'User is already a member of this board'
);
}
const
member
=
await
this
.
prisma
.
boardMember
.
create
({
data
:
{
boardId
,
userId
:
dto
.
userId
,
role
:
dto
.
role
||
'MEMBER'
,
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
,
},
},
},
});
this
.
logger
.
log
(
`User
${
dto
.
userId
}
added to board
${
boardId
}
by
${
currentUser
.
email
}
`
);
return
{
id
:
member
.
id
,
userId
:
member
.
userId
,
role
:
member
.
role
,
joinedAt
:
member
.
joinedAt
,
user
:
member
.
user
,
};
}
async
addMembersBulk
(
boardId
:
string
,
dto
:
AddBoardMembersBulkDto
,
currentUser
:
RequestUser
):
Promise
<
{
added
:
number
;
skipped
:
number
}
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can manage board members'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
boardId
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
let
added
=
0
;
let
skipped
=
0
;
for
(
const
userId
of
dto
.
userIds
)
{
try
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
userId
,
deletedAt
:
null
}
});
if
(
!
user
)
{
skipped
++
;
continue
;
}
const
existing
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
});
if
(
existing
)
{
skipped
++
;
continue
;
}
await
this
.
prisma
.
boardMember
.
create
({
data
:
{
boardId
,
userId
,
role
:
dto
.
role
||
'MEMBER'
,
},
});
added
++
;
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to add member
${
userId
}
:
${
err
.
message
}
`
);
skipped
++
;
}
}
this
.
logger
.
log
(
`Bulk add to board
${
boardId
}
:
${
added
}
added,
${
skipped
}
skipped by
${
currentUser
.
email
}
`
);
return
{
added
,
skipped
};
}
async
removeMember
(
boardId
:
string
,
userId
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can manage board members'
);
}
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
});
if
(
!
membership
)
{
throw
new
NotFoundException
(
'User is not a member of this board'
);
}
await
this
.
prisma
.
boardMember
.
delete
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
});
this
.
logger
.
log
(
`User
${
userId
}
removed from board
${
boardId
}
by
${
currentUser
.
email
}
`
);
}
async
updateMemberRole
(
boardId
:
string
,
userId
:
string
,
dto
:
UpdateBoardMemberRoleDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can change member roles'
);
}
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
,
},
},
},
});
if
(
!
membership
)
{
throw
new
NotFoundException
(
'User is not a member of this board'
);
}
const
validRoles
=
[
'OWNER'
,
'ADMIN'
,
'MEMBER'
,
'VIEWER'
];
if
(
!
validRoles
.
includes
(
dto
.
role
))
{
throw
new
BadRequestException
(
`Invalid role. Must be one of:
${
validRoles
.
join
(
', '
)}
`
);
}
const
updated
=
await
this
.
prisma
.
boardMember
.
update
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
data
:
{
role
:
dto
.
role
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
,
},
},
},
});
return
{
id
:
updated
.
id
,
userId
:
updated
.
userId
,
role
:
updated
.
role
,
joinedAt
:
updated
.
joinedAt
,
user
:
updated
.
user
,
};
}
async
getMembers
(
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'
);
}
this
.
enforceReadAccess
(
board
,
currentUser
);
const
members
=
await
this
.
prisma
.
boardMember
.
findMany
({
where
:
{
boardId
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
displayName
:
true
,
avatar
:
true
,
role
:
true
,
status
:
true
,
},
},
},
orderBy
:
{
joinedAt
:
'asc'
},
});
return
members
.
map
((
m
)
=>
({
id
:
m
.
id
,
userId
:
m
.
userId
,
role
:
m
.
role
,
joinedAt
:
m
.
joinedAt
,
user
:
m
.
user
,
}));
}
// ─── NEXT CARD NUMBER ────────────────────────────────────
async
getNextCardNumber
(
boardId
:
string
):
Promise
<
{
key
:
string
;
number
:
number
}
>
{
const
board
=
await
this
.
prisma
.
board
.
findUnique
({
where
:
{
id
:
boardId
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
const
nextNumber
=
board
.
nextCardNumber
;
await
this
.
prisma
.
board
.
update
({
where
:
{
id
:
boardId
},
data
:
{
nextCardNumber
:
nextNumber
+
1
},
});
return
{
key
:
board
.
key
,
number
:
nextNumber
};
}
// ─── HELPERS ─────────────────────────────────────────────
async
isMember
(
boardId
:
string
,
userId
:
string
):
Promise
<
boolean
>
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
});
return
!!
membership
;
}
async
getMemberRole
(
boardId
:
string
,
userId
:
string
):
Promise
<
string
|
null
>
{
const
membership
=
await
this
.
prisma
.
boardMember
.
findUnique
({
where
:
{
boardId_userId
:
{
boardId
,
userId
}
},
});
return
membership
?.
role
||
null
;
}
private
enforceReadAccess
(
board
:
any
,
currentUser
:
RequestUser
):
void
{
if
(
currentUser
.
role
===
'SUPER_ADMIN'
||
currentUser
.
role
===
'ADMIN'
)
{
return
;
}
const
isMember
=
board
.
members
?.
some
((
m
:
any
)
=>
m
.
userId
===
currentUser
.
id
);
if
(
!
isMember
&&
board
.
visibility
!==
'PUBLIC'
)
{
throw
new
ForbiddenException
(
'You do not have access to this board'
);
}
}
private
generateBoardKey
(
name
:
string
):
string
{
const
cleaned
=
name
.
toUpperCase
()
.
replace
(
/
[^
A-Z0-9
\s]
/g
,
''
)
.
trim
()
.
split
(
/
\s
+/
)
.
slice
(
0
,
3
)
.
map
((
word
)
=>
word
.
slice
(
0
,
4
))
.
join
(
''
);
return
cleaned
.
slice
(
0
,
8
)
||
'BOARD'
;
}
}
\ No newline at end of file
backend/src/modules/boards/dto/board-filter.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsOptional
,
IsString
,
IsBoolean
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
BoardFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
IsString
()
visibility
?:
string
;
@
IsOptional
()
@
Type
(()
=>
Boolean
)
@
IsBoolean
()
isArchived
?:
boolean
;
@
IsOptional
()
@
IsString
()
memberId
?:
string
;
}
\ No newline at end of file
backend/src/modules/boards/dto/board-member.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsString
,
IsOptional
,
IsEnum
,
IsArray
}
from
'class-validator'
;
export
class
AddBoardMemberDto
{
@
IsString
()
userId
:
string
;
@
IsOptional
()
@
IsString
()
role
?:
string
;
}
export
class
AddBoardMembersBulkDto
{
@
IsArray
()
@
IsString
({
each
:
true
})
userIds
:
string
[];
@
IsOptional
()
@
IsString
()
role
?:
string
;
}
export
class
UpdateBoardMemberRoleDto
{
@
IsString
()
role
:
string
;
}
export
class
RemoveBoardMemberDto
{
@
IsString
()
userId
:
string
;
}
\ No newline at end of file
backend/src/modules/boards/dto/board-response.dto.ts
0 → 100644
View file @
ba81ccf6
export
class
BoardSummaryResponseDto
{
id
:
string
;
name
:
string
;
description
:
string
|
null
;
key
:
string
;
visibility
:
string
;
color
:
string
|
null
;
icon
:
string
|
null
;
isArchived
:
boolean
;
memberCount
:
number
;
cardCount
:
number
;
allowContractorCreation
:
boolean
;
autoArchiveDoneCardsDays
:
number
;
deadlineExcludesHolidays
:
boolean
;
createdById
:
string
;
createdAt
:
string
;
updatedAt
:
string
;
}
export
class
BoardDetailResponseDto
extends
BoardSummaryResponseDto
{
columns
:
ColumnResponseDto
[];
members
:
BoardMemberResponseDto
[];
}
export
class
ColumnResponseDto
{
id
:
string
;
name
:
string
;
icon
:
string
|
null
;
position
:
number
;
type
:
string
;
isDone
:
boolean
;
isDefault
:
boolean
;
wipLimit
:
number
|
null
;
wipLimitTotal
:
number
|
null
;
color
:
string
|
null
;
cardCount
:
number
;
}
export
class
BoardMemberResponseDto
{
id
:
string
;
userId
:
string
;
role
:
string
;
joinedAt
:
string
;
user
:
{
id
:
string
;
firstName
:
string
;
lastName
:
string
;
displayName
:
string
|
null
;
avatar
:
string
|
null
;
role
:
string
;
};
}
\ No newline at end of file
backend/src/modules/boards/dto/board-template.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsString
,
IsOptional
,
MinLength
,
MaxLength
}
from
'class-validator'
;
export
class
CreateBoardTemplateDto
{
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
100
)
name
:
string
;
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
@
IsString
()
boardId
:
string
;
}
export
class
UpdateBoardTemplateDto
{
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
100
)
name
?:
string
;
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
}
\ No newline at end of file
backend/src/modules/boards/dto/create-board.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsString
,
IsOptional
,
IsBoolean
,
IsInt
,
IsArray
,
MinLength
,
MaxLength
,
Matches
,
Min
,
Max
,
}
from
'class-validator'
;
export
class
CreateBoardDto
{
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
100
)
name
:
string
;
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
@
IsOptional
()
@
IsString
()
@
MinLength
(
2
)
@
MaxLength
(
10
)
@
Matches
(
/^
[
A-Z0-9_
]
+$/
,
{
message
:
'Board key must be uppercase alphanumeric with underscores'
})
key
?:
string
;
@
IsOptional
()
@
IsString
()
visibility
?:
string
;
@
IsOptional
()
@
IsString
()
color
?:
string
;
@
IsOptional
()
@
IsString
()
icon
?:
string
;
@
IsOptional
()
@
IsBoolean
()
allowContractorCreation
?:
boolean
;
@
IsOptional
()
@
IsInt
()
@
Min
(
1
)
@
Max
(
365
)
autoArchiveDoneCardsDays
?:
number
;
@
IsOptional
()
@
IsBoolean
()
deadlineExcludesHolidays
?:
boolean
;
@
IsOptional
()
@
IsArray
()
@
IsString
({
each
:
true
})
memberUserIds
?:
string
[];
@
IsOptional
()
@
IsString
()
templateId
?:
string
;
}
\ No newline at end of file
backend/src/modules/boards/dto/update-board.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsString
,
IsOptional
,
IsBoolean
,
IsInt
,
MinLength
,
MaxLength
,
Matches
,
Min
,
Max
,
}
from
'class-validator'
;
export
class
UpdateBoardDto
{
@
IsOptional
()
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
100
)
name
?:
string
;
@
IsOptional
()
@
IsString
()
@
MaxLength
(
500
)
description
?:
string
;
@
IsOptional
()
@
IsString
()
@
MinLength
(
2
)
@
MaxLength
(
10
)
@
Matches
(
/^
[
A-Z0-9_
]
+$/
,
{
message
:
'Board key must be uppercase alphanumeric with underscores'
})
key
?:
string
;
@
IsOptional
()
@
IsString
()
visibility
?:
string
;
@
IsOptional
()
@
IsString
()
color
?:
string
;
@
IsOptional
()
@
IsString
()
icon
?:
string
;
@
IsOptional
()
@
IsBoolean
()
allowContractorCreation
?:
boolean
;
@
IsOptional
()
@
IsInt
()
@
Min
(
1
)
@
Max
(
365
)
autoArchiveDoneCardsDays
?:
number
;
@
IsOptional
()
@
IsBoolean
()
deadlineExcludesHolidays
?:
boolean
;
}
\ No newline at end of file
backend/src/modules/columns/columns.controller.ts
0 → 100644
View file @
ba81ccf6
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
ColumnsService
}
from
'./columns.service'
;
import
{
CreateColumnDto
}
from
'./dto/create-column.dto'
;
import
{
UpdateColumnDto
,
ReorderColumnsDto
,
DeleteColumnDto
}
from
'./dto/update-column.dto'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Controller
(
'columns'
)
export
class
ColumnsController
{
constructor
(
private
readonly
columnsService
:
ColumnsService
)
{}
@
Get
(
'board/:boardId'
)
async
findByBoard
(@
Param
(
'boardId'
)
boardId
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
columnsService
.
findByBoard
(
boardId
,
user
);
}
@
Post
()
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
createCustomColumn
(@
Body
()
dto
:
CreateColumnDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
columnsService
.
createCustomColumn
(
dto
,
user
);
}
@
Put
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
UpdateColumnDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
columnsService
.
update
(
id
,
dto
,
user
);
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
deleteCustomColumn
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
DeleteColumnDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
await
this
.
columnsService
.
deleteCustomColumn
(
id
,
dto
,
user
);
return
{
message
:
'Column deleted and cards migrated'
};
}
@
Post
(
'reorder'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
reorderColumns
(@
Body
()
dto
:
ReorderColumnsDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
columnsService
.
reorderColumns
(
dto
,
user
);
}
}
\ No newline at end of file
backend/src/modules/columns/columns.module.ts
0 → 100644
View file @
ba81ccf6
import
{
Module
}
from
'@nestjs/common'
;
import
{
ColumnsController
}
from
'./columns.controller'
;
import
{
ColumnsService
}
from
'./columns.service'
;
@
Module
({
controllers
:
[
ColumnsController
],
providers
:
[
ColumnsService
],
exports
:
[
ColumnsService
],
})
export
class
ColumnsModule
{}
\ No newline at end of file
backend/src/modules/columns/columns.service.ts
0 → 100644
View file @
ba81ccf6
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
CreateColumnDto
}
from
'./dto/create-column.dto'
;
import
{
UpdateColumnDto
,
ReorderColumnsDto
,
DeleteColumnDto
}
from
'./dto/update-column.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
const
MAX_CUSTOM_COLUMNS
=
5
;
@
Injectable
()
export
class
ColumnsService
{
private
readonly
logger
=
new
Logger
(
ColumnsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
findByBoard
(
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'
);
}
const
columns
=
await
this
.
prisma
.
column
.
findMany
({
where
:
{
boardId
},
orderBy
:
{
position
:
'asc'
},
});
// Get card counts per column
const
cardCounts
=
await
Promise
.
all
(
columns
.
map
(
async
(
col
)
=>
{
try
{
const
count
=
await
this
.
prisma
.
card
.
count
({
where
:
{
columnId
:
col
.
id
,
deletedAt
:
null
},
});
return
{
id
:
col
.
id
,
count
};
}
catch
{
return
{
id
:
col
.
id
,
count
:
0
};
}
}),
);
const
countMap
:
Record
<
string
,
number
>
=
{};
for
(
const
cc
of
cardCounts
)
{
countMap
[
cc
.
id
]
=
cc
.
count
;
}
return
columns
.
map
((
col
)
=>
({
id
:
col
.
id
,
boardId
:
col
.
boardId
,
name
:
col
.
name
,
icon
:
col
.
icon
,
position
:
col
.
position
,
type
:
col
.
type
,
isDone
:
col
.
isDone
,
isDefault
:
col
.
isDefault
,
wipLimit
:
col
.
wipLimit
,
wipLimitTotal
:
col
.
wipLimitTotal
,
color
:
col
.
color
,
cardCount
:
countMap
[
col
.
id
]
||
0
,
}));
}
async
createCustomColumn
(
dto
:
CreateColumnDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can add custom columns'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
dto
.
boardId
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
// Count existing custom columns
const
customCount
=
await
this
.
prisma
.
column
.
count
({
where
:
{
boardId
:
dto
.
boardId
,
isDefault
:
false
},
});
if
(
customCount
>=
MAX_CUSTOM_COLUMNS
)
{
throw
new
BadRequestException
(
`Maximum
${
MAX_CUSTOM_COLUMNS
}
custom columns per board`
);
}
// Custom columns go between FROZEN and IN_REVIEW
// Find the FROZEN and IN_REVIEW columns
const
allColumns
=
await
this
.
prisma
.
column
.
findMany
({
where
:
{
boardId
:
dto
.
boardId
},
orderBy
:
{
position
:
'asc'
},
});
const
frozenCol
=
allColumns
.
find
((
c
)
=>
c
.
type
===
'FROZEN'
);
const
inReviewCol
=
allColumns
.
find
((
c
)
=>
c
.
type
===
'IN_REVIEW'
);
if
(
!
frozenCol
||
!
inReviewCol
)
{
throw
new
BadRequestException
(
'Cannot find Frozen or In Review columns'
);
}
// New position: right before In Review
const
newPosition
=
inReviewCol
.
position
;
// Shift In Review and Done up by 1
await
this
.
prisma
.
column
.
updateMany
({
where
:
{
boardId
:
dto
.
boardId
,
position
:
{
gte
:
newPosition
},
},
data
:
{
position
:
{
increment
:
1
},
},
});
const
column
=
await
this
.
prisma
.
column
.
create
({
data
:
{
boardId
:
dto
.
boardId
,
name
:
dto
.
name
,
icon
:
dto
.
icon
||
'📂'
,
position
:
newPosition
,
type
:
'CUSTOM'
,
isDone
:
false
,
isDefault
:
false
,
wipLimit
:
dto
.
wipLimit
||
null
,
wipLimitTotal
:
dto
.
wipLimitTotal
||
null
,
color
:
dto
.
color
||
null
,
},
});
this
.
logger
.
log
(
`Custom column "
${
dto
.
name
}
" created on board
${
dto
.
boardId
}
by
${
currentUser
.
email
}
`
);
return
{
id
:
column
.
id
,
boardId
:
column
.
boardId
,
name
:
column
.
name
,
icon
:
column
.
icon
,
position
:
column
.
position
,
type
:
column
.
type
,
isDone
:
column
.
isDone
,
isDefault
:
column
.
isDefault
,
wipLimit
:
column
.
wipLimit
,
wipLimitTotal
:
column
.
wipLimitTotal
,
color
:
column
.
color
,
cardCount
:
0
,
};
}
async
update
(
id
:
string
,
dto
:
UpdateColumnDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can edit columns'
);
}
const
column
=
await
this
.
prisma
.
column
.
findUnique
({
where
:
{
id
}
});
if
(
!
column
)
{
throw
new
NotFoundException
(
'Column not found'
);
}
// Default columns: can only update icon, wipLimit, wipLimitTotal, color
// Custom columns: can update name, icon, wipLimit, wipLimitTotal, color
const
updateData
:
any
=
{};
if
(
dto
.
icon
!==
undefined
)
updateData
.
icon
=
dto
.
icon
;
if
(
dto
.
wipLimit
!==
undefined
)
updateData
.
wipLimit
=
dto
.
wipLimit
||
null
;
if
(
dto
.
wipLimitTotal
!==
undefined
)
updateData
.
wipLimitTotal
=
dto
.
wipLimitTotal
||
null
;
if
(
dto
.
color
!==
undefined
)
updateData
.
color
=
dto
.
color
;
if
(
dto
.
name
!==
undefined
)
{
if
(
column
.
isDefault
)
{
throw
new
BadRequestException
(
'Cannot rename default columns'
);
}
updateData
.
name
=
dto
.
name
;
}
const
updated
=
await
this
.
prisma
.
column
.
update
({
where
:
{
id
},
data
:
updateData
,
});
return
{
id
:
updated
.
id
,
boardId
:
updated
.
boardId
,
name
:
updated
.
name
,
icon
:
updated
.
icon
,
position
:
updated
.
position
,
type
:
updated
.
type
,
isDone
:
updated
.
isDone
,
isDefault
:
updated
.
isDefault
,
wipLimit
:
updated
.
wipLimit
,
wipLimitTotal
:
updated
.
wipLimitTotal
,
color
:
updated
.
color
,
};
}
async
deleteCustomColumn
(
id
:
string
,
dto
:
DeleteColumnDto
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can delete columns'
);
}
const
column
=
await
this
.
prisma
.
column
.
findUnique
({
where
:
{
id
}
});
if
(
!
column
)
{
throw
new
NotFoundException
(
'Column not found'
);
}
if
(
column
.
isDefault
)
{
throw
new
BadRequestException
(
'Cannot delete default columns'
);
}
// Verify target column exists and is on the same board
const
targetColumn
=
await
this
.
prisma
.
column
.
findUnique
({
where
:
{
id
:
dto
.
migrateCardsToColumnId
}
});
if
(
!
targetColumn
||
targetColumn
.
boardId
!==
column
.
boardId
)
{
throw
new
BadRequestException
(
'Invalid target column for card migration'
);
}
// Migrate cards to target column
try
{
const
maxPosition
=
await
this
.
prisma
.
card
.
aggregate
({
where
:
{
columnId
:
dto
.
migrateCardsToColumnId
,
deletedAt
:
null
},
_max
:
{
position
:
true
},
});
let
nextPosition
=
(
maxPosition
.
_max
?.
position
||
0
)
+
1
;
const
cardsToMigrate
=
await
this
.
prisma
.
card
.
findMany
({
where
:
{
columnId
:
id
,
deletedAt
:
null
},
orderBy
:
{
position
:
'asc'
},
});
for
(
const
card
of
cardsToMigrate
)
{
await
this
.
prisma
.
card
.
update
({
where
:
{
id
:
card
.
id
},
data
:
{
columnId
:
dto
.
migrateCardsToColumnId
,
position
:
nextPosition
++
,
},
});
}
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Card migration during column delete:
${
err
.
message
}
. Cards table may not exist yet.`
);
}
// Delete the column
await
this
.
prisma
.
column
.
delete
({
where
:
{
id
}
});
// Re-normalize positions
await
this
.
normalizePositions
(
column
.
boardId
);
this
.
logger
.
log
(
`Custom column
${
id
}
deleted from board
${
column
.
boardId
}
by
${
currentUser
.
email
}
`
);
}
async
reorderColumns
(
dto
:
ReorderColumnsDto
,
currentUser
:
RequestUser
):
Promise
<
any
[]
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can reorder columns'
);
}
const
board
=
await
this
.
prisma
.
board
.
findFirst
({
where
:
{
id
:
dto
.
boardId
,
deletedAt
:
null
}
});
if
(
!
board
)
{
throw
new
NotFoundException
(
'Board not found'
);
}
const
allColumns
=
await
this
.
prisma
.
column
.
findMany
({
where
:
{
boardId
:
dto
.
boardId
},
orderBy
:
{
position
:
'asc'
},
});
// Validate: all column IDs must belong to this board
const
boardColumnIds
=
new
Set
(
allColumns
.
map
((
c
)
=>
c
.
id
));
for
(
const
colId
of
dto
.
columnIds
)
{
if
(
!
boardColumnIds
.
has
(
colId
))
{
throw
new
BadRequestException
(
`Column
${
colId
}
does not belong to board
${
dto
.
boardId
}
`
);
}
}
// Enforce: default columns must maintain their relative order
// Backlog < Todo < Doing < Frozen < [custom] < In Review < Done
const
defaultOrder
=
[
'BACKLOG'
,
'TODO'
,
'DOING'
,
'FROZEN'
,
'IN_REVIEW'
,
'DONE'
];
const
columnTypeMap
:
Record
<
string
,
string
>
=
{};
for
(
const
col
of
allColumns
)
{
columnTypeMap
[
col
.
id
]
=
col
.
type
;
}
let
lastDefaultIndex
=
-
1
;
for
(
const
colId
of
dto
.
columnIds
)
{
const
colType
=
columnTypeMap
[
colId
];
if
(
colType
!==
'CUSTOM'
)
{
const
defaultIdx
=
defaultOrder
.
indexOf
(
colType
);
if
(
defaultIdx
<
lastDefaultIndex
)
{
throw
new
BadRequestException
(
'Default columns must maintain their relative order (Backlog → Todo → Doing → Frozen → In Review → Done)'
);
}
lastDefaultIndex
=
defaultIdx
;
}
}
// Apply new positions
for
(
let
i
=
0
;
i
<
dto
.
columnIds
.
length
;
i
++
)
{
await
this
.
prisma
.
column
.
update
({
where
:
{
id
:
dto
.
columnIds
[
i
]
},
data
:
{
position
:
i
},
});
}
return
this
.
findByBoard
(
dto
.
boardId
,
currentUser
);
}
private
async
normalizePositions
(
boardId
:
string
):
Promise
<
void
>
{
const
columns
=
await
this
.
prisma
.
column
.
findMany
({
where
:
{
boardId
},
orderBy
:
{
position
:
'asc'
},
});
for
(
let
i
=
0
;
i
<
columns
.
length
;
i
++
)
{
if
(
columns
[
i
].
position
!==
i
)
{
await
this
.
prisma
.
column
.
update
({
where
:
{
id
:
columns
[
i
].
id
},
data
:
{
position
:
i
},
});
}
}
}
}
\ No newline at end of file
backend/src/modules/columns/dto/create-column.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsString
,
IsOptional
,
IsInt
,
Min
,
Max
,
MinLength
,
MaxLength
}
from
'class-validator'
;
export
class
CreateColumnDto
{
@
IsString
()
boardId
:
string
;
@
IsString
()
@
MinLength
(
1
)
@
MaxLength
(
50
)
name
:
string
;
@
IsOptional
()
@
IsString
()
icon
?:
string
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
wipLimit
?:
number
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
wipLimitTotal
?:
number
;
@
IsOptional
()
@
IsString
()
color
?:
string
;
}
\ No newline at end of file
backend/src/modules/columns/dto/update-column.dto.ts
0 → 100644
View file @
ba81ccf6
import
{
IsString
,
IsOptional
,
IsInt
,
Min
,
MaxLength
,
IsArray
}
from
'class-validator'
;
export
class
UpdateColumnDto
{
@
IsOptional
()
@
IsString
()
@
MaxLength
(
50
)
name
?:
string
;
@
IsOptional
()
@
IsString
()
icon
?:
string
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
wipLimit
?:
number
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
wipLimitTotal
?:
number
;
@
IsOptional
()
@
IsString
()
color
?:
string
;
}
export
class
ReorderColumnsDto
{
@
IsString
()
boardId
:
string
;
@
IsArray
()
@
IsString
({
each
:
true
})
columnIds
:
string
[];
}
export
class
DeleteColumnDto
{
@
IsString
()
migrateCardsToColumnId
:
string
;
}
\ No newline at end of file
prisma/schema-boards.prisma
0 → 100644
View file @
ba81ccf6
//
============================================================
//
BOARDS
+
COLUMNS
—
Add
these
models
to
your
main
schema
.
prisma
//
============================================================
model
Board
{
id
String
@
id
@
default
(
uuid
())
name
String
description
String
?
key
String
@
unique
//
e
.
g
.
"PROJ"
—
used
for
card
numbering
visibility
String
@
default
(
"PRIVATE"
)
//
PUBLIC
,
PRIVATE
,
TEAM
color
String
?
icon
String
?
allowContractorCreation
Boolean
@
default
(
true
)
autoArchiveDoneCardsDays
Int
@
default
(
30
)
deadlineExcludesHolidays
Boolean
@
default
(
false
)
nextCardNumber
Int
@
default
(
1
)
isArchived
Boolean
@
default
(
false
)
archivedAt
DateTime
?
deletedAt
DateTime
?
createdById
String
createdBy
User
@
relation
(
"BoardCreator"
,
fields
:
[
createdById
],
references
:
[
id
],
onDelete
:
RESTRICT
)
columns
Column
[]
members
BoardMember
[]
labels
Label
[]
@
relation
(
"BoardLabels"
)
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
createdById
])
@@
index
([
isArchived
])
@@
index
([
deletedAt
])
@@
index
([
name
])
}
model
Column
{
id
String
@
id
@
default
(
uuid
())
boardId
String
board
Board
@
relation
(
fields
:
[
boardId
],
references
:
[
id
],
onDelete
:
CASCADE
)
name
String
icon
String
?
position
Int
type
String
//
BACKLOG
,
TODO
,
DOING
,
FROZEN
,
IN_REVIEW
,
DONE
,
CUSTOM
isDone
Boolean
@
default
(
false
)
isDefault
Boolean
@
default
(
true
)
wipLimit
Int
?
//
per
-
user
WIP
limit
wipLimitTotal
Int
?
//
total
WIP
limit
for
the
column
color
String
?
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
boardId
])
@@
index
([
boardId
,
position
])
}
model
BoardMember
{
id
String
@
id
@
default
(
uuid
())
boardId
String
board
Board
@
relation
(
fields
:
[
boardId
],
references
:
[
id
],
onDelete
:
CASCADE
)
userId
String
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
CASCADE
)
role
String
@
default
(
"MEMBER"
)
//
OWNER
,
ADMIN
,
MEMBER
,
VIEWER
joinedAt
DateTime
@
default
(
now
())
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
unique
([
boardId
,
userId
])
@@
index
([
userId
])
@@
index
([
boardId
])
}
model
BoardTemplate
{
id
String
@
id
@
default
(
uuid
())
name
String
description
String
?
boardConfig
Json
//
column
config
,
board
settings
labelConfig
Json
?
//
label
definitions
createdById
String
createdBy
User
@
relation
(
"BoardTemplateCreator"
,
fields
:
[
createdById
],
references
:
[
id
],
onDelete
:
RESTRICT
)
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
createdById
])
}
\ 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