import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Ticket } from './entities/ticket.entity'; import { TicketMessage } from './entities/ticket-message.entity'; import { CreateTicketDto, ReplyTicketDto } from './dto/ticket.dto'; import { TicketDepartment, TicketStatus, TicketPriority, UserRole } from '../common/enums'; @Injectable() export class TicketsService { private readonly logger = new Logger(TicketsService.name); constructor( @InjectRepository(Ticket) private ticketsRepo: Repository, @InjectRepository(TicketMessage) private messagesRepo: Repository, ) {} // ─── User: Create ticket ─── async create(userId: string, userRole: UserRole, dto: CreateTicketDto): Promise { const ticket = this.ticketsRepo.create({ subject: dto.subject, department: dto.department, priority: dto.priority || TicketPriority.MEDIUM, userId, status: TicketStatus.OPEN, }); const saved = await this.ticketsRepo.save(ticket); // Create first message const msg = this.messagesRepo.create({ ticketId: saved.id, senderId: userId, senderRole: userRole, message: dto.message, }); await this.messagesRepo.save(msg); this.logger.log(`Ticket created: "${dto.subject}" → ${dto.department} by user ${userId}`); return this.findOne(saved.id, userId, userRole); } // ─── User: List my tickets ─── async findMyTickets(userId: string): Promise { return this.ticketsRepo.find({ where: { userId }, relations: ['messages', 'messages.sender'], order: { updatedAt: 'DESC' }, }); } // ─── User/Staff: Get single ticket ─── async findOne(ticketId: string, userId: string, userRole: UserRole): Promise { const ticket = await this.ticketsRepo.findOne({ where: { id: ticketId }, relations: ['messages', 'messages.sender', 'user'], }); if (!ticket) { throw new NotFoundException('Ticket not found'); } // Access control: user can only see own tickets // Staff can see tickets of their department (or admin sees all) if (userRole === UserRole.USER && ticket.userId !== userId) { throw new ForbiddenException('Access denied'); } if (userRole === UserRole.TECHNICAL && ticket.department !== TicketDepartment.TECHNICAL) { // Technical staff can only see technical tickets (unless admin) throw new ForbiddenException('This ticket belongs to another department'); } if (userRole === UserRole.SALES && ticket.department !== TicketDepartment.SALES) { throw new ForbiddenException('This ticket belongs to another department'); } // ADMIN can see all // Sort messages by date if (ticket.messages) { ticket.messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } return ticket; } // ─── Reply to ticket ─── async reply(ticketId: string, userId: string, userRole: UserRole, dto: ReplyTicketDto): Promise { // Verify access first await this.findOne(ticketId, userId, userRole); const msg = this.messagesRepo.create({ ticketId, senderId: userId, senderRole: userRole, message: dto.message, }); const saved = await this.messagesRepo.save(msg); // Update ticket status directly (avoid cascade issues with loaded relations) const newStatus = userRole === UserRole.USER ? TicketStatus.WAITING : TicketStatus.ANSWERED; await this.ticketsRepo.update(ticketId, { status: newStatus }); this.logger.log(`Reply on ticket ${ticketId} by ${userRole} user ${userId}`); return saved; } // ─── Close ticket ─── async close(ticketId: string, userId: string, userRole: UserRole): Promise { await this.findOne(ticketId, userId, userRole); await this.ticketsRepo.update(ticketId, { status: TicketStatus.CLOSED, closedAt: new Date(), }); return this.findOne(ticketId, userId, userRole); } // ─── Update ticket fields (safe, no cascade) ─── async updateTicketFields(ticketId: string, data: Partial): Promise { await this.ticketsRepo.update(ticketId, data); } // ─── Unanswered ticket counts ─── async getUnansweredCounts(): Promise<{ technical: number; sales: number; total: number }> { const unanswered = await this.ticketsRepo.find({ where: [ { status: TicketStatus.OPEN }, { status: TicketStatus.WAITING }, ], }); const technical = unanswered.filter((t) => t.department === TicketDepartment.TECHNICAL).length; const sales = unanswered.filter((t) => t.department === TicketDepartment.SALES).length; return { technical, sales, total: technical + sales }; } // ─── Staff: List department tickets ─── async findByDepartment(department: TicketDepartment, status?: TicketStatus): Promise { const where: any = { department }; if (status) { where.status = status; } return this.ticketsRepo.find({ where, relations: ['user', 'messages'], order: { updatedAt: 'DESC' }, }); } // ─── Admin: List all tickets ─── async findAll(department?: TicketDepartment, status?: TicketStatus): Promise { const where: any = {}; if (department) where.department = department; if (status) where.status = status; return this.ticketsRepo.find({ where, relations: ['user', 'messages'], order: { updatedAt: 'DESC' }, }); } // ─── Admin: Ticket statistics ─── async getStats(): Promise<{ totalTickets: number; openTickets: number; avgResponseTimeMinutes: number; byDepartment: Record; }> { const allTickets = await this.ticketsRepo.find({ relations: ['messages', 'messages.sender'], }); const totalTickets = allTickets.length; const openTickets = allTickets.filter((t) => t.status !== TicketStatus.CLOSED).length; // Calculate average first response time let totalResponseMs = 0; let respondedCount = 0; for (const ticket of allTickets) { if (!ticket.messages || ticket.messages.length < 2) continue; // Sort messages by date const sorted = [...ticket.messages].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); // Find first staff reply const firstUserMsg = sorted[0]; const firstStaffReply = sorted.find( (m) => m.senderRole !== UserRole.USER && m.senderId !== ticket.userId, ); if (firstStaffReply) { totalResponseMs += new Date(firstStaffReply.createdAt).getTime() - new Date(firstUserMsg.createdAt).getTime(); respondedCount++; } } const avgResponseTimeMinutes = respondedCount > 0 ? Math.round(totalResponseMs / respondedCount / 60000) : 0; // By department breakdown const byDepartment: Record = {}; for (const dept of Object.values(TicketDepartment)) { const deptTickets = allTickets.filter((t) => t.department === dept); byDepartment[dept] = { total: deptTickets.length, open: deptTickets.filter((t) => t.status === TicketStatus.OPEN || t.status === TicketStatus.WAITING).length, answered: deptTickets.filter((t) => t.status === TicketStatus.ANSWERED).length, closed: deptTickets.filter((t) => t.status === TicketStatus.CLOSED).length, }; } return { totalTickets, openTickets, avgResponseTimeMinutes, byDepartment }; } }