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