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