diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index 2eed767..d7f14a5 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -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, + ) { + 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( diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index a962e35..7f6ed27 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -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) {