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:
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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user