diff --git a/packages/iios-service/prisma/migrations/20260706182400_add_interaction_annotations/migration.sql b/packages/iios-service/prisma/migrations/20260706182400_add_interaction_annotations/migration.sql new file mode 100644 index 0000000..9c845fe --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260706182400_add_interaction_annotations/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "IiosInteractionAnnotation" ( + "id" TEXT NOT NULL, + "scopeId" TEXT NOT NULL, + "targetInteractionId" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "annotationType" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IiosInteractionAnnotation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IiosInteractionAnnotation_targetInteractionId_idx" ON "IiosInteractionAnnotation"("targetInteractionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "IiosInteractionAnnotation_scopeId_targetInteractionId_actor_key" ON "IiosInteractionAnnotation"("scopeId", "targetInteractionId", "actorId", "annotationType", "value"); + +-- AddForeignKey +ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_targetInteractionId_fkey" FOREIGN KEY ("targetInteractionId") REFERENCES "IiosInteraction"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "IiosInteractionAnnotation" ADD CONSTRAINT "IiosInteractionAnnotation_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "IiosActorRef"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 4d2f480..b346944 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -303,6 +303,7 @@ model IiosScope { channels IiosChannel[] threads IiosThread[] interactions IiosInteraction[] + annotations IiosInteractionAnnotation[] inboxItems IiosInboxItem[] supportQueues IiosSupportQueue[] tickets IiosTicket[] @@ -348,6 +349,7 @@ model IiosActorRef { threadsCreated IiosThread[] @relation("ThreadCreatedBy") participations IiosThreadParticipant[] interactions IiosInteraction[] + annotations IiosInteractionAnnotation[] receipts IiosMessageReceipt[] unreadCounters IiosUnreadCounter[] inboxItemsOwned IiosInboxItem[] @@ -444,6 +446,7 @@ model IiosInteraction { parts IiosMessagePart[] receipts IiosMessageReceipt[] + annotations IiosInteractionAnnotation[] inboxItems IiosInboxItem[] ticketsCreatedFrom IiosTicket[] @@ -451,6 +454,26 @@ model IiosInteraction { @@index([threadId, occurredAt]) } +/// A generic annotation an actor attaches to an interaction. Both `annotationType` +/// and `value` are OPAQUE, app-supplied strings the kernel stores and aggregates +/// but never interprets — e.g. the chat app writes type "reaction" / value "👍". +/// The same primitive backs pins, saves, flags, tags later. No chat vocabulary here. +model IiosInteractionAnnotation { + id String @id @default(cuid()) + scopeId String + scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade) + targetInteractionId String + target IiosInteraction @relation(fields: [targetInteractionId], references: [id], onDelete: Cascade) + actorId String + actor IiosActorRef @relation(fields: [actorId], references: [id]) + annotationType String + value String + createdAt DateTime @default(now()) + + @@unique([scopeId, targetInteractionId, actorId, annotationType, value]) + @@index([targetInteractionId]) +} + model IiosMessagePart { id String @id @default(cuid()) interactionId String diff --git a/packages/iios-service/src/messaging/message.gateway.ts b/packages/iios-service/src/messaging/message.gateway.ts index 9501443..604cdca 100644 --- a/packages/iios-service/src/messaging/message.gateway.ts +++ b/packages/iios-service/src/messaging/message.gateway.ts @@ -107,6 +107,26 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection { return msg; } + @SubscribeMessage('annotate') + async annotate( + @ConnectedSocket() client: Socket, + @MessageBody() body: { threadId: string; interactionId: string; type: string; value: string }, + ) { + const { principal } = client.data as SocketState; + const r = await this.messages.toggleAnnotation(body.interactionId, principal, body.type, body.value); + // Broadcast the refreshed user list for this (interaction,type,value) so every client re-renders. + this.server.to(r.threadId).emit('annotation', { + threadId: r.threadId, + interactionId: r.interactionId, + type: r.type, + value: r.value, + op: r.op, + users: r.users, + userId: principal.userId, + }); + return r; + } + @SubscribeMessage('read') async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { const { principal } = client.data as SocketState; diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index 33b4278..a752ece 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -9,6 +9,13 @@ import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver export type { MessagePrincipal }; +/** A generic annotation aggregate on a message (opaque type/value + who applied it). */ +export interface AnnotationDto { + type: string; + value: string; + users: string[]; +} + export interface MessageDto { id: string; threadId: string; @@ -17,6 +24,7 @@ export interface MessageDto { content: string; contentRef?: string; parentInteractionId?: string; + annotations: AnnotationDto[]; traceId: string; createdAt: Date; } @@ -356,11 +364,84 @@ export class MessageService { orderBy: { occurredAt: 'asc' }, include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } }, }); - return interactions.map((i) => this.toDto(i, threadId)); + const annotations = await this.annotationsByInteraction(interactions.map((i) => i.id)); + return interactions.map((i) => this.toDto(i, threadId, annotations.get(i.id) ?? [])); + } + + /** + * Toggle a generic annotation (actor attaches/removes an opaque label on an interaction). + * `annotationType`/`value` are app-supplied and never interpreted here (the chat app uses + * type "reaction" + an emoji value). Governed: only a thread participant may annotate. + * Returns the refreshed user list for that (type,value) so callers can broadcast it. + */ + async toggleAnnotation( + interactionId: string, + principal: MessagePrincipal, + annotationType: string, + value: string, + ): Promise<{ interactionId: string; threadId: string; type: string; value: string; op: 'add' | 'remove'; users: string[] }> { + const target = await this.prisma.iiosInteraction.findUnique({ + where: { id: interactionId }, + select: { id: true, threadId: true, scopeId: true }, + }); + if (!target || !target.threadId) throw new NotFoundException('message not found'); + const actor = await this.actors.resolveActor(target.scopeId, principal); + const isMember = + (await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId: target.threadId, actorId: actor.id } } })) !== null; + await decideOrThrow(this.ports, { action: 'iios.interaction.annotate', threadId: target.threadId, scopeId: target.scopeId, isMember }); + + const key = { + scopeId_targetInteractionId_actorId_annotationType_value: { + scopeId: target.scopeId, + targetInteractionId: interactionId, + actorId: actor.id, + annotationType, + value, + }, + }; + const existing = await this.prisma.iiosInteractionAnnotation.findUnique({ where: key }); + let op: 'add' | 'remove'; + if (existing) { + await this.prisma.iiosInteractionAnnotation.delete({ where: { id: existing.id } }); + op = 'remove'; + } else { + await this.prisma.iiosInteractionAnnotation.create({ + data: { scopeId: target.scopeId, targetInteractionId: interactionId, actorId: actor.id, annotationType, value }, + }); + op = 'add'; + } + + const users = + (await this.annotationsByInteraction([interactionId])).get(interactionId)?.find((a) => a.type === annotationType && a.value === value)?.users ?? []; + return { interactionId, threadId: target.threadId, type: annotationType, value, op, users }; } // ─── helpers ──────────────────────────────────────────────────── + /** Aggregate annotations for the given interactions into { type, value, users[] } groups. */ + private async annotationsByInteraction(interactionIds: string[]): Promise> { + const map = new Map(); + if (interactionIds.length === 0) return map; + const rows = await this.prisma.iiosInteractionAnnotation.findMany({ + where: { targetInteractionId: { in: interactionIds } }, + include: { actor: { include: { sourceHandle: true } } }, + orderBy: { createdAt: 'asc' }, + }); + for (const r of rows) { + const user = r.actor?.sourceHandle?.externalId ?? r.actor?.displayName ?? r.actorId; + const list = map.get(r.targetInteractionId) ?? []; + let entry = list.find((e) => e.type === r.annotationType && e.value === r.value); + if (!entry) { + entry = { type: r.annotationType, value: r.value, users: [] }; + list.push(entry); + } + entry.users.push(user); + map.set(r.targetInteractionId, list); + } + for (const list of map.values()) for (const e of list) e.users.sort(); // deterministic order + return map; + } + private toDto( interaction: { id: string; @@ -372,6 +453,7 @@ export class MessageService { parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>; }, threadId: string, + annotations: AnnotationDto[] = [], ): MessageDto { const text = interaction.parts.find((p) => p.kind === 'TEXT'); const file = interaction.parts.find((p) => p.contentRef); @@ -383,6 +465,7 @@ export class MessageService { content: text?.bodyText ?? '', contentRef: file?.contentRef ?? undefined, parentInteractionId: interaction.parentInteractionId ?? undefined, + annotations, traceId: interaction.traceId ?? '', createdAt: interaction.occurredAt, }; diff --git a/packages/iios-service/src/messaging/message.spec.ts b/packages/iios-service/src/messaging/message.spec.ts index 5347ea1..b97ac68 100644 --- a/packages/iios-service/src/messaging/message.spec.ts +++ b/packages/iios-service/src/messaging/message.spec.ts @@ -155,3 +155,51 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => { expect(cross.parentInteractionId).toBeUndefined(); // parent not in this thread }); }); + +describe('Interaction annotations (generic reactions primitive)', () => { + it('toggleAnnotation adds then removes (toggle) and aggregates users into history', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); + await s.addParticipant(threadId, alice, 'bob'); + await s.openThread(threadId, bob); + const msg = await s.send(threadId, alice, { content: 'ship it' }, 'k1'); + + const add = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍'); + expect(add.op).toBe('add'); + expect(add.users).toEqual(['bob']); + + const add2 = await s.toggleAnnotation(msg.id, alice, 'reaction', '👍'); // alice also 👍 + expect(add2.op).toBe('add'); + expect(add2.users).toEqual(['alice', 'bob']); // sorted, deterministic + + const rem = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍'); // bob toggles off + expect(rem.op).toBe('remove'); + expect(rem.users).toEqual(['alice']); + + const hist = await s.history(threadId); + const m = hist.find((x) => x.id === msg.id)!; + expect(m.annotations).toEqual([{ type: 'reaction', value: '👍', users: ['alice'] }]); + }); + + it('different values coexist for the same actor (👍 and 🎉 both stick)', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); + const msg = await s.send(threadId, alice, { content: 'hi' }, 'k1'); + await s.toggleAnnotation(msg.id, alice, 'reaction', '👍'); + await s.toggleAnnotation(msg.id, alice, 'reaction', '🎉'); + const m = (await s.history(threadId)).find((x) => x.id === msg.id)!; + expect(m.annotations).toEqual( + expect.arrayContaining([ + { type: 'reaction', value: '👍', users: ['alice'] }, + { type: 'reaction', value: '🎉', users: ['alice'] }, + ]), + ); + }); + + it('a non-member cannot annotate (governed by policy)', async () => { + const s = gov(); + const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); + const msg = await s.send(threadId, alice, { content: 'secret' }, 'k1'); + await expect(s.toggleAnnotation(msg.id, bob, 'reaction', '👍')).rejects.toBeInstanceOf(PolicyDeniedError); + }); +}); diff --git a/packages/iios-service/src/platform/dev-opa.port.ts b/packages/iios-service/src/platform/dev-opa.port.ts index 1aca2cc..3ea0b4d 100644 --- a/packages/iios-service/src/platform/dev-opa.port.ts +++ b/packages/iios-service/src/platform/dev-opa.port.ts @@ -13,6 +13,7 @@ export interface OpaInput { participantCount?: number; callerRole?: string; // 'MEMBER' | 'ADMIN' alreadyMember?: boolean; + isMember?: boolean; [k: string]: unknown; } @@ -37,6 +38,10 @@ export class DevOpaPort { if (!i.membership) return allow(); return i.alreadyMember ? allow() : deny('you are not a member of this thread'); } + case 'iios.interaction.annotate': { + // Annotating (e.g. reacting to) a message requires being a participant of its thread. + return i.isMember ? allow() : deny('you are not a member of this thread'); + } default: return allow(); }