diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index 28b0221..f0b83e0 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -29,11 +29,30 @@ export class ChannelsController { memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [], onDuty: body.onDuty as string | undefined, listeners: Array.isArray(body.listeners) ? (body.listeners as string[]) : [], + bypassUserIds: Array.isArray(body.bypassUserIds) + ? (body.bypassUserIds as string[]) + : [], }, userId, ); } + // Move an order member into the bypass list (discuss/work only). + @Post(':id/bypass') + bypass( + @Req() req: AuthedRequest, + @Param('id') channelId: string, + @Body() body: Record, + ) { + const userId = req.userId ?? ''; + if (!userId) throw new UnauthorizedException('missing user'); + return this.channelsService.moveToBypass( + channelId, + userId, + String(body.userId ?? ''), + ); + } + @Get(':id/members') members(@Req() req: AuthedRequest, @Param('id') channelId: string) { const userId = req.userId ?? ''; diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index 79d1c48..ab61067 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -20,6 +20,9 @@ type CreateChannelInput = { onDuty?: string; // optional when xType === 'custom': users to wake on this channel listeners?: string[]; + // discuss/work only: members excluded from rotation (no wakeup unless + // @-mentioned). order and bypass partition the members disjointly. + bypassUserIds?: string[]; }; @Injectable() @@ -56,12 +59,13 @@ export class ChannelsService { .map((c) => ({ ...c, isMember: memberChannelIds.has(c.id) })); } - async channelMembers(channelId: string): Promise<{ userId: string }[]> { + async channelMembers(channelId: string): Promise<{ userId: string; bypass: boolean }[]> { const rows = await this.memberRepo.find({ where: { channelId }, order: { createdAt: 'ASC' }, }); - return rows.map((r) => ({ userId: r.userId })); + const bypass = new Set(await this.turnService.getBypassUserIds(channelId)); + return rows.map((r) => ({ userId: r.userId, bypass: bypass.has(r.userId) })); } async closeChannel(channelId: string, userId: string) { @@ -164,9 +168,28 @@ export class ChannelsService { // discuss/work: initialize rotation state (order = members sorted by id, // currentSpeaker = null until someone proactively speaks) if (xType === 'discuss' || xType === 'work') { - await this.turnService.initForChannel(channel.id, [...memberIds]); + const bypass = (input.bypassUserIds ?? []) + .map((x) => String(x ?? '').trim()) + .filter((x) => x && memberIds.has(x)); + await this.turnService.initForChannel(channel.id, [...memberIds], bypass); } return channel; } + + // 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) { + const channel = await this.channelRepo.findOne({ where: { id: channelId } }); + if (!channel) throw new NotFoundException('channel not found'); + if (channel.xType !== 'discuss' && channel.xType !== 'work') { + throw new BadRequestException('bypass only applies to discuss/work channels'); + } + const actor = await this.memberRepo.findOne({ where: { channelId, userId: actorUserId } }); + if (!actor && !channel.isPublic) throw new ForbiddenException('not a channel member'); + const target = await this.memberRepo.findOne({ where: { channelId, userId: targetUserId } }); + if (!target) throw new BadRequestException('target is not a channel member'); + await this.turnService.moveToBypass(channelId, targetUserId); + return { status: 'ok', channelId, userId: targetUserId, bypass: true }; + } } diff --git a/src/channels/turn.service.ts b/src/channels/turn.service.ts index 73e5188..89b567b 100644 --- a/src/channels/turn.service.ts +++ b/src/channels/turn.service.ts @@ -43,6 +43,7 @@ export class TurnService { norepStreak: [], lastNormalSpeaker: null, frames: [], + bypassUserIds: [], }); return manager.save(ChannelTurnState, state); } @@ -52,6 +53,21 @@ export class TurnService { return state.frames; } + private bypass(state: ChannelTurnState): string[] { + if (!Array.isArray(state.bypassUserIds)) state.bypassUserIds = []; + return state.bypassUserIds; + } + + // Push a mention sub-frame, enforcing the nesting cap. Max 4 sub-frames + // (5 levels incl. root); a 5th push evicts the bottom-most sub-frame + // (the one directly above root) and shifts the rest down: + // root->A->B->C->D + E => root->B->C->D->E + private pushFrame(state: ChannelTurnState, order: string[]): void { + const fr = this.frames(state); + while (fr.length >= 4) fr.shift(); + fr.push({ order, idx: 0 }); + } + // effective current speaker = top sub-frame's pointer, else root speaker private effectiveCurrent(state: ChannelTurnState): string | null { const fr = this.frames(state); @@ -80,10 +96,17 @@ export class TurnService { return this.effectiveCurrent(state); } - async initForChannel(channelId: string, memberUserIds: string[]): Promise { + async initForChannel( + channelId: string, + memberUserIds: string[], + bypassUserIds: string[] = [], + ): Promise { await this.dataSource.transaction(async (manager) => { const existing = await manager.findOne(ChannelTurnState, { where: { channelId } }); - const order = [...new Set(memberUserIds)].sort(); + const members = [...new Set(memberUserIds)]; + const bypassSet = new Set(bypassUserIds.filter((u) => members.includes(u))); + // order and bypass are a disjoint partition of members + const order = members.filter((u) => !bypassSet.has(u)).sort(); const base = { orderUserIds: order, currentSpeaker: null, @@ -91,6 +114,7 @@ export class TurnService { norepStreak: [] as string[], lastNormalSpeaker: null, frames: [] as TurnFrame[], + bypassUserIds: [...bypassSet], }; if (existing) { Object.assign(existing, base); @@ -101,16 +125,53 @@ export class TurnService { }); } + // Read-only: userIds currently in the bypass list (no rotation wakeup + // unless @-mentioned). Empty if no turn state / not discuss-work. + async getBypassUserIds(channelId: string): Promise { + const state = await this.dataSource + .getRepository(ChannelTurnState) + .findOne({ where: { channelId } }); + return state && Array.isArray(state.bypassUserIds) ? state.bypassUserIds : []; + } + async onMemberAdded(channelId: string, userId: string): Promise { await this.dataSource.transaction(async (manager) => { const state = await this.ensureState(manager, channelId); - if (!state.orderUserIds.includes(userId)) { + const inBypass = this.bypass(state).includes(userId); + if (!state.orderUserIds.includes(userId) && !inBypass) { state.orderUserIds = [...state.orderUserIds, userId]; await manager.save(ChannelTurnState, state); } }); } + // Move an order member into the bypass list (any channel member may do + // this). If they are the current speaker, the next one takes over. + async moveToBypass(channelId: string, userId: string): Promise { + await this.dataSource.transaction(async (manager) => { + const state = await this.ensureState(manager, channelId); + const order = state.orderUserIds; + const idx = order.indexOf(userId); + if (idx === -1) return; // not in rotation (already bypass / unknown) + + if (state.currentSpeaker === userId) { + const next = order.length > 1 ? order[(idx + 1) % order.length] : null; + state.currentSpeaker = next === userId ? null : next; + } + state.orderUserIds = order.filter((u) => u !== userId); + if (!state.orderUserIds.length) state.currentSpeaker = null; + state.norepStreak = state.norepStreak.filter((u) => u !== userId); + // remove from active sub-frames (re-enters only via a future mention) + state.frames = this.frames(state) + .map((f) => ({ order: f.order.filter((u) => u !== userId), idx: f.idx })) + .filter((f) => f.order.length > 0) + .map((f) => ({ order: f.order, idx: Math.min(f.idx, f.order.length - 1) })); + const bp = this.bypass(state); + if (!bp.includes(userId)) bp.push(userId); + await manager.save(ChannelTurnState, state); + }); + } + async onMemberRemoved(channelId: string, userId: string): Promise { await this.dataSource.transaction(async (manager) => { const state = await this.loadLocked(manager, channelId); @@ -128,6 +189,7 @@ export class TurnService { state.currentSpeaker = state.orderUserIds.length ? nextCurrent : null; } state.norepStreak = state.norepStreak.filter((u) => u !== userId); + state.bypassUserIds = this.bypass(state).filter((u) => u !== userId); // strip the leaver from every sub-frame; drop emptied frames; clamp idx const fr = this.frames(state) @@ -165,7 +227,7 @@ export class TurnService { const cur = top.order[Math.min(top.idx, top.order.length - 1)]; if (authorUserId === cur) { if (atList.length) { - fr.push({ order: atList, idx: 0 }); + this.pushFrame(state, atList); await manager.save(ChannelTurnState, state); return { wakeupUserId: atList[0] }; } @@ -202,7 +264,7 @@ export class TurnService { // current speaker mentioning -> push a sub-frame; root pointer (this // speaker) is left as-is and resumes after the sub-frame pops if (atList.length) { - fr.push({ order: atList, idx: 0 }); + this.pushFrame(state, atList); await manager.save(ChannelTurnState, state); return { wakeupUserId: atList[0] }; } diff --git a/src/entities/channel-turn-state.entity.ts b/src/entities/channel-turn-state.entity.ts index 3c4ee87..a0dfbec 100644 --- a/src/entities/channel-turn-state.entity.ts +++ b/src/entities/channel-turn-state.entity.ts @@ -18,10 +18,17 @@ export class ChannelTurnState { @Column({ name: 'channel_id', type: 'char', length: 36 }) channelId!: string; - // speaking order; userIds + // speaking order; userIds. order and bypass are a DISJOINT partition of + // the channel's members. @Column({ name: 'order_user_ids', type: 'json' }) orderUserIds!: string[]; + // members excluded from rotation: never woken by normal rotation, only when + // @-mentioned (then transiently pulled into a sub-frame; back to bypass on + // pop). discuss/work only. + @Column({ name: 'bypass_user_ids', type: 'json', nullable: true }) + bypassUserIds!: string[] | null; + // null = paused (created, or all-members-consecutively-/no-reply) @Column({ name: 'current_speaker', type: 'varchar', length: 64, nullable: true }) currentSpeaker!: string | null;