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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user