Merge feat/triage-wake-mapping-reassign: privileged triage wake_mapping endpoint
This commit is contained in:
@@ -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';
|
import { ChannelsService } from './channels.service.js';
|
||||||
|
|
||||||
// ApiKeyGuard attaches the introspected Center user id onto the request.
|
// ApiKeyGuard attaches the introspected Center user id onto the request,
|
||||||
type AuthedRequest = { userId?: string };
|
// and sets isSystem=true for system-key callers (x-fabric-system-key).
|
||||||
|
type AuthedRequest = { userId?: string; isSystem?: boolean };
|
||||||
|
|
||||||
@Controller('channels')
|
@Controller('channels')
|
||||||
export class ChannelsController {
|
export class ChannelsController {
|
||||||
@@ -59,6 +60,29 @@ export class ChannelsController {
|
|||||||
return this.channelsService.updatePurpose(channelId, userId, body.purpose);
|
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).
|
// Move an order member into the bypass list (discuss/work only).
|
||||||
@Post(':id/bypass')
|
@Post(':id/bypass')
|
||||||
bypass(
|
bypass(
|
||||||
|
|||||||
@@ -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).
|
// Move an order member into the bypass list (discuss/work only).
|
||||||
// Any channel member may do this.
|
// Any channel member may do this.
|
||||||
async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {
|
async moveToBypass(channelId: string, actorUserId: string, targetUserId: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user