diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 46a0025..62750d9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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 {} diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index a1b2744..016752a 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -21,9 +21,9 @@ export class ApplicationsService { ) {} async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise { - // 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`); } diff --git a/backend/src/clusters/clusters.controller.ts b/backend/src/clusters/clusters.controller.ts index 9fe7cf3..ed0ee11 100644 --- a/backend/src/clusters/clusters.controller.ts +++ b/backend/src/clusters/clusters.controller.ts @@ -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' }; diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index 81de7f3..e6e1c78 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -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 { diff --git a/backend/src/tickets/dto/ticket.dto.ts b/backend/src/tickets/dto/ticket.dto.ts new file mode 100644 index 0000000..d3d0581 --- /dev/null +++ b/backend/src/tickets/dto/ticket.dto.ts @@ -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; +} diff --git a/backend/src/tickets/entities/ticket-message.entity.ts b/backend/src/tickets/entities/ticket-message.entity.ts new file mode 100644 index 0000000..2f0c069 --- /dev/null +++ b/backend/src/tickets/entities/ticket-message.entity.ts @@ -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; +} diff --git a/backend/src/tickets/entities/ticket.entity.ts b/backend/src/tickets/entities/ticket.entity.ts new file mode 100644 index 0000000..3fc1589 --- /dev/null +++ b/backend/src/tickets/entities/ticket.entity.ts @@ -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; +} diff --git a/backend/src/tickets/tickets.controller.ts b/backend/src/tickets/tickets.controller.ts new file mode 100644 index 0000000..1666f9d --- /dev/null +++ b/backend/src/tickets/tickets.controller.ts @@ -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); + } +} diff --git a/backend/src/tickets/tickets.module.ts b/backend/src/tickets/tickets.module.ts new file mode 100644 index 0000000..5facfe7 --- /dev/null +++ b/backend/src/tickets/tickets.module.ts @@ -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 {} diff --git a/backend/src/tickets/tickets.service.ts b/backend/src/tickets/tickets.service.ts new file mode 100644 index 0000000..492aa11 --- /dev/null +++ b/backend/src/tickets/tickets.service.ts @@ -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, + @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 { + 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 { + 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 { + 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 }; + } +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index bb3a16b..25eafea 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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' }; diff --git a/frontend/src/app/dashboard/admin/tickets/page.tsx b/frontend/src/app/dashboard/admin/tickets/page.tsx new file mode 100644 index 0000000..ab54a5f --- /dev/null +++ b/frontend/src/app/dashboard/admin/tickets/page.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import api from '@/lib/api'; +import type { Ticket, TicketStats, TicketDepartment, TicketStatus } from '@/types'; + +const statusColors: Record = { + open: 'bg-yellow-100 text-yellow-700', + waiting: 'bg-orange-100 text-orange-700', + answered: 'bg-green-100 text-green-700', + closed: 'bg-gray-100 text-gray-500', +}; + +const priorityColors: Record = { + low: 'bg-blue-100 text-blue-700', + medium: 'bg-yellow-100 text-yellow-700', + high: 'bg-red-100 text-red-700', +}; + +export default function AdminTicketsPage() { + const [deptFilter, setDeptFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + + const { data: stats } = useQuery({ + queryKey: ['ticket-stats'], + queryFn: () => api.get('/tickets/admin/stats').then((r) => r.data), + }); + + const { data: tickets = [], isLoading } = useQuery({ + queryKey: ['admin-tickets', deptFilter, statusFilter], + queryFn: () => { + const params = new URLSearchParams(); + if (deptFilter) params.append('department', deptFilter); + if (statusFilter) params.append('status', statusFilter); + const qs = params.toString(); + return api.get(`/tickets/admin/all${qs ? `?${qs}` : ''}`).then((r) => r.data); + }, + }); + + const formatResponseTime = (minutes: number) => { + if (minutes < 60) return `${minutes} min`; + if (minutes < 1440) return `${Math.round(minutes / 60)} hours`; + return `${Math.round(minutes / 1440)} days`; + }; + + return ( +
+
+

📋 All Tickets

+

Overview of all support tickets across departments

+
+ + {/* Stats Cards */} + {stats && ( +
+
+

Total Tickets

+

{stats.totalTickets}

+
+
+

Open Tickets

+

{stats.openTickets}

+
+
+

Avg Response Time

+

+ {stats.avgResponseTimeMinutes > 0 ? formatResponseTime(stats.avgResponseTimeMinutes) : '—'} +

+
+
+

Departments

+
+ {Object.entries(stats.byDepartment).map(([dept, data]) => ( +
+ {dept === 'technical' ? '🔧' : '💼'} {dept} + + {data.open} open / {data.total} total + +
+ ))} +
+
+
+ )} + + {/* Filters */} +
+
+ Department: + {(['' as const, 'technical' as TicketDepartment, 'sales' as TicketDepartment]).map((dept) => ( + + ))} +
+
+ Status: + {(['', 'open', 'waiting', 'answered', 'closed'] as const).map((status) => ( + + ))} +
+
+ + {/* Tickets Table */} + {isLoading ? ( +
+
+
+ ) : tickets.length === 0 ? ( +
+ 📭 +

No tickets found

+
+ ) : ( +
+ {tickets.map((ticket) => ( + +
+
+
+

{ticket.subject}

+ + {ticket.status} + + + {ticket.priority} + + + {ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'} + +
+
+ {ticket.user && ( + + 👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email}) + + )} + + {new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + + {ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''} +
+
+ +
+ + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx index c265706..6cdbe97 100644 --- a/frontend/src/app/dashboard/admin/users/page.tsx +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -3,11 +3,14 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; import toast from 'react-hot-toast'; import type { AdminUser } from '@/types'; export default function AdminUsersPage() { const queryClient = useQueryClient(); + const currentUser = useAuthStore((s) => s.user); + const isAdmin = currentUser?.role === 'admin'; const [search, setSearch] = useState(''); const [showForm, setShowForm] = useState(false); const [form, setForm] = useState({ @@ -15,7 +18,7 @@ export default function AdminUsersPage() { password: '', firstName: '', lastName: '', - role: 'user' as 'user' | 'admin', + role: 'user' as 'user' | 'admin' | 'technical' | 'sales', }); const { data: users = [], isLoading } = useQuery({ @@ -116,10 +119,12 @@ export default function AdminUsersPage() {
+ ))} + + + {/* Tickets List */} + {isLoading ? ( +
+
+
+ ) : tickets.length === 0 ? ( +
+ +

No tickets

+

+ {statusFilter ? `No ${statusFilter} tickets` : 'No tickets in this department'} +

+
+ ) : ( +
+ {tickets.map((ticket) => ( + +
+
+
+

{ticket.subject}

+ + {ticket.status} + + + {ticket.priority} + +
+
+ {ticket.user && ( + + 👤 {ticket.user.firstName} {ticket.user.lastName} ({ticket.user.email}) + + )} + + {new Date(ticket.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} + + {ticket.messages?.length || 0} msg{(ticket.messages?.length || 0) !== 1 ? 's' : ''} +
+
+ +
+ + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/dashboard/tickets/[id]/page.tsx b/frontend/src/app/dashboard/tickets/[id]/page.tsx new file mode 100644 index 0000000..0cdbf8c --- /dev/null +++ b/frontend/src/app/dashboard/tickets/[id]/page.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useRouter } from 'next/navigation'; +import api from '@/lib/api'; +import { useAuthStore } from '@/lib/store'; +import toast from 'react-hot-toast'; +import type { Ticket } from '@/types'; + +const statusColors: Record = { + open: 'bg-yellow-100 text-yellow-700', + waiting: 'bg-orange-100 text-orange-700', + answered: 'bg-green-100 text-green-700', + closed: 'bg-gray-100 text-gray-500', +}; + +export default function TicketDetailPage() { + const { id } = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const user = useAuthStore((s) => s.user); + const [reply, setReply] = useState(''); + const messagesEndRef = useRef(null); + + const { data: ticket, isLoading } = useQuery({ + queryKey: ['ticket', id], + queryFn: () => api.get(`/tickets/${id}`).then((r) => r.data), + refetchInterval: 10000, + }); + + const replyMutation = useMutation({ + mutationFn: (message: string) => api.post(`/tickets/${id}/reply`, { message }).then((r) => r.data), + onSuccess: () => { + setReply(''); + queryClient.invalidateQueries({ queryKey: ['ticket', id] }); + toast.success('Reply sent'); + }, + onError: (err: any) => toast.error(err.response?.data?.message || 'Failed to send reply'), + }); + + const closeMutation = useMutation({ + mutationFn: () => api.patch(`/tickets/${id}/close`).then((r) => r.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['ticket', id] }); + toast.success('Ticket closed'); + }, + }); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [ticket?.messages]); + + const handleSubmitReply = (e: React.FormEvent) => { + e.preventDefault(); + if (reply.trim()) { + replyMutation.mutate(reply.trim()); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!ticket) { + return
Ticket not found
; + } + + return ( +
+ {/* Header */} +
+
+ +

{ticket.subject}

+
+ + {ticket.status} + + + {ticket.department === 'technical' ? '🔧 Technical' : '💼 Sales'} + + + {new Date(ticket.createdAt).toLocaleString()} + + {ticket.user && ( + + by {ticket.user.firstName} {ticket.user.lastName} + + )} +
+
+ {ticket.status !== 'closed' && ( + + )} +
+ + {/* Messages */} +
+ {ticket.messages?.map((msg) => { + const isMe = msg.senderId === user?.id; + const isStaff = msg.senderRole !== 'user'; + return ( +
+
+ {!isMe && ( +

+ {msg.sender ? `${msg.sender.firstName} ${msg.sender.lastName}` : 'Unknown'} + {isStaff && ( + + {msg.senderRole === 'admin' ? 'Admin' : msg.senderRole === 'technical' ? 'Technical' : 'Sales'} + + )} +

+ )} +

{msg.message}

+

+ {new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+ ); + })} +
+
+ + {/* Reply Box */} + {ticket.status !== 'closed' ? ( +
+
+