feat: ticketing system with technical/sales roles and department routing

Backend:
- Added TECHNICAL and SALES roles to UserRole enum
- Added TicketDepartment, TicketStatus, TicketPriority enums
- Created Ticket and TicketMessage entities with relationships
- Created TicketsModule with full CRUD service and controller
- Ticket routing: users create tickets to technical/sales departments
- Staff reply updates status (answered), user reply sets waiting
- Role-based access: technical staff sees technical tickets, sales sees sales tickets
- Admin sees all tickets with stats (total, open, avg response time)
- Updated access control: technical role has admin-level access (except role change)
- Sales role can view users and handle sales tickets
- Clusters controller: technical role can manage clusters/pools
- Users controller: technical/sales can view users, only admin changes roles

Frontend:
- New user ticket pages: list (with create form) + detail (chat-style messages)
- Staff ticket panel: filtered by department with status filters
- Admin all-tickets page with statistics dashboard and department/status filters
- Updated sidebar: role-based nav items (admin/technical/sales sections)
- Role badges in header for technical (blue) and sales (green)
- Admin users page: new roles in dropdowns, role change restricted to admin only
- Deploy page: technical role gets cluster selection access like admin
This commit is contained in:
keyhan
2026-04-06 12:36:41 +03:30
parent 4fd102468e
commit 3ab647be2e
20 changed files with 1301 additions and 56 deletions
+197
View File
@@ -0,0 +1,197 @@
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> {
const ticket = await this.findOne(ticketId, userId, userRole);
const msg = this.messagesRepo.create({
ticketId: ticket.id,
senderId: userId,
senderRole: userRole,
message: dto.message,
});
const saved = await this.messagesRepo.save(msg);
// Update ticket status based on who replied
if (userRole === UserRole.USER) {
ticket.status = TicketStatus.WAITING;
} else {
// Staff or admin replied
ticket.status = TicketStatus.ANSWERED;
}
await this.ticketsRepo.save(ticket);
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> {
const ticket = await this.findOne(ticketId, userId, userRole);
ticket.status = TicketStatus.CLOSED;
ticket.closedAt = new Date();
return this.ticketsRepo.save(ticket);
}
// ─── 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 };
}
}