Merge feat/triage-wake-mapping-reassign: privileged triage wake_mapping endpoint

This commit is contained in:
h z
2026-06-10 17:32:50 +01:00
2 changed files with 67 additions and 3 deletions

View File

@@ -1,8 +1,9 @@
import { BadRequestException, Body, Controller, Get, Param, Patch, Post, Query, Req, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Patch, Post, Put, Query, Req, UnauthorizedException } from '@nestjs/common';
import { ChannelsService } from './channels.service.js';
// ApiKeyGuard attaches the introspected Center user id onto the request.
type AuthedRequest = { userId?: string };
// ApiKeyGuard attaches the introspected Center user id onto the request,
// and sets isSystem=true for system-key callers (x-fabric-system-key).
type AuthedRequest = { userId?: string; isSystem?: boolean };
@Controller('channels')
export class ChannelsController {
@@ -59,6 +60,29 @@ export class ChannelsController {
return this.channelsService.updatePurpose(channelId, userId, body.purpose);
}
// Reassign a triage channel's wake_mapping (who triage arrivals wake) —
// the on-call-handoff baton. PRIVILEGED: requires the system key
// (x-fabric-system-key → req.isSystem); a normal agent's guild token is
// rejected. Restricted to triage channels in the service. Body:
// { wakeUserIds: string[] } (the new on-duty user id(s)).
@Put(':id/wake-mapping')
setWakeMapping(
@Req() req: AuthedRequest,
@Param('id') channelId: string,
@Body() body: Record<string, unknown>,
) {
if (!req.isSystem) {
throw new ForbiddenException('wake_mapping reassignment requires the system key');
}
const wakeUserIds = Array.isArray(body.wakeUserIds)
? (body.wakeUserIds as unknown[]).map((u) => String(u ?? '').trim()).filter(Boolean)
: [];
if (!wakeUserIds.length) {
throw new BadRequestException('wakeUserIds (non-empty string[]) is required');
}
return this.channelsService.setTriageWakeMapping(channelId, wakeUserIds);
}
// Move an order member into the bypass list (discuss/work only).
@Post(':id/bypass')
bypass(

View File

@@ -248,6 +248,46 @@ export class ChannelsService {
};
}
// Reassign a TRIAGE channel's wake_mapping — the set of users woken by
// triage arrivals (the on-call-handoff baton). Privileged: the caller is
// gated at the controller on the system key, not a per-user membership
// check, because handing the baton to the *incoming* agent is exactly an
// operation the outgoing agent's own token shouldn't be trusted to scope.
//
// Atomically replaces every wake row for the channel. New wake users are
// ensured to be channel members first (the realtime gateway only routes a
// triage message to members, so a non-member on-duty agent would never be
// woken). Restricted to xType=triage; other types derive wake differently
// (custom=listeners, discuss/work=rotation) and must not be poked here.
async setTriageWakeMapping(channelId: string, wakeUserIds: string[]) {
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) throw new NotFoundException('channel not found');
if (channel.xType !== 'triage') {
throw new BadRequestException('wake_mapping reassignment is only allowed for triage channels');
}
const ids = [...new Set(wakeUserIds.map((u) => String(u ?? '').trim()).filter(Boolean))];
if (!ids.length) throw new BadRequestException('wakeUserIds must contain at least one user id');
const newMembers: string[] = [];
for (const userId of ids) {
const existing = await this.memberRepo.findOne({ where: { channelId, userId } });
if (!existing) {
await this.memberRepo.save(this.memberRepo.create({ channelId, userId }));
newMembers.push(userId);
}
}
// Atomic swap: drop all current wake rows, then insert the new set.
await this.wakeRepo.delete({ channelId });
await this.wakeRepo.save(ids.map((userId) => this.wakeRepo.create({ channelId, userId })));
if (newMembers.length) {
this.notifyMembership('joined', channelId, newMembers, { xType: channel.xType });
}
return { id: channel.id, name: channel.name, xType: channel.xType, wakeUserIds: ids };
}
// Move an order member into the bypass list (discuss/work only).
// Any channel member may do this.
async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {