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:
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "IiosInboxItemKind" ADD VALUE 'MENTION';
|
||||
@@ -79,6 +79,7 @@ enum IiosInboxItemKind {
|
||||
NEEDS_REPLY
|
||||
NEEDS_REVIEW
|
||||
NEEDS_APPROVAL
|
||||
MENTION
|
||||
SUPPORT_UPDATE
|
||||
MEETING_FOLLOWUP
|
||||
DIGEST
|
||||
|
||||
@@ -10,6 +10,7 @@ interface MessageSentData {
|
||||
interactionId: string;
|
||||
threadId: string;
|
||||
senderActorId: string;
|
||||
mentions?: string[]; // opaque app notify-list (userIds); the kernel never parses "@"
|
||||
}
|
||||
interface MessageReadData {
|
||||
threadId: string;
|
||||
@@ -57,6 +58,60 @@ export class InboxProjector implements OnModuleInit {
|
||||
if (p.actorId === data.senderActorId) continue;
|
||||
await this.upsertNeedsReply(thread.scopeId, p.actorId, data.threadId, data.interactionId, traceId, thread.subject);
|
||||
}
|
||||
|
||||
// Targeted mention notifications from the app-supplied opaque notify-list.
|
||||
const mentions = data.mentions ?? [];
|
||||
if (mentions.length > 0) {
|
||||
const src = await this.prisma.iiosInteraction.findUnique({
|
||||
where: { id: data.interactionId },
|
||||
include: { parts: { where: { kind: 'TEXT' }, take: 1 }, actor: { include: { sourceHandle: true } } },
|
||||
});
|
||||
const senderName = src?.actor?.sourceHandle?.externalId ?? 'Someone';
|
||||
const text = src?.parts[0]?.bodyText ?? undefined;
|
||||
for (const userId of mentions) {
|
||||
await this.createMention(thread.scopeId, userId, data.threadId, data.interactionId, data.senderActorId, senderName, text, traceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One MENTION item per (owner, source message) for a mentioned participant (never the sender). */
|
||||
private async createMention(
|
||||
scopeId: string,
|
||||
userId: string,
|
||||
threadId: string,
|
||||
sourceInteractionId: string,
|
||||
senderActorId: string,
|
||||
senderName: string,
|
||||
summary: string | undefined,
|
||||
traceId: string | undefined,
|
||||
): Promise<void> {
|
||||
const handle = await this.prisma.iiosSourceHandle.findUnique({
|
||||
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: userId } },
|
||||
});
|
||||
if (!handle) return;
|
||||
const actor = await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } });
|
||||
if (!actor || actor.id === senderActorId) return; // resolvable, and never notify the sender
|
||||
const isMember = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
|
||||
if (!isMember) return; // only thread participants get mention items
|
||||
const existing = await this.prisma.iiosInboxItem.findFirst({ where: { ownerActorId: actor.id, sourceInteractionId, kind: 'MENTION' } });
|
||||
if (existing) return; // idempotent per source message
|
||||
|
||||
const item = await this.prisma.iiosInboxItem.create({
|
||||
data: {
|
||||
scopeId,
|
||||
ownerActorId: actor.id,
|
||||
kind: 'MENTION',
|
||||
title: `${senderName} mentioned you`,
|
||||
summary,
|
||||
priority: 'HIGH',
|
||||
threadId,
|
||||
sourceInteractionId,
|
||||
traceId,
|
||||
},
|
||||
});
|
||||
await this.prisma.iiosInboxItemStateHistory.create({
|
||||
data: { inboxItemId: item.id, toState: 'OPEN', reasonCode: 'mentioned' },
|
||||
});
|
||||
}
|
||||
|
||||
async onMessageRead(event: CloudEvent): Promise<void> {
|
||||
@@ -67,21 +122,24 @@ export class InboxProjector implements OnModuleInit {
|
||||
|
||||
private async applyMessageRead(event: CloudEvent): Promise<void> {
|
||||
const data = event.data as MessageReadData;
|
||||
const item = await this.prisma.iiosInboxItem.findFirst({
|
||||
// Reading the thread clears the reader's activity AND mention items for it.
|
||||
const items = await this.prisma.iiosInboxItem.findMany({
|
||||
where: {
|
||||
threadId: data.threadId,
|
||||
ownerActorId: data.actorId,
|
||||
kind: 'NEEDS_REPLY',
|
||||
kind: { in: ['NEEDS_REPLY', 'MENTION'] },
|
||||
state: { in: ['OPEN', 'SNOOZED'] },
|
||||
},
|
||||
});
|
||||
if (!item) return;
|
||||
await this.prisma.$transaction([
|
||||
if (items.length === 0) return;
|
||||
await this.prisma.$transaction(
|
||||
items.flatMap((item) => [
|
||||
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(
|
||||
|
||||
@@ -88,6 +88,38 @@ describe('InboxProjector (P3 work surface)', () => {
|
||||
expect(await itemsFor('bob')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('message.sent with mentions → a MENTION item for the mentioned participant only', async () => {
|
||||
const m = ms();
|
||||
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await m.addParticipant(threadId, alice, 'bob');
|
||||
await m.addParticipant(threadId, alice, 'carol');
|
||||
await m.send(threadId, alice, { content: 'hey @bob look' }, 'k1', undefined, undefined, ['bob']);
|
||||
|
||||
await projector().onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
|
||||
|
||||
const bobMentions = (await itemsFor('bob')).filter((i) => i.kind === 'MENTION');
|
||||
expect(bobMentions).toHaveLength(1);
|
||||
expect(bobMentions[0]?.state).toBe('OPEN');
|
||||
expect(bobMentions[0]?.sourceInteractionId).toBeTruthy();
|
||||
expect((await itemsFor('carol')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // not mentioned
|
||||
expect((await itemsFor('alice')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // never the sender
|
||||
});
|
||||
|
||||
it('reading the thread resolves the reader’s MENTION item to DONE', async () => {
|
||||
const m = ms();
|
||||
const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
|
||||
await m.addParticipant(threadId, alice, 'bob');
|
||||
const sent = await m.send(threadId, alice, { content: '@bob ping' }, 'k1', undefined, undefined, ['bob']);
|
||||
|
||||
const proj = projector();
|
||||
await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
|
||||
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('OPEN');
|
||||
|
||||
await m.markRead(threadId, bob, sent.id);
|
||||
await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
|
||||
expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('DONE');
|
||||
});
|
||||
|
||||
it('message.read → the recipient item goes DONE', async () => {
|
||||
const m = ms();
|
||||
const { threadId } = await m.openThread(null, alice);
|
||||
|
||||
@@ -91,7 +91,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
@SubscribeMessage('send_message')
|
||||
async sendMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { threadId: string; content: string; contentRef?: string; parentInteractionId?: string },
|
||||
@MessageBody() body: { threadId: string; content: string; contentRef?: string; parentInteractionId?: string; mentions?: string[] },
|
||||
) {
|
||||
const { principal } = client.data as SocketState;
|
||||
const msg = await this.messages.send(
|
||||
@@ -101,6 +101,7 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
||||
randomUUID(),
|
||||
undefined,
|
||||
body.parentInteractionId,
|
||||
body.mentions,
|
||||
);
|
||||
this.emittedLocally.add(msg.id);
|
||||
this.server.to(body.threadId).emit('message', msg);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -202,4 +202,28 @@ describe('Interaction annotations (generic reactions primitive)', () => {
|
||||
const msg = await s.send(threadId, alice, { content: 'secret' }, 'k1');
|
||||
await expect(s.toggleAnnotation(msg.id, bob, 'reaction', '👍')).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||
});
|
||||
|
||||
it('listMyAnnotated returns the caller’s saved messages with thread context; others see none', async () => {
|
||||
const s = gov();
|
||||
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
|
||||
await s.addParticipant(threadId, alice, 'bob');
|
||||
const msg = await s.send(threadId, alice, { content: 'save this' }, 'k1');
|
||||
await s.toggleAnnotation(msg.id, alice, 'save', ''); // save is a personal annotation
|
||||
|
||||
const saved = await s.listMyAnnotated(alice, 'save');
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]).toMatchObject({ threadId, threadSubject: 'Design' });
|
||||
expect(saved[0]?.message.content).toBe('save this');
|
||||
expect(await s.listMyAnnotated(bob, 'save')).toHaveLength(0); // bob saved nothing
|
||||
});
|
||||
|
||||
it('send carries an OPAQUE mentions[] into the message event (kernel never parses @)', async () => {
|
||||
const s = svc();
|
||||
const { threadId } = await s.openThread(null, alice);
|
||||
await s.openThread(threadId, bob);
|
||||
await s.send(threadId, alice, { content: 'hi @bob' }, 'k1', undefined, undefined, ['bob']);
|
||||
const ev = await prisma.iiosOutboxEvent.findFirstOrThrow({ where: { eventType: 'com.insignia.iios.message.sent.v1' } });
|
||||
const ce = ev.cloudEvent as { data: { mentions?: string[] } };
|
||||
expect(ce.data.mentions).toEqual(['bob']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ThreadsService } from './threads.service';
|
||||
import { MessageService } from '../messaging/message.service';
|
||||
@@ -28,6 +29,12 @@ export class ThreadsController {
|
||||
return this.messages.listThreads(this.principal(auth));
|
||||
}
|
||||
|
||||
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
|
||||
@Get('my-annotations')
|
||||
async myAnnotations(@Headers('authorization') auth?: string, @Query('type') type = 'save') {
|
||||
return this.messages.listMyAnnotated(this.principal(auth), type);
|
||||
}
|
||||
|
||||
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
|
||||
Reference in New Issue
Block a user