feat: add ticket comments, file attachments, mention notifications
- Comment module with CRUD and file upload via multer - @mention parsing with user notification - Assignment notification on ticket update - Notification service enhanced with ticketId linking - ServeStaticModule for uploaded files
This commit is contained in:
@@ -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 },
|
||||
|
||||
52
src/comment/comment.controller.ts
Normal file
52
src/comment/comment.controller.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/comment/comment.module.ts
Normal file
13
src/comment/comment.module.ts
Normal file
@@ -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 {}
|
||||
117
src/comment/comment.service.ts
Normal file
117
src/comment/comment.service.ts
Normal file
@@ -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)];
|
||||
}
|
||||
}
|
||||
7
src/comment/dto/create-comment.dto.ts
Normal file
7
src/comment/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
content!: string;
|
||||
}
|
||||
30
src/common/multer/multer.config.ts
Normal file
30
src/common/multer/multer.config.ts
Normal file
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<void>) | 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: {
|
||||
|
||||
Reference in New Issue
Block a user