feat(iios): @mentions via inbox fan-out + my-annotations query (pins/saves)

Mentions ride the existing event-driven inbox — no chat parsing in the kernel:
- send() carries an OPAQUE mentions[] (userIds) into the message event; the kernel
  never parses "@". The app supplies the notify-list.
- InboxProjector fans out a MENTION inbox item (new generic inbox kind) to each
  mentioned *participant* (never the sender), idempotent per source message; reading
  the thread resolves the reader's MENTION + NEEDS_REPLY items to DONE.
- New MENTION value in the generic IiosInboxItemKind taxonomy (migration).

Pins/saves reuse the annotation primitive; new generic query powers a Saved list:
- MessageService.listMyAnnotated(principal, type) + GET /v1/threads/my-annotations
  returns the caller's annotated messages (type "save") with thread context.

Tests: 182 pass (+4: mention fan-out, resolve-on-read, saved query, opaque mentions).
Verified live: mention→inbox delivery, read→resolve, pin/save persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 01:35:30 +05:30
parent f3c4ba72b5
commit 8c814d9b86
8 changed files with 181 additions and 11 deletions
@@ -191,6 +191,7 @@ export class MessageService {
idempotencyKey: string,
traceId: string = randomUUID(),
parentInteractionId?: string,
mentions?: string[],
): Promise<MessageDto> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
@@ -245,7 +246,9 @@ export class MessageService {
datacontenttype: 'application/json',
traceparent: `00-${traceId.replace(/-/g, '')}-0000000000000000-01`,
insignia: { scopeSnapshotId: thread.scopeId, correlationId: traceId, idempotencyKey, dataClass: 'internal' },
data: { interactionId: created.id, threadId, senderActorId: actor.id },
// `mentions` is an OPAQUE app-supplied notify-list (userIds) — the kernel never parses
// "@"; the inbox projector generically fans out a notification to those actors.
data: { interactionId: created.id, threadId, senderActorId: actor.id, mentions: mentions ?? [] },
};
await tx.iiosOutboxEvent.create({
data: {
@@ -416,6 +419,48 @@ export class MessageService {
return { interactionId, threadId: target.threadId, type: annotationType, value, op, users };
}
/**
* Generic "my annotated interactions" — every message the caller annotated with a given
* type, newest first, with thread context. The app uses type "save" for a personal
* bookmarks list (and could use "pin" for a cross-thread pinned view). Generic: the kernel
* never interprets the type string.
*/
async listMyAnnotated(
principal: MessagePrincipal,
annotationType: string,
): Promise<Array<{ message: MessageDto; threadId: string; threadSubject: string | null }>> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal);
const anns = await this.prisma.iiosInteractionAnnotation.findMany({
where: { actorId: actor.id, annotationType },
orderBy: { createdAt: 'desc' },
select: { targetInteractionId: true },
});
const ids = anns.map((a) => a.targetInteractionId);
if (ids.length === 0) return [];
const [interactions, agg] = await Promise.all([
this.prisma.iiosInteraction.findMany({
where: { id: { in: ids } },
include: {
parts: { orderBy: { partIndex: 'asc' } },
actor: { include: { sourceHandle: true } },
thread: { select: { subject: true } },
},
}),
this.annotationsByInteraction(ids),
]);
const byId = new Map(interactions.map((i) => [i.id, i]));
return ids
.map((id) => byId.get(id))
.filter((i): i is NonNullable<typeof i> => Boolean(i && i.threadId))
.map((i) => ({
message: this.toDto(i, i.threadId as string, agg.get(i.id) ?? []),
threadId: i.threadId as string,
threadSubject: i.thread?.subject ?? null,
}));
}
// ─── helpers ────────────────────────────────────────────────────
/** Aggregate annotations for the given interactions into { type, value, users[] } groups. */