Commit 22a7a58a authored by Administrator's avatar Administrator

Update 19 files via Son of Anton

parent fbb29954
......@@ -19,6 +19,8 @@ import { UsersModule } from './modules/users/users.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { BoardsModule } from './modules/boards/boards.module';
import { ColumnsModule } from './modules/columns/columns.module';
import { LabelsModule } from './modules/labels/labels.module';
import { CardsModule } from './modules/cards/cards.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
......@@ -44,6 +46,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
OnboardingModule,
BoardsModule,
ColumnsModule,
LabelsModule,
CardsModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class CardAssignmentService {
private readonly logger = new Logger(CardAssignmentService.name);
constructor(private readonly prisma: PrismaService) {}
async assign(cardId: string, assigneeIds: string[], currentUser: RequestUser): Promise<any> {
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
include: {
column: { select: { boardId: true } },
assignees: { select: { id: true } },
},
});
if (!card) {
throw new NotFoundException('Card not found');
}
// Contractors cannot assign anyone
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot assign cards');
}
// TEAM_LEAD: can only assign members of their boards
if (currentUser.role === 'TEAM_LEAD') {
const membership = await this.prisma.boardMember.findUnique({
where: { boardId_userId: { boardId: card.column.boardId, userId: currentUser.id } },
});
if (!membership) {
throw new ForbiddenException('You can only assign cards on boards you manage');
}
}
// Verify all assignees are board members
for (const userId of assigneeIds) {
const user = await this.prisma.user.findFirst({
where: { id: userId, deletedAt: null },
});
if (!user) {
this.logger.warn(`User ${userId} not found, skipping assignment`);
continue;
}
const isMember = await this.prisma.boardMember.findUnique({
where: { boardId_userId: { boardId: card.column.boardId, userId } },
});
if (!isMember) {
this.logger.warn(`User ${userId} is not a member of board ${card.column.boardId}, skipping`);
continue;
}
// Connect the assignee
await this.prisma.card.update({
where: { id: cardId },
data: { assignees: { connect: { id: userId } } },
});
// Log activity
try {
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'ASSIGNED',
metadata: {
assigneeId: userId,
assigneeName: `${user.firstName} ${user.lastName}`,
},
},
});
} catch (err) {
this.logger.warn(`Failed to log assignment activity: ${err.message}`);
}
}
this.logger.log(`Card ${cardId}: assigned ${assigneeIds.length} user(s) by ${currentUser.email}`);
return this.getCardAssignees(cardId);
}
async unassign(cardId: string, userIds: string[], currentUser: RequestUser): Promise<any> {
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
});
if (!card) {
throw new NotFoundException('Card not found');
}
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot unassign cards');
}
for (const userId of userIds) {
await this.prisma.card.update({
where: { id: cardId },
data: { assignees: { disconnect: { id: userId } } },
});
try {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { firstName: true, lastName: true },
});
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'UNASSIGNED',
metadata: {
unassignedUserId: userId,
unassignedUserName: user ? `${user.firstName} ${user.lastName}` : userId,
},
},
});
} catch (err) {
this.logger.warn(`Failed to log unassignment activity: ${err.message}`);
}
}
this.logger.log(`Card ${cardId}: unassigned ${userIds.length} user(s) by ${currentUser.email}`);
return this.getCardAssignees(cardId);
}
async getCardAssignees(cardId: string): Promise<any[]> {
const card = await this.prisma.card.findUnique({
where: { id: cardId },
select: {
assignees: {
select: {
id: true,
firstName: true,
lastName: true,
displayName: true,
avatar: true,
role: true,
},
},
},
});
return card?.assignees || [];
}
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { MoveCardDto } from './dto/move-card.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class CardMovementService {
private readonly logger = new Logger(CardMovementService.name);
constructor(private readonly prisma: PrismaService) {}
async moveCard(cardId: string, dto: MoveCardDto, currentUser: RequestUser): Promise<any> {
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
include: {
column: { select: { id: true, boardId: true, type: true, name: true } },
assignees: { select: { id: true } },
},
});
if (!card) {
throw new NotFoundException('Card not found');
}
const targetColumn = await this.prisma.column.findUnique({
where: { id: dto.columnId },
});
if (!targetColumn) {
throw new NotFoundException('Target column not found');
}
if (targetColumn.boardId !== card.column.boardId) {
throw new BadRequestException('Cannot move card to a column on a different board');
}
const sourceColumn = card.column;
// If same column, just reorder
if (sourceColumn.id === targetColumn.id) {
return this.reorderInColumn(card, dto.position ?? 0);
}
// ─── PERMISSION ENFORCEMENT ─────────────────────────────────
this.enforceMovementPermissions(currentUser, sourceColumn, targetColumn, card);
// ─── FROZEN COLUMN LOGIC ─────────────────────────────────────
if (targetColumn.type === 'FROZEN') {
if (!dto.frozenReason || dto.frozenReason.length < 20) {
throw new BadRequestException('Moving to Frozen requires a reason of at least 20 characters');
}
}
// ─── WIP LIMIT CHECK ─────────────────────────────────────────
await this.checkWipLimits(targetColumn, card, currentUser);
// ─── CALCULATE POSITION ──────────────────────────────────────
const newPosition = await this.calculatePosition(targetColumn.id, dto.position);
// ─── TRACK TIMING ────────────────────────────────────────────
const now = new Date();
const updateData: any = {
columnId: targetColumn.id,
position: newPosition,
};
// Entering Frozen
if (targetColumn.type === 'FROZEN') {
updateData.frozenReason = dto.frozenReason;
updateData.frozenAt = now;
}
// Leaving Frozen — calculate frozen time
if (sourceColumn.type === 'FROZEN' && targetColumn.type !== 'FROZEN') {
updateData.frozenReason = null;
if (card.frozenAt) {
const frozenMs = now.getTime() - new Date(card.frozenAt).getTime();
const existingFrozenMs = (card.frozenTimeHours || 0) * 3600000;
updateData.frozenTimeHours = (existingFrozenMs + frozenMs) / 3600000;
}
updateData.frozenAt = null;
}
// First move to DOING — track cycle time start
if (targetColumn.type === 'DOING' && !card.startedAt) {
updateData.startedAt = now;
}
// Moving to DONE
if (targetColumn.type === 'DONE') {
updateData.completedAt = now;
// Calculate lead time (creation → done)
const leadMs = now.getTime() - new Date(card.createdAt).getTime();
updateData.leadTimeHours = leadMs / 3600000;
// Calculate cycle time (first doing → done, minus frozen time)
if (card.startedAt) {
const cycleMs = now.getTime() - new Date(card.startedAt).getTime();
const frozenMs = (updateData.frozenTimeHours || card.frozenTimeHours || 0) * 3600000;
updateData.cycleTimeHours = (cycleMs - frozenMs) / 3600000;
}
}
// Moving OUT of Done (reopening)
if (sourceColumn.type === 'DONE' && targetColumn.type !== 'DONE') {
updateData.completedAt = null;
updateData.leadTimeHours = null;
updateData.cycleTimeHours = null;
}
const updated = await this.prisma.card.update({
where: { id: cardId },
data: updateData,
});
// ─── LOG ACTIVITY ────────────────────────────────────────────
try {
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'MOVED',
metadata: {
fromColumn: sourceColumn.name,
fromColumnId: sourceColumn.id,
toColumn: targetColumn.name,
toColumnId: targetColumn.id,
frozenReason: dto.frozenReason || null,
},
},
});
} catch (err) {
this.logger.warn(`Failed to log card activity: ${err.message}`);
}
this.logger.log(
`Card ${cardId} moved from "${sourceColumn.name}" to "${targetColumn.name}" by ${currentUser.email}`,
);
return updated;
}
private enforceMovementPermissions(
currentUser: RequestUser,
sourceColumn: any,
targetColumn: any,
card: any,
): void {
const isSA = currentUser.role === 'SUPER_ADMIN';
const isAdmin = currentUser.role === 'ADMIN';
const isTL = currentUser.role === 'TEAM_LEAD';
const isContractor = currentUser.role === 'CONTRACTOR';
// Moving TO Done — only SA, Admin, TL
if (targetColumn.type === 'DONE') {
if (isContractor) {
throw new ForbiddenException(
'Only Project Leaders and Admins can mark tasks as Done',
);
}
}
// Moving FROM Done — only SA
if (sourceColumn.type === 'DONE') {
if (!isSA) {
throw new ForbiddenException('Only Super Admin can reopen cards from Done');
}
}
// Contractors can only move their own assigned cards (except to Done)
if (isContractor) {
const isAssigned = card.assignees?.some((a: any) => a.id === currentUser.id);
if (!isAssigned) {
throw new ForbiddenException('You can only move cards assigned to you');
}
}
}
private async checkWipLimits(targetColumn: any, card: any, currentUser: RequestUser): Promise<void> {
// Per-user WIP limit
if (targetColumn.wipLimit && targetColumn.wipLimit > 0) {
const assigneeIds = card.assignees?.map((a: any) => a.id) || [];
if (assigneeIds.length > 0) {
for (const assigneeId of assigneeIds) {
const userCardCount = await this.prisma.card.count({
where: {
columnId: targetColumn.id,
deletedAt: null,
assignees: { some: { id: assigneeId } },
},
});
if (userCardCount >= targetColumn.wipLimit) {
throw new BadRequestException(
`WIP limit reached in "${targetColumn.name}". Move a card out first.`,
);
}
}
}
}
// Total WIP limit
if (targetColumn.wipLimitTotal && targetColumn.wipLimitTotal > 0) {
const totalCount = await this.prisma.card.count({
where: { columnId: targetColumn.id, deletedAt: null },
});
if (totalCount >= targetColumn.wipLimitTotal) {
throw new BadRequestException(
`Total WIP limit reached in "${targetColumn.name}" (${totalCount}/${targetColumn.wipLimitTotal}). Move a card out first.`,
);
}
}
}
private async calculatePosition(columnId: string, requestedPosition?: number): Promise<number> {
const maxResult = await this.prisma.card.aggregate({
where: { columnId, deletedAt: null },
_max: { position: true },
});
const maxPosition = maxResult._max?.position || 0;
if (requestedPosition !== undefined && requestedPosition !== null) {
return requestedPosition;
}
// Place at the end
return maxPosition + 1;
}
private async reorderInColumn(card: any, newPosition: number): Promise<any> {
return this.prisma.card.update({
where: { id: card.id },
data: { position: newPosition },
});
}
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CardsService } from './cards.service';
import { CardMovementService } from './card-movement.service';
import { CardAssignmentService } from './card-assignment.service';
import { CreateCardDto } from './dto/create-card.dto';
import { UpdateCardDto } from './dto/update-card.dto';
import { MoveCardDto } from './dto/move-card.dto';
import { CardFilterDto } from './dto/card-filter.dto';
import { DuplicateCardDto } from './dto/duplicate-card.dto';
import { AssignCardDto, UnassignCardDto, SetBountyDto } from './dto/assign-card.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('cards')
export class CardsController {
constructor(
private readonly cardsService: CardsService,
private readonly cardMovementService: CardMovementService,
private readonly cardAssignmentService: CardAssignmentService,
) {}
@Post()
async create(@Body() dto: CreateCardDto, @CurrentUser() user: RequestUser) {
return this.cardsService.create(dto, user);
}
@Get()
async findAll(@Query() filter: CardFilterDto, @CurrentUser() user: RequestUser) {
return this.cardsService.findAll(filter, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.cardsService.findById(id, user);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardsService.update(id, dto, user);
}
@Put(':id/move')
@HttpCode(HttpStatus.OK)
async moveCard(
@Param('id') id: string,
@Body() dto: MoveCardDto,
@CurrentUser() user: RequestUser,
) {
await this.cardMovementService.moveCard(id, dto, user);
return this.cardsService.findById(id, user);
}
@Put(':id/assign')
@HttpCode(HttpStatus.OK)
async assignCard(
@Param('id') id: string,
@Body() dto: AssignCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardAssignmentService.assign(id, dto.assigneeIds, user);
}
@Put(':id/unassign')
@HttpCode(HttpStatus.OK)
async unassignCard(
@Param('id') id: string,
@Body() dto: UnassignCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardAssignmentService.unassign(id, dto.userIds, user);
}
@Put(':id/bounty')
@HttpCode(HttpStatus.OK)
async setBounty(
@Param('id') id: string,
@Body() dto: SetBountyDto,
@CurrentUser() user: RequestUser,
) {
return this.cardsService.setBounty(id, dto.bountyPiasters, dto.bountySplitJson, user);
}
@Post(':id/duplicate')
async duplicateCard(
@Param('id') id: string,
@Body() dto: DuplicateCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardsService.duplicate(id, dto, user);
}
@Post(':id/archive')
@HttpCode(HttpStatus.OK)
async archiveCard(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.cardsService.archive(id, user);
return { message: 'Card archived' };
}
@Post(':id/restore')
@HttpCode(HttpStatus.OK)
async restoreCard(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.cardsService.restore(id, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async permanentDelete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.cardsService.permanentDelete(id, user);
return { message: 'Card permanently deleted' };
}
@Get(':id/activity')
async getActivity(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.cardsService.getCardActivity(id, user);
}
@Post(':id/watch')
@HttpCode(HttpStatus.OK)
async watchCard(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.cardsService.watchCard(id, user);
return { message: 'Now watching this card' };
}
@Delete(':id/watch')
@HttpCode(HttpStatus.OK)
async unwatchCard(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.cardsService.unwatchCard(id, user);
return { message: 'Stopped watching this card' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { CardsController } from './cards.controller';
import { CardsService } from './cards.service';
import { CardMovementService } from './card-movement.service';
import { CardAssignmentService } from './card-assignment.service';
@Module({
controllers: [CardsController],
providers: [CardsService, CardMovementService, CardAssignmentService],
exports: [CardsService, CardMovementService, CardAssignmentService],
})
export class CardsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCardDto } from './dto/create-card.dto';
import { UpdateCardDto } from './dto/update-card.dto';
import { CardFilterDto } from './dto/card-filter.dto';
import { DuplicateCardDto } from './dto/duplicate-card.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import {
getSkip,
buildPaginatedResponse,
PaginatedResult,
} from '../../common/utils/pagination.util';
@Injectable()
export class CardsService {
private readonly logger = new Logger(CardsService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateCardDto, currentUser: RequestUser): Promise<any> {
const board = await this.prisma.board.findFirst({
where: { id: dto.boardId, deletedAt: null },
});
if (!board) {
throw new NotFoundException('Board not found');
}
// Permission: Contractors can only create in Backlog if board allows
if (currentUser.role === 'CONTRACTOR') {
if (!board.allowContractorCreation) {
throw new ForbiddenException('Contractors cannot create cards on this board');
}
}
// Determine target column
let columnId = dto.columnId;
if (!columnId) {
const backlogColumn = await this.prisma.column.findFirst({
where: { boardId: dto.boardId, type: 'BACKLOG' },
});
if (!backlogColumn) {
throw new BadRequestException('No Backlog column found on this board');
}
columnId = backlogColumn.id;
}
// Contractors can ONLY create in Backlog
if (currentUser.role === 'CONTRACTOR') {
const targetColumn = await this.prisma.column.findUnique({ where: { id: columnId } });
if (!targetColumn || targetColumn.type !== 'BACKLOG') {
throw new ForbiddenException('Contractors can only create cards in the Backlog column');
}
}
// Verify column belongs to this board
const column = await this.prisma.column.findFirst({
where: { id: columnId, boardId: dto.boardId },
});
if (!column) {
throw new BadRequestException('Column does not belong to this board');
}
// Generate card number
const nextNumber = board.nextCardNumber;
await this.prisma.board.update({
where: { id: dto.boardId },
data: { nextCardNumber: nextNumber + 1 },
});
const cardNumber = `${board.key}-${nextNumber}`;
// Calculate position (bottom of column)
const maxPos = await this.prisma.card.aggregate({
where: { columnId, deletedAt: null },
_max: { position: true },
});
const position = (maxPos._max?.position || 0) + 1;
// Create card
const card = await this.prisma.card.create({
data: {
title: dto.title,
description: dto.description || null,
cardNumber,
columnId,
position,
priority: dto.priority || 'NONE',
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
estimatedHours: dto.estimatedHours || null,
bountyPiasters: dto.bountyPiasters || 0,
createdById: currentUser.id,
version: 1,
},
});
// Connect labels
if (dto.labelIds && dto.labelIds.length > 0) {
await this.prisma.card.update({
where: { id: card.id },
data: {
labels: { connect: dto.labelIds.map((id) => ({ id })) },
},
});
}
// Connect assignees (only SA, Admin, TL can assign)
if (dto.assigneeIds && dto.assigneeIds.length > 0 && currentUser.role !== 'CONTRACTOR') {
await this.prisma.card.update({
where: { id: card.id },
data: {
assignees: { connect: dto.assigneeIds.map((id) => ({ id })) },
},
});
}
// Log activity
try {
await this.prisma.cardActivity.create({
data: {
cardId: card.id,
userId: currentUser.id,
action: 'CREATED',
metadata: { title: card.title, cardNumber },
},
});
} catch (err) {
this.logger.warn(`Failed to log card creation activity: ${err.message}`);
}
this.logger.log(`Card ${cardNumber} created on board ${board.key} by ${currentUser.email}`);
return this.findById(card.id, currentUser);
}
async findAll(filter: CardFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 50;
const where: any = { deletedAt: null };
if (filter.isArchived !== undefined) {
where.isArchived = filter.isArchived;
} else {
where.isArchived = false;
}
if (filter.boardId) {
where.column = { boardId: filter.boardId };
}
if (filter.columnId) {
where.columnId = filter.columnId;
}
if (filter.assigneeId) {
where.assignees = { some: { id: filter.assigneeId } };
}
if (filter.priority) {
where.priority = filter.priority;
}
if (filter.labelIds) {
const ids = filter.labelIds.split(',').filter(Boolean);
if (ids.length > 0) {
where.labels = { some: { id: { in: ids } } };
}
}
if (filter.hasBounty === true) {
where.bountyPiasters = { gt: 0 };
} else if (filter.hasBounty === false) {
where.bountyPiasters = 0;
}
if (filter.isOverdue === true) {
where.dueDate = { lt: new Date() };
where.completedAt = null;
}
if (filter.dueDateFrom || filter.dueDateTo) {
where.dueDate = where.dueDate || {};
if (filter.dueDateFrom) where.dueDate.gte = new Date(filter.dueDateFrom);
if (filter.dueDateTo) where.dueDate.lte = new Date(filter.dueDateTo);
}
if (filter.createdById) {
where.createdById = filter.createdById;
}
if (filter.search) {
where.OR = [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ description: { contains: filter.search, mode: 'insensitive' } },
{ cardNumber: { contains: filter.search, mode: 'insensitive' } },
];
}
// Contractors only see cards on their boards
if (currentUser.role === 'CONTRACTOR') {
where.column = {
...where.column,
board: { members: { some: { userId: currentUser.id } } },
};
}
const [cards, total] = await Promise.all([
this.prisma.card.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { [filter.sortBy || 'position']: filter.sortOrder || 'asc' },
include: {
labels: { select: { id: true, name: true, color: true, textColor: true } },
assignees: { select: { id: true, firstName: true, lastName: true, avatar: true } },
column: { select: { id: true, name: true, boardId: true, type: true } },
_count: { select: { comments: true, attachments: true } },
},
}),
this.prisma.card.count({ where }),
]);
const enriched = cards.map((card: any) => this.formatCardSummary(card));
return buildPaginatedResponse(enriched, total, { page, limit, sortOrder: filter.sortOrder || 'asc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const card = await this.prisma.card.findFirst({
where: { id, deletedAt: null },
include: {
labels: { select: { id: true, name: true, color: true, textColor: true } },
assignees: {
select: { id: true, firstName: true, lastName: true, displayName: true, avatar: true, role: true },
},
watchers: {
select: { id: true, firstName: true, lastName: true, avatar: true },
},
createdBy: {
select: { id: true, firstName: true, lastName: true, avatar: true },
},
column: {
select: { id: true, name: true, type: true, boardId: true, board: { select: { name: true, key: true } } },
},
_count: { select: { comments: true, attachments: true } },
},
});
if (!card) {
throw new NotFoundException('Card not found');
}
// Checklist progress
let checklistProgress: { completed: number; total: number } | null = null;
try {
const checklists = await this.prisma.checklist.findMany({
where: { cardId: id },
include: { items: true },
});
if (checklists.length > 0) {
let total = 0;
let completed = 0;
for (const cl of checklists) {
for (const item of cl.items) {
total++;
if (item.isCompleted) completed++;
}
}
checklistProgress = { completed, total };
}
} catch {
// Checklist tables may not exist yet
}
return {
id: card.id,
cardNumber: card.cardNumber,
title: card.title,
description: card.description,
boardId: card.column.boardId,
boardName: (card.column as any).board?.name,
boardKey: (card.column as any).board?.key,
columnId: card.columnId,
columnName: card.column.name,
position: card.position,
priority: card.priority,
dueDate: card.dueDate,
isOverdue: card.dueDate ? new Date(card.dueDate) < new Date() && !card.completedAt : false,
estimatedHours: card.estimatedHours,
actualHours: card.actualHours,
bountyPiasters: card.bountyPiasters,
bountySplit: card.bountySplit,
coverImage: card.coverImage,
frozenReason: card.frozenReason,
isArchived: card.isArchived,
version: card.version,
startedAt: card.startedAt,
completedAt: card.completedAt,
leadTimeHours: card.leadTimeHours,
cycleTimeHours: card.cycleTimeHours,
frozenTimeHours: card.frozenTimeHours,
commentCount: card._count.comments,
attachmentCount: card._count.attachments,
assigneeCount: card.assignees.length,
checklistProgress,
labels: card.labels,
assignees: card.assignees,
watchers: card.watchers,
createdBy: card.createdBy,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
};
}
async update(id: string, dto: UpdateCardDto, currentUser: RequestUser): Promise<any> {
const card = await this.prisma.card.findFirst({
where: { id, deletedAt: null },
include: {
column: { select: { boardId: true } },
assignees: { select: { id: true } },
},
});
if (!card) {
throw new NotFoundException('Card not found');
}
// Optimistic locking
if (dto.version !== undefined && dto.version !== card.version) {
throw new ConflictException(
'This card was modified by another user. Please refresh and try again.',
);
}
// Permission: Contractors can only edit their assigned cards (limited fields)
if (currentUser.role === 'CONTRACTOR') {
const isAssigned = card.assignees.some((a) => a.id === currentUser.id);
if (!isAssigned) {
throw new ForbiddenException('You can only edit cards assigned to you');
}
// Contractors can only update: description, estimatedHours, actualHours
const allowedFields = ['description', 'estimatedHours', 'actualHours', 'version'];
for (const key of Object.keys(dto)) {
if (!allowedFields.includes(key) && (dto as any)[key] !== undefined) {
throw new ForbiddenException(`Contractors cannot change the field: ${key}`);
}
}
}
const updateData: any = { version: { increment: 1 } };
const changes: any = {};
if (dto.title !== undefined) {
changes.title = { from: card.title, to: dto.title };
updateData.title = dto.title;
}
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.priority !== undefined) {
changes.priority = { from: card.priority, to: dto.priority };
updateData.priority = dto.priority;
}
if (dto.dueDate !== undefined) {
changes.dueDate = { from: card.dueDate, to: dto.dueDate };
updateData.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
}
if (dto.estimatedHours !== undefined) updateData.estimatedHours = dto.estimatedHours;
if (dto.actualHours !== undefined) updateData.actualHours = dto.actualHours;
if (dto.coverImage !== undefined) updateData.coverImage = dto.coverImage;
await this.prisma.card.update({
where: { id },
data: updateData,
});
// Log significant changes
if (Object.keys(changes).length > 0) {
try {
await this.prisma.cardActivity.create({
data: {
cardId: id,
userId: currentUser.id,
action: 'UPDATED',
metadata: changes,
},
});
} catch (err) {
this.logger.warn(`Failed to log card update activity: ${err.message}`);
}
}
return this.findById(id, currentUser);
}
async archive(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot archive cards');
}
const card = await this.prisma.card.findFirst({
where: { id, deletedAt: null, isArchived: false },
});
if (!card) {
throw new NotFoundException('Card not found or already archived');
}
await this.prisma.card.update({
where: { id },
data: { isArchived: true, archivedAt: new Date() },
});
try {
await this.prisma.cardActivity.create({
data: {
cardId: id,
userId: currentUser.id,
action: 'ARCHIVED',
metadata: {},
},
});
} catch {
// Activity table may not exist
}
this.logger.log(`Card ${id} archived by ${currentUser.email}`);
}
async restore(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can restore archived cards');
}
const card = await this.prisma.card.findFirst({
where: { id, deletedAt: null, isArchived: true },
include: { column: { select: { boardId: true } } },
});
if (!card) {
throw new NotFoundException('Archived card not found');
}
// Restore to backlog
const backlog = await this.prisma.column.findFirst({
where: { boardId: card.column.boardId, type: 'BACKLOG' },
});
if (!backlog) {
throw new BadRequestException('No Backlog column found');
}
const maxPos = await this.prisma.card.aggregate({
where: { columnId: backlog.id, deletedAt: null },
_max: { position: true },
});
await this.prisma.card.update({
where: { id },
data: {
isArchived: false,
archivedAt: null,
columnId: backlog.id,
position: (maxPos._max?.position || 0) + 1,
},
});
this.logger.log(`Card ${id} restored by ${currentUser.email}`);
return this.findById(id, currentUser);
}
async permanentDelete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can permanently delete cards');
}
const card = await this.prisma.card.findFirst({ where: { id, deletedAt: null } });
if (!card) {
throw new NotFoundException('Card not found');
}
await this.prisma.card.update({
where: { id },
data: { deletedAt: new Date(), isArchived: true },
});
this.logger.log(`Card ${card.cardNumber} permanently deleted by ${currentUser.email}`);
}
async duplicate(id: string, dto: DuplicateCardDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot duplicate cards');
}
const card = await this.prisma.card.findFirst({
where: { id, deletedAt: null },
include: {
labels: { select: { id: true } },
column: { select: { boardId: true } },
},
});
if (!card) {
throw new NotFoundException('Card not found');
}
const targetBoardId = dto.targetBoardId || card.column.boardId;
const targetBoard = await this.prisma.board.findFirst({
where: { id: targetBoardId, deletedAt: null },
});
if (!targetBoard) {
throw new NotFoundException('Target board not found');
}
// Find backlog of target board
const backlogColumn = await this.prisma.column.findFirst({
where: { boardId: targetBoardId, type: 'BACKLOG' },
});
if (!backlogColumn) {
throw new BadRequestException('Target board has no Backlog column');
}
// Generate card number on target board
const nextNumber = targetBoard.nextCardNumber;
await this.prisma.board.update({
where: { id: targetBoardId },
data: { nextCardNumber: nextNumber + 1 },
});
const maxPos = await this.prisma.card.aggregate({
where: { columnId: backlogColumn.id, deletedAt: null },
_max: { position: true },
});
const newCard = await this.prisma.card.create({
data: {
title: `Copy of ${card.title}`,
description: card.description,
cardNumber: `${targetBoard.key}-${nextNumber}`,
columnId: backlogColumn.id,
position: (maxPos._max?.position || 0) + 1,
priority: card.priority,
dueDate: dto.keepDeadline ? card.dueDate : null,
estimatedHours: card.estimatedHours,
bountyPiasters: 0, // Bounty is NOT copied
createdById: currentUser.id,
version: 1,
},
});
// Copy labels (if on same board, or org-level labels)
if (card.labels.length > 0) {
const labelIds = card.labels.map((l: any) => l.id);
// Filter: only connect labels valid on target board
const validLabels = await this.prisma.label.findMany({
where: {
id: { in: labelIds },
OR: [
{ scope: 'ORGANIZATION' },
{ scope: 'BOARD', boardId: targetBoardId },
],
},
});
if (validLabels.length > 0) {
await this.prisma.card.update({
where: { id: newCard.id },
data: { labels: { connect: validLabels.map((l) => ({ id: l.id })) } },
});
}
}
// Copy checklists
try {
const checklists = await this.prisma.checklist.findMany({
where: { cardId: id },
include: { items: { orderBy: { position: 'asc' } } },
});
for (const cl of checklists) {
const newChecklist = await this.prisma.checklist.create({
data: {
cardId: newCard.id,
title: cl.title,
position: cl.position,
},
});
for (const item of cl.items) {
await this.prisma.checklistItem.create({
data: {
checklistId: newChecklist.id,
title: item.title,
position: item.position,
isCompleted: false, // Always unchecked in copy
},
});
}
}
} catch {
// Checklist tables may not exist yet
}
this.logger.log(`Card ${card.cardNumber} duplicated as ${newCard.cardNumber} by ${currentUser.email}`);
return this.findById(newCard.id, currentUser);
}
async setBounty(
cardId: string,
bountyPiasters: number,
bountySplitJson: string | undefined,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can set bounties');
}
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
});
if (!card) throw new NotFoundException('Card not found');
if (card.completedAt) {
throw new BadRequestException('Cannot modify bounty on a completed card');
}
const updateData: any = { bountyPiasters };
if (bountySplitJson) {
try {
updateData.bountySplit = JSON.parse(bountySplitJson);
} catch {
throw new BadRequestException('Invalid bounty split JSON');
}
}
await this.prisma.card.update({
where: { id: cardId },
data: updateData,
});
try {
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'BOUNTY_SET',
metadata: {
bountyPiasters,
previousBounty: card.bountyPiasters,
},
},
});
} catch {
// Activity table might not exist
}
return this.findById(cardId, currentUser);
}
async getCardActivity(cardId: string, currentUser: RequestUser): Promise<any[]> {
const card = await this.prisma.card.findFirst({ where: { id: cardId, deletedAt: null } });
if (!card) throw new NotFoundException('Card not found');
try {
return await this.prisma.cardActivity.findMany({
where: { cardId },
orderBy: { createdAt: 'desc' },
take: 200,
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
} catch {
return [];
}
}
async watchCard(cardId: string, currentUser: RequestUser): Promise<void> {
const card = await this.prisma.card.findFirst({ where: { id: cardId, deletedAt: null } });
if (!card) throw new NotFoundException('Card not found');
await this.prisma.card.update({
where: { id: cardId },
data: { watchers: { connect: { id: currentUser.id } } },
});
}
async unwatchCard(cardId: string, currentUser: RequestUser): Promise<void> {
await this.prisma.card.update({
where: { id: cardId },
data: { watchers: { disconnect: { id: currentUser.id } } },
});
}
private formatCardSummary(card: any): any {
let checklistProgress: { completed: number; total: number } | null = null;
return {
id: card.id,
cardNumber: card.cardNumber,
title: card.title,
boardId: card.column?.boardId,
columnId: card.columnId,
columnName: card.column?.name,
columnType: card.column?.type,
position: card.position,
priority: card.priority,
dueDate: card.dueDate,
isOverdue: card.dueDate ? new Date(card.dueDate) < new Date() && !card.completedAt : false,
bountyPiasters: card.bountyPiasters,
coverImage: card.coverImage,
isArchived: card.isArchived,
frozenReason: card.frozenReason,
assigneeCount: card.assignees?.length || 0,
commentCount: card._count?.comments || 0,
attachmentCount: card._count?.attachments || 0,
checklistProgress,
labels: card.labels || [],
assignees: card.assignees || [],
completedAt: card.completedAt,
createdAt: card.createdAt,
};
}
}
\ No newline at end of file
import { IsString, IsArray, IsOptional } from 'class-validator';
export class AssignCardDto {
@IsArray()
@IsString({ each: true })
assigneeIds: string[];
}
export class UnassignCardDto {
@IsArray()
@IsString({ each: true })
userIds: string[];
}
export class SetBountyDto {
@IsOptional()
bountyPiasters: number;
@IsOptional()
@IsString()
bountySplitJson?: string;
// JSON string of { userId: percentage } for multi-assignee split
}
\ No newline at end of file
import { IsOptional, IsString, IsBoolean, IsArray, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class CardFilterDto extends PaginationDto {
@IsOptional()
@IsString()
boardId?: string;
@IsOptional()
@IsString()
columnId?: string;
@IsOptional()
@IsString()
assigneeId?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsString({ each: true })
labelIds?: string;
// Comma-separated label IDs
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
hasBounty?: boolean;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isOverdue?: boolean;
@IsOptional()
@IsString()
dueDateFrom?: string;
@IsOptional()
@IsString()
dueDateTo?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isArchived?: boolean;
@IsOptional()
@IsString()
createdById?: string;
}
\ No newline at end of file
export class CardSummaryResponseDto {
id: string;
cardNumber: string;
title: string;
boardId: string;
columnId: string;
position: number;
priority: string;
dueDate: string | null;
isOverdue: boolean;
bountyPiasters: number;
coverImage: string | null;
isArchived: boolean;
assigneeCount: number;
commentCount: number;
attachmentCount: number;
checklistProgress: { completed: number; total: number } | null;
labels: Array<{ id: string; name: string; color: string; textColor: string | null }>;
assignees: Array<{ id: string; firstName: string; lastName: string; avatar: string | null }>;
createdAt: string;
}
export class CardDetailResponseDto extends CardSummaryResponseDto {
description: string | null;
estimatedHours: number | null;
actualHours: number | null;
frozenReason: string | null;
bountySplit: any;
version: number;
startedAt: string | null;
completedAt: string | null;
leadTimeHours: number | null;
cycleTimeHours: number | null;
frozenTimeHours: number | null;
createdBy: { id: string; firstName: string; lastName: string; avatar: string | null };
watchers: Array<{ id: string; firstName: string; lastName: string; avatar: string | null }>;
columnName: string;
boardName: string;
boardKey: string;
updatedAt: string;
}
\ No newline at end of file
import {
IsString,
IsOptional,
IsArray,
IsNumber,
IsInt,
IsBoolean,
Min,
Max,
MinLength,
MaxLength,
} from 'class-validator';
export class CreateCardDto {
@IsString()
boardId: string;
@IsOptional()
@IsString()
columnId?: string;
// If not provided, defaults to Backlog column of the board
@IsString()
@MinLength(1)
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsString()
dueDate?: string;
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
labelIds?: string[];
@IsOptional()
@IsInt()
@Min(0)
bountyPiasters?: number;
}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean } from 'class-validator';
export class DuplicateCardDto {
@IsOptional()
@IsString()
targetBoardId?: string;
// If not provided, duplicates to the same board
@IsOptional()
@IsBoolean()
keepDeadline?: boolean;
}
\ No newline at end of file
import { IsString, IsOptional, IsNumber, MinLength } from 'class-validator';
export class MoveCardDto {
@IsString()
columnId: string;
@IsOptional()
@IsNumber()
position?: number;
@IsOptional()
@IsString()
@MinLength(20, { message: 'Frozen reason must be at least 20 characters' })
frozenReason?: string;
// Required when moving to Frozen column
}
\ No newline at end of file
import {
IsString,
IsOptional,
IsNumber,
IsInt,
Min,
Max,
MinLength,
MaxLength,
IsArray,
} from 'class-validator';
export class UpdateCardDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsString()
dueDate?: string;
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@IsOptional()
@IsNumber()
@Min(0)
actualHours?: number;
@IsOptional()
@IsString()
coverImage?: string;
@IsOptional()
@IsInt()
@Min(0)
version?: number;
// For optimistic locking
}
\ No newline at end of file
import { IsString, IsOptional, MinLength, MaxLength, Matches } from 'class-validator';
export class CreateLabelDto {
@IsString()
@MinLength(1)
@MaxLength(20)
name: string;
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color (e.g., #EF4444)' })
color: string;
@IsOptional()
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Text color must be a valid hex color' })
textColor?: string;
@IsOptional()
@IsString()
boardId?: string;
// If boardId is provided → BOARD scope. If not → ORGANIZATION scope.
}
\ No newline at end of file
export class LabelResponseDto {
id: string;
name: string;
color: string;
textColor: string | null;
scope: string;
boardId: string | null;
createdById: string | null;
cardCount?: number;
createdAt: string;
updatedAt: string;
}
\ No newline at end of file
import { IsString, IsOptional, MinLength, MaxLength, Matches } from 'class-validator';
export class UpdateLabelDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(20)
name?: string;
@IsOptional()
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color' })
color?: string;
@IsOptional()
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Text color must be a valid hex color' })
textColor?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { LabelsService } from './labels.service';
import { CreateLabelDto } from './dto/create-label.dto';
import { UpdateLabelDto } from './dto/update-label.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('labels')
export class LabelsController {
constructor(private readonly labelsService: LabelsService) {}
@Post()
async create(@Body() dto: CreateLabelDto, @CurrentUser() user: RequestUser) {
return this.labelsService.create(dto, user);
}
@Get('organization')
async findOrgLabels() {
return this.labelsService.findOrgLabels();
}
@Get('board/:boardId')
async findBoardLabels(@Param('boardId') boardId: string, @CurrentUser() user: RequestUser) {
return this.labelsService.findBoardLabels(boardId, user);
}
@Get(':id')
async findById(@Param('id') id: string) {
return this.labelsService.findById(id);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateLabelDto,
@CurrentUser() user: RequestUser,
) {
return this.labelsService.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
const result = await this.labelsService.delete(id, user);
return { message: `Label deleted. ${result.affectedCards} cards affected.`, ...result };
}
@Post(':labelId/cards/:cardId')
@HttpCode(HttpStatus.OK)
async applyToCard(
@Param('labelId') labelId: string,
@Param('cardId') cardId: string,
@CurrentUser() user: RequestUser,
) {
await this.labelsService.applyToCard(labelId, cardId, user);
return { message: 'Label applied to card' };
}
@Delete(':labelId/cards/:cardId')
@HttpCode(HttpStatus.OK)
async removeFromCard(
@Param('labelId') labelId: string,
@Param('cardId') cardId: string,
@CurrentUser() user: RequestUser,
) {
await this.labelsService.removeFromCard(labelId, cardId, user);
return { message: 'Label removed from card' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { LabelsController } from './labels.controller';
import { LabelsService } from './labels.service';
@Module({
controllers: [LabelsController],
providers: [LabelsService],
exports: [LabelsService],
})
export class LabelsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
ConflictException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateLabelDto } from './dto/create-label.dto';
import { UpdateLabelDto } from './dto/update-label.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class LabelsService {
private readonly logger = new Logger(LabelsService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateLabelDto, currentUser: RequestUser): Promise<any> {
const scope = dto.boardId ? 'BOARD' : 'ORGANIZATION';
// Permission check
if (scope === 'ORGANIZATION') {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can create organization-level labels');
}
} else {
if (
currentUser.role !== 'SUPER_ADMIN' &&
currentUser.role !== 'ADMIN' &&
currentUser.role !== 'TEAM_LEAD'
) {
throw new ForbiddenException('Only Super Admin, Admin, and Project Leaders can create board labels');
}
// Verify board exists
const board = await this.prisma.board.findFirst({
where: { id: dto.boardId, deletedAt: null },
});
if (!board) {
throw new NotFoundException('Board not found');
}
// If TEAM_LEAD, verify they are a member of this board
if (currentUser.role === 'TEAM_LEAD') {
const membership = await this.prisma.boardMember.findUnique({
where: { boardId_userId: { boardId: dto.boardId!, userId: currentUser.id } },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this board');
}
}
}
// Check for duplicate name within scope
const boardIdForUnique = dto.boardId || 'org-level';
const existing = await this.prisma.label.findUnique({
where: { name_boardId: { name: dto.name, boardId: boardIdForUnique } },
});
if (existing) {
throw new ConflictException(
`Label "${dto.name}" already exists ${scope === 'BOARD' ? 'on this board' : 'at organization level'}`,
);
}
// Auto-calculate text color for contrast if not provided
const textColor = dto.textColor || this.calculateContrastColor(dto.color);
const label = await this.prisma.label.create({
data: {
name: dto.name,
color: dto.color,
textColor,
scope,
boardId: dto.boardId || null,
createdById: currentUser.id,
},
});
this.logger.log(
`Label "${dto.name}" (${scope}) created by ${currentUser.email}${dto.boardId ? ` on board ${dto.boardId}` : ''}`,
);
return this.formatLabel(label);
}
async findOrgLabels(): Promise<any[]> {
const labels = await this.prisma.label.findMany({
where: { scope: 'ORGANIZATION' },
orderBy: { name: 'asc' },
});
return Promise.all(labels.map((l) => this.formatLabelWithCount(l)));
}
async findBoardLabels(boardId: string, currentUser: RequestUser): Promise<any[]> {
const board = await this.prisma.board.findFirst({
where: { id: boardId, deletedAt: null },
});
if (!board) {
throw new NotFoundException('Board not found');
}
// Get both org-level and board-level labels
const labels = await this.prisma.label.findMany({
where: {
OR: [
{ scope: 'ORGANIZATION' },
{ scope: 'BOARD', boardId },
],
},
orderBy: [{ scope: 'asc' }, { name: 'asc' }],
});
return Promise.all(labels.map((l) => this.formatLabelWithCount(l, boardId)));
}
async findById(id: string): Promise<any> {
const label = await this.prisma.label.findUnique({ where: { id } });
if (!label) {
throw new NotFoundException('Label not found');
}
return this.formatLabelWithCount(label);
}
async update(id: string, dto: UpdateLabelDto, currentUser: RequestUser): Promise<any> {
const label = await this.prisma.label.findUnique({ where: { id } });
if (!label) {
throw new NotFoundException('Label not found');
}
// Permission check
if (label.scope === 'ORGANIZATION') {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can edit organization-level labels');
}
} else {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
if (currentUser.role === 'TEAM_LEAD' && label.boardId) {
const membership = await this.prisma.boardMember.findUnique({
where: { boardId_userId: { boardId: label.boardId, userId: currentUser.id } },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this board');
}
} else {
throw new ForbiddenException('You do not have permission to edit this label');
}
}
}
// Check name uniqueness if name is changing
if (dto.name && dto.name !== label.name) {
const boardIdForUnique = label.boardId || 'org-level';
const existing = await this.prisma.label.findUnique({
where: { name_boardId: { name: dto.name, boardId: boardIdForUnique } },
});
if (existing && existing.id !== id) {
throw new ConflictException(`Label "${dto.name}" already exists in this scope`);
}
}
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.color !== undefined) {
updateData.color = dto.color;
if (!dto.textColor) {
updateData.textColor = this.calculateContrastColor(dto.color);
}
}
if (dto.textColor !== undefined) updateData.textColor = dto.textColor;
const updated = await this.prisma.label.update({
where: { id },
data: updateData,
});
return this.formatLabel(updated);
}
async delete(id: string, currentUser: RequestUser): Promise<{ affectedCards: number }> {
const label = await this.prisma.label.findUnique({ where: { id } });
if (!label) {
throw new NotFoundException('Label not found');
}
// Permission check
if (label.scope === 'ORGANIZATION') {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete organization-level labels');
}
} else {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
if (currentUser.role === 'TEAM_LEAD' && label.boardId) {
const membership = await this.prisma.boardMember.findUnique({
where: { boardId_userId: { boardId: label.boardId, userId: currentUser.id } },
});
if (!membership) {
throw new ForbiddenException('You are not a member of this board');
}
} else {
throw new ForbiddenException('You do not have permission to delete this label');
}
}
}
// Count affected cards
let affectedCards = 0;
try {
affectedCards = await this.prisma.card.count({
where: {
labels: { some: { id } },
deletedAt: null,
},
});
} catch {
// Cards table may not be fully set up yet
}
// Delete label (Prisma will handle the many-to-many disconnection)
await this.prisma.label.delete({ where: { id } });
this.logger.log(
`Label "${label.name}" deleted by ${currentUser.email}. ${affectedCards} cards affected.`,
);
return { affectedCards };
}
async applyToCard(labelId: string, cardId: string, currentUser: RequestUser): Promise<void> {
const label = await this.prisma.label.findUnique({ where: { id: labelId } });
if (!label) throw new NotFoundException('Label not found');
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
include: { column: { select: { boardId: true } } },
});
if (!card) throw new NotFoundException('Card not found');
// Verify the label is accessible on this board
if (label.scope === 'BOARD' && label.boardId !== card.column.boardId) {
throw new BadRequestException('This label is not available on this board');
}
// Permission: Contractors can apply labels to their own assigned cards
if (currentUser.role === 'CONTRACTOR') {
const isAssigned = await this.prisma.card.findFirst({
where: {
id: cardId,
assignees: { some: { id: currentUser.id } },
},
});
if (!isAssigned) {
throw new ForbiddenException('You can only apply labels to cards assigned to you');
}
}
await this.prisma.card.update({
where: { id: cardId },
data: {
labels: { connect: { id: labelId } },
},
});
}
async removeFromCard(labelId: string, cardId: string, currentUser: RequestUser): Promise<void> {
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
});
if (!card) throw new NotFoundException('Card not found');
if (currentUser.role === 'CONTRACTOR') {
const isAssigned = await this.prisma.card.findFirst({
where: {
id: cardId,
assignees: { some: { id: currentUser.id } },
},
});
if (!isAssigned) {
throw new ForbiddenException('You can only manage labels on cards assigned to you');
}
}
await this.prisma.card.update({
where: { id: cardId },
data: {
labels: { disconnect: { id: labelId } },
},
});
}
private formatLabel(label: any): any {
return {
id: label.id,
name: label.name,
color: label.color,
textColor: label.textColor,
scope: label.scope,
boardId: label.boardId,
createdById: label.createdById,
createdAt: label.createdAt,
updatedAt: label.updatedAt,
};
}
private async formatLabelWithCount(label: any, boardId?: string): Promise<any> {
let cardCount = 0;
try {
const where: any = {
labels: { some: { id: label.id } },
deletedAt: null,
};
if (boardId) {
where.column = { boardId };
}
cardCount = await this.prisma.card.count({ where });
} catch {
// Cards table may not exist yet
}
return {
...this.formatLabel(label),
cardCount,
};
}
private calculateContrastColor(hexColor: string): string {
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// W3C luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
}
}
\ No newline at end of file
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