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:
@@ -9,6 +9,7 @@ import { DeploymentsModule } from './deployments/deployments.module';
|
||||
import { ClustersModule } from './clusters/clusters.module';
|
||||
import { KubernetesModule } from './kubernetes/kubernetes.module';
|
||||
import { BuildModule } from './build/build.module';
|
||||
import { TicketsModule } from './tickets/tickets.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
@@ -56,6 +57,7 @@ import configuration from './config/configuration';
|
||||
ClustersModule,
|
||||
KubernetesModule,
|
||||
BuildModule,
|
||||
TicketsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -21,9 +21,9 @@ export class ApplicationsService {
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> {
|
||||
// Only admin users can manually select cluster or pool
|
||||
// Only admin/technical users can manually select cluster or pool
|
||||
// Regular users always get the default cluster assignment
|
||||
if (userRole !== UserRole.ADMIN) {
|
||||
if (userRole !== UserRole.ADMIN && userRole !== UserRole.TECHNICAL) {
|
||||
if (dto.clusterId || dto.poolId) {
|
||||
this.logger.warn(`Non-admin user ${userId} attempted manual cluster/pool selection — ignoring`);
|
||||
}
|
||||
|
||||
@@ -41,50 +41,50 @@ export class ClustersController {
|
||||
// ─── Cluster admin endpoints ──────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Register a new Kubernetes cluster (Admin/Technical)' })
|
||||
async create(@Body() dto: CreateClusterDto) {
|
||||
return this.clustersService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all clusters (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'List all clusters (Admin/Technical)' })
|
||||
async findAll() {
|
||||
return this.clustersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id/resources')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Get cluster resource usage — nodes, CPU, memory, pods (Admin/Technical)' })
|
||||
async getClusterResources(@Param('id') id: string) {
|
||||
return this.clustersService.getClusterResources(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get cluster details (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Get cluster details (Admin/Technical)' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.clustersService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update cluster configuration (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Update cluster configuration (Admin/Technical)' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateClusterDto) {
|
||||
return this.clustersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Test connectivity to a registered cluster (Admin/Technical)' })
|
||||
async testConnection(@Param('id') id: string) {
|
||||
return this.clustersService.testClusterById(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Remove a cluster (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Remove a cluster (Admin/Technical)' })
|
||||
async delete(@Param('id') id: string) {
|
||||
await this.clustersService.delete(id);
|
||||
return { message: 'Cluster deleted' };
|
||||
@@ -93,36 +93,36 @@ export class ClustersController {
|
||||
// ─── Cluster Pool admin endpoints ─────────────────────────────────
|
||||
|
||||
@Post('pools')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Create a cluster pool for load balancing (Admin/Technical)' })
|
||||
async createPool(@Body() dto: CreateClusterPoolDto) {
|
||||
return this.clustersService.createPool(dto);
|
||||
}
|
||||
|
||||
@Get('pools')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all cluster pools (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'List all cluster pools (Admin/Technical)' })
|
||||
async findAllPools() {
|
||||
return this.clustersService.findAllPools();
|
||||
}
|
||||
|
||||
@Get('pools/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get cluster pool details (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Get cluster pool details (Admin/Technical)' })
|
||||
async findOnePool(@Param('id') id: string) {
|
||||
return this.clustersService.findOnePool(id);
|
||||
}
|
||||
|
||||
@Patch('pools/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Update cluster pool (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Update cluster pool (Admin/Technical)' })
|
||||
async updatePool(@Param('id') id: string, @Body() dto: UpdateClusterPoolDto) {
|
||||
return this.clustersService.updatePool(id, dto);
|
||||
}
|
||||
|
||||
@Delete('pools/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Delete a cluster pool (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Delete a cluster pool (Admin/Technical)' })
|
||||
async deletePool(@Param('id') id: string) {
|
||||
await this.clustersService.deletePool(id);
|
||||
return { message: 'Cluster pool deleted' };
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
export enum UserRole {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
TECHNICAL = 'technical',
|
||||
SALES = 'sales',
|
||||
}
|
||||
|
||||
export enum TicketDepartment {
|
||||
TECHNICAL = 'technical',
|
||||
SALES = 'sales',
|
||||
}
|
||||
|
||||
export enum TicketStatus {
|
||||
OPEN = 'open',
|
||||
ANSWERED = 'answered',
|
||||
WAITING = 'waiting', // waiting for staff reply
|
||||
CLOSED = 'closed',
|
||||
}
|
||||
|
||||
export enum TicketPriority {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
}
|
||||
|
||||
export enum AppRuntime {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { IsString, IsEnum, IsOptional, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { TicketDepartment, TicketPriority, TicketStatus } from '../../common/enums';
|
||||
|
||||
export class CreateTicketDto {
|
||||
@ApiProperty({ example: 'Cannot deploy my app' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
subject: string;
|
||||
|
||||
@ApiProperty({ enum: TicketDepartment, example: 'technical' })
|
||||
@IsEnum(TicketDepartment)
|
||||
department: TicketDepartment;
|
||||
|
||||
@ApiPropertyOptional({ enum: TicketPriority, example: 'medium' })
|
||||
@IsOptional()
|
||||
@IsEnum(TicketPriority)
|
||||
priority?: TicketPriority;
|
||||
|
||||
@ApiProperty({ example: 'I am getting an error when trying to deploy...' })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ReplyTicketDto {
|
||||
@ApiProperty({ example: 'We are looking into your issue...' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class UpdateTicketStatusDto {
|
||||
@ApiProperty({ enum: TicketStatus })
|
||||
@IsEnum(TicketStatus)
|
||||
status: TicketStatus;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Ticket } from './ticket.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { UserRole } from '../../common/enums';
|
||||
|
||||
@Entity('ticket_messages')
|
||||
export class TicketMessage {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
message: string;
|
||||
|
||||
@Column()
|
||||
ticketId: string;
|
||||
|
||||
@ManyToOne(() => Ticket, (ticket) => ticket.messages, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'ticketId' })
|
||||
ticket: Ticket;
|
||||
|
||||
@Column()
|
||||
senderId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'senderId' })
|
||||
sender: User;
|
||||
|
||||
@Column({ type: 'enum', enum: UserRole })
|
||||
senderRole: UserRole;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { TicketDepartment, TicketStatus, TicketPriority } from '../../common/enums';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { TicketMessage } from './ticket-message.entity';
|
||||
|
||||
@Entity('tickets')
|
||||
export class Ticket {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
subject: string;
|
||||
|
||||
@Column({ type: 'enum', enum: TicketDepartment })
|
||||
department: TicketDepartment;
|
||||
|
||||
@Column({ type: 'enum', enum: TicketStatus, default: TicketStatus.OPEN })
|
||||
status: TicketStatus;
|
||||
|
||||
@Column({ type: 'enum', enum: TicketPriority, default: TicketPriority.MEDIUM })
|
||||
priority: TicketPriority;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@OneToMany(() => TicketMessage, (msg) => msg.ticket, { cascade: true })
|
||||
messages: TicketMessage[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
closedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole, TicketDepartment, TicketStatus } from '../common/enums';
|
||||
import { CreateTicketDto, ReplyTicketDto, UpdateTicketStatusDto } from './dto/ticket.dto';
|
||||
|
||||
@ApiTags('Tickets')
|
||||
@ApiBearerAuth()
|
||||
@Controller('tickets')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
export class TicketsController {
|
||||
constructor(private readonly ticketsService: TicketsService) {}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// STATIC ROUTES FIRST (before :id param routes)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new ticket (any user)' })
|
||||
async create(@Request() req: any, @Body() dto: CreateTicketDto) {
|
||||
return this.ticketsService.create(req.user.id, req.user.role, dto);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({ summary: 'List my tickets' })
|
||||
async findMyTickets(@Request() req: any) {
|
||||
return this.ticketsService.findMyTickets(req.user.id);
|
||||
}
|
||||
|
||||
// ─── Staff endpoints ───
|
||||
@Get('staff/technical')
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'List technical department tickets (Technical/Admin)' })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TicketStatus })
|
||||
async findTechnicalTickets(@Query('status') status?: TicketStatus) {
|
||||
return this.ticketsService.findByDepartment(TicketDepartment.TECHNICAL, status);
|
||||
}
|
||||
|
||||
@Get('staff/sales')
|
||||
@Roles(UserRole.ADMIN, UserRole.SALES)
|
||||
@ApiOperation({ summary: 'List sales department tickets (Sales/Admin)' })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TicketStatus })
|
||||
async findSalesTickets(@Query('status') status?: TicketStatus) {
|
||||
return this.ticketsService.findByDepartment(TicketDepartment.SALES, status);
|
||||
}
|
||||
|
||||
// ─── Admin endpoints ───
|
||||
@Get('admin/all')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all tickets (Admin only)' })
|
||||
@ApiQuery({ name: 'department', required: false, enum: TicketDepartment })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TicketStatus })
|
||||
async findAllTickets(
|
||||
@Query('department') department?: TicketDepartment,
|
||||
@Query('status') status?: TicketStatus,
|
||||
) {
|
||||
return this.ticketsService.findAll(department, status);
|
||||
}
|
||||
|
||||
@Get('admin/stats')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Get ticket statistics (Admin only)' })
|
||||
async getStats() {
|
||||
return this.ticketsService.getStats();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// PARAMETERIZED ROUTES (after static routes)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get ticket detail with messages' })
|
||||
async findOne(@Param('id') id: string, @Request() req: any) {
|
||||
return this.ticketsService.findOne(id, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Post(':id/reply')
|
||||
@ApiOperation({ summary: 'Reply to a ticket' })
|
||||
async reply(@Param('id') id: string, @Request() req: any, @Body() dto: ReplyTicketDto) {
|
||||
return this.ticketsService.reply(id, req.user.id, req.user.role, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/close')
|
||||
@ApiOperation({ summary: 'Close a ticket' })
|
||||
async close(@Param('id') id: string, @Request() req: any) {
|
||||
return this.ticketsService.close(id, req.user.id, req.user.role);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
|
||||
@ApiOperation({ summary: 'Update ticket status (Staff/Admin)' })
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@Body() dto: UpdateTicketStatusDto,
|
||||
) {
|
||||
const ticket = await this.ticketsService.findOne(id, req.user.id, req.user.role);
|
||||
ticket.status = dto.status;
|
||||
if (dto.status === TicketStatus.CLOSED) {
|
||||
ticket.closedAt = new Date();
|
||||
}
|
||||
return this.ticketsService['ticketsRepo'].save(ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { TicketsController } from './tickets.controller';
|
||||
import { Ticket } from './entities/ticket.entity';
|
||||
import { TicketMessage } from './entities/ticket-message.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Ticket, TicketMessage])],
|
||||
controllers: [TicketsController],
|
||||
providers: [TicketsService],
|
||||
exports: [TicketsService],
|
||||
})
|
||||
export class TicketsModule {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -63,16 +63,16 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'List all users with optional search (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL, UserRole.SALES)
|
||||
@ApiOperation({ summary: 'List all users with optional search (Staff/Admin)' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name or email' })
|
||||
async findAll(@Query('search') search?: string) {
|
||||
return this.usersService.findAll(search);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Create a new user (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Create a new user (Admin/Technical only)' })
|
||||
async adminCreate(@Body() dto: AdminCreateUserDto) {
|
||||
return this.usersService.adminCreate(dto);
|
||||
}
|
||||
@@ -86,16 +86,16 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Patch(':id/deactivate')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Deactivate a user (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Deactivate a user (Admin/Technical)' })
|
||||
async deactivate(@Param('id') id: string) {
|
||||
await this.usersService.deactivate(id);
|
||||
return { message: 'User deactivated' };
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Activate a user (Admin only)' })
|
||||
@Roles(UserRole.ADMIN, UserRole.TECHNICAL)
|
||||
@ApiOperation({ summary: 'Activate a user (Admin/Technical)' })
|
||||
async activate(@Param('id') id: string) {
|
||||
await this.usersService.activate(id);
|
||||
return { message: 'User activated' };
|
||||
|
||||
Reference in New Issue
Block a user