a64f8407b9
- Add /tickets/unanswered-counts API endpoint for staff/admin - Show red badge with unanswered count on sidebar nav (staff tickets, admin tickets) - Add summary cards (unanswered/answered/total) to staff tickets page - Highlight unanswered count in admin tickets stats cards - Staff can create tickets to OTHER departments only (not their own) - Frontend hides own department from ticket creation dropdown - Sidebar badges auto-refresh every 30 seconds
214 lines
7.7 KiB
TypeScript
214 lines
7.7 KiB
TypeScript
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<Ticket>,
|
|
@InjectRepository(TicketMessage)
|
|
private messagesRepo: Repository<TicketMessage>,
|
|
) {}
|
|
|
|
// ─── User: Create ticket ───
|
|
async create(userId: string, userRole: UserRole, dto: CreateTicketDto): Promise<Ticket> {
|
|
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<Ticket[]> {
|
|
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<Ticket> {
|
|
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<TicketMessage> {
|
|
// 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<Ticket> {
|
|
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<Ticket>): Promise<void> {
|
|
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<Ticket[]> {
|
|
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<Ticket[]> {
|
|
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<string, { total: number; open: number; answered: number; closed: number }>;
|
|
}> {
|
|
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<string, { total: number; open: number; answered: number; closed: number }> = {};
|
|
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 };
|
|
}
|
|
}
|