Files
iios/packages/iios-service/src/messaging/message.service.ts
T
maaz519 c4206f9809 fix(iios): thread subject on create, graceful open_thread error, isolated test DB
- openThread/createThread accept a generic `subject` (group name) — stored on the
  thread, echoed via listThreads; kernel never interprets it.
- Gateway open_thread now returns an ACK'd { error } on failure instead of throwing
  (which never acked → clients hung on "loading"). Non-member/missing-thread opens
  fail cleanly.
- Tests run against an isolated `iios_test` database (vitest globalSetup creates +
  migrates it; test.env overrides DATABASE_URL) so `pnpm test` can never TRUNCATE the
  dev database again. Verified: full suite green, dev DB row counts unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 19:39:19 +05:30

391 lines
17 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts';
import { PrismaService } from '../prisma/prisma.service';
import { PLATFORM_PORTS } from '../platform/platform-ports';
import { decideOrThrow } from '../platform/fail-closed';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
export type { MessagePrincipal };
export interface MessageDto {
id: string;
threadId: string;
senderActorId: string;
senderName: string;
content: string;
contentRef?: string;
parentInteractionId?: string;
traceId: string;
createdAt: Date;
}
export interface ThreadSummary {
threadId: string;
subject: string | null;
membership?: string;
participants: string[];
participantCount: number;
unread: number;
lastMessage?: string;
lastAt?: Date;
}
export interface OpenThreadResult {
threadId: string;
status: string;
history: MessageDto[];
}
/**
* Native direct messaging (P2) over the P1 kernel. Pure message semantics — no
* tickets/agents/SLA. Send writes an interaction + parts + a `message.sent`
* outbox event in one tx, bumps every other participant's unread, and stamps a
* trace id (DB → event). Reuses the kernel handle/actor resolution from ingest.
*/
@Injectable()
export class MessageService {
constructor(
private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver,
) {}
/**
* Open an existing thread, or create one when no id is given. On create, an optional
* generic `membership` attribute is stamped on the thread's metadata (the app's DM/group
* hint — the kernel never branches on it; policy does), and the creator joins as ADMIN
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
*/
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
const thread = await this.prisma.iiosThread.create({
data: {
scopeId: scope.id,
createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
},
});
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
return { threadId: thread.id, status: thread.status, history: [] };
}
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const actor = await this.actors.resolveActor(thread.scopeId, principal);
const alreadyMember =
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
// Join is governed ONLY for threads that opted into a membership model (chat dm/group);
// support/inbox/generic threads (no membership attr) keep open-join, unchanged.
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember });
await this.actors.ensureParticipant(threadId, actor.id);
return { threadId, status: thread.status, history: await this.history(threadId) };
}
/**
* Governed thread membership (a member adds another user by userId). Fail-closed via
* policy: the DM cap / group-admin rule is enforced by OPA (dev stub now, real later);
* the kernel only supplies generic context and adds the participant. The target's actor
* is resolved-or-created so you can add someone who hasn't logged in yet.
*/
async addParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string, role = 'MEMBER'): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.add',
threadId,
scopeId: thread.scopeId,
membership,
participantCount,
callerRole: callerP?.participantRole,
targetUserId,
role,
});
const scope = await this.actors.resolveScope(principal);
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
await this.actors.ensureParticipant(threadId, target.id, role);
return { threadId, participantCount: participantCount + 1 };
}
/** Generic "my threads": every thread the caller participates in, with last message + unread. */
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal);
const memberships = await this.prisma.iiosThreadParticipant.findMany({ where: { actorId: actor.id }, select: { threadId: true } });
const threadIds = memberships.map((m) => m.threadId);
if (threadIds.length === 0) return [];
const [threads, unreads, allParts] = await Promise.all([
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
this.prisma.iiosThreadParticipant.findMany({
where: { threadId: { in: threadIds } },
include: { actor: { include: { sourceHandle: true } } },
}),
]);
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
const membersBy = new Map<string, string[]>();
for (const p of allParts) {
const name = p.actor?.sourceHandle?.externalId ?? p.actor?.displayName ?? p.actorId;
membersBy.set(p.threadId, [...(membersBy.get(p.threadId) ?? []), name]);
}
const summaries = await Promise.all(
threads.map(async (t) => {
const last = await this.prisma.iiosInteraction.findFirst({
where: { threadId: t.id },
orderBy: { occurredAt: 'desc' },
include: { parts: { where: { kind: 'TEXT' }, take: 1 } },
});
const members = membersBy.get(t.id) ?? [];
return {
threadId: t.id,
subject: t.subject,
membership: (t.metadata as { membership?: string } | null)?.membership,
participants: members,
participantCount: members.length,
unread: unreadBy.get(t.id) ?? 0,
lastMessage: last?.parts[0]?.bodyText ?? undefined,
lastAt: last?.occurredAt,
};
}),
);
return summaries.sort((a, b) => (b.lastAt?.getTime() ?? 0) - (a.lastAt?.getTime() ?? 0));
}
async send(
threadId: string,
principal: MessagePrincipal,
body: { content: string; contentRef?: string },
idempotencyKey: string,
traceId: string = randomUUID(),
parentInteractionId?: string,
): Promise<MessageDto> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.message.send', threadId, scopeId: thread.scopeId });
const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.actors.ensureParticipant(threadId, actor.id);
// A reply links to its parent — but only if the parent is in the same thread (else ignored).
const parentRef = parentInteractionId
? (await this.prisma.iiosInteraction.findFirst({ where: { id: parentInteractionId, threadId }, select: { id: true } }))?.id
: undefined;
// Idempotency: a repeat key returns the existing message (no re-increment).
const existing = await this.prisma.iiosInteraction.findUnique({
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
if (existing) return this.toDto(existing, threadId);
try {
const interaction = await this.prisma.$transaction(async (tx) => {
const created = await tx.iiosInteraction.create({
data: {
scopeId: thread.scopeId,
kind: 'MESSAGE',
threadId,
actorId: actor.id,
idempotencyKey,
status: 'NORMALIZED',
traceId,
parentInteractionId: parentRef,
},
});
const parts: Prisma.IiosMessagePartCreateManyInput[] = [
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
];
if (body.contentRef) {
parts.push({ interactionId: created.id, partIndex: 1, kind: 'FILE_REF', contentRef: body.contentRef });
}
await tx.iiosMessagePart.createMany({ data: parts });
const event: CloudEvent = {
specversion: '1.0',
id: `evt_${created.id}`,
type: IIOS_EVENTS.messageSent,
source: `iios/message/${thread.scopeId}`,
subject: `interaction/${created.id}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
traceparent: `00-${traceId.replace(/-/g, '')}-0000000000000000-01`,
insignia: { scopeSnapshotId: thread.scopeId, correlationId: traceId, idempotencyKey, dataClass: 'internal' },
data: { interactionId: created.id, threadId, senderActorId: actor.id },
};
await tx.iiosOutboxEvent.create({
data: {
aggregateType: 'interaction',
aggregateId: created.id,
eventType: IIOS_EVENTS.messageSent,
cloudEvent: event as unknown as Prisma.InputJsonValue,
partitionKey: `${thread.scopeId}:${threadId}`,
},
});
// Bump unread for every participant except the sender.
const participants = await tx.iiosThreadParticipant.findMany({ where: { threadId } });
for (const p of participants) {
if (p.actorId === actor.id) continue;
await tx.iiosUnreadCounter.upsert({
where: { threadId_actorId: { threadId, actorId: p.actorId } },
create: { threadId, actorId: p.actorId, unreadCount: 1 },
update: { unreadCount: { increment: 1 } },
});
}
return tx.iiosInteraction.findUniqueOrThrow({
where: { id: created.id },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
});
return this.toDto(interaction, threadId);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
return this.toDto(winner, threadId);
}
throw err;
}
}
async markRead(
threadId: string,
principal: MessagePrincipal,
interactionId: string,
): Promise<{ interactionId: string; actorId: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const actor = await this.actors.resolveActor(thread.scopeId, principal);
const readEvent: CloudEvent = {
specversion: '1.0',
id: `evt_read_${interactionId}_${actor.id}`,
type: IIOS_EVENTS.messageRead,
source: `iios/message/${thread.scopeId}`,
subject: `interaction/${interactionId}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
insignia: { scopeSnapshotId: thread.scopeId, idempotencyKey: `read:${interactionId}:${actor.id}`, dataClass: 'internal' },
data: { threadId, actorId: actor.id, interactionId },
};
await this.prisma.$transaction([
this.prisma.iiosMessageReceipt.upsert({
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'READ' } },
create: { interactionId, actorId: actor.id, receiptKind: 'READ' },
update: { occurredAt: new Date() },
}),
this.prisma.iiosUnreadCounter.upsert({
where: { threadId_actorId: { threadId, actorId: actor.id } },
create: { threadId, actorId: actor.id, unreadCount: 0, lastReadInteractionId: interactionId },
update: { unreadCount: 0, lastReadInteractionId: interactionId },
}),
this.prisma.iiosOutboxEvent.create({
data: {
aggregateType: 'interaction',
aggregateId: interactionId,
eventType: IIOS_EVENTS.messageRead,
cloudEvent: readEvent as unknown as Prisma.InputJsonValue,
partitionKey: `${thread.scopeId}:${threadId}`,
},
}),
]);
return { interactionId, actorId: actor.id };
}
async markDelivered(
threadId: string,
principal: MessagePrincipal,
interactionId: string,
): Promise<{ interactionId: string; actorId: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const actor = await this.actors.resolveActor(thread.scopeId, principal);
await this.prisma.iiosMessageReceipt.upsert({
where: { interactionId_actorId_receiptKind: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' } },
create: { interactionId, actorId: actor.id, receiptKind: 'DELIVERED' },
update: { occurredAt: new Date() },
});
return { interactionId, actorId: actor.id };
}
async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> {
const i = await this.prisma.iiosInteraction.findUnique({
where: { id },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
if (!i || !i.threadId) return null;
if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller
return this.toDto(i, i.threadId);
}
async history(threadId: string): Promise<MessageDto[]> {
const interactions = await this.prisma.iiosInteraction.findMany({
where: { threadId },
orderBy: { occurredAt: 'asc' },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
return interactions.map((i) => this.toDto(i, threadId));
}
// ─── helpers ────────────────────────────────────────────────────
private toDto(
interaction: {
id: string;
actorId: string | null;
actor?: { displayName: string | null; sourceHandle: { externalId: string } | null } | null;
parentInteractionId?: string | null;
traceId: string | null;
occurredAt: Date;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
},
threadId: string,
): MessageDto {
const text = interaction.parts.find((p) => p.kind === 'TEXT');
const file = interaction.parts.find((p) => p.contentRef);
return {
id: interaction.id,
threadId,
senderActorId: interaction.actorId ?? '',
senderName: interaction.actor?.sourceHandle?.externalId ?? interaction.actor?.displayName ?? 'unknown',
content: text?.bodyText ?? '',
contentRef: file?.contentRef ?? undefined,
parentInteractionId: interaction.parentInteractionId ?? undefined,
traceId: interaction.traceId ?? '',
createdAt: interaction.occurredAt,
};
}
}