From e9e61b52bcc37278b698486bea3f6ae2ff5a274d Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 11:58:52 +0530 Subject: [PATCH] feat: P4.4 support REST (tickets/escalate/callbacks/queue admin) + kernel-client methods 8 routes under /v1/support (session-auth). RestClient gains createTicket/escalate/ listTickets/patchTicket/requestCallback/createQueue/joinQueue/setAvailability + Ticket/CallbackRequest types. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-kernel-client/src/rest.ts | 48 +++++++++++- packages/iios-kernel-client/src/types.ts | 28 +++++++ .../src/support/support.controller.ts | 76 +++++++++++++++++++ .../iios-service/src/support/support.dto.ts | 35 +++++++++ .../src/support/support.module.ts | 2 + 5 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 packages/iios-service/src/support/support.controller.ts create mode 100644 packages/iios-service/src/support/support.dto.ts diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 5f5e724..2562031 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -1,5 +1,5 @@ import type { IngestInteractionRequest } from '@insignia/iios-contracts'; -import type { Message, InboxItem, InboxState } from './types'; +import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest } from './types'; export interface RestConfig { serviceUrl: string; @@ -57,6 +57,52 @@ export class RestClient { return (await r.json()) as InboxItem; } + // ─── support ────────────────────────────────────────────────── + async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise { + return this.post('/v1/support/tickets', body); + } + async escalate(threadId: string, subject?: string): Promise { + return this.post('/v1/support/escalate', { threadId, subject }); + } + async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise { + const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() }); + if (!r.ok) throw new Error(`listTickets ${r.status}`); + return (await r.json()) as Ticket[]; + } + async patchTicket(id: string, state: TicketState): Promise { + const r = await fetch(this.url(`/v1/support/tickets/${id}`), { + method: 'PATCH', + headers: this.headers(), + body: JSON.stringify({ state }), + }); + if (!r.ok) throw new Error(`patchTicket ${r.status}`); + return (await r.json()) as Ticket; + } + async requestCallback(body: { preferChannel: string; preferredTime?: string; notes?: string; ticketId?: string }): Promise { + return this.post('/v1/support/callbacks', body); + } + async createQueue(name: string): Promise<{ id: string }> { + return this.post<{ id: string }>('/v1/support/queues', { name }); + } + async joinQueue(queueId: string): Promise { + return this.post('/v1/support/queues/' + queueId + '/members', {}); + } + async setAvailability(state: string): Promise { + const r = await fetch(this.url('/v1/support/members/me/availability'), { + method: 'PATCH', + headers: this.headers(), + body: JSON.stringify({ state }), + }); + if (!r.ok) throw new Error(`setAvailability ${r.status}`); + return r.json(); + } + + private async post(path: string, body: unknown): Promise { + const r = await fetch(this.url(path), { method: 'POST', headers: this.headers(), body: JSON.stringify(body) }); + if (!r.ok) throw new Error(`POST ${path} ${r.status}`); + return (await r.json()) as T; + } + async ingest(body: IngestInteractionRequest, idempotencyKey: string): Promise { const r = await fetch(this.url('/v1/interactions/ingest'), { method: 'POST', diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 8d70be0..db64aab 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -49,6 +49,34 @@ export interface InboxItem { updatedAt: string; } +export type TicketState = + | 'NEW' + | 'OPEN' + | 'PENDING_CUSTOMER' + | 'PENDING_INTERNAL' + | 'RESOLVED' + | 'CLOSED' + | 'CANCELLED'; + +export interface Ticket { + id: string; + subject: string; + state: TicketState; + priority: string; + queueId?: string | null; + requesterActorId?: string | null; + assignedActorId?: string | null; + createdAt: string; + updatedAt: string; + threadLinks?: Array<{ threadId: string; relationKind: string }>; +} + +export interface CallbackRequest { + id: string; + status: string; + preferChannel: string; +} + /** Minimal socket surface so the facade can be unit-tested with a fake. */ export interface SocketLike { on(event: string, handler: (...args: unknown[]) => void): unknown; diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts new file mode 100644 index 0000000..2cccfdb --- /dev/null +++ b/packages/iios-service/src/support/support.controller.ts @@ -0,0 +1,76 @@ +import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; +import { IiosTicketState } from '@prisma/client'; +import { SupportService } from './support.service'; +import { AssignmentService } from './assignment.service'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { MessagePrincipal } from '../identity/actor.resolver'; +import { + AvailabilityDto, + CallbackDto, + CreateQueueDto, + CreateTicketDto, + EscalateDto, + PatchTicketDto, +} from './support.dto'; + +@Controller('v1/support') +export class SupportController { + constructor( + private readonly support: SupportService, + private readonly assignment: AssignmentService, + private readonly session: SessionVerifier, + ) {} + + @Post('tickets') + async createTicket(@Body() body: CreateTicketDto, @Headers('authorization') auth?: string) { + return this.support.createTicket(this.principal(auth), body); + } + + @Post('escalate') + async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) { + return this.support.escalate(body.threadId, this.principal(auth), body.subject); + } + + @Get('tickets') + async listTickets(@Query('scope') scope?: string, @Headers('authorization') auth?: string) { + return this.support.listTickets(this.principal(auth), { scope: scope === 'assigned' ? 'assigned' : 'mine' }); + } + + @Patch('tickets/:id') + async patchTicket(@Param('id') id: string, @Body() body: PatchTicketDto, @Headers('authorization') auth?: string) { + return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason); + } + + @Post('callbacks') + async callback(@Body() body: CallbackDto, @Headers('authorization') auth?: string) { + return this.support.requestCallback(this.principal(auth), { + preferChannel: body.preferChannel, + preferredTime: body.preferredTime ? new Date(body.preferredTime) : undefined, + notes: body.notes, + ticketId: body.ticketId, + }); + } + + // ─── admin / agent ──────────────────────────────────────────────── + + @Post('queues') + async createQueue(@Body() body: CreateQueueDto, @Headers('authorization') auth?: string) { + return this.assignment.createQueue(this.principal(auth), body.name); + } + + @Post('queues/:id/members') + async joinQueue(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.assignment.addMember(id, this.principal(auth)); + } + + @Patch('members/me/availability') + async availability(@Body() body: AvailabilityDto, @Headers('authorization') auth?: string) { + return this.assignment.setAvailability(this.principal(auth), body.state); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/support/support.dto.ts b/packages/iios-service/src/support/support.dto.ts new file mode 100644 index 0000000..95961f5 --- /dev/null +++ b/packages/iios-service/src/support/support.dto.ts @@ -0,0 +1,35 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; + +const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const; +const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const; + +export class CreateTicketDto { + @IsString() subject!: string; + @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; + @IsOptional() @IsString() threadId?: string; +} + +export class EscalateDto { + @IsString() threadId!: string; + @IsOptional() @IsString() subject?: string; +} + +export class PatchTicketDto { + @IsIn(STATES) state!: (typeof STATES)[number]; + @IsOptional() @IsString() reason?: string; +} + +export class CallbackDto { + @IsString() preferChannel!: string; + @IsOptional() @IsString() preferredTime?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsString() ticketId?: string; +} + +export class CreateQueueDto { + @IsString() name!: string; +} + +export class AvailabilityDto { + @IsString() state!: string; +} diff --git a/packages/iios-service/src/support/support.module.ts b/packages/iios-service/src/support/support.module.ts index 7257017..3a8e2f1 100644 --- a/packages/iios-service/src/support/support.module.ts +++ b/packages/iios-service/src/support/support.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { OutboxModule } from '../outbox/outbox.module'; import { SupportService } from './support.service'; import { AssignmentService } from './assignment.service'; +import { SupportController } from './support.controller'; @Module({ imports: [OutboxModule], + controllers: [SupportController], providers: [SupportService, AssignmentService], exports: [SupportService, AssignmentService], })