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:
@@ -165,16 +165,39 @@ export class MessageService {
|
||||
if (!thread) throw new NotFoundException('thread not found');
|
||||
const actor = await this.actors.resolveActor(thread.scopeId, principal);
|
||||
|
||||
await this.prisma.iiosMessageReceipt.upsert({
|
||||
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'READ' } },
|
||||
create: { interactionId, actorId: actor.id, receiptKind: 'READ' },
|
||||
update: { occurredAt: new Date() },
|
||||
});
|
||||
await this.prisma.iiosUnreadCounter.upsert({
|
||||
where: { threadId_actorId: { threadId, actorId: actor.id } },
|
||||
create: { threadId, actorId: actor.id, unreadCount: 0, lastReadInteractionId: interactionId },
|
||||
update: { unreadCount: 0, lastReadInteractionId: interactionId },
|
||||
});
|
||||
const readEvent: CloudEvent = {
|
||||
specversion: '1.0',
|
||||
id: `evt_read_${interactionId}_${actor.id}`,
|
||||
type: IIOS_EVENTS.messageRead,
|
||||
source: `iios/message/${thread.scopeId}`,
|
||||
subject: `interaction/${interactionId}`,
|
||||
time: new Date().toISOString(),
|
||||
datacontenttype: 'application/json',
|
||||
insignia: { scopeSnapshotId: thread.scopeId, idempotencyKey: `read:${interactionId}:${actor.id}`, dataClass: 'internal' },
|
||||
data: { threadId, actorId: actor.id, interactionId },
|
||||
};
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.iiosMessageReceipt.upsert({
|
||||
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'READ' } },
|
||||
create: { interactionId, actorId: actor.id, receiptKind: 'READ' },
|
||||
update: { occurredAt: new Date() },
|
||||
}),
|
||||
this.prisma.iiosUnreadCounter.upsert({
|
||||
where: { threadId_actorId: { threadId, actorId: actor.id } },
|
||||
create: { threadId, actorId: actor.id, unreadCount: 0, lastReadInteractionId: interactionId },
|
||||
update: { unreadCount: 0, lastReadInteractionId: interactionId },
|
||||
}),
|
||||
this.prisma.iiosOutboxEvent.create({
|
||||
data: {
|
||||
aggregateType: 'interaction',
|
||||
aggregateId: interactionId,
|
||||
eventType: IIOS_EVENTS.messageRead,
|
||||
cloudEvent: readEvent as unknown as Prisma.InputJsonValue,
|
||||
partitionKey: `${thread.scopeId}:${threadId}`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { interactionId, actorId: actor.id };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { MessageService, type MessagePrincipal } from './message.service';
|
||||
import { ActorResolver } from '../identity/actor.resolver';
|
||||
@@ -14,18 +15,7 @@ const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'po
|
||||
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
|
||||
|
||||
async function clean(): Promise<void> {
|
||||
await prisma.iiosUnreadCounter.deleteMany();
|
||||
await prisma.iiosMessageReceipt.deleteMany();
|
||||
await prisma.iiosOutboxEvent.deleteMany();
|
||||
await prisma.iiosProcessedEvent.deleteMany();
|
||||
await prisma.iiosMessagePart.deleteMany();
|
||||
await prisma.iiosInteraction.deleteMany();
|
||||
await prisma.iiosThreadParticipant.deleteMany();
|
||||
await prisma.iiosThread.deleteMany();
|
||||
await prisma.iiosActorRef.deleteMany();
|
||||
await prisma.iiosSourceHandle.deleteMany();
|
||||
await prisma.iiosChannel.deleteMany();
|
||||
await prisma.iiosScope.deleteMany();
|
||||
await resetDb(prisma);
|
||||
}
|
||||
|
||||
async function actorIdFor(userId: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user