Feat/s3 storage #2
@@ -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",
|
||||
|
||||
@@ -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<ThreadSummary[]> {
|
||||
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<string, string> }): Promise<ThreadSummary[]> {
|
||||
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<string, unknown> }): 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<Ticket> {
|
||||
async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): 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 escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
|
||||
return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
|
||||
}
|
||||
/** Manually assign a ticket to a specific user (generic assignment override). */
|
||||
async assignTicket(id: string, userId: string): Promise<Ticket> {
|
||||
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
|
||||
}
|
||||
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
|
||||
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | null;
|
||||
threadLinks?: Array<{ threadId: string; relationKind: string }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> | 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<OpenThreadResult> {
|
||||
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<OpenThreadResult> {
|
||||
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<ThreadSummary[]> {
|
||||
/**
|
||||
* 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<string, string> }): Promise<ThreadSummary[]> {
|
||||
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<string, unknown> | 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<string, string[]>();
|
||||
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<string, unknown> | null) ?? null,
|
||||
participants: members,
|
||||
participantCount: members.length,
|
||||
unread: unreadBy.get(t.id) ?? 0,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export class EscalateDto {
|
||||
@IsString() threadId!: string;
|
||||
@IsOptional() @IsString() subject?: string;
|
||||
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class AssignTicketDto {
|
||||
@IsString() userId!: string;
|
||||
}
|
||||
|
||||
export class PatchTicketDto {
|
||||
|
||||
@@ -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<string, unknown> },
|
||||
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<string, unknown>) {
|
||||
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<string, unknown> | 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<void> {
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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<string, unknown> }, @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. */
|
||||
|
||||
Reference in New Issue
Block a user