diff --git a/packages/iios-service/prisma/migrations/20260706194823_add_mention_inbox_kind/migration.sql b/packages/iios-service/prisma/migrations/20260706194823_add_mention_inbox_kind/migration.sql new file mode 100644 index 0000000..aaac07a --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260706194823_add_mention_inbox_kind/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "IiosInboxItemKind" ADD VALUE 'MENTION'; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index b346944..e535349 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -79,6 +79,7 @@ enum IiosInboxItemKind { NEEDS_REPLY NEEDS_REVIEW NEEDS_APPROVAL + MENTION SUPPORT_UPDATE MEETING_FOLLOWUP DIGEST diff --git a/packages/iios-service/src/inbox/inbox.projector.ts b/packages/iios-service/src/inbox/inbox.projector.ts index 0e3a212..403eb50 100644 --- a/packages/iios-service/src/inbox/inbox.projector.ts +++ b/packages/iios-service/src/inbox/inbox.projector.ts @@ -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 { + 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 { @@ -67,21 +122,24 @@ export class InboxProjector implements OnModuleInit { private async applyMessageRead(event: CloudEvent): Promise { 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([ - 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' }, - }), - ]); + 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( diff --git a/packages/iios-service/src/inbox/inbox.spec.ts b/packages/iios-service/src/inbox/inbox.spec.ts index 9b631a6..96d5733 100644 --- a/packages/iios-service/src/inbox/inbox.spec.ts +++ b/packages/iios-service/src/inbox/inbox.spec.ts @@ -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); diff --git a/packages/iios-service/src/messaging/message.gateway.ts b/packages/iios-service/src/messaging/message.gateway.ts index 604cdca..2d0e81b 100644 --- a/packages/iios-service/src/messaging/message.gateway.ts +++ b/packages/iios-service/src/messaging/message.gateway.ts @@ -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); diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index a752ece..beaa0d1 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -191,6 +191,7 @@ export class MessageService { idempotencyKey: string, traceId: string = randomUUID(), parentInteractionId?: string, + mentions?: string[], ): Promise { 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> { + 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 => 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. */ diff --git a/packages/iios-service/src/messaging/message.spec.ts b/packages/iios-service/src/messaging/message.spec.ts index b97ac68..c6babe8 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -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']); + }); }); diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 02e5e0b..0dd9da9 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -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)