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
@@ -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;
@@ -303,6 +303,7 @@ model IiosScope {
channels IiosChannel[] channels IiosChannel[]
threads IiosThread[] threads IiosThread[]
interactions IiosInteraction[] interactions IiosInteraction[]
annotations IiosInteractionAnnotation[]
inboxItems IiosInboxItem[] inboxItems IiosInboxItem[]
supportQueues IiosSupportQueue[] supportQueues IiosSupportQueue[]
tickets IiosTicket[] tickets IiosTicket[]
@@ -348,6 +349,7 @@ model IiosActorRef {
threadsCreated IiosThread[] @relation("ThreadCreatedBy") threadsCreated IiosThread[] @relation("ThreadCreatedBy")
participations IiosThreadParticipant[] participations IiosThreadParticipant[]
interactions IiosInteraction[] interactions IiosInteraction[]
annotations IiosInteractionAnnotation[]
receipts IiosMessageReceipt[] receipts IiosMessageReceipt[]
unreadCounters IiosUnreadCounter[] unreadCounters IiosUnreadCounter[]
inboxItemsOwned IiosInboxItem[] inboxItemsOwned IiosInboxItem[]
@@ -444,6 +446,7 @@ model IiosInteraction {
parts IiosMessagePart[] parts IiosMessagePart[]
receipts IiosMessageReceipt[] receipts IiosMessageReceipt[]
annotations IiosInteractionAnnotation[]
inboxItems IiosInboxItem[] inboxItems IiosInboxItem[]
ticketsCreatedFrom IiosTicket[] ticketsCreatedFrom IiosTicket[]
@@ -451,6 +454,26 @@ model IiosInteraction {
@@index([threadId, occurredAt]) @@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 { model IiosMessagePart {
id String @id @default(cuid()) id String @id @default(cuid())
interactionId String interactionId String
@@ -107,6 +107,26 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
return msg; 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') @SubscribeMessage('read')
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) { async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
const { principal } = client.data as SocketState; const { principal } = client.data as SocketState;
@@ -9,6 +9,13 @@ import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver
export type { MessagePrincipal }; 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 { export interface MessageDto {
id: string; id: string;
threadId: string; threadId: string;
@@ -17,6 +24,7 @@ export interface MessageDto {
content: string; content: string;
contentRef?: string; contentRef?: string;
parentInteractionId?: string; parentInteractionId?: string;
annotations: AnnotationDto[];
traceId: string; traceId: string;
createdAt: Date; createdAt: Date;
} }
@@ -356,11 +364,84 @@ export class MessageService {
orderBy: { occurredAt: 'asc' }, orderBy: { occurredAt: 'asc' },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } }, 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 ──────────────────────────────────────────────────── // ─── 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( private toDto(
interaction: { interaction: {
id: string; id: string;
@@ -372,6 +453,7 @@ export class MessageService {
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
}, },
threadId: string, threadId: string,
annotations: AnnotationDto[] = [],
): MessageDto { ): MessageDto {
const text = interaction.parts.find((p) => p.kind === 'TEXT'); const text = interaction.parts.find((p) => p.kind === 'TEXT');
const file = interaction.parts.find((p) => p.contentRef); const file = interaction.parts.find((p) => p.contentRef);
@@ -383,6 +465,7 @@ export class MessageService {
content: text?.bodyText ?? '', content: text?.bodyText ?? '',
contentRef: file?.contentRef ?? undefined, contentRef: file?.contentRef ?? undefined,
parentInteractionId: interaction.parentInteractionId ?? undefined, parentInteractionId: interaction.parentInteractionId ?? undefined,
annotations,
traceId: interaction.traceId ?? '', traceId: interaction.traceId ?? '',
createdAt: interaction.occurredAt, createdAt: interaction.occurredAt,
}; };
@@ -155,3 +155,51 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect(cross.parentInteractionId).toBeUndefined(); // parent not in this thread 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; participantCount?: number;
callerRole?: string; // 'MEMBER' | 'ADMIN' callerRole?: string; // 'MEMBER' | 'ADMIN'
alreadyMember?: boolean; alreadyMember?: boolean;
isMember?: boolean;
[k: string]: unknown; [k: string]: unknown;
} }
@@ -37,6 +38,10 @@ export class DevOpaPort {
if (!i.membership) return allow(); if (!i.membership) return allow();
return i.alreadyMember ? allow() : deny('you are not a member of this thread'); 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: default:
return allow(); return allow();
} }