Commit ba81ccf6 authored by Administrator's avatar Administrator

Update 17 files via Son of Anton

parent f9cd0fe6
......@@ -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 },
......
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
// ============================================================
// 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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment