feat(iios): generic opaque metadata + manual ticket assign (glue prereqs)
Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:
- thread: accept an opaque `metadata` bag on create (merged with membership),
echo it in listThreads, and filter listThreads by `?metadata[key]=value`
(equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
inherited); add SupportService.assignTo — a policy-gated manual assignment
of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
assignTicket; ThreadSummary/Ticket expose the opaque bag.
Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user