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
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { PlatformModule } from './platform/platform.module';
import { IdentityModule } from './identity/identity.module';
import { InteractionsModule } from './interactions/interactions.module';
import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module';
@@ -9,7 +10,7 @@ import { HealthController } from './health.controller';
import { DevController } from './dev/dev.controller';
@Module({
imports: [PrismaModule, PlatformModule, InteractionsModule, OutboxModule, ThreadsModule, MessageModule],
imports: [PrismaModule, PlatformModule, IdentityModule, InteractionsModule, OutboxModule, ThreadsModule, MessageModule],
controllers: [HealthController, DevController],
})
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 { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
/** The authenticated sender, derived from the session port (token). */
export interface MessagePrincipal {
userId: string;
orgId: string;
appId: string;
tenantId?: string;
displayName?: string;
}
export type { MessagePrincipal };
export interface MessageDto {
id: string;
@@ -42,26 +36,27 @@ export class MessageService {
constructor(
private readonly prisma: PrismaService,
@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. */
async openThread(threadId: string | null, principal: MessagePrincipal): Promise<OpenThreadResult> {
if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.resolveScope(principal);
const actor = await this.resolveActor(scope.id, principal);
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
const thread = await this.prisma.iiosThread.create({
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: [] };
}
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const actor = await this.resolveActor(thread.scopeId, principal);
await this.ensureParticipant(threadId, actor.id);
const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.actors.ensureParticipant(threadId, actor.id);
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 });
const actor = await this.resolveActor(thread.scopeId, principal);
await this.ensureParticipant(threadId, actor.id);
const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.actors.ensureParticipant(threadId, actor.id);
// Idempotency: a repeat key returns the existing message (no re-increment).
const existing = await this.prisma.iiosInteraction.findUnique({
@@ -168,7 +163,7 @@ export class MessageService {
): Promise<{ interactionId: string; actorId: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
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({
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'READ' } },
@@ -190,7 +185,7 @@ export class MessageService {
): Promise<{ interactionId: string; actorId: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
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({
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' } },
create: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' },
@@ -219,39 +214,6 @@ export class MessageService {
// ─── 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(
interaction: { id: string; actorId: string | null; traceId: string | null; occurredAt: Date; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }> },
threadId: string,
@@ -2,12 +2,13 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { makeFakePorts } from '@insignia/iios-testkit';
import { MessageService, type MessagePrincipal } from './message.service';
import { ActorResolver } from '../identity/actor.resolver';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
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 bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };