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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
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 {
|
export interface RestConfig {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
@@ -57,6 +57,52 @@ export class RestClient {
|
|||||||
return (await r.json()) as InboxItem;
|
return (await r.json()) as InboxItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── support ──────────────────────────────────────────────────
|
||||||
|
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> {
|
||||||
|
return this.post<Ticket>('/v1/support/tickets', body);
|
||||||
|
}
|
||||||
|
async escalate(threadId: string, subject?: string): Promise<Ticket> {
|
||||||
|
return this.post<Ticket>('/v1/support/escalate', { threadId, subject });
|
||||||
|
}
|
||||||
|
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<CallbackRequest> {
|
||||||
|
return this.post<CallbackRequest>('/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<unknown> {
|
||||||
|
return this.post('/v1/support/queues/' + queueId + '/members', {});
|
||||||
|
}
|
||||||
|
async setAvailability(state: string): Promise<unknown> {
|
||||||
|
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<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<unknown> {
|
async ingest(body: IngestInteractionRequest, idempotencyKey: string): Promise<unknown> {
|
||||||
const r = await fetch(this.url('/v1/interactions/ingest'), {
|
const r = await fetch(this.url('/v1/interactions/ingest'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -49,6 +49,34 @@ export interface InboxItem {
|
|||||||
updatedAt: string;
|
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. */
|
/** Minimal socket surface so the facade can be unit-tested with a fake. */
|
||||||
export interface SocketLike {
|
export interface SocketLike {
|
||||||
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { OutboxModule } from '../outbox/outbox.module';
|
import { OutboxModule } from '../outbox/outbox.module';
|
||||||
import { SupportService } from './support.service';
|
import { SupportService } from './support.service';
|
||||||
import { AssignmentService } from './assignment.service';
|
import { AssignmentService } from './assignment.service';
|
||||||
|
import { SupportController } from './support.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
|
controllers: [SupportController],
|
||||||
providers: [SupportService, AssignmentService],
|
providers: [SupportService, AssignmentService],
|
||||||
exports: [SupportService, AssignmentService],
|
exports: [SupportService, AssignmentService],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user