feat(service): P3.1 inbox tables + extract ActorResolver

IiosInboxItem + IiosInboxItemStateHistory (migration inbox-init). Extracted
resolveScope/resolveActor/ensureParticipant into a shared ActorResolver
(IdentityModule) so message + inbox layers share identity resolution; MessageService
delegates (P2 tests unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 02:41:50 +05:30
parent 4d05f7c1b1
commit 7b174e24fa
7 changed files with 214 additions and 54 deletions
@@ -0,0 +1,62 @@
-- CreateEnum
CREATE TYPE "IiosInboxItemKind" AS ENUM ('NEEDS_REPLY', 'NEEDS_REVIEW', 'NEEDS_APPROVAL', 'SUPPORT_UPDATE', 'MEETING_FOLLOWUP', 'DIGEST', 'SYSTEM_ALERT', 'CRM_OWNER_INTEREST');
-- CreateEnum
CREATE TYPE "IiosInboxState" AS ENUM ('OPEN', 'SNOOZED', 'DONE', 'ARCHIVED', 'CANCELLED', 'STALE');
-- CreateTable
CREATE TABLE "IiosInboxItem" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"ownerActorId" TEXT NOT NULL,
"kind" "IiosInboxItemKind" NOT NULL,
"state" "IiosInboxState" NOT NULL DEFAULT 'OPEN',
"title" TEXT NOT NULL,
"summary" TEXT,
"priority" TEXT NOT NULL DEFAULT 'NORMAL',
"dueAt" TIMESTAMP(3),
"sourceInteractionId" TEXT,
"threadId" TEXT,
"traceId" TEXT,
"actionRef" JSONB,
"policyDecisionRef" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"metadata" JSONB,
CONSTRAINT "IiosInboxItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "IiosInboxItemStateHistory" (
"id" TEXT NOT NULL,
"inboxItemId" TEXT NOT NULL,
"fromState" "IiosInboxState",
"toState" "IiosInboxState" NOT NULL,
"actorId" TEXT,
"reasonCode" TEXT,
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosInboxItemStateHistory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosInboxItem_ownerActorId_state_priority_idx" ON "IiosInboxItem"("ownerActorId", "state", "priority");
-- CreateIndex
CREATE INDEX "IiosInboxItemStateHistory_inboxItemId_idx" ON "IiosInboxItemStateHistory"("inboxItemId");
-- AddForeignKey
ALTER TABLE "IiosInboxItem" ADD CONSTRAINT "IiosInboxItem_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInboxItem" ADD CONSTRAINT "IiosInboxItem_ownerActorId_fkey" FOREIGN KEY ("ownerActorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInboxItem" ADD CONSTRAINT "IiosInboxItem_sourceInteractionId_fkey" FOREIGN KEY ("sourceInteractionId") REFERENCES "IiosInteraction"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInboxItem" ADD CONSTRAINT "IiosInboxItem_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "IiosThread"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "IiosInboxItemStateHistory" ADD CONSTRAINT "IiosInboxItemStateHistory_inboxItemId_fkey" FOREIGN KEY ("inboxItemId") REFERENCES "IiosInboxItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+72 -2
View File
@@ -75,6 +75,26 @@ enum IiosReceiptKind {
RATE_LIMITED RATE_LIMITED
} }
enum IiosInboxItemKind {
NEEDS_REPLY
NEEDS_REVIEW
NEEDS_APPROVAL
SUPPORT_UPDATE
MEETING_FOLLOWUP
DIGEST
SYSTEM_ALERT
CRM_OWNER_INTEREST
}
enum IiosInboxState {
OPEN
SNOOZED
DONE
ARCHIVED
CANCELLED
STALE
}
// ─── Kernel tables ──────────────────────────────────────────────── // ─── Kernel tables ────────────────────────────────────────────────
/// Frozen six-vector scope. orgId + appId are REQUIRED (no global-by-null). /// Frozen six-vector scope. orgId + appId are REQUIRED (no global-by-null).
@@ -93,6 +113,7 @@ model IiosScope {
channels IiosChannel[] channels IiosChannel[]
threads IiosThread[] threads IiosThread[]
interactions IiosInteraction[] interactions IiosInteraction[]
inboxItems IiosInboxItem[]
@@index([orgId, appId, tenantId]) @@index([orgId, appId, tenantId])
} }
@@ -135,6 +156,7 @@ model IiosActorRef {
interactions IiosInteraction[] interactions IiosInteraction[]
receipts IiosMessageReceipt[] receipts IiosMessageReceipt[]
unreadCounters IiosUnreadCounter[] unreadCounters IiosUnreadCounter[]
inboxItemsOwned IiosInboxItem[]
} }
/// A configured channel surface (PORTAL in P1). /// A configured channel surface (PORTAL in P1).
@@ -177,6 +199,7 @@ model IiosThread {
participants IiosThreadParticipant[] participants IiosThreadParticipant[]
interactions IiosInteraction[] interactions IiosInteraction[]
unreadCounters IiosUnreadCounter[] unreadCounters IiosUnreadCounter[]
inboxItems IiosInboxItem[]
@@index([scopeId, status]) @@index([scopeId, status])
} }
@@ -219,8 +242,9 @@ model IiosInteraction {
traceId String? traceId String?
metadata Json? metadata Json?
parts IiosMessagePart[] parts IiosMessagePart[]
receipts IiosMessageReceipt[] receipts IiosMessageReceipt[]
inboxItems IiosInboxItem[]
@@unique([scopeId, idempotencyKey]) @@unique([scopeId, idempotencyKey])
@@index([threadId, occurredAt]) @@index([threadId, occurredAt])
@@ -299,3 +323,49 @@ model IiosUnreadCounter {
@@id([threadId, actorId]) @@id([threadId, actorId])
} }
// ─── P3: Inbox (work/awareness surface) ───────────────────────────
/// A durable action/awareness item owned by one actor. Fed by the event
/// projector (deterministic, collapse-per-thread). NOT a thin unread projection.
model IiosInboxItem {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
ownerActorId String
ownerActor IiosActorRef @relation(fields: [ownerActorId], references: [id])
kind IiosInboxItemKind
state IiosInboxState @default(OPEN)
title String
summary String?
priority String @default("NORMAL")
dueAt DateTime?
sourceInteractionId String?
sourceInteraction IiosInteraction? @relation(fields: [sourceInteractionId], references: [id])
threadId String?
thread IiosThread? @relation(fields: [threadId], references: [id])
traceId String?
actionRef Json?
policyDecisionRef String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metadata Json?
stateHistory IiosInboxItemStateHistory[]
@@index([ownerActorId, state, priority])
}
/// Audit trail of inbox-item state transitions.
model IiosInboxItemStateHistory {
id String @id @default(cuid())
inboxItemId String
inboxItem IiosInboxItem @relation(fields: [inboxItemId], references: [id], onDelete: Cascade)
fromState IiosInboxState?
toState IiosInboxState
actorId String?
reasonCode String?
at DateTime @default(now())
@@index([inboxItemId])
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module'; import { InteractionsModule } from './interactions/interactions.module';
import { OutboxModule } from './outbox/outbox.module'; import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module'; import { ThreadsModule } from './threads/threads.module';
@@ -9,7 +10,7 @@ import { HealthController } from './health.controller';
import { DevController } from './dev/dev.controller'; import { DevController } from './dev/dev.controller';
@Module({ @Module({
imports: [PrismaModule, PlatformModule, InteractionsModule, OutboxModule, ThreadsModule, MessageModule], imports: [PrismaModule, PlatformModule, IdentityModule, InteractionsModule, OutboxModule, ThreadsModule, MessageModule],
controllers: [HealthController, DevController], controllers: [HealthController, DevController],
}) })
export class AppModule {} export class AppModule {}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
/** The authenticated principal derived from the session port (token). */
export interface MessagePrincipal {
userId: string;
orgId: string;
appId: string;
tenantId?: string;
displayName?: string;
}
/**
* Resolves a principal to kernel rows (scope → source handle → actor) and
* manages thread participation. Shared by the message + inbox layers so neither
* re-implements identity resolution. Unknown handles stay UNVERIFIED (the MDM
* port resolution is applied at ingest; native users get a PORTAL_USER handle).
*/
@Injectable()
export class ActorResolver {
constructor(private readonly prisma: PrismaService) {}
async resolveScope(principal: MessagePrincipal) {
return (
(await this.prisma.iiosScope.findFirst({
where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null },
})) ??
(await this.prisma.iiosScope.create({
data: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId },
}))
);
}
async resolveActor(scopeId: string, principal: MessagePrincipal) {
const handle = await this.prisma.iiosSourceHandle.upsert({
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } },
create: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId, displayName: principal.displayName },
update: { lastSeenAt: new Date(), displayName: principal.displayName },
});
return (
(await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } })) ??
(await this.prisma.iiosActorRef.create({
data: { kind: 'HUMAN', sourceHandleId: handle.id, displayName: principal.displayName },
}))
);
}
async ensureParticipant(threadId: string, actorId: string): Promise<void> {
await this.prisma.iiosThreadParticipant.upsert({
where: { threadId_actorId: { threadId, actorId } },
create: { threadId, actorId },
update: {},
});
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { ActorResolver } from './actor.resolver';
@Global()
@Module({
providers: [ActorResolver],
exports: [ActorResolver],
})
export class IdentityModule {}
@@ -5,15 +5,9 @@ import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contr
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PLATFORM_PORTS } from '../platform/platform-ports'; import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed'; import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
/** The authenticated sender, derived from the session port (token). */ export type { MessagePrincipal };
export interface MessagePrincipal {
userId: string;
orgId: string;
appId: string;
tenantId?: string;
displayName?: string;
}
export interface MessageDto { export interface MessageDto {
id: string; id: string;
@@ -42,26 +36,27 @@ export class MessageService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver,
) {} ) {}
/** Open an existing thread, or create one when no id is given. Joins as participant. */ /** Open an existing thread, or create one when no id is given. Joins as participant. */
async openThread(threadId: string | null, principal: MessagePrincipal): Promise<OpenThreadResult> { async openThread(threadId: string | null, principal: MessagePrincipal): Promise<OpenThreadResult> {
if (!threadId) { if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.resolveScope(principal); const scope = await this.actors.resolveScope(principal);
const actor = await this.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
const thread = await this.prisma.iiosThread.create({ const thread = await this.prisma.iiosThread.create({
data: { scopeId: scope.id, createdByActorId: actor.id }, data: { scopeId: scope.id, createdByActorId: actor.id },
}); });
await this.ensureParticipant(thread.id, actor.id); await this.actors.ensureParticipant(thread.id, actor.id);
return { threadId: thread.id, status: thread.status, history: [] }; return { threadId: thread.id, status: thread.status, history: [] };
} }
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId }); await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const actor = await this.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.ensureParticipant(threadId, actor.id); await this.actors.ensureParticipant(threadId, actor.id);
return { threadId, status: thread.status, history: await this.history(threadId) }; return { threadId, status: thread.status, history: await this.history(threadId) };
} }
@@ -77,8 +72,8 @@ export class MessageService {
await decideOrThrow(this.ports, { action: 'iios.message.send', threadId, scopeId: thread.scopeId }); await decideOrThrow(this.ports, { action: 'iios.message.send', threadId, scopeId: thread.scopeId });
const actor = await this.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.ensureParticipant(threadId, actor.id); await this.actors.ensureParticipant(threadId, actor.id);
// Idempotency: a repeat key returns the existing message (no re-increment). // Idempotency: a repeat key returns the existing message (no re-increment).
const existing = await this.prisma.iiosInteraction.findUnique({ const existing = await this.prisma.iiosInteraction.findUnique({
@@ -168,7 +163,7 @@ export class MessageService {
): Promise<{ interactionId: string; actorId: string }> { ): Promise<{ interactionId: string; actorId: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
const actor = await this.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.prisma.iiosMessageReceipt.upsert({ await this.prisma.iiosMessageReceipt.upsert({
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'READ' } }, where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'READ' } },
@@ -190,7 +185,7 @@ export class MessageService {
): Promise<{ interactionId: string; actorId: string }> { ): Promise<{ interactionId: string; actorId: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
const actor = await this.resolveActor(thread.scopeId, principal); const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.prisma.iiosMessageReceipt.upsert({ await this.prisma.iiosMessageReceipt.upsert({
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' } }, where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' } },
create: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' }, create: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' },
@@ -219,39 +214,6 @@ export class MessageService {
// ─── helpers ──────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────
private async resolveScope(principal: MessagePrincipal) {
return (
(await this.prisma.iiosScope.findFirst({
where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null },
})) ??
(await this.prisma.iiosScope.create({
data: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId },
}))
);
}
private async resolveActor(scopeId: string, principal: MessagePrincipal) {
const handle = await this.prisma.iiosSourceHandle.upsert({
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } },
create: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId, displayName: principal.displayName },
update: { lastSeenAt: new Date(), displayName: principal.displayName },
});
return (
(await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } })) ??
(await this.prisma.iiosActorRef.create({
data: { kind: 'HUMAN', sourceHandleId: handle.id, displayName: principal.displayName },
}))
);
}
private async ensureParticipant(threadId: string, actorId: string): Promise<void> {
await this.prisma.iiosThreadParticipant.upsert({
where: { threadId_actorId: { threadId, actorId } },
create: { threadId, actorId },
update: {},
});
}
private toDto( private toDto(
interaction: { id: string; actorId: string | null; traceId: string | null; occurredAt: Date; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }> }, interaction: { id: string; actorId: string | null; traceId: string | null; occurredAt: Date; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }> },
threadId: string, threadId: string,
@@ -2,12 +2,13 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit'; import { makeFakePorts } from '@insignia/iios-testkit';
import { MessageService, type MessagePrincipal } from './message.service'; import { MessageService, type MessagePrincipal } from './message.service';
import { ActorResolver } from '../identity/actor.resolver';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } }); const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService; const asService = prisma as unknown as PrismaService;
const svc = () => new MessageService(asService, makeFakePorts()); const svc = () => new MessageService(asService, makeFakePorts(), new ActorResolver(asService));
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' }; const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };