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
This diff is collapsed.
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
This diff is collapsed.
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