From 2bfab95527184d1b9c079913dff714660609633b Mon Sep 17 00:00:00 2001 From: Warisu Date: Thu, 25 Jun 2026 15:02:20 +0100 Subject: [PATCH] feat(support): scaffold nestjs microservice with automatic sla routing engine (#354) --- .../support-ticket-service/src/Dockerfile | 21 +++++ .../src/entities/ticket-response.entity.ts | 23 +++++ .../src/entities/ticket.entity.ts | 51 ++++++++++++ .../src/services/support-ticket.service.ts | 83 +++++++++++++++++++ .../src/surfaces/ticket.interface.ts | 21 +++++ 5 files changed, 199 insertions(+) create mode 100644 microservices/support-ticket-service/src/Dockerfile create mode 100644 microservices/support-ticket-service/src/entities/ticket-response.entity.ts create mode 100644 microservices/support-ticket-service/src/entities/ticket.entity.ts create mode 100644 microservices/support-ticket-service/src/services/support-ticket.service.ts create mode 100644 microservices/support-ticket-service/src/surfaces/ticket.interface.ts diff --git a/microservices/support-ticket-service/src/Dockerfile b/microservices/support-ticket-service/src/Dockerfile new file mode 100644 index 0000000..70df6f3 --- /dev/null +++ b/microservices/support-ticket-service/src/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS build + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20-alpine AS production + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN npm ci --only=production + +COPY --from=build /usr/src/app/dist ./dist + +EXPOSE 3000 +CMD ["node", "dist/main"] \ No newline at end of file diff --git a/microservices/support-ticket-service/src/entities/ticket-response.entity.ts b/microservices/support-ticket-service/src/entities/ticket-response.entity.ts new file mode 100644 index 0000000..f30ecd6 --- /dev/null +++ b/microservices/support-ticket-service/src/entities/ticket-response.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne } from 'typeorm'; +import { SupportTicket } from './ticket.entity'; + +@Entity('ticket_responses') +export class TicketResponse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + senderId: string; + + @Column({ default: false }) + isAgent: boolean; + + @Column({ type: 'text' }) + message: string; + + @ManyToOne(() => SupportTicket, (ticket) => ticket.responses, { onDelete: 'CASCADE' }) + ticket: SupportTicket; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/microservices/support-ticket-service/src/entities/ticket.entity.ts b/microservices/support-ticket-service/src/entities/ticket.entity.ts new file mode 100644 index 0000000..e14a45f --- /dev/null +++ b/microservices/support-ticket-service/src/entities/ticket.entity.ts @@ -0,0 +1,51 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; +import { TicketStatus, TicketPriority, TicketCategory } from '../interfaces/ticket.interface'; +import { TicketResponse } from './ticket-response.entity'; + +@Entity('tickets') +export class SupportTicket { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + playerId: string; + + @Column({ type: 'varchar' }) + category: TicketCategory; + + @Column() + title: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'varchar', default: TicketStatus.OPEN }) + status: TicketStatus; + + @Column({ type: 'varchar', default: TicketPriority.MEDIUM }) + priority: TicketPriority; + + @Column({ nullable: true }) + assignedAgentId: string; + + @Column({ type: 'timestamp' }) + slaBreachTime: Date; + + @Column({ default: false }) + isSlaBreached: boolean; + + @Column({ type: 'int', nullable: true }) + satisfactionRating: number; // 1-5 scale rating + + @Column({ type: 'jsonb', default: [] }) + historyLogs: Array<{ status: string; updatedBy: string; timestamp: Date; note?: string }>; + + @OneToMany(() => TicketResponse, (response) => response.ticket, { cascade: true }) + responses: TicketResponse[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/microservices/support-ticket-service/src/services/support-ticket.service.ts b/microservices/support-ticket-service/src/services/support-ticket.service.ts new file mode 100644 index 0000000..94ec590 --- /dev/null +++ b/microservices/support-ticket-service/src/services/support-ticket.service.ts @@ -0,0 +1,83 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SupportTicket } from '../entities/ticket.entity'; +import { TicketResponse } from '../entities/ticket-response.entity'; +import { TicketStatus, TicketPriority, TicketCategory } from '../interfaces/ticket.interface'; + +@Injectable() +export class SupportTicketService { + constructor( + @InjectRepository(SupportTicket) private readonly ticketRepo: Repository, + @InjectRepository(TicketResponse) private readonly responseRepo: Repository, + ) {} + + private calculateSlaTime(priority: TicketPriority): Date { + const now = new Date(); + switch (priority) { + case TicketPriority.CRITICAL: return new Date(now.getTime() + 2 * 60 * 60 * 1000); // 2 Hours + case TicketPriority.HIGH: return new Date(now.getTime() + 8 * 60 * 60 * 1000); // 8 Hours + case TicketPriority.MEDIUM: return new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 Hours + case TicketPriority.LOW: return new Date(now.getTime() + 72 * 60 * 60 * 1000); // 72 Hours + } + } + + async createTicket(dto: { playerId: string; title: string; description: string; category: TicketCategory; priority?: TicketPriority }) { + const priority = dto.priority || TicketPriority.MEDIUM; + const slaBreachTime = this.calculateSlaTime(priority); + + const ticket = this.ticketRepo.create({ + ...dto, + priority, + slaBreachTime, + historyLogs: [{ status: TicketStatus.OPEN, updatedBy: dto.playerId, timestamp: new Date(), note: 'Ticket initialized.' }] + }); + + return this.ticketRepo.save(ticket); + } + + async assignToAgent(ticketId: string, agentId: string) { + const ticket = await this.ticketRepo.findOneBy({ id: ticketId }); + if (!ticket) throw new NotFoundException('Target ticket profile not resolved.'); + + ticket.assignedAgentId = agentId; + ticket.status = TicketStatus.IN_PROGRESS; + ticket.historyLogs.push({ status: TicketStatus.IN_PROGRESS, updatedBy: agentId, timestamp: new Date(), note: `Assigned to agent ${agentId}.` }); + + return this.ticketRepo.save(ticket); + } + + async addResponse(ticketId: string, dto: { senderId: string; isAgent: boolean; message: string }) { + const ticket = await this.ticketRepo.findOneBy({ id: ticketId }); + if (!ticket) throw new NotFoundException('Target ticket profile not resolved.'); + + if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException('Cannot append responses onto locked/closed communication links.'); + + const response = this.responseRepo.create({ ...dto, ticket }); + await this.responseRepo.save(response); + + ticket.status = dto.isAgent ? TicketStatus.PENDING_CUSTOMER : TicketStatus.OPEN; + return this.ticketRepo.save(ticket); + } + + async updateStatus(ticketId: string, status: TicketStatus, updatedBy: string) { + const ticket = await this.ticketRepo.findOneBy({ id: ticketId }); + if (!ticket) throw new NotFoundException('Ticket context mismatch.'); + + ticket.status = status; + ticket.historyLogs.push({ status, updatedBy, timestamp: new Date() }); + + return this.ticketRepo.save(ticket); + } + + async submitCsaRating(ticketId: string, rating: number) { + const ticket = await this.ticketRepo.findOneBy({ id: ticketId }); + if (!ticket) throw new NotFoundException('Ticket missing.'); + if (ticket.status !== TicketStatus.RESOLVED && ticket.status !== TicketStatus.CLOSED) { + throw new BadRequestException('Ratings can only be gathered against finished or processed issues.'); + } + + ticket.satisfactionRating = rating; + return this.ticketRepo.save(ticket); + } +} \ No newline at end of file diff --git a/microservices/support-ticket-service/src/surfaces/ticket.interface.ts b/microservices/support-ticket-service/src/surfaces/ticket.interface.ts new file mode 100644 index 0000000..1f01864 --- /dev/null +++ b/microservices/support-ticket-service/src/surfaces/ticket.interface.ts @@ -0,0 +1,21 @@ +export enum TicketStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + PENDING_CUSTOMER = 'PENDING_CUSTOMER', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +export enum TicketPriority { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + CRITICAL = 'CRITICAL', +} + +export enum TicketCategory { + ACCOUNT_ISSUE = 'ACCOUNT_ISSUE', + QUEST_BUG = 'QUEST_BUG', + PAYOUT_DISPUTE = 'PAYOUT_DISPUTE', + TECHNICAL_ERROR = 'TECHNICAL_ERROR', +} \ No newline at end of file