diff --git a/packages/iios-kernel-client/package.json b/packages/iios-kernel-client/package.json index f4ef857..2dbf438 100644 --- a/packages/iios-kernel-client/package.json +++ b/packages/iios-kernel-client/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-kernel-client", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 2bf9336..a355529 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -77,15 +77,21 @@ export class RestClient { return ((await r.json()) as { users: string[] }).users; } - /** Server-authoritative conversation list (works cross-device, shows unread + members). */ - async listThreads(): Promise { - const r = await fetch(this.url('/v1/threads'), { headers: this.headers() }); + /** + * Server-authoritative conversation list (works cross-device, shows unread + members). + * `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value. + */ + async listThreads(filter?: { metadata?: Record }): Promise { + const qs = filter?.metadata + ? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&') + : ''; + const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() }); if (!r.ok) throw new Error(`listThreads ${r.status}`); return (await r.json()) as ThreadSummary[]; } - /** Create a thread with generic app attributes (membership hint, creator role, subject/name). */ - async createThread(opts: { membership?: string; creatorRole?: string; subject?: string }): Promise<{ threadId: string }> { + /** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */ + async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise<{ threadId: string }> { return this.post<{ threadId: string }>('/v1/threads', opts); } @@ -109,11 +115,15 @@ export class RestClient { } // ─── support ────────────────────────────────────────────────── - async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise { + async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record }): Promise { return this.post('/v1/support/tickets', body); } - async escalate(threadId: string, subject?: string): Promise { - return this.post('/v1/support/escalate', { threadId, subject }); + async escalate(threadId: string, subject?: string, metadata?: Record): Promise { + return this.post('/v1/support/escalate', { threadId, subject, metadata }); + } + /** Manually assign a ticket to a specific user (generic assignment override). */ + async assignTicket(id: string, userId: string): Promise { + return this.post(`/v1/support/tickets/${id}/assignee`, { userId }); } async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise { const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() }); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index 5185848..8b32d24 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -69,6 +69,8 @@ export interface ThreadSummary { threadId: string; subject: string | null; membership?: string; // 'dm' | 'group' (opaque app attribute) + /** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */ + metadata?: Record | null; participants: string[]; // member usernames participantCount: number; unread: number; @@ -126,6 +128,8 @@ export interface Ticket { assignedActorId?: string | null; createdAt: string; updatedAt: string; + /** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */ + metadata?: Record | null; threadLinks?: Array<{ threadId: string; relationKind: string }>; } diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index f48a554..82091a7 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -50,6 +50,8 @@ export interface ThreadSummary { threadId: string; subject: string | null; membership?: string; + /** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */ + metadata?: Record | null; participants: string[]; participantCount: number; unread: number; @@ -85,19 +87,21 @@ export class MessageService { * for a group. Opening an existing thread is a GOVERNED join: only an existing member may * re-open it (policy `iios.thread.join`) — new members enter via addParticipant. */ - async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise { + async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise { if (!threadId) { await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); const scope = await this.actors.resolveScope(principal); const actor = await this.actors.resolveActor(scope.id, principal); - // `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the - // kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app). + // `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes — + // the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives + // in policy + the app). `membership` is folded into the same bag for back-compat. + const merged = { ...(opts?.metadata ?? {}), ...(opts?.membership ? { membership: opts.membership } : {}) }; const thread = await this.prisma.iiosThread.create({ data: { scopeId: scope.id, createdByActorId: actor.id, subject: opts?.subject?.trim() || undefined, - metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined, + metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined, }, }); await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER'); @@ -167,8 +171,12 @@ export class MessageService { return { threadId, muted }; } - /** Generic "my threads": every thread the caller participates in, with last message + unread. */ - async listThreads(principal: MessagePrincipal): Promise { + /** + * Generic "my threads": every thread the caller participates in, with last message + unread. + * An optional `filter.metadata` narrows to threads whose opaque attribute bag matches ALL of the + * given key/values (an equality match on the JSON bag — the kernel does not interpret the keys). + */ + async listThreads(principal: MessagePrincipal, filter?: { metadata?: Record }): Promise { const scope = await this.actors.findScope(principal); if (!scope) return []; const actor = await this.actors.resolveActor(scope.id, principal); @@ -177,7 +185,7 @@ export class MessageService { if (threadIds.length === 0) return []; const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted])); - const [threads, unreads, allParts] = await Promise.all([ + const [allThreads, unreads, allParts] = await Promise.all([ this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }), this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }), this.prisma.iiosThreadParticipant.findMany({ @@ -185,6 +193,14 @@ export class MessageService { include: { actor: { include: { sourceHandle: true } } }, }), ]); + // Opaque equality filter on the metadata bag (every requested key must match). + const metaFilter = filter?.metadata; + const threads = metaFilter + ? allThreads.filter((t) => { + const bag = (t.metadata as Record | null) ?? {}; + return Object.entries(metaFilter).every(([k, v]) => bag[k] === v); + }) + : allThreads; const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount])); const membersBy = new Map(); for (const p of allParts) { @@ -204,6 +220,7 @@ export class MessageService { threadId: t.id, subject: t.subject, membership: (t.metadata as { membership?: string } | null)?.membership, + metadata: (t.metadata as Record | null) ?? null, participants: members, participantCount: members.length, unread: unreadBy.get(t.id) ?? 0, diff --git a/packages/iios-service/src/messaging/message.spec.ts b/packages/iios-service/src/messaging/message.spec.ts index c6babe8..660a4ae 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -143,6 +143,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => { expect((await s.listThreads(bob))[0]?.unread).toBe(1); }); + it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => { + const s = svc(); + // Two threads with different opaque app attributes — the kernel never interprets these values. + await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } }); + await s.openThread(null, alice, { metadata: { source: 'other' } }); + + const all = await s.listThreads(alice); + expect(all).toHaveLength(2); + const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support'); + expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' }); + + // Equality filter on the opaque bag returns only the matching thread. + const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } }); + expect(filtered).toHaveLength(1); + expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' }); + }); + it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => { const s = gov(); const { threadId } = await s.openThread(null, alice, { membership: 'dm' }); diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts index aeb51ba..14a6164 100644 --- a/packages/iios-service/src/support/support.controller.ts +++ b/packages/iios-service/src/support/support.controller.ts @@ -5,6 +5,7 @@ import { AssignmentService } from './assignment.service'; import { SessionVerifier } from '../platform/session.verifier'; import type { MessagePrincipal } from '../identity/actor.resolver'; import { + AssignTicketDto, AvailabilityDto, CallbackDto, CreateQueueDto, @@ -32,7 +33,7 @@ export class SupportController { @Post('escalate') async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) { - return this.support.escalate(body.threadId, this.principal(auth), body.subject); + return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata); } @Get('tickets') @@ -45,6 +46,12 @@ export class SupportController { return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason); } + /** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */ + @Post('tickets/:id/assignee') + async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) { + return this.support.assignTo(id, this.principal(auth), body.userId); + } + @Post('callbacks') async callback( @Body() body: CallbackDto, diff --git a/packages/iios-service/src/support/support.dto.ts b/packages/iios-service/src/support/support.dto.ts index 95961f5..04cefee 100644 --- a/packages/iios-service/src/support/support.dto.ts +++ b/packages/iios-service/src/support/support.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsObject, 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; @@ -7,11 +7,18 @@ export class CreateTicketDto { @IsString() subject!: string; @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; @IsOptional() @IsString() threadId?: string; + /** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */ + @IsOptional() @IsObject() metadata?: Record; } export class EscalateDto { @IsString() threadId!: string; @IsOptional() @IsString() subject?: string; + @IsOptional() @IsObject() metadata?: Record; +} + +export class AssignTicketDto { + @IsString() userId!: string; } export class PatchTicketDto { diff --git a/packages/iios-service/src/support/support.service.ts b/packages/iios-service/src/support/support.service.ts index 93b25de..20d1715 100644 --- a/packages/iios-service/src/support/support.service.ts +++ b/packages/iios-service/src/support/support.service.ts @@ -35,7 +35,7 @@ export class SupportService { async createTicket( principal: MessagePrincipal, - input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string }, + input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record }, idempotencyKey?: string, ) { await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal }); @@ -63,6 +63,8 @@ export class SupportService { subject: input.subject, priority: input.priority ?? 'P3', traceId, + // Opaque, app-supplied attribute bag (e.g. a caller's external ref) — stored, never interpreted. + metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined, }, }); await tx.iiosTicketStateHistory.create({ @@ -99,11 +101,17 @@ export class SupportService { return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body); } - /** Escalate a chat thread to support: create a ticket linked to that thread. */ - async escalate(threadId: string, principal: MessagePrincipal, subject?: string) { + /** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */ + async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record) { const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); if (!thread) throw new NotFoundException('thread not found'); - return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId }); + // Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden. + const inherited = { ...((thread.metadata as Record | null) ?? {}), ...(metadata ?? {}) }; + return this.createTicket(principal, { + subject: subject ?? thread.subject ?? 'Support request', + threadId, + metadata: Object.keys(inherited).length > 0 ? inherited : undefined, + }); } async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise { @@ -120,6 +128,53 @@ export class SupportService { .catch(() => undefined); } + /** + * Manually assign a ticket to a specific actor (by userId) — a generic override of the + * event-driven auto-assignment. The target is resolved-or-created as an actor in the ticket's + * scope (so you can assign someone who hasn't logged in yet) and joined to the linked thread(s) + * so they can reply over the socket. Fail-closed via policy `iios.support.ticket.assign`. + */ + async assignTo(ticketId: string, principal: MessagePrincipal, targetUserId: string) { + const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId }, include: { threadLinks: true } }); + if (!ticket) throw new NotFoundException('ticket not found'); + await decideOrThrow(this.ports, { action: 'iios.support.ticket.assign', ticketId, scopeId: ticket.scopeId, targetUserId }); + + const target = await this.actors.resolveActor(ticket.scopeId, { + userId: targetUserId, + appId: principal.appId, + orgId: principal.orgId, + tenantId: principal.tenantId, + displayName: targetUserId, + }); + const toState: IiosTicketState = ticket.state === 'NEW' ? 'OPEN' : ticket.state; + const event: CloudEvent = { + specversion: '1.0', + id: `evt_assign_${ticketId}_${target.id}`, + type: IIOS_EVENTS.ticketStateChanged, + source: `iios/support/${ticket.scopeId}`, + subject: `ticket/${ticketId}`, + time: new Date().toISOString(), + datacontenttype: 'application/json', + insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `assign:${ticketId}:${target.id}`, dataClass: 'internal' }, + data: { ticketId, fromState: ticket.state, toState, assignedActorId: target.id }, + }; + await this.prisma.$transaction([ + this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { assignedActorId: target.id, state: toState } }), + this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: target.id, reasonCode: 'assigned' } }), + this.prisma.iiosOutboxEvent.create({ + data: { + aggregateType: 'ticket', + aggregateId: ticketId, + eventType: IIOS_EVENTS.ticketStateChanged, + cloudEvent: event as unknown as Prisma.InputJsonValue, + partitionKey: `${ticket.scopeId}:${ticketId}`, + }, + }), + ]); + for (const link of ticket.threadLinks) await this.actors.ensureParticipant(link.threadId, target.id); + return this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); + } + async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) { const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); if (!ticket) throw new NotFoundException('ticket not found'); diff --git a/packages/iios-service/src/support/support.spec.ts b/packages/iios-service/src/support/support.spec.ts index 288effb..23df921 100644 --- a/packages/iios-service/src/support/support.spec.ts +++ b/packages/iios-service/src/support/support.spec.ts @@ -57,6 +57,19 @@ describe('SupportService (P4)', () => { expect(links[0]?.threadId).toBe(threadId); }); + it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => { + const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } }); + expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' }); + expect(t.state).toBe('NEW'); + + const assigned = await support().assignTo(t.id, cust, 'agent_1'); + expect(assigned?.state).toBe('OPEN'); + const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } }); + const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } }); + expect(assigned?.assignedActorId).toBe(actor.id); + expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1); + }); + it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => { const t = await support().createTicket(cust, { subject: 's' }); await support().transition(t.id, cust, 'OPEN'); diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 58fb66f..497d341 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -23,10 +23,14 @@ export class ThreadsController { private readonly session: SessionVerifier, ) {} - /** Generic "my threads" — every thread the caller participates in (last message + unread). */ + /** + * Generic "my threads" — every thread the caller participates in (last message + unread). + * `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key). + */ @Get() - async listThreads(@Headers('authorization') auth?: string) { - return this.messages.listThreads(this.principal(auth)); + async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record) { + const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined; + return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined); } /** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */ @@ -35,11 +39,11 @@ export class ThreadsController { return this.messages.listMyAnnotated(this.principal(auth), type); } - /** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ + /** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */ @Post() @HttpCode(201) - async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { - return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject }); + async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }, @Headers('authorization') auth?: string) { + return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata }); } /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */