Feat/s3 storage #2

Closed
maaz519 wants to merge 30 commits from feat/s3-storage into main
10 changed files with 162 additions and 28 deletions
Showing only changes of commit b918a21083 - Show all commits
+1 -1
View File
@@ -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",
+18 -8
View File
@@ -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() });
+4
View File
@@ -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. */