Commit 130e018c authored by Administrator's avatar Administrator

Update 19 files via Son of Anton

parent fe02219a
......@@ -21,6 +21,9 @@ 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 { CommentsModule } from './modules/comments/comments.module';
import { ChecklistsModule } from './modules/checklists/checklists.module';
import { AttachmentsModule } from './modules/attachments/attachments.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
......@@ -48,6 +51,9 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
ColumnsModule,
LabelsModule,
CardsModule,
CommentsModule,
ChecklistsModule,
AttachmentsModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
HttpCode,
HttpStatus,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AttachmentsService } from './attachments.service';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
@Controller('attachments')
export class AttachmentsController {
constructor(private readonly attachmentsService: AttachmentsService) {}
@Post('card/:cardId')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 26214400 }, // 25MB hard limit at multer level
}),
)
async upload(
@Param('cardId') cardId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: RequestUser,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.attachmentsService.upload(cardId, file, user);
}
@Get('card/:cardId')
async findByCard(@Param('cardId') cardId: string, @CurrentUser() user: RequestUser) {
return this.attachmentsService.findByCard(cardId, user);
}
@Get(':id/download')
async getDownloadUrl(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.attachmentsService.getDownloadUrl(id, user);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.attachmentsService.delete(id, user);
return { message: 'Attachment deleted' };
}
@Put(':id/cover/:cardId')
@HttpCode(HttpStatus.OK)
async setCoverImage(
@Param('id') id: string,
@Param('cardId') cardId: string,
@CurrentUser() user: RequestUser,
) {
return this.attachmentsService.setCoverImage(cardId, id, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { AttachmentsController } from './attachments.controller';
import { AttachmentsService } from './attachments.service';
import { MinioService } from './minio.service';
@Module({
imports: [
MulterModule.register({
storage: undefined, // Use memory storage (buffer) — we upload to MinIO ourselves
}),
],
controllers: [AttachmentsController],
providers: [AttachmentsService, MinioService],
exports: [AttachmentsService, MinioService],
})
export class AttachmentsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
import { MinioService } from './minio.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { generateStoragePath, isAllowedMimeType, isWithinSizeLimit, isImageMimeType } from '../../common/utils/file.util';
@Injectable()
export class AttachmentsService {
private readonly logger = new Logger(AttachmentsService.name);
private readonly maxFileSizeBytes: number;
private readonly maxAttachmentsPerCard: number;
private readonly allowedMimeTypes: string[];
constructor(
private readonly prisma: PrismaService,
private readonly minioService: MinioService,
private readonly configService: ConfigService,
) {
this.maxFileSizeBytes = this.configService.get<number>('upload.maxFileSizeBytes') || 26214400;
this.maxAttachmentsPerCard = this.configService.get<number>('upload.maxAttachmentsPerCard') || 20;
this.allowedMimeTypes = this.configService.get<string[]>('upload.allowedFileMimeTypes') || [];
}
async upload(
cardId: string,
file: Express.Multer.File,
currentUser: RequestUser,
): Promise<any> {
if (!file) {
throw new BadRequestException('No file provided');
}
// Validate file size
if (!isWithinSizeLimit(file.size, this.maxFileSizeBytes)) {
throw new BadRequestException(
`File too large. Maximum size: ${Math.round(this.maxFileSizeBytes / 1048576)}MB`,
);
}
// Validate MIME type
if (this.allowedMimeTypes.length > 0 && !isAllowedMimeType(file.mimetype, this.allowedMimeTypes)) {
throw new BadRequestException(
`File type "${file.mimetype}" is not allowed`,
);
}
// Verify card exists
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');
}
// Permission check
if (currentUser.role === 'CONTRACTOR') {
const isAssigned = card.assignees.some((a: any) => a.id === currentUser.id);
if (!isAssigned) {
throw new ForbiddenException('You can only attach files to cards assigned to you');
}
}
// Check attachment limit
const existingCount = await this.prisma.attachment.count({
where: { cardId },
});
if (existingCount >= this.maxAttachmentsPerCard) {
throw new BadRequestException(
`Maximum ${this.maxAttachmentsPerCard} attachments per card. Delete an existing attachment first.`,
);
}
// Generate storage path
const storagePath = generateStoragePath('cards', cardId, file.originalname);
// Upload to MinIO
try {
await this.minioService.upload(storagePath, file.buffer, file.mimetype, file.size);
} catch (err) {
this.logger.error(`MinIO upload failed: ${err.message}`);
throw new BadRequestException('File upload failed. Please try again.');
}
// Create database record
const attachment = await this.prisma.attachment.create({
data: {
cardId,
originalName: file.originalname,
storagePath,
mimeType: file.mimetype,
sizeBytes: file.size,
uploadedById: currentUser.id,
},
include: {
uploadedBy: {
select: { id: true, firstName: true, lastName: true, avatar: true },
},
},
});
// If this is the first image attachment and card has no cover, set as cover
if (isImageMimeType(file.mimetype) && !card.coverImage) {
try {
const url = await this.minioService.getPresignedUrl(storagePath, 86400 * 7);
await this.prisma.card.update({
where: { id: cardId },
data: { coverImage: url },
});
} catch {
// Non-critical — cover image is cosmetic
}
}
// Log activity
try {
await this.prisma.cardActivity.create({
data: {
cardId,
userId: currentUser.id,
action: 'ATTACHMENT_ADDED',
metadata: {
fileName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
},
},
});
} catch (err) {
this.logger.warn(`Failed to log attachment activity: ${err.message}`);
}
this.logger.log(
`Attachment "${file.originalname}" uploaded to card ${cardId} by ${currentUser.email}`,
);
return this.formatAttachment(attachment);
}
async findByCard(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');
}
const attachments = await this.prisma.attachment.findMany({
where: { cardId },
orderBy: { createdAt: 'desc' },
include: {
uploadedBy: {
select: { id: true, firstName: true, lastName: true, avatar: true },
},
},
});
const result = [];
for (const att of attachments) {
result.push(await this.formatAttachment(att));
}
return result;
}
async getDownloadUrl(id: string, currentUser: RequestUser): Promise<{ url: string; fileName: string }> {
const attachment = await this.prisma.attachment.findUnique({ where: { id } });
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
try {
const url = await this.minioService.getPresignedUrl(attachment.storagePath, 3600);
return { url, fileName: attachment.originalName };
} catch (err) {
this.logger.error(`Failed to generate download URL: ${err.message}`);
throw new BadRequestException('Could not generate download link');
}
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
const attachment = await this.prisma.attachment.findUnique({
where: { id },
include: {
card: {
include: {
column: { select: { boardId: true } },
},
},
},
});
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
// Permission: Contractors cannot delete. TL can on own boards. Admin/SA can anywhere.
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot delete attachments');
}
if (currentUser.role === 'TEAM_LEAD') {
const membership = await this.prisma.boardMember.findUnique({
where: {
boardId_userId: {
boardId: attachment.card.column.boardId,
userId: currentUser.id,
},
},
});
if (!membership) {
throw new ForbiddenException('You can only manage attachments on your boards');
}
}
// Delete from MinIO
try {
await this.minioService.delete(attachment.storagePath);
} catch (err) {
this.logger.error(`Failed to delete from MinIO: ${err.message}`);
// Continue with DB deletion even if MinIO fails
}
// If this was the cover image, clear it
if (attachment.card.coverImage) {
try {
await this.prisma.card.update({
where: { id: attachment.cardId },
data: { coverImage: null },
});
} catch {
// Non-critical
}
}
// Delete DB record
await this.prisma.attachment.delete({ where: { id } });
// Log activity
try {
await this.prisma.cardActivity.create({
data: {
cardId: attachment.cardId,
userId: currentUser.id,
action: 'ATTACHMENT_REMOVED',
metadata: {
fileName: attachment.originalName,
},
},
});
} catch (err) {
this.logger.warn(`Failed to log attachment deletion: ${err.message}`);
}
this.logger.log(`Attachment ${id} deleted by ${currentUser.email}`);
}
async setCoverImage(cardId: string, attachmentId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot change cover images');
}
const attachment = await this.prisma.attachment.findFirst({
where: { id: attachmentId, cardId },
});
if (!attachment) {
throw new NotFoundException('Attachment not found on this card');
}
if (!isImageMimeType(attachment.mimeType)) {
throw new BadRequestException('Only image attachments can be set as cover');
}
try {
const url = await this.minioService.getPresignedUrl(attachment.storagePath, 86400 * 30);
await this.prisma.card.update({
where: { id: cardId },
data: { coverImage: url },
});
return { coverImage: url };
} catch (err) {
throw new BadRequestException('Failed to set cover image');
}
}
private async formatAttachment(attachment: any): Promise<any> {
let downloadUrl: string | null = null;
try {
downloadUrl = await this.minioService.getPresignedUrl(attachment.storagePath, 3600);
} catch {
// MinIO might not be available
}
return {
id: attachment.id,
cardId: attachment.cardId,
originalName: attachment.originalName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
isImage: isImageMimeType(attachment.mimeType),
downloadUrl,
uploadedBy: attachment.uploadedBy || null,
createdAt: attachment.createdAt,
};
}
}
\ No newline at end of file
export class AttachmentResponseDto {
id: string;
cardId: string;
originalName: string;
mimeType: string;
sizeBytes: number;
isImage: boolean;
downloadUrl: string | null;
uploadedBy: {
id: string;
firstName: string;
lastName: string;
avatar: string | null;
};
createdAt: string;
}
\ No newline at end of file
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as Minio from 'minio';
import { Readable } from 'stream';
@Injectable()
export class MinioService implements OnModuleInit {
private readonly logger = new Logger(MinioService.name);
private client: Minio.Client;
private bucket: string;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
const endPoint = this.configService.get<string>('minio.endPoint') || 'localhost';
const port = this.configService.get<number>('minio.port') || 9000;
const useSSL = this.configService.get<boolean>('minio.useSSL') || false;
const accessKey = this.configService.get<string>('minio.accessKey') || 'minioadmin';
const secretKey = this.configService.get<string>('minio.secretKey') || 'minioadmin';
this.bucket = this.configService.get<string>('minio.bucket') || 'hr-files';
this.client = new Minio.Client({
endPoint,
port,
useSSL,
accessKey,
secretKey,
});
try {
const bucketExists = await this.client.bucketExists(this.bucket);
if (!bucketExists) {
await this.client.makeBucket(this.bucket);
this.logger.log(`Bucket "${this.bucket}" created`);
} else {
this.logger.log(`Bucket "${this.bucket}" already exists`);
}
} catch (err) {
this.logger.error(`MinIO initialization failed: ${err.message}`);
this.logger.warn('File storage will not be available. Ensure MinIO is running.');
}
}
async upload(
storagePath: string,
buffer: Buffer,
mimeType: string,
size: number,
): Promise<void> {
await this.client.putObject(this.bucket, storagePath, buffer, size, {
'Content-Type': mimeType,
});
this.logger.log(`File uploaded to ${this.bucket}/${storagePath} (${size} bytes)`);
}
async uploadStream(
storagePath: string,
stream: Readable,
mimeType: string,
size: number,
): Promise<void> {
await this.client.putObject(this.bucket, storagePath, stream, size, {
'Content-Type': mimeType,
});
}
async getPresignedUrl(storagePath: string, expirySeconds: number = 3600): Promise<string> {
return this.client.presignedGetObject(this.bucket, storagePath, expirySeconds);
}
async getPresignedUploadUrl(storagePath: string, expirySeconds: number = 600): Promise<string> {
return this.client.presignedPutObject(this.bucket, storagePath, expirySeconds);
}
async delete(storagePath: string): Promise<void> {
await this.client.removeObject(this.bucket, storagePath);
this.logger.log(`File deleted: ${this.bucket}/${storagePath}`);
}
async getFileStream(storagePath: string): Promise<Readable> {
return this.client.getObject(this.bucket, storagePath);
}
async getFileStat(storagePath: string): Promise<Minio.BucketItemStat> {
return this.client.statObject(this.bucket, storagePath);
}
async exists(storagePath: string): Promise<boolean> {
try {
await this.client.statObject(this.bucket, storagePath);
return true;
} catch {
return false;
}
}
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ChecklistsService } from './checklists.service';
import { CreateChecklistDto, CreateChecklistItemDto } from './dto/create-checklist.dto';
import { UpdateChecklistDto, UpdateChecklistItemDto, ReorderChecklistItemsDto } from './dto/update-checklist.dto';
import { ToggleChecklistItemDto } from './dto/toggle-item.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
@Controller('checklists')
export class ChecklistsController {
constructor(private readonly checklistsService: ChecklistsService) {}
@Post()
async create(@Body() dto: CreateChecklistDto, @CurrentUser() user: RequestUser) {
return this.checklistsService.create(dto, user);
}
@Get('card/:cardId')
async findByCard(@Param('cardId') cardId: string) {
return this.checklistsService.findByCard(cardId);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateChecklistDto,
@CurrentUser() user: RequestUser,
) {
return this.checklistsService.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.checklistsService.delete(id, user);
return { message: 'Checklist deleted' };
}
@Post(':id/items')
async addItem(
@Param('id') id: string,
@Body() dto: CreateChecklistItemDto,
@CurrentUser() user: RequestUser,
) {
return this.checklistsService.addItem(id, dto, user);
}
@Put('items/:itemId')
async updateItem(
@Param('itemId') itemId: string,
@Body() dto: UpdateChecklistItemDto,
@CurrentUser() user: RequestUser,
) {
return this.checklistsService.updateItem(itemId, dto, user);
}
@Put('items/:itemId/toggle')
@HttpCode(HttpStatus.OK)
async toggleItem(
@Param('itemId') itemId: string,
@Body() dto: ToggleChecklistItemDto,
@CurrentUser() user: RequestUser,
) {
return this.checklistsService.toggleItem(itemId, dto, user);
}
@Delete('items/:itemId')
@HttpCode(HttpStatus.OK)
async deleteItem(@Param('itemId') itemId: string, @CurrentUser() user: RequestUser) {
await this.checklistsService.deleteItem(itemId, user);
return { message: 'Checklist item deleted' };
}
@Put(':id/reorder')
@HttpCode(HttpStatus.OK)
async reorderItems(
@Param('id') id: string,
@Body() dto: ReorderChecklistItemsDto,
@CurrentUser() user: RequestUser,
) {
return this.checklistsService.reorderItems(id, dto, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { ChecklistsController } from './checklists.controller';
import { ChecklistsService } from './checklists.service';
@Module({
controllers: [ChecklistsController],
providers: [ChecklistsService],
exports: [ChecklistsService],
})
export class ChecklistsModule {}
\ No newline at end of file
This diff is collapsed.
import { IsString, IsOptional, IsArray, ValidateNested, MinLength, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateChecklistDto {
@IsString()
cardId: string;
@IsString()
@MinLength(1)
@MaxLength(100)
title: string;
}
export class CreateChecklistItemDto {
@IsString()
@MinLength(1)
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
assigneeId?: string;
@IsOptional()
@IsString()
dueDate?: string;
}
export class BulkCreateChecklistItemsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateChecklistItemDto)
items: CreateChecklistItemDto[];
}
\ No newline at end of file
import { IsBoolean } from 'class-validator';
export class ToggleChecklistItemDto {
@IsBoolean()
isCompleted: boolean;
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, MinLength, MaxLength } from 'class-validator';
export class UpdateChecklistDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
title?: string;
}
export class UpdateChecklistItemDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
assigneeId?: string;
@IsOptional()
@IsString()
dueDate?: string;
}
export class ReorderChecklistItemsDto {
@IsArray()
@IsString({ each: true })
itemIds: string[];
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('comments')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Post()
async create(@Body() dto: CreateCommentDto, @CurrentUser() user: RequestUser) {
return this.commentsService.create(dto, user);
}
@Get('card/:cardId')
async findByCard(@Param('cardId') cardId: string, @CurrentUser() user: RequestUser) {
return this.commentsService.findByCard(cardId, user);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() dto: UpdateCommentDto,
@CurrentUser() user: RequestUser,
) {
return this.commentsService.update(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.commentsService.delete(id, user);
return { message: 'Comment deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@Module({
controllers: [CommentsController],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
const EDIT_WINDOW_MINUTES = 15;
@Injectable()
export class CommentsService {
private readonly logger = new Logger(CommentsService.name);
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateCommentDto, currentUser: RequestUser): Promise<any> {
const card = await this.prisma.card.findFirst({
where: { id: dto.cardId, deletedAt: null },
include: {
column: { select: { boardId: true } },
assignees: { select: { id: true } },
watchers: { select: { id: true } },
},
});
if (!card) {
throw new NotFoundException('Card not found');
}
// Permission: Contractors can only comment on cards they're assigned to or watching
if (currentUser.role === 'CONTRACTOR') {
const isAssigned = card.assignees.some((a: any) => a.id === currentUser.id);
const isWatcher = card.watchers.some((w: any) => w.id === currentUser.id);
if (!isAssigned && !isWatcher) {
throw new ForbiddenException('You can only comment on cards you are assigned to or watching');
}
}
// TEAM_LEAD: must be a member of the board
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 comment on cards in your boards');
}
}
const editableUntil = new Date(Date.now() + EDIT_WINDOW_MINUTES * 60 * 1000);
const comment = await this.prisma.comment.create({
data: {
cardId: dto.cardId,
userId: currentUser.id,
content: dto.content,
editableUntil,
mentions: dto.mentions || [],
isEdited: false,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
displayName: true,
avatar: true,
},
},
},
});
// Log card activity
try {
await this.prisma.cardActivity.create({
data: {
cardId: dto.cardId,
userId: currentUser.id,
action: 'COMMENTED',
metadata: {
commentId: comment.id,
preview: dto.content.substring(0, 100),
},
},
});
} catch (err) {
this.logger.warn(`Failed to log comment activity: ${err.message}`);
}
this.logger.log(`Comment created on card ${dto.cardId} by ${currentUser.email}`);
return this.formatComment(comment, currentUser.id);
}
async findByCard(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');
}
const comments = await this.prisma.comment.findMany({
where: { cardId },
orderBy: { createdAt: 'asc' },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
displayName: true,
avatar: true,
},
},
},
});
return comments.map((c: any) => this.formatComment(c, currentUser.id));
}
async update(id: string, dto: UpdateCommentDto, currentUser: RequestUser): Promise<any> {
const comment = await this.prisma.comment.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
displayName: true,
avatar: true,
},
},
},
});
if (!comment) {
throw new NotFoundException('Comment not found');
}
// Only the author can edit
if (comment.userId !== currentUser.id && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('You can only edit your own comments');
}
// Check edit window (Super Admin bypasses)
if (currentUser.role !== 'SUPER_ADMIN') {
const now = new Date();
if (now > comment.editableUntil) {
throw new BadRequestException(
'Edit window has expired. Comments can only be edited within 15 minutes of posting.',
);
}
}
// Store original content on first edit
const updateData: any = {
content: dto.content,
isEdited: true,
};
if (!comment.isEdited) {
updateData.originalContent = comment.content;
}
const updated = await this.prisma.comment.update({
where: { id },
data: updateData,
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
displayName: true,
avatar: true,
},
},
},
});
this.logger.log(`Comment ${id} edited by ${currentUser.email}`);
return this.formatComment(updated, currentUser.id);
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete comments');
}
const comment = await this.prisma.comment.findUnique({ where: { id } });
if (!comment) {
throw new NotFoundException('Comment not found');
}
await this.prisma.comment.delete({ where: { id } });
// Log deletion activity
try {
await this.prisma.cardActivity.create({
data: {
cardId: comment.cardId,
userId: currentUser.id,
action: 'COMMENT_DELETED',
metadata: {
commentId: id,
deletedContent: comment.content.substring(0, 200),
},
},
});
} catch (err) {
this.logger.warn(`Failed to log comment deletion: ${err.message}`);
}
this.logger.log(`Comment ${id} deleted by ${currentUser.email}`);
}
private formatComment(comment: any, viewerId: string): any {
const now = new Date();
const canEdit = comment.userId === viewerId && now < new Date(comment.editableUntil);
return {
id: comment.id,
cardId: comment.cardId,
content: comment.content,
isEdited: comment.isEdited,
originalContent: comment.originalContent || null,
editableUntil: comment.editableUntil,
canEdit,
mentions: comment.mentions || [],
user: comment.user,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
}
\ No newline at end of file
export class CommentResponseDto {
id: string;
cardId: string;
content: string;
isEdited: boolean;
originalContent: string | null;
editableUntil: string;
canEdit: boolean;
mentions: string[] | null;
user: {
id: string;
firstName: string;
lastName: string;
displayName: string | null;
avatar: string | null;
};
createdAt: string;
updatedAt: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsArray, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
cardId: string;
@IsString()
@MinLength(1, { message: 'Comment cannot be empty' })
content: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
mentions?: string[];
}
\ No newline at end of file
import { IsString, MinLength } from 'class-validator';
export class UpdateCommentDto {
@IsString()
@MinLength(1, { message: 'Comment cannot be empty' })
content: string;
}
\ No newline at end of file
// ═══════════════════════════════════════════
// COMMENT, ATTACHMENT, CHECKLIST models
// Merge into your existing schema if missing
// ═══════════════════════════════════════════
model Comment {
id String @id @default(uuid())
cardId String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
userId String
user User @relation("UserComments", fields: [userId], references: [id])
content String
isEdited Boolean @default(false)
originalContent String?
editableUntil DateTime
mentions Json? @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([cardId])
@@index([userId])
@@index([createdAt])
}
model Attachment {
id String @id @default(uuid())
cardId String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
originalName String
storagePath String
mimeType String
sizeBytes Int
uploadedById String
uploadedBy User @relation("UserAttachments", fields: [uploadedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([cardId])
@@index([uploadedById])
}
model Checklist {
id String @id @default(uuid())
cardId String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
title String
position Int @default(0)
items ChecklistItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([cardId])
}
model ChecklistItem {
id String @id @default(uuid())
checklistId String
checklist Checklist @relation(fields: [checklistId], references: [id], onDelete: Cascade)
title String
position Int @default(0)
isCompleted Boolean @default(false)
completedAt DateTime?
assigneeId String?
assignee User? @relation("ChecklistItemAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([checklistId])
@@index([assigneeId])
}
model CardActivity {
id String @id @default(uuid())
cardId String
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
userId String?
user User? @relation("UserCardActivities", fields: [userId], references: [id], onDelete: SetNull)
action String
metadata Json?
createdAt DateTime @default(now())
@@index([cardId])
@@index([userId])
@@index([createdAt])
}
\ 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