Commit 8494e6c4 authored by Administrator's avatar Administrator

Update 14 files via Son of Anton

parent dbe7775f
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { BoardsService } from './boards.service'; import { BoardsService } from './boards.service';
import { BoardTemplateService } from './board-template.service'; import { BoardTemplateService } from './board-template.service';
import { SavedFilterService } from './saved-filter.service';
import { CreateBoardDto } from './dto/create-board.dto'; import { CreateBoardDto } from './dto/create-board.dto';
import { UpdateBoardDto } from './dto/update-board.dto'; import { UpdateBoardDto } from './dto/update-board.dto';
import { BoardFilterDto } from './dto/board-filter.dto'; import { BoardFilterDto } from './dto/board-filter.dto';
...@@ -21,6 +22,7 @@ import { ...@@ -21,6 +22,7 @@ import {
UpdateBoardMemberRoleDto, UpdateBoardMemberRoleDto,
} from './dto/board-member.dto'; } from './dto/board-member.dto';
import { CreateBoardTemplateDto, UpdateBoardTemplateDto } from './dto/board-template.dto'; import { CreateBoardTemplateDto, UpdateBoardTemplateDto } from './dto/board-template.dto';
import { CreateSavedFilterDto, UpdateSavedFilterDto } from './dto/saved-filter.dto';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
...@@ -29,6 +31,7 @@ export class BoardsController { ...@@ -29,6 +31,7 @@ export class BoardsController {
constructor( constructor(
private readonly boardsService: BoardsService, private readonly boardsService: BoardsService,
private readonly boardTemplateService: BoardTemplateService, private readonly boardTemplateService: BoardTemplateService,
private readonly savedFilterService: SavedFilterService,
) {} ) {}
// ─── BOARD CRUD ────────────────────────────────────────── // ─── BOARD CRUD ──────────────────────────────────────────
...@@ -51,11 +54,7 @@ export class BoardsController { ...@@ -51,11 +54,7 @@ export class BoardsController {
@Put(':id') @Put(':id')
@Roles('SUPER_ADMIN', 'ADMIN') @Roles('SUPER_ADMIN', 'ADMIN')
async update( async update(@Param('id') id: string, @Body() dto: UpdateBoardDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: UpdateBoardDto,
@CurrentUser() user: RequestUser,
) {
return this.boardsService.update(id, dto, user); return this.boardsService.update(id, dto, user);
} }
...@@ -92,22 +91,14 @@ export class BoardsController { ...@@ -92,22 +91,14 @@ export class BoardsController {
@Post(':id/members') @Post(':id/members')
@Roles('SUPER_ADMIN', 'ADMIN') @Roles('SUPER_ADMIN', 'ADMIN')
async addMember( async addMember(@Param('id') id: string, @Body() dto: AddBoardMemberDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: AddBoardMemberDto,
@CurrentUser() user: RequestUser,
) {
return this.boardsService.addMember(id, dto, user); return this.boardsService.addMember(id, dto, user);
} }
@Post(':id/members/bulk') @Post(':id/members/bulk')
@Roles('SUPER_ADMIN', 'ADMIN') @Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async addMembersBulk( async addMembersBulk(@Param('id') id: string, @Body() dto: AddBoardMembersBulkDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: AddBoardMembersBulkDto,
@CurrentUser() user: RequestUser,
) {
return this.boardsService.addMembersBulk(id, dto, user); return this.boardsService.addMembersBulk(id, dto, user);
} }
...@@ -125,11 +116,7 @@ export class BoardsController { ...@@ -125,11 +116,7 @@ export class BoardsController {
@Delete(':id/members/:userId') @Delete(':id/members/:userId')
@Roles('SUPER_ADMIN', 'ADMIN') @Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async removeMember( async removeMember(@Param('id') id: string, @Param('userId') userId: string, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
await this.boardsService.removeMember(id, userId, user); await this.boardsService.removeMember(id, userId, user);
return { message: 'Member removed from board' }; return { message: 'Member removed from board' };
} }
...@@ -171,4 +158,32 @@ export class BoardsController { ...@@ -171,4 +158,32 @@ export class BoardsController {
await this.boardTemplateService.delete(templateId, user); await this.boardTemplateService.delete(templateId, user);
return { message: 'Board template deleted' }; return { message: 'Board template deleted' };
} }
// ─── SAVED FILTERS ──────────────────────────────────────
@Get(':id/filters')
async getSavedFilters(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.savedFilterService.findAll(id, user);
}
@Post('filters')
async createSavedFilter(@Body() dto: CreateSavedFilterDto, @CurrentUser() user: RequestUser) {
return this.savedFilterService.create(dto, user);
}
@Put('filters/:filterId')
async updateSavedFilter(
@Param('filterId') filterId: string,
@Body() dto: UpdateSavedFilterDto,
@CurrentUser() user: RequestUser,
) {
return this.savedFilterService.update(filterId, dto, user);
}
@Delete('filters/:filterId')
@HttpCode(HttpStatus.OK)
async deleteSavedFilter(@Param('filterId') filterId: string, @CurrentUser() user: RequestUser) {
await this.savedFilterService.delete(filterId, user);
return { message: 'Saved filter deleted' };
}
} }
\ No newline at end of file
...@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; ...@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { BoardsController } from './boards.controller'; import { BoardsController } from './boards.controller';
import { BoardsService } from './boards.service'; import { BoardsService } from './boards.service';
import { BoardTemplateService } from './board-template.service'; import { BoardTemplateService } from './board-template.service';
import { SavedFilterService } from './saved-filter.service';
@Module({ @Module({
controllers: [BoardsController], controllers: [BoardsController],
providers: [BoardsService, BoardTemplateService], providers: [BoardsService, BoardTemplateService, SavedFilterService],
exports: [BoardsService, BoardTemplateService], exports: [BoardsService, BoardTemplateService, SavedFilterService],
}) })
export class BoardsModule {} export class BoardsModule {}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength } from 'class-validator';
export class CreateSavedFilterDto {
@IsOptional()
@IsString()
boardId?: string;
@IsString()
@MinLength(1)
@MaxLength(50)
name: string;
filterConfig: any; // The filter object
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
export class UpdateSavedFilterDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(50)
name?: string;
@IsOptional()
filterConfig?: any;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateSavedFilterDto, UpdateSavedFilterDto } from './dto/saved-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class SavedFilterService {
private readonly logger = new Logger(SavedFilterService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateSavedFilterDto, currentUser: RequestUser): Promise<any> {
// If setting as default, unset existing default for this board
if (dto.isDefault && dto.boardId) {
await this.prisma.savedFilter.updateMany({
where: { userId: currentUser.id, boardId: dto.boardId, isDefault: true },
data: { isDefault: false },
});
}
return this.prisma.savedFilter.create({
data: {
userId: currentUser.id,
boardId: dto.boardId || null,
name: dto.name,
filterConfig: dto.filterConfig,
isDefault: dto.isDefault || false,
},
});
}
async findAll(boardId: string | undefined, currentUser: RequestUser): Promise<any[]> {
const where: any = { userId: currentUser.id };
if (boardId) where.boardId = boardId;
return this.prisma.savedFilter.findMany({
where,
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
}
async update(id: string, dto: UpdateSavedFilterDto, currentUser: RequestUser): Promise<any> {
const filter = await this.prisma.savedFilter.findUnique({ where: { id } });
if (!filter) throw new NotFoundException('Saved filter not found');
if (filter.userId !== currentUser.id) {
throw new ForbiddenException('You can only edit your own saved filters');
}
if (dto.isDefault && filter.boardId) {
await this.prisma.savedFilter.updateMany({
where: { userId: currentUser.id, boardId: filter.boardId, isDefault: true, id: { not: id } },
data: { isDefault: false },
});
}
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.filterConfig !== undefined) updateData.filterConfig = dto.filterConfig;
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
return this.prisma.savedFilter.update({ where: { id }, data: updateData });
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
const filter = await this.prisma.savedFilter.findUnique({ where: { id } });
if (!filter) throw new NotFoundException('Saved filter not found');
if (filter.userId !== currentUser.id && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('You can only delete your own saved filters');
}
await this.prisma.savedFilter.delete({ where: { id } });
}
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class CardDependencyService {
private readonly logger = new Logger(CardDependencyService.name);
constructor(private readonly prisma: PrismaService) {}
async addDependency(
cardId: string,
blockedByCardId: string,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot manage card dependencies');
}
if (cardId === blockedByCardId) {
throw new BadRequestException('A card cannot block itself');
}
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');
const blockingCard = await this.prisma.card.findFirst({
where: { id: blockedByCardId, deletedAt: null },
include: { column: { select: { boardId: true } } },
});
if (!blockingCard) throw new NotFoundException('Blocking card not found');
// PL can only manage on 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 manage dependencies on your boards');
}
}
// Check for existing
const existing = await this.prisma.cardDependency.findUnique({
where: { blockingCardId_blockedCardId: { blockingCardId: blockedByCardId, blockedCardId: cardId } },
});
if (existing) {
throw new ConflictException('This dependency already exists');
}
// Check for circular dependencies (A blocks B, B blocks A)
const reverse = await this.prisma.cardDependency.findUnique({
where: { blockingCardId_blockedCardId: { blockingCardId: cardId, blockedCardId: blockedByCardId } },
});
if (reverse) {
throw new BadRequestException('Circular dependency detected. This card already blocks the other card.');
}
// Deep circular check — walk the dependency chain
const isCircular = await this.checkCircularDependency(blockedByCardId, cardId);
if (isCircular) {
throw new BadRequestException('Adding this dependency would create a circular chain');
}
const dependency = await this.prisma.cardDependency.create({
data: {
blockingCardId: blockedByCardId,
blockedCardId: cardId,
createdById: currentUser.id,
},
});
// Log activity on both cards
try {
const blockingCardData = await this.prisma.card.findUnique({
where: { id: blockedByCardId },
select: { cardNumber: true, title: true },
});
const blockedCardData = await this.prisma.card.findUnique({
where: { id: cardId },
select: { cardNumber: true, title: true },
});
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'DEPENDENCY_ADDED',
metadata: {
blockedBy: blockedByCardId,
blockedByNumber: blockingCardData?.cardNumber,
blockedByTitle: blockingCardData?.title,
},
},
});
await this.prisma.cardActivity.create({
data: {
cardId: blockedByCardId,
userId: currentUser.id,
action: 'DEPENDENCY_ADDED',
metadata: {
blocks: cardId,
blocksNumber: blockedCardData?.cardNumber,
blocksTitle: blockedCardData?.title,
},
},
});
} catch (err) {
this.logger.warn(`Failed to log dependency activity: ${err.message}`);
}
this.logger.log(`Dependency added: ${blockedByCardId} blocks ${cardId} by ${currentUser.email}`);
return this.getDependencies(cardId);
}
async removeDependency(
cardId: string,
blockedByCardId: string,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot manage card dependencies');
}
const dep = await this.prisma.cardDependency.findUnique({
where: { blockingCardId_blockedCardId: { blockingCardId: blockedByCardId, blockedCardId: cardId } },
});
if (!dep) throw new NotFoundException('Dependency not found');
await this.prisma.cardDependency.delete({
where: { blockingCardId_blockedCardId: { blockingCardId: blockedByCardId, blockedCardId: cardId } },
});
try {
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'DEPENDENCY_REMOVED',
metadata: { removedBlockedBy: blockedByCardId },
},
});
} catch { /* non-critical */ }
this.logger.log(`Dependency removed: ${blockedByCardId} no longer blocks ${cardId}`);
return this.getDependencies(cardId);
}
async getDependencies(cardId: string): Promise<{ blockedBy: any[]; blocks: any[] }> {
const [blockedByRels, blocksRels] = await Promise.all([
this.prisma.cardDependency.findMany({
where: { blockedCardId: cardId },
include: {
blockingCard: {
select: { id: true, cardNumber: true, title: true, completedAt: true, columnId: true,
column: { select: { name: true, type: true } } },
},
},
}),
this.prisma.cardDependency.findMany({
where: { blockingCardId: cardId },
include: {
blockedCard: {
select: { id: true, cardNumber: true, title: true, completedAt: true, columnId: true,
column: { select: { name: true, type: true } } },
},
},
}),
]);
return {
blockedBy: blockedByRels.map((r) => ({
id: r.id,
card: r.blockingCard,
isResolved: !!r.blockingCard.completedAt,
createdAt: r.createdAt,
})),
blocks: blocksRels.map((r) => ({
id: r.id,
card: r.blockedCard,
isResolved: !!r.blockedCard.completedAt,
createdAt: r.createdAt,
})),
};
}
async isBlocked(cardId: string): Promise<boolean> {
const unresolvedBlockers = await this.prisma.cardDependency.count({
where: {
blockedCardId: cardId,
blockingCard: { completedAt: null, deletedAt: null },
},
});
return unresolvedBlockers > 0;
}
private async checkCircularDependency(startCardId: string, targetCardId: string): Promise<boolean> {
const visited = new Set<string>();
const queue = [startCardId];
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
const deps = await this.prisma.cardDependency.findMany({
where: { blockedCardId: current },
select: { blockingCardId: true },
});
for (const dep of deps) {
if (dep.blockingCardId === targetCardId) return true;
queue.push(dep.blockingCardId);
}
// Safety: don't traverse more than 100 nodes
if (visited.size > 100) return false;
}
return false;
}
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCardTemplateDto, UpdateCardTemplateDto } from './dto/card-template.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class CardTemplateService {
private readonly logger = new Logger(CardTemplateService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateCardTemplateDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot manage card templates');
}
if (!['BOARD', 'ORGANIZATION'].includes(dto.scope)) {
throw new BadRequestException('Scope must be BOARD or ORGANIZATION');
}
if (dto.scope === 'ORGANIZATION' && currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can create organization-level templates');
}
if (dto.scope === 'BOARD') {
if (!dto.boardId) {
throw new BadRequestException('Board ID is required for board-level templates');
}
const board = await this.prisma.board.findFirst({ where: { id: dto.boardId, deletedAt: null } });
if (!board) throw new NotFoundException('Board not found');
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 can only create templates on your boards');
}
}
}
const template = await this.prisma.cardTemplate.create({
data: {
name: dto.name,
description: dto.description || null,
titleTemplate: dto.titleTemplate,
bodyTemplate: dto.bodyTemplate || null,
priority: dto.priority || 'NONE',
estimatedHours: dto.estimatedHours || null,
checklistConfig: dto.checklistConfig || null,
labelIds: dto.labelIds || null,
scope: dto.scope,
boardId: dto.scope === 'BOARD' ? dto.boardId : null,
createdById: currentUser.id,
},
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
board: { select: { id: true, name: true, key: true } },
},
});
this.logger.log(`Card template "${dto.name}" created by ${currentUser.email} (${dto.scope})`);
return template;
}
async findAll(boardId: string | undefined, currentUser: RequestUser): Promise<any[]> {
const where: any = {};
if (boardId) {
where.OR = [
{ scope: 'ORGANIZATION' },
{ scope: 'BOARD', boardId },
];
} else {
if (currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN') {
// See all templates
} else {
where.OR = [{ scope: 'ORGANIZATION' }];
}
}
return this.prisma.cardTemplate.findMany({
where,
orderBy: [{ scope: 'asc' }, { name: 'asc' }],
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
board: { select: { id: true, name: true, key: true } },
},
});
}
async findById(id: string): Promise<any> {
const template = await this.prisma.cardTemplate.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
board: { select: { id: true, name: true, key: true } },
},
});
if (!template) throw new NotFoundException('Card template not found');
return template;
}
async update(id: string, dto: UpdateCardTemplateDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot manage card templates');
}
const template = await this.prisma.cardTemplate.findUnique({ where: { id } });
if (!template) throw new NotFoundException('Card template not found');
if (currentUser.role === 'TEAM_LEAD' && template.createdById !== currentUser.id) {
throw new ForbiddenException('You can only edit templates you created');
}
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.titleTemplate !== undefined) updateData.titleTemplate = dto.titleTemplate;
if (dto.bodyTemplate !== undefined) updateData.bodyTemplate = dto.bodyTemplate;
if (dto.priority !== undefined) updateData.priority = dto.priority;
if (dto.estimatedHours !== undefined) updateData.estimatedHours = dto.estimatedHours;
if (dto.checklistConfig !== undefined) updateData.checklistConfig = dto.checklistConfig;
if (dto.labelIds !== undefined) updateData.labelIds = dto.labelIds;
return this.prisma.cardTemplate.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
board: { select: { id: true, name: true, key: true } },
},
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot manage card templates');
}
const template = await this.prisma.cardTemplate.findUnique({ where: { id } });
if (!template) throw new NotFoundException('Card template not found');
if (currentUser.role === 'TEAM_LEAD' && template.createdById !== currentUser.id) {
throw new ForbiddenException('You can only delete templates you created');
}
await this.prisma.cardTemplate.delete({ where: { id } });
this.logger.log(`Card template ${id} deleted by ${currentUser.email}`);
}
}
\ No newline at end of file
...@@ -13,12 +13,18 @@ import { ...@@ -13,12 +13,18 @@ import {
import { CardsService } from './cards.service'; import { CardsService } from './cards.service';
import { CardMovementService } from './card-movement.service'; import { CardMovementService } from './card-movement.service';
import { CardAssignmentService } from './card-assignment.service'; import { CardAssignmentService } from './card-assignment.service';
import { CardTemplateService } from './card-template.service';
import { RecurringCardService } from './recurring-card.service';
import { CardDependencyService } from './card-dependency.service';
import { ViewsService } from './views.service';
import { CreateCardDto } from './dto/create-card.dto'; import { CreateCardDto } from './dto/create-card.dto';
import { UpdateCardDto } from './dto/update-card.dto'; import { UpdateCardDto } from './dto/update-card.dto';
import { MoveCardDto } from './dto/move-card.dto'; import { MoveCardDto } from './dto/move-card.dto';
import { CardFilterDto } from './dto/card-filter.dto'; import { CardFilterDto } from './dto/card-filter.dto';
import { DuplicateCardDto } from './dto/duplicate-card.dto'; import { DuplicateCardDto } from './dto/duplicate-card.dto';
import { AssignCardDto, UnassignCardDto, SetBountyDto } from './dto/assign-card.dto'; import { AssignCardDto, UnassignCardDto, SetBountyDto } from './dto/assign-card.dto';
import { CreateCardTemplateDto, UpdateCardTemplateDto } from './dto/card-template.dto';
import { CreateRecurringCardDto, UpdateRecurringCardDto } from './dto/recurring-card.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
...@@ -28,8 +34,14 @@ export class CardsController { ...@@ -28,8 +34,14 @@ export class CardsController {
private readonly cardsService: CardsService, private readonly cardsService: CardsService,
private readonly cardMovementService: CardMovementService, private readonly cardMovementService: CardMovementService,
private readonly cardAssignmentService: CardAssignmentService, private readonly cardAssignmentService: CardAssignmentService,
private readonly cardTemplateService: CardTemplateService,
private readonly recurringCardService: RecurringCardService,
private readonly cardDependencyService: CardDependencyService,
private readonly viewsService: ViewsService,
) {} ) {}
// ─── CORE CARD CRUD ──────────────────────────────────
@Post() @Post()
async create(@Body() dto: CreateCardDto, @CurrentUser() user: RequestUser) { async create(@Body() dto: CreateCardDto, @CurrentUser() user: RequestUser) {
return this.cardsService.create(dto, user); return this.cardsService.create(dto, user);
...@@ -40,67 +52,48 @@ export class CardsController { ...@@ -40,67 +52,48 @@ export class CardsController {
return this.cardsService.findAll(filter, user); return this.cardsService.findAll(filter, user);
} }
@Get('my-tasks')
async getMyTasks(@CurrentUser() user: RequestUser) {
return this.viewsService.getMyTasks(user);
}
@Get(':id') @Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) { async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.cardsService.findById(id, user); return this.cardsService.findById(id, user);
} }
@Put(':id') @Put(':id')
async update( async update(@Param('id') id: string, @Body() dto: UpdateCardDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: UpdateCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardsService.update(id, dto, user); return this.cardsService.update(id, dto, user);
} }
@Put(':id/move') @Put(':id/move')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async moveCard( async moveCard(@Param('id') id: string, @Body() dto: MoveCardDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: MoveCardDto,
@CurrentUser() user: RequestUser,
) {
await this.cardMovementService.moveCard(id, dto, user); await this.cardMovementService.moveCard(id, dto, user);
return this.cardsService.findById(id, user); return this.cardsService.findById(id, user);
} }
@Put(':id/assign') @Put(':id/assign')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async assignCard( async assignCard(@Param('id') id: string, @Body() dto: AssignCardDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: AssignCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardAssignmentService.assign(id, dto.assigneeIds, user); return this.cardAssignmentService.assign(id, dto.assigneeIds, user);
} }
@Put(':id/unassign') @Put(':id/unassign')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async unassignCard( async unassignCard(@Param('id') id: string, @Body() dto: UnassignCardDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: UnassignCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardAssignmentService.unassign(id, dto.userIds, user); return this.cardAssignmentService.unassign(id, dto.userIds, user);
} }
@Put(':id/bounty') @Put(':id/bounty')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async setBounty( async setBounty(@Param('id') id: string, @Body() dto: SetBountyDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: SetBountyDto,
@CurrentUser() user: RequestUser,
) {
return this.cardsService.setBounty(id, dto.bountyPiasters, dto.bountySplitJson, user); return this.cardsService.setBounty(id, dto.bountyPiasters, dto.bountySplitJson, user);
} }
@Post(':id/duplicate') @Post(':id/duplicate')
async duplicateCard( async duplicateCard(@Param('id') id: string, @Body() dto: DuplicateCardDto, @CurrentUser() user: RequestUser) {
@Param('id') id: string,
@Body() dto: DuplicateCardDto,
@CurrentUser() user: RequestUser,
) {
return this.cardsService.duplicate(id, dto, user); return this.cardsService.duplicate(id, dto, user);
} }
...@@ -143,4 +136,135 @@ export class CardsController { ...@@ -143,4 +136,135 @@ export class CardsController {
await this.cardsService.unwatchCard(id, user); await this.cardsService.unwatchCard(id, user);
return { message: 'Stopped watching this card' }; return { message: 'Stopped watching this card' };
} }
// ─── DEPENDENCIES ────────────────────────────────────
@Get(':id/dependencies')
async getDependencies(@Param('id') id: string) {
return this.cardDependencyService.getDependencies(id);
}
@Post(':id/dependencies')
@HttpCode(HttpStatus.OK)
async addDependency(
@Param('id') id: string,
@Body('blockedByCardId') blockedByCardId: string,
@CurrentUser() user: RequestUser,
) {
return this.cardDependencyService.addDependency(id, blockedByCardId, user);
}
@Delete(':id/dependencies/:blockedByCardId')
@HttpCode(HttpStatus.OK)
async removeDependency(
@Param('id') id: string,
@Param('blockedByCardId') blockedByCardId: string,
@CurrentUser() user: RequestUser,
) {
return this.cardDependencyService.removeDependency(id, blockedByCardId, user);
}
// ─── CARD TEMPLATES ──────────────────────────────────
@Post('templates')
async createTemplate(@Body() dto: CreateCardTemplateDto, @CurrentUser() user: RequestUser) {
return this.cardTemplateService.create(dto, user);
}
@Get('templates/list')
async listTemplates(@Query('boardId') boardId: string, @CurrentUser() user: RequestUser) {
return this.cardTemplateService.findAll(boardId, user);
}
@Get('templates/:templateId')
async getTemplate(@Param('templateId') templateId: string) {
return this.cardTemplateService.findById(templateId);
}
@Put('templates/:templateId')
async updateTemplate(
@Param('templateId') templateId: string,
@Body() dto: UpdateCardTemplateDto,
@CurrentUser() user: RequestUser,
) {
return this.cardTemplateService.update(templateId, dto, user);
}
@Delete('templates/:templateId')
@HttpCode(HttpStatus.OK)
async deleteTemplate(@Param('templateId') templateId: string, @CurrentUser() user: RequestUser) {
await this.cardTemplateService.delete(templateId, user);
return { message: 'Card template deleted' };
}
// ─── RECURRING CARDS ─────────────────────────────────
@Post('recurring')
async createRecurring(@Body() dto: CreateRecurringCardDto, @CurrentUser() user: RequestUser) {
return this.recurringCardService.create(dto, user);
}
@Get('recurring/list')
async listRecurring(@Query('boardId') boardId: string, @CurrentUser() user: RequestUser) {
return this.recurringCardService.findAll(boardId, user);
}
@Get('recurring/:recurringId')
async getRecurring(@Param('recurringId') recurringId: string, @CurrentUser() user: RequestUser) {
return this.recurringCardService.findById(recurringId, user);
}
@Put('recurring/:recurringId')
async updateRecurring(
@Param('recurringId') recurringId: string,
@Body() dto: UpdateRecurringCardDto,
@CurrentUser() user: RequestUser,
) {
return this.recurringCardService.update(recurringId, dto, user);
}
@Post('recurring/:recurringId/pause')
@HttpCode(HttpStatus.OK)
async pauseRecurring(@Param('recurringId') recurringId: string, @CurrentUser() user: RequestUser) {
return this.recurringCardService.pause(recurringId, user);
}
@Post('recurring/:recurringId/resume')
@HttpCode(HttpStatus.OK)
async resumeRecurring(@Param('recurringId') recurringId: string, @CurrentUser() user: RequestUser) {
return this.recurringCardService.resume(recurringId, user);
}
@Delete('recurring/:recurringId')
@HttpCode(HttpStatus.OK)
async deleteRecurring(@Param('recurringId') recurringId: string, @CurrentUser() user: RequestUser) {
await this.recurringCardService.delete(recurringId, user);
return { message: 'Recurring card definition deleted' };
}
// ─── BOARD VIEWS ─────────────────────────────────────
@Get('board/:boardId/activity')
async getBoardActivity(
@Param('boardId') boardId: string,
@Query('page') page: string,
@Query('limit') limit: string,
@CurrentUser() user: RequestUser,
) {
return this.viewsService.getBoardActivity(
boardId, user,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
);
}
@Get('board/:boardId/calendar')
async getBoardCalendar(
@Param('boardId') boardId: string,
@Query('start') start: string,
@Query('end') end: string,
@CurrentUser() user: RequestUser,
) {
return this.viewsService.getCardsWithDeadlines(boardId, start, end, user);
}
} }
\ No newline at end of file
...@@ -3,10 +3,30 @@ import { CardsController } from './cards.controller'; ...@@ -3,10 +3,30 @@ import { CardsController } from './cards.controller';
import { CardsService } from './cards.service'; import { CardsService } from './cards.service';
import { CardMovementService } from './card-movement.service'; import { CardMovementService } from './card-movement.service';
import { CardAssignmentService } from './card-assignment.service'; import { CardAssignmentService } from './card-assignment.service';
import { CardTemplateService } from './card-template.service';
import { RecurringCardService } from './recurring-card.service';
import { CardDependencyService } from './card-dependency.service';
import { ViewsService } from './views.service';
@Module({ @Module({
controllers: [CardsController], controllers: [CardsController],
providers: [CardsService, CardMovementService, CardAssignmentService], providers: [
exports: [CardsService, CardMovementService, CardAssignmentService], CardsService,
CardMovementService,
CardAssignmentService,
CardTemplateService,
RecurringCardService,
CardDependencyService,
ViewsService,
],
exports: [
CardsService,
CardMovementService,
CardAssignmentService,
CardTemplateService,
RecurringCardService,
CardDependencyService,
ViewsService,
],
}) })
export class CardsModule {} export class CardsModule {}
\ No newline at end of file
import { IsString, IsOptional, IsArray, IsNumber, Min, MinLength, MaxLength } from 'class-validator';
export class CreateCardTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsString()
@MinLength(1)
@MaxLength(200)
titleTemplate: string;
@IsOptional()
@IsString()
bodyTemplate?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@IsOptional()
checklistConfig?: any; // Array of { title, items }
@IsOptional()
@IsArray()
@IsString({ each: true })
labelIds?: string[];
@IsString()
scope: string; // BOARD, ORGANIZATION
@IsOptional()
@IsString()
boardId?: string;
}
export class UpdateCardTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(200)
titleTemplate?: string;
@IsOptional()
@IsString()
bodyTemplate?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@IsOptional()
checklistConfig?: any;
@IsOptional()
@IsArray()
@IsString({ each: true })
labelIds?: string[];
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, IsDateString, Min, MinLength, MaxLength } from 'class-validator';
export class CreateRecurringCardDto {
@IsString()
boardId: string;
@IsString()
@MinLength(1)
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
titleTemplate?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@IsOptional()
checklistConfig?: any;
@IsOptional()
@IsArray()
@IsString({ each: true })
labelIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsString()
recurrenceType: string; // DAILY, WEEKLY, BIWEEKLY, MONTHLY, CUSTOM
@IsOptional()
recurrenceConfig?: any; // { dayOfWeek?, dayOfMonth?, intervalDays? }
@IsOptional()
@IsDateString()
startDate?: string;
}
export class UpdateRecurringCardDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
titleTemplate?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
priority?: string;
@IsOptional()
@IsNumber()
@Min(0)
estimatedHours?: number;
@IsOptional()
checklistConfig?: any;
@IsOptional()
@IsArray()
@IsString({ each: true })
labelIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
@IsOptional()
@IsString()
recurrenceType?: string;
@IsOptional()
recurrenceConfig?: any;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateRecurringCardDto, UpdateRecurringCardDto } from './dto/recurring-card.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class RecurringCardService {
private readonly logger = new Logger(RecurringCardService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateRecurringCardDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot create recurring cards');
}
const validTypes = ['DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY', 'CUSTOM'];
if (!validTypes.includes(dto.recurrenceType)) {
throw new BadRequestException(`Recurrence type must be one of: ${validTypes.join(', ')}`);
}
const board = await this.prisma.board.findFirst({ where: { id: dto.boardId, deletedAt: null } });
if (!board) throw new NotFoundException('Board not found');
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 can only create recurring cards on your boards');
}
}
const startDate = dto.startDate ? new Date(dto.startDate) : new Date();
const nextCreationDate = this.calculateNextDate(dto.recurrenceType, dto.recurrenceConfig, startDate);
const definition = await this.prisma.recurringCardDefinition.create({
data: {
boardId: dto.boardId,
title: dto.title,
titleTemplate: dto.titleTemplate || null,
description: dto.description || null,
priority: dto.priority || 'NONE',
estimatedHours: dto.estimatedHours || null,
checklistConfig: dto.checklistConfig || null,
labelIds: dto.labelIds || null,
assigneeIds: dto.assigneeIds || null,
recurrenceType: dto.recurrenceType,
recurrenceConfig: dto.recurrenceConfig || null,
isActive: true,
nextCreationDate,
createdById: currentUser.id,
},
include: {
board: { select: { id: true, name: true, key: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
this.logger.log(
`Recurring card definition "${dto.title}" created on board ${board.key}${dto.recurrenceType} by ${currentUser.email}`,
);
return definition;
}
async findAll(boardId: string | undefined, currentUser: RequestUser): Promise<any[]> {
const where: any = {};
if (boardId) {
where.boardId = boardId;
}
if (currentUser.role === 'TEAM_LEAD') {
where.board = { members: { some: { userId: currentUser.id } } };
} else if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot view recurring card definitions');
}
return this.prisma.recurringCardDefinition.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
board: { select: { id: true, name: true, key: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const def = await this.prisma.recurringCardDefinition.findUnique({
where: { id },
include: {
board: { select: { id: true, name: true, key: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!def) throw new NotFoundException('Recurring card definition not found');
return def;
}
async update(id: string, dto: UpdateRecurringCardDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot manage recurring cards');
}
const def = await this.prisma.recurringCardDefinition.findUnique({ where: { id } });
if (!def) throw new NotFoundException('Recurring card definition not found');
if (currentUser.role === 'TEAM_LEAD' && def.createdById !== currentUser.id) {
throw new ForbiddenException('You can only edit recurring cards you created');
}
const updateData: any = {};
const fields = [
'title', 'titleTemplate', 'description', 'priority', 'estimatedHours',
'checklistConfig', 'labelIds', 'assigneeIds', 'recurrenceType', 'recurrenceConfig', 'isActive',
];
for (const field of fields) {
if ((dto as any)[field] !== undefined) updateData[field] = (dto as any)[field];
}
// Recalculate next creation date if recurrence changed
if (dto.recurrenceType || dto.recurrenceConfig) {
const rType = dto.recurrenceType || def.recurrenceType;
const rConfig = dto.recurrenceConfig || def.recurrenceConfig;
updateData.nextCreationDate = this.calculateNextDate(rType, rConfig, new Date());
}
return this.prisma.recurringCardDefinition.update({
where: { id },
data: updateData,
include: {
board: { select: { id: true, name: true, key: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async pause(id: string, currentUser: RequestUser): Promise<any> {
return this.update(id, { isActive: false }, currentUser);
}
async resume(id: string, currentUser: RequestUser): Promise<any> {
const def = await this.prisma.recurringCardDefinition.findUnique({ where: { id } });
if (!def) throw new NotFoundException('Recurring card definition not found');
const nextDate = this.calculateNextDate(def.recurrenceType, def.recurrenceConfig as any, new Date());
return this.prisma.recurringCardDefinition.update({
where: { id },
data: { isActive: true, nextCreationDate: nextDate },
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot delete recurring cards');
}
const def = await this.prisma.recurringCardDefinition.findUnique({ where: { id } });
if (!def) throw new NotFoundException('Recurring card definition not found');
if (currentUser.role === 'TEAM_LEAD' && def.createdById !== currentUser.id) {
throw new ForbiddenException('You can only delete recurring cards you created');
}
await this.prisma.recurringCardDefinition.delete({ where: { id } });
this.logger.log(`Recurring card definition ${id} deleted by ${currentUser.email}`);
}
private calculateNextDate(recurrenceType: string, config: any, fromDate: Date): Date {
const next = new Date(fromDate);
next.setHours(6, 0, 0, 0); // Default creation at 6AM
switch (recurrenceType) {
case 'DAILY':
next.setDate(next.getDate() + 1);
break;
case 'WEEKLY':
next.setDate(next.getDate() + 7);
break;
case 'BIWEEKLY':
next.setDate(next.getDate() + 14);
break;
case 'MONTHLY':
next.setMonth(next.getMonth() + 1);
break;
case 'CUSTOM':
const intervalDays = config?.intervalDays || 7;
next.setDate(next.getDate() + intervalDays);
break;
default:
next.setDate(next.getDate() + 7);
}
return next;
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class ViewsService {
private readonly logger = new Logger(ViewsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Cross-board "My Tasks" view — all cards assigned to the current user
* grouped by board, then by column status
*/
async getMyTasks(currentUser: RequestUser): Promise<any> {
const cards = await this.prisma.card.findMany({
where: {
assignees: { some: { id: currentUser.id } },
deletedAt: null,
isArchived: false,
},
orderBy: [{ dueDate: { sort: 'asc', nulls: 'last' } }, { position: '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, type: true,
board: { select: { id: true, name: true, key: true } },
},
},
_count: { select: { comments: true, attachments: true } },
},
});
// Group by board, then by column type priority
const columnPriority: Record<string, number> = {
DOING: 0, TODO: 1, IN_REVIEW: 2, FROZEN: 3, CUSTOM: 4, BACKLOG: 5, DONE: 6,
};
const boardMap = new Map<string, { board: any; cards: any[] }>();
for (const card of cards) {
const boardId = card.column.board.id;
if (!boardMap.has(boardId)) {
boardMap.set(boardId, { board: card.column.board, cards: [] });
}
boardMap.get(boardId)!.cards.push({
id: card.id,
cardNumber: card.cardNumber,
title: card.title,
columnId: card.column.id,
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,
frozenReason: card.frozenReason,
labels: card.labels,
assignees: card.assignees,
commentCount: card._count.comments,
attachmentCount: card._count.attachments,
completedAt: card.completedAt,
createdAt: card.createdAt,
});
}
const result = Array.from(boardMap.values()).map((group) => ({
board: group.board,
cards: group.cards.sort((a, b) => {
const aPri = columnPriority[a.columnType] ?? 4;
const bPri = columnPriority[b.columnType] ?? 4;
return aPri - bPri;
}),
cardCount: group.cards.length,
overdueCount: group.cards.filter((c) => c.isOverdue).length,
}));
return {
totalCards: cards.length,
totalOverdue: cards.filter((c) => c.dueDate && new Date(c.dueDate) < new Date() && !c.completedAt).length,
boards: result,
};
}
/**
* Board Activity Feed — chronological list of all actions on a board
*/
async getBoardActivity(
boardId: string,
currentUser: RequestUser,
page: number = 1,
limit: number = 50,
): Promise<PaginatedResult<any>> {
// Get all card IDs on this board
const columns = await this.prisma.column.findMany({
where: { boardId },
select: { id: true },
});
const columnIds = columns.map((c) => c.id);
const cardIds = await this.prisma.card.findMany({
where: { columnId: { in: columnIds } },
select: { id: true },
});
const ids = cardIds.map((c) => c.id);
if (ids.length === 0) {
return buildPaginatedResponse([], 0, { page, limit, sortOrder: 'desc' });
}
const [activities, total] = await Promise.all([
this.prisma.cardActivity.findMany({
where: { cardId: { in: ids } },
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
card: { select: { id: true, cardNumber: true, title: true } },
},
}),
this.prisma.cardActivity.count({ where: { cardId: { in: ids } } }),
]);
return buildPaginatedResponse(activities, total, { page, limit, sortOrder: 'desc' });
}
/**
* Cards due within N days for calendar view
*/
async getCardsWithDeadlines(
boardId: string,
startDate: string,
endDate: string,
currentUser: RequestUser,
): Promise<any[]> {
const start = new Date(startDate);
const end = new Date(endDate);
const where: any = {
dueDate: { gte: start, lte: end },
deletedAt: null,
isArchived: false,
column: { boardId },
};
return this.prisma.card.findMany({
where,
orderBy: { dueDate: 'asc' },
select: {
id: true,
cardNumber: true,
title: true,
dueDate: true,
priority: true,
completedAt: true,
bountyPiasters: true,
column: { select: { id: true, name: true, type: true } },
labels: { select: { id: true, name: true, color: true } },
assignees: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
}
}
\ No newline at end of file
// ============================================================ // ─── ENHANCED KANBAN MODELS ──────────────────────────────────────
// BOARDS + COLUMNS Add these models to your main schema.prisma // Phase 2B: Card templates, recurring cards, dependencies, saved filters
// ============================================================
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]) model CardTemplate {
@@index([isArchived]) id String @id @default(uuid())
@@index([deletedAt]) createdAt DateTime @default(now())
@@index([name]) updatedAt DateTime @updatedAt
}
model Column { name String
id String @id @default(uuid()) description String?
boardId String titleTemplate String // e.g. "Weekly Code Review: [Module Name]"
board Board @relation(fields: [boardId], references: [id], onDelete: CASCADE) bodyTemplate String? // Pre-filled description (rich text)
name String priority String @default("NONE")
icon String? estimatedHours Float?
position Int checklistConfig Json? // Array of { title, items: string[] }
type String // BACKLOG, TODO, DOING, FROZEN, IN_REVIEW, DONE, CUSTOM labelIds Json? // Array of label IDs to auto-apply
isDone Boolean @default(false)
isDefault Boolean @default(true) scope String @default("BOARD") // BOARD, ORGANIZATION
wipLimit Int? // per-user WIP limit boardId String?
wipLimitTotal Int? // total WIP limit for the column board Board? @relation("BoardCardTemplates", fields: [boardId], references: [id], onDelete: Cascade)
color String?
createdAt DateTime @default(now()) createdById String
updatedAt DateTime @updatedAt createdBy User @relation("CardTemplateCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
@@index([boardId]) @@index([boardId])
@@index([boardId, position]) @@index([scope])
@@index([createdById])
} }
model BoardMember { model RecurringCardDefinition {
id String @id @default(uuid()) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([boardId, userId]) boardId String
@@index([userId]) board Board @relation("BoardRecurringCards", fields: [boardId], references: [id], onDelete: Cascade)
title String
titleTemplate String? // e.g. "Weekly Review — [DATE]"
description String?
priority String @default("NONE")
estimatedHours Float?
checklistConfig Json? // Array of { title, items: string[] }
labelIds Json?
// Assignment
assigneeIds Json? // Array of user IDs to auto-assign
// Recurrence
recurrenceType String // DAILY, WEEKLY, BIWEEKLY, MONTHLY, CUSTOM
recurrenceConfig Json? // { dayOfWeek?, dayOfMonth?, intervalDays? }
// Tracking
isActive Boolean @default(true)
lastCreatedAt DateTime?
nextCreationDate DateTime?
createdById String
createdBy User @relation("RecurringCardCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
@@index([boardId]) @@index([boardId])
@@index([isActive])
@@index([nextCreationDate])
} }
model BoardTemplate { model CardDependency {
id String @id @default(uuid()) id String @id @default(uuid())
name String createdAt DateTime @default(now())
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]) blockingCardId String // This card blocks...
blockingCard Card @relation("CardBlocks", fields: [blockingCardId], references: [id], onDelete: Cascade)
blockedCardId String // ...this card
blockedCard Card @relation("CardBlockedBy", fields: [blockedCardId], references: [id], onDelete: Cascade)
createdById String?
@@unique([blockingCardId, blockedCardId])
@@index([blockingCardId])
@@index([blockedCardId])
}
model SavedFilter {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation("UserSavedFilters", fields: [userId], references: [id], onDelete: Cascade)
boardId String?
board Board? @relation("BoardSavedFilters", fields: [boardId], references: [id], onDelete: Cascade)
name String
filterConfig Json // { assigneeIds?, labelIds?, priority?, deadline?, columnIds?, hasBounty?, search?, labelLogic? }
isDefault Boolean @default(false) // Auto-apply when opening the board
@@index([userId])
@@index([boardId])
@@index([userId, boardId])
} }
\ No newline at end of file
import { BoardVisibility, BoardMemberRole } from '../enums'; export interface SavedFilter {
export interface BoardSummary {
id: string; id: string;
userId: string;
boardId: string | null;
name: string; name: string;
description: string | null; filterConfig: FilterConfig;
visibility: BoardVisibility; isDefault: boolean;
prefix: string | null;
color: string | null;
icon: string | null;
isArchived: boolean;
memberCount: number;
cardCount: number;
createdAt: string; createdAt: string;
updatedAt: string;
} }
export interface BoardDetail extends BoardSummary { export interface FilterConfig {
columns: ColumnData[]; assigneeIds?: string[];
labels: LabelData[]; labelIds?: string[];
members: BoardMemberData[]; labelLogic?: 'AND' | 'OR';
priority?: string[];
deadline?: 'overdue' | 'today' | 'this_week' | 'this_month' | 'no_deadline' | null;
dueDateFrom?: string;
dueDateTo?: string;
columnIds?: string[];
hasBounty?: boolean | null;
search?: string;
createdById?: string;
isArchived?: boolean;
} }
export interface ColumnData { export interface CardTemplate {
id: string; id: string;
name: string; name: string;
position: number; description: string | null;
color: string | null; titleTemplate: string;
wipLimit: number | null; bodyTemplate: string | null;
isDone: boolean; priority: string;
cardCount: number; estimatedHours: number | null;
checklistConfig: any;
labelIds: string[] | null;
scope: string;
boardId: string | null;
createdById: string;
createdAt: string;
} }
export interface LabelData { export interface RecurringCardDefinition {
id: string; id: string;
name: string; boardId: string;
color: string; title: string;
titleTemplate: string | null;
description: string | null;
priority: string;
recurrenceType: string;
recurrenceConfig: any;
isActive: boolean;
lastCreatedAt: string | null;
nextCreationDate: string | null;
createdById: string;
createdAt: string;
} }
export interface BoardMemberData { export interface CardDependency {
id: string; id: string;
userId: string; card: {
role: BoardMemberRole;
user: {
id: string; id: string;
firstName: string; cardNumber: string;
lastName: string; title: string;
displayName: string | null; completedAt: string | null;
avatar: string | null; column: { name: string; type: string };
}; };
joinedAt: string; isResolved: boolean;
createdAt: string;
}
export interface CardDependencies {
blockedBy: CardDependency[];
blocks: CardDependency[];
} }
\ 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