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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user