feat(channels): bypass-list for discuss/work rotation
- channel_turn_state.bypass_user_ids: order and bypass form a disjoint
partition of the channel members; bypass excluded from rotation.
- initForChannel(channelId, members, bypass=[]) computes order = members
− bypass; create() passes bypassUserIds (∩ members) for discuss/work.
- pushFrame() enforces mention nesting cap: max 4 sub-frames (5 levels
incl. root); overflow evicts the bottom-most (root->A..D + E => root->B..E).
- mention sites use pushFrame so bypass members are only transiently
pulled in via @-mention, then return to bypass on pop.
- moveToBypass(): move an order member to bypass mid-rotation; if current
speaker, successor takes over. onMemberRemoved also strips bypass.
- POST /channels/:id/bypass; GET :id/members now returns {userId,bypass}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,11 +29,30 @@ export class ChannelsController {
|
|||||||
memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [],
|
memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [],
|
||||||
onDuty: body.onDuty as string | undefined,
|
onDuty: body.onDuty as string | undefined,
|
||||||
listeners: Array.isArray(body.listeners) ? (body.listeners as string[]) : [],
|
listeners: Array.isArray(body.listeners) ? (body.listeners as string[]) : [],
|
||||||
|
bypassUserIds: Array.isArray(body.bypassUserIds)
|
||||||
|
? (body.bypassUserIds as string[])
|
||||||
|
: [],
|
||||||
},
|
},
|
||||||
userId,
|
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<string, unknown>,
|
||||||
|
) {
|
||||||
|
const userId = req.userId ?? '';
|
||||||
|
if (!userId) throw new UnauthorizedException('missing user');
|
||||||
|
return this.channelsService.moveToBypass(
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
String(body.userId ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/members')
|
@Get(':id/members')
|
||||||
members(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
members(@Req() req: AuthedRequest, @Param('id') channelId: string) {
|
||||||
const userId = req.userId ?? '';
|
const userId = req.userId ?? '';
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type CreateChannelInput = {
|
|||||||
onDuty?: string;
|
onDuty?: string;
|
||||||
// optional when xType === 'custom': users to wake on this channel
|
// optional when xType === 'custom': users to wake on this channel
|
||||||
listeners?: string[];
|
listeners?: string[];
|
||||||
|
// discuss/work only: members excluded from rotation (no wakeup unless
|
||||||
|
// @-mentioned). order and bypass partition the members disjointly.
|
||||||
|
bypassUserIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,12 +59,13 @@ export class ChannelsService {
|
|||||||
.map((c) => ({ ...c, isMember: memberChannelIds.has(c.id) }));
|
.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({
|
const rows = await this.memberRepo.find({
|
||||||
where: { channelId },
|
where: { channelId },
|
||||||
order: { createdAt: 'ASC' },
|
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) {
|
async closeChannel(channelId: string, userId: string) {
|
||||||
@@ -164,9 +168,28 @@ export class ChannelsService {
|
|||||||
// discuss/work: initialize rotation state (order = members sorted by id,
|
// discuss/work: initialize rotation state (order = members sorted by id,
|
||||||
// currentSpeaker = null until someone proactively speaks)
|
// currentSpeaker = null until someone proactively speaks)
|
||||||
if (xType === 'discuss' || xType === 'work') {
|
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;
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class TurnService {
|
|||||||
norepStreak: [],
|
norepStreak: [],
|
||||||
lastNormalSpeaker: null,
|
lastNormalSpeaker: null,
|
||||||
frames: [],
|
frames: [],
|
||||||
|
bypassUserIds: [],
|
||||||
});
|
});
|
||||||
return manager.save(ChannelTurnState, state);
|
return manager.save(ChannelTurnState, state);
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,21 @@ export class TurnService {
|
|||||||
return state.frames;
|
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
|
// effective current speaker = top sub-frame's pointer, else root speaker
|
||||||
private effectiveCurrent(state: ChannelTurnState): string | null {
|
private effectiveCurrent(state: ChannelTurnState): string | null {
|
||||||
const fr = this.frames(state);
|
const fr = this.frames(state);
|
||||||
@@ -80,10 +96,17 @@ export class TurnService {
|
|||||||
return this.effectiveCurrent(state);
|
return this.effectiveCurrent(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
async initForChannel(channelId: string, memberUserIds: string[]): Promise<void> {
|
async initForChannel(
|
||||||
|
channelId: string,
|
||||||
|
memberUserIds: string[],
|
||||||
|
bypassUserIds: string[] = [],
|
||||||
|
): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const existing = await manager.findOne(ChannelTurnState, { where: { channelId } });
|
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 = {
|
const base = {
|
||||||
orderUserIds: order,
|
orderUserIds: order,
|
||||||
currentSpeaker: null,
|
currentSpeaker: null,
|
||||||
@@ -91,6 +114,7 @@ export class TurnService {
|
|||||||
norepStreak: [] as string[],
|
norepStreak: [] as string[],
|
||||||
lastNormalSpeaker: null,
|
lastNormalSpeaker: null,
|
||||||
frames: [] as TurnFrame[],
|
frames: [] as TurnFrame[],
|
||||||
|
bypassUserIds: [...bypassSet],
|
||||||
};
|
};
|
||||||
if (existing) {
|
if (existing) {
|
||||||
Object.assign(existing, base);
|
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<string[]> {
|
||||||
|
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<void> {
|
async onMemberAdded(channelId: string, userId: string): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.ensureState(manager, channelId);
|
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];
|
state.orderUserIds = [...state.orderUserIds, userId];
|
||||||
await manager.save(ChannelTurnState, state);
|
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<void> {
|
||||||
|
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<void> {
|
async onMemberRemoved(channelId: string, userId: string): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.loadLocked(manager, channelId);
|
const state = await this.loadLocked(manager, channelId);
|
||||||
@@ -128,6 +189,7 @@ export class TurnService {
|
|||||||
state.currentSpeaker = state.orderUserIds.length ? nextCurrent : null;
|
state.currentSpeaker = state.orderUserIds.length ? nextCurrent : null;
|
||||||
}
|
}
|
||||||
state.norepStreak = state.norepStreak.filter((u) => u !== userId);
|
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
|
// strip the leaver from every sub-frame; drop emptied frames; clamp idx
|
||||||
const fr = this.frames(state)
|
const fr = this.frames(state)
|
||||||
@@ -165,7 +227,7 @@ export class TurnService {
|
|||||||
const cur = top.order[Math.min(top.idx, top.order.length - 1)];
|
const cur = top.order[Math.min(top.idx, top.order.length - 1)];
|
||||||
if (authorUserId === cur) {
|
if (authorUserId === cur) {
|
||||||
if (atList.length) {
|
if (atList.length) {
|
||||||
fr.push({ order: atList, idx: 0 });
|
this.pushFrame(state, atList);
|
||||||
await manager.save(ChannelTurnState, state);
|
await manager.save(ChannelTurnState, state);
|
||||||
return { wakeupUserId: atList[0] };
|
return { wakeupUserId: atList[0] };
|
||||||
}
|
}
|
||||||
@@ -202,7 +264,7 @@ export class TurnService {
|
|||||||
// current speaker mentioning -> push a sub-frame; root pointer (this
|
// current speaker mentioning -> push a sub-frame; root pointer (this
|
||||||
// speaker) is left as-is and resumes after the sub-frame pops
|
// speaker) is left as-is and resumes after the sub-frame pops
|
||||||
if (atList.length) {
|
if (atList.length) {
|
||||||
fr.push({ order: atList, idx: 0 });
|
this.pushFrame(state, atList);
|
||||||
await manager.save(ChannelTurnState, state);
|
await manager.save(ChannelTurnState, state);
|
||||||
return { wakeupUserId: atList[0] };
|
return { wakeupUserId: atList[0] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ export class ChannelTurnState {
|
|||||||
@Column({ name: 'channel_id', type: 'char', length: 36 })
|
@Column({ name: 'channel_id', type: 'char', length: 36 })
|
||||||
channelId!: string;
|
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' })
|
@Column({ name: 'order_user_ids', type: 'json' })
|
||||||
orderUserIds!: string[];
|
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)
|
// null = paused (created, or all-members-consecutively-/no-reply)
|
||||||
@Column({ name: 'current_speaker', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'current_speaker', type: 'varchar', length: 64, nullable: true })
|
||||||
currentSpeaker!: string | null;
|
currentSpeaker!: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user