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
@@ -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 };
}