feat(threads): governed group settings — rename, list members, remove participant
- MessageService.renameThread / removeParticipant / listParticipants, each fail-closed via OPA (kernel stays generic — no dm/group branching) - dev OPA: iios.thread.update + iios.thread.participant.remove rules (group requires ADMIN; ungoverned threads unchanged) - REST: PATCH /v1/threads/:id, GET + DELETE /v1/threads/:id/participants - tests: admin renames/removes, member is denied, member list carries roles (message.spec + dev-opa.port.spec) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,75 @@ export class MessageService {
|
||||
return { threadId, participantCount: participantCount + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Governed thread rename (a generic subject update). Policy decides who may rename — for a
|
||||
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
|
||||
* the subject; "group settings" meaning lives in the app + policy, not here.
|
||||
*/
|
||||
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
|
||||
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 membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||
|
||||
await decideOrThrow(this.ports, {
|
||||
action: 'iios.thread.update',
|
||||
threadId,
|
||||
scopeId: thread.scopeId,
|
||||
membership,
|
||||
callerRole: callerP?.participantRole,
|
||||
});
|
||||
|
||||
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
|
||||
return { threadId, subject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Governed participant removal. Policy decides who may remove — for a membership thread the dev
|
||||
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
|
||||
*/
|
||||
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): 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 membership = (thread.metadata as { membership?: string } | null)?.membership;
|
||||
|
||||
await decideOrThrow(this.ports, {
|
||||
action: 'iios.thread.participant.remove',
|
||||
threadId,
|
||||
scopeId: thread.scopeId,
|
||||
membership,
|
||||
callerRole: callerP?.participantRole,
|
||||
targetUserId,
|
||||
});
|
||||
|
||||
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.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
|
||||
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
|
||||
return { threadId, participantCount };
|
||||
}
|
||||
|
||||
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
|
||||
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
|
||||
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 parts = await this.prisma.iiosThreadParticipant.findMany({
|
||||
where: { threadId },
|
||||
include: { actor: { include: { sourceHandle: true } } },
|
||||
});
|
||||
return parts.map((p) => ({
|
||||
userId: p.actor?.sourceHandle?.externalId ?? '',
|
||||
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||
role: p.participantRole ?? 'MEMBER',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Toggle the caller's per-thread notification mute flag. */
|
||||
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
|
||||
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
|
||||
|
||||
Reference in New Issue
Block a user