diff --git a/src/app.module.ts b/src/app.module.ts index 0db00ed..7874216 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; import { HealthModule } from './health/health.module'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; @@ -28,6 +30,7 @@ import { SchedulerModule } from './scheduler/scheduler.module'; import { AccountingModule } from './accounting/accounting.module'; import { PayrollModule } from './payroll/payroll.module'; import { RoleModule } from './role/role.module'; +import { CommentModule } from './comment/comment.module'; import { GlobalExceptionFilter } from './common/filters/http-exception.filter'; import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { PermissionsGuard } from './common/guards/permissions.guard'; @@ -69,6 +72,11 @@ import { AccessGuard } from './common/guards/access.guard'; AccountingModule, PayrollModule, RoleModule, + CommentModule, + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'uploads'), + serveRoot: '/uploads', + }), ], providers: [ { provide: APP_FILTER, useClass: GlobalExceptionFilter }, diff --git a/src/comment/comment.controller.ts b/src/comment/comment.controller.ts new file mode 100644 index 0000000..7ac6e35 --- /dev/null +++ b/src/comment/comment.controller.ts @@ -0,0 +1,52 @@ +import { + Controller, + Get, + Post, + Param, + Body, + UseGuards, + UseInterceptors, + UploadedFiles, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { FileFieldsInterceptor } from '@nestjs/platform-express'; +import { CommentService } from './comment.service'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { Roles } from '../common/decorators/roles.decorator'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { TenantGuard } from '../common/guards/tenant.guard'; +import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator'; +import { multerOptions } from '../common/multer/multer.config'; + +@Controller('tickets/:ticketId/comments') +@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard) +export class CommentController { + constructor(private readonly commentService: CommentService) {} + + @Get() + @Roles('technician') + async findAll( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + ) { + return this.commentService.findAll(user.tenantId, ticketId); + } + + @Post() + @Roles('technician') + @UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions)) + async create( + @CurrentUser() user: CurrentUserPayload, + @Param('ticketId') ticketId: string, + @Body() dto: CreateCommentDto, + @UploadedFiles() files?: { files?: Express.Multer.File[] }, + ) { + return this.commentService.create( + user.tenantId, + ticketId, + user.sub, + dto.content, + files?.files, + ); + } +} diff --git a/src/comment/comment.module.ts b/src/comment/comment.module.ts new file mode 100644 index 0000000..d742b1b --- /dev/null +++ b/src/comment/comment.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CommentController } from './comment.controller'; +import { CommentService } from './comment.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { NotificationModule } from '../notification/notification.module'; + +@Module({ + imports: [PrismaModule, NotificationModule], + controllers: [CommentController], + providers: [CommentService], + exports: [CommentService], +}) +export class CommentModule {} diff --git a/src/comment/comment.service.ts b/src/comment/comment.service.ts new file mode 100644 index 0000000..bf4ca0e --- /dev/null +++ b/src/comment/comment.service.ts @@ -0,0 +1,117 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotificationService } from '../notification/notification.service'; + +@Injectable() +export class CommentService { + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + ) {} + + async findAll(tenantId: string, ticketId: string) { + const db = this.prisma.forTenant(tenantId); + const ticket = await db.ticket.findFirst({ where: { id: ticketId } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + return this.prisma.ticketComment.findMany({ + where: { tenantId, ticketId }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + attachments: true, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async create( + tenantId: string, + ticketId: string, + userId: string, + content: string, + files?: Express.Multer.File[], + ) { + const db = this.prisma.forTenant(tenantId); + const ticket = await db.ticket.findFirst({ where: { id: ticketId } }); + if (!ticket) throw new NotFoundException('Ticket not found'); + + const comment = await this.prisma.ticketComment.create({ + data: { + tenantId, + ticketId, + userId, + content, + attachments: files?.length + ? { + create: files.map((f) => ({ + fileName: f.originalname, + filePath: f.filename, + fileType: f.mimetype, + fileSize: f.size, + })), + } + : undefined, + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + attachments: true, + }, + }); + + // Parse @mentions and notify mentioned users + const mentions = this.parseMentions(content); + if (mentions.length > 0) { + const db2 = this.prisma.forTenant(tenantId); + const users = await db2.user.findMany({ + where: { tenantId, isActive: true }, + select: { id: true, firstName: true, lastName: true }, + }); + + const commenterName = `${comment.author.firstName} ${comment.author.lastName}`; + + for (const mention of mentions) { + const mentionedUser = users.find( + (u) => + `${u.firstName} ${u.lastName}`.toLowerCase() === mention.toLowerCase() || + u.firstName.toLowerCase() === mention.toLowerCase(), + ); + if (mentionedUser && mentionedUser.id !== userId) { + await this.notificationService.create(tenantId, { + userId: mentionedUser.id, + type: 'in_app', + channel: 'mention', + title: 'You were mentioned', + message: `${commenterName} mentioned you in "${ticket.title}"`, + ticketId, + }); + } + } + } + + // Notify ticket creator (if not the commenter) + if (ticket.createdById && ticket.createdById !== userId) { + const commenterName = `${comment.author.firstName} ${comment.author.lastName}`; + await this.notificationService.create(tenantId, { + userId: ticket.createdById, + type: 'in_app', + channel: 'comment_added', + title: 'New comment on your ticket', + message: `${commenterName} commented on "${ticket.title}"`, + ticketId, + }); + } + + return comment; + } + + /** Extract @FirstName or @FirstNameLastName from content */ + private parseMentions(content: string): string[] { + const regex = /@(\w+(?:\s+\w+)?)/g; + const matches: string[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(content)) !== null) { + matches.push(match[1]); + } + return [...new Set(matches)]; + } +} diff --git a/src/comment/dto/create-comment.dto.ts b/src/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..fdc01a5 --- /dev/null +++ b/src/comment/dto/create-comment.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class CreateCommentDto { + @IsString() + @MinLength(1) + content!: string; +} diff --git a/src/common/multer/multer.config.ts b/src/common/multer/multer.config.ts new file mode 100644 index 0000000..9630c56 --- /dev/null +++ b/src/common/multer/multer.config.ts @@ -0,0 +1,30 @@ +import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; + +export const multerOptions: MulterOptions = { + storage: diskStorage({ + destination: './uploads', + filename: (_req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + cb(null, uniqueSuffix + extname(file.originalname)); + }, + }), + limits: { + fileSize: 5 * 1024 * 1024, // 5MB per file + }, + fileFilter: (_req, file, cb) => { + const allowed = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + ]; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`File type ${file.mimetype} not allowed`), false); + } + }, +}; diff --git a/src/notification/notification.service.ts b/src/notification/notification.service.ts index 67e62e0..bf5050f 100644 --- a/src/notification/notification.service.ts +++ b/src/notification/notification.service.ts @@ -40,6 +40,7 @@ export class NotificationService { channel: string; title: string; message: string; + ticketId?: string; }) { return this.prisma.notification.create({ data: { @@ -50,6 +51,7 @@ export class NotificationService { channel: data.channel, title: data.title, message: data.message, + ticketId: data.ticketId, sentAt: new Date(), }, }); diff --git a/src/ticket/ticket.service.ts b/src/ticket/ticket.service.ts index c759cd0..81987b9 100644 --- a/src/ticket/ticket.service.ts +++ b/src/ticket/ticket.service.ts @@ -4,6 +4,7 @@ import { BadRequestException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { NotificationService } from '../notification/notification.service'; import { CreateTicketDto } from './dto/create-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto'; @@ -20,7 +21,10 @@ export class TicketService { onTicketResolved: ((event: TicketResolvedEvent) => Promise) | null = null; - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notificationService: NotificationService, + ) {} async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) { const db = this.prisma.forTenant(tenantId); @@ -111,6 +115,18 @@ export class TicketService { }); } + // Notify newly assigned user + if (dto.assigneeId && dto.assigneeId !== existing.assigneeId) { + this.notificationService.create(tenantId, { + userId: dto.assigneeId, + type: 'in_app', + channel: 'ticket_assigned', + title: 'Ticket assigned to you', + message: `You were assigned to "${existing.title}"`, + ticketId: id, + }).catch(() => {}); // non-blocking + } + return this.prisma.ticket.update({ where: { id }, data: {