feat(iios): generic interaction-annotation primitive (backs emoji reactions)

Adds IiosInteractionAnnotation — an actor attaches an OPAQUE (annotationType, value)
label to an interaction. The kernel stores + aggregates them but never interprets the
strings (the chat app writes type "reaction" / value = emoji); the same primitive backs
pins/saves/flags/tags later. No chat vocabulary in kernel code — verified by grep.

- MessageService.toggleAnnotation(): governed (participant-only via new OPA rule
  iios.interaction.annotate), idempotent toggle keyed on
  (scope, interaction, actor, type, value); history DTO carries aggregated
  { type, value, users[] } groups.
- Gateway: `annotate` event → broadcasts `annotation` (refreshed user list) to the room.
- DevOpaPort: annotate allowed only for thread members (real OPA can tighten later).
- Migration add_interaction_annotations (additive; dev data untouched).

Tests: 178 pass (+3: toggle/aggregate, coexisting values, non-member denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:20:47 +05:30
parent c4206f9809
commit f3c4ba72b5
6 changed files with 207 additions and 1 deletions
@@ -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;
@@ -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<Map<string, AnnotationDto[]>> {
const map = new Map<string, AnnotationDto[]>();
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,
};
@@ -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);
});
});
@@ -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();
}