feat(service): P3.2 InboxProjector + message.read event + scheduled relay

Event-driven projector creates/collapses NEEDS_REPLY items from message.sent
(traceId propagated; inbox.item.created emitted) and resolves them to DONE on
message.read; idempotent via processed-event ledger. markRead now emits a
message.read outbox event (atomic). OutboxRelay runs on a 500ms timer in the
app (off in tests). Shared resetDb (TRUNCATE CASCADE) fixes cross-spec FK
isolation. 32 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 02:48:44 +05:30
parent 7b174e24fa
commit a2b95afaf0
13 changed files with 340 additions and 56 deletions
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { OutboxModule } from '../outbox/outbox.module';
import { InboxProjector } from './inbox.projector';
@Module({
imports: [OutboxModule],
providers: [InboxProjector],
exports: [InboxProjector],
})
export class InboxModule {}
@@ -0,0 +1,136 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { OutboxBus } from '../outbox/outbox.bus';
interface MessageSentData {
interactionId: string;
threadId: string;
senderActorId: string;
}
interface MessageReadData {
threadId: string;
actorId: string;
interactionId: string;
}
/**
* Projects message events into Inbox work-items (P3). Inbox reacts to events —
* it never imports the message layer. Deterministic + idempotent (collapse one
* OPEN NEEDS_REPLY per owner+thread; dedupe each event via the processed-event
* ledger). Reading a thread resolves the owner's item to DONE.
*/
@Injectable()
export class InboxProjector implements OnModuleInit {
private readonly consumer = 'inbox-projector';
constructor(
private readonly prisma: PrismaService,
private readonly bus: OutboxBus,
) {}
onModuleInit(): void {
this.bus.on(IIOS_EVENTS.messageSent, (p) => void this.onMessageSent(p as CloudEvent).catch(() => {}));
this.bus.on(IIOS_EVENTS.messageRead, (p) => void this.onMessageRead(p as CloudEvent).catch(() => {}));
}
async onMessageSent(event: CloudEvent): Promise<void> {
if (!(await this.claim(event.id))) return;
const data = event.data as MessageSentData;
const traceId = event.insignia?.correlationId;
const thread = await this.prisma.iiosThread.findUnique({ where: { id: data.threadId } });
if (!thread) return;
const participants = await this.prisma.iiosThreadParticipant.findMany({ where: { threadId: data.threadId } });
for (const p of participants) {
if (p.actorId === data.senderActorId) continue;
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
}
}
async onMessageRead(event: CloudEvent): Promise<void> {
if (!(await this.claim(event.id))) return;
const data = event.data as MessageReadData;
const item = await this.prisma.iiosInboxItem.findFirst({
where: {
threadId: data.threadId,
ownerActorId: data.actorId,
kind: 'NEEDS_REPLY',
state: { in: ['OPEN', 'SNOOZED'] },
},
});
if (!item) return;
await this.prisma.$transaction([
this.prisma.iiosInboxItem.update({ where: { id: item.id }, data: { state: 'DONE' } }),
this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: item.id, fromState: item.state, toState: 'DONE', reasonCode: 'message_read' },
}),
]);
}
private async upsertNeedsReply(
scopeId: string,
ownerActorId: string,
threadId: string,
sourceInteractionId: string,
traceId: string | undefined,
subject: string | null,
): Promise<void> {
const existing = await this.prisma.iiosInboxItem.findFirst({
where: { ownerActorId, threadId, kind: 'NEEDS_REPLY', state: { in: ['OPEN', 'SNOOZED'] } },
});
if (existing) {
await this.prisma.iiosInboxItem.update({
where: { id: existing.id },
data: { sourceInteractionId, traceId: traceId ?? existing.traceId, state: 'OPEN' },
});
return;
}
const item = await this.prisma.iiosInboxItem.create({
data: {
scopeId,
ownerActorId,
kind: 'NEEDS_REPLY',
title: subject ?? 'New messages',
threadId,
sourceInteractionId,
traceId,
},
});
await this.prisma.iiosInboxItemStateHistory.create({
data: { inboxItemId: item.id, toState: 'OPEN', reasonCode: 'message_sent' },
});
const created: CloudEvent = {
specversion: '1.0',
id: `evt_${item.id}`,
type: IIOS_EVENTS.inboxItemCreated,
source: `iios/inbox/${scopeId}`,
subject: `inbox_item/${item.id}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
insignia: { scopeSnapshotId: scopeId, correlationId: traceId, idempotencyKey: `inbox:${item.id}`, dataClass: 'internal' },
data: { inboxItemId: item.id, ownerActorId, threadId, kind: 'NEEDS_REPLY' },
};
await this.prisma.iiosOutboxEvent.create({
data: {
aggregateType: 'inbox_item',
aggregateId: item.id,
eventType: IIOS_EVENTS.inboxItemCreated,
cloudEvent: created as unknown as Prisma.InputJsonValue,
partitionKey: `${scopeId}:${threadId}`,
},
});
}
/** Idempotent claim: false if this event id was already processed. */
private async claim(eventId: string): Promise<boolean> {
const res = await this.prisma.iiosProcessedEvent.createMany({
data: [{ consumerName: this.consumer, eventId }],
skipDuplicates: true,
});
return res.count > 0;
}
}
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { InboxProjector } from './inbox.projector';
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 actors = new ActorResolver(asService);
const ms = () => new MessageService(asService, makeFakePorts(), actors);
const projector = () => new InboxProjector(asService, new OutboxBus());
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' };
async function clean(): Promise<void> {
await resetDb(prisma);
}
async function actorIdFor(userId: string): Promise<string> {
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
return actor.id;
}
async function itemsFor(userId: string) {
return prisma.iiosInboxItem.findMany({ where: { ownerActorId: await actorIdFor(userId) } });
}
async function eventsOf(type: string) {
const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: type }, orderBy: { createdAt: 'asc' } });
return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
}
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await clean(); });
describe('InboxProjector (P3 work surface)', () => {
it('message.sent → NEEDS_REPLY for the recipient (not the sender), with the message traceId', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
const sent = await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
const [event] = await eventsOf(IIOS_EVENTS.messageSent);
await projector().onMessageSent(event);
const bobItems = await itemsFor('bob');
expect(bobItems).toHaveLength(1);
expect(bobItems[0]?.kind).toBe('NEEDS_REPLY');
expect(bobItems[0]?.state).toBe('OPEN');
expect(bobItems[0]?.traceId).toBe(sent.traceId);
expect(await itemsFor('alice')).toHaveLength(0);
});
it('collapses per thread: two messages → still 1 open item', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
await m.send(threadId, alice, { content: '1' }, 'k1');
await m.send(threadId, alice, { content: '2' }, 'k2');
const proj = projector();
for (const e of await eventsOf(IIOS_EVENTS.messageSent)) await proj.onMessageSent(e);
expect(await itemsFor('bob')).toHaveLength(1);
});
it('is idempotent: replaying the same event id creates no duplicate', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
await m.send(threadId, alice, { content: 'hi' }, 'k1');
const [event] = await eventsOf(IIOS_EVENTS.messageSent);
const proj = projector();
await proj.onMessageSent(event);
await proj.onMessageSent(event); // replay
expect(await itemsFor('bob')).toHaveLength(1);
});
it('message.read → the recipient item goes DONE', async () => {
const m = ms();
const { threadId } = await m.openThread(null, alice);
await m.openThread(threadId, bob);
const sent = await m.send(threadId, alice, { content: 'hi' }, 'k1');
const proj = projector();
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
expect((await itemsFor('bob'))[0]?.state).toBe('OPEN');
await m.markRead(threadId, bob, sent.id);
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
expect((await itemsFor('bob'))[0]?.state).toBe('DONE');
const history = await prisma.iiosInboxItemStateHistory.findMany({ where: { toState: 'DONE' } });
expect(history.length).toBeGreaterThanOrEqual(1);
});
});