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:
kevin-asprec
2026-05-04 16:35:33 +08:00
parent d7ab370bb5
commit 29c6b70878
8 changed files with 246 additions and 1 deletions

View File

@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
@@ -28,6 +30,7 @@ import { SchedulerModule } from './scheduler/scheduler.module';
import { AccountingModule } from './accounting/accounting.module'; import { AccountingModule } from './accounting/accounting.module';
import { PayrollModule } from './payroll/payroll.module'; import { PayrollModule } from './payroll/payroll.module';
import { RoleModule } from './role/role.module'; import { RoleModule } from './role/role.module';
import { CommentModule } from './comment/comment.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter'; import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor'; import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { PermissionsGuard } from './common/guards/permissions.guard'; import { PermissionsGuard } from './common/guards/permissions.guard';
@@ -69,6 +72,11 @@ import { AccessGuard } from './common/guards/access.guard';
AccountingModule, AccountingModule,
PayrollModule, PayrollModule,
RoleModule, RoleModule,
CommentModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
], ],
providers: [ providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_FILTER, useClass: GlobalExceptionFilter },

View 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,
);
}
}

View 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 {}

View 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)];
}
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@MinLength(1)
content!: string;
}

View 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);
}
},
};

View File

@@ -40,6 +40,7 @@ export class NotificationService {
channel: string; channel: string;
title: string; title: string;
message: string; message: string;
ticketId?: string;
}) { }) {
return this.prisma.notification.create({ return this.prisma.notification.create({
data: { data: {
@@ -50,6 +51,7 @@ export class NotificationService {
channel: data.channel, channel: data.channel,
title: data.title, title: data.title,
message: data.message, message: data.message,
ticketId: data.ticketId,
sentAt: new Date(), sentAt: new Date(),
}, },
}); });

View File

@@ -4,6 +4,7 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
import { CreateTicketDto } from './dto/create-ticket.dto'; import { CreateTicketDto } from './dto/create-ticket.dto';
import { UpdateTicketDto } from './dto/update-ticket.dto'; import { UpdateTicketDto } from './dto/update-ticket.dto';
@@ -20,7 +21,10 @@ export class TicketService {
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null = onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
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 }) { async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
const db = this.prisma.forTenant(tenantId); 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({ return this.prisma.ticket.update({
where: { id }, where: { id },
data: { data: {