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
@@ -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