Initial release: MCP server enforcing Worker-Reviewer loop
Diligence prevents AI agents from shipping quick fixes that break things by enforcing a research-propose-verify loop before any code changes. Key features: - Worker sub-agent researches and proposes with file:line citations - Reviewer sub-agent independently verifies claims by searching codebase - Iterates until approved (max 5 rounds) - Loads project-specific context from .claude/CODEBASE_CONTEXT.md - State persisted across sessions Validated on production codebase: caught architectural mistake (broker subscriptions on client-side code) that naive agent would have shipped.
This commit is contained in:
150
test/fixture/.claude/CODEBASE_CONTEXT.md
Normal file
150
test/fixture/.claude/CODEBASE_CONTEXT.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Codebase Context: Test Fixture
|
||||
|
||||
This is a simplified test codebase that mirrors real-world patterns. Use this context to understand the architecture before making changes.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
src/
|
||||
├── broker/
|
||||
│ └── events.ts # Broker event bus (Subject-based pub/sub)
|
||||
├── services/
|
||||
│ ├── user-block.service.ts # Blocking logic
|
||||
│ ├── voice-channel.service.ts # Voice channels and DM calls
|
||||
│ ├── chat.service.ts # Chat channels and messages
|
||||
│ └── team.service.ts # Team state and permission caching
|
||||
└── controllers/
|
||||
└── roles.controller.ts # REST API for roles
|
||||
```
|
||||
|
||||
## Critical Pattern: Broker Events
|
||||
|
||||
**All state changes that affect multiple services MUST emit broker events.**
|
||||
|
||||
### Available Events
|
||||
|
||||
| Event | Emitted When | Expected Subscribers |
|
||||
|-------|--------------|---------------------|
|
||||
| `BusUserBlockChange` | User blocks/unblocks another | Voice services, DM services |
|
||||
| `BusTeamRoleChange` | Role created/updated/deleted | Permission caches |
|
||||
| `BusTeamMemberRoleChange` | User role assigned/removed | Permission caches |
|
||||
| `BusVoiceParticipant` | User joins/leaves voice | Voice UI components |
|
||||
| `BusDmCall` | DM call state changes | Call observers |
|
||||
|
||||
### Pattern Example
|
||||
|
||||
```typescript
|
||||
// CORRECT: Emit event after state change
|
||||
async updateRole(teamId, roleId, updates) {
|
||||
const role = await db.update(roleId, updates);
|
||||
|
||||
BusTeamRoleChange.next({ // ← MUST emit event
|
||||
teamId,
|
||||
roleId,
|
||||
action: 'updated',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return role;
|
||||
}
|
||||
```
|
||||
|
||||
## Critical Pattern: Permission vs Action Checks
|
||||
|
||||
**Permission = Visibility. Action = Separate Check.**
|
||||
|
||||
### Why This Matters
|
||||
|
||||
For DM channels, blocking creates a `'read'` permission, NOT `'denied'`. The user can still SEE the DM channel, but cannot SEND messages.
|
||||
|
||||
```typescript
|
||||
// Permission check (for visibility)
|
||||
if (isBlocked) {
|
||||
return { permission: 'read', reason: 'blocked' }; // ← 'read', not 'denied'
|
||||
}
|
||||
|
||||
// Action check (separate from permission)
|
||||
async sendMessage(userId, channel, content) {
|
||||
if (await isBlockingEitherWay(userA, userB)) {
|
||||
throw new Error('Cannot send messages'); // ← Separate check
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Voice Permission Pattern
|
||||
|
||||
For DM channels, voice permissions are **always true**:
|
||||
|
||||
```typescript
|
||||
return {
|
||||
permission: 'read',
|
||||
voiceListen: true, // Always true for DM
|
||||
voiceTalk: true, // Blocking checked on JOIN, not here
|
||||
voiceWebcam: true,
|
||||
voiceScreenshare: true,
|
||||
};
|
||||
```
|
||||
|
||||
Blocking is enforced by **action checks** in:
|
||||
- `joinVoiceChannel()` - line 33
|
||||
- `startDmCall()` - line 56
|
||||
|
||||
## Critical Pattern: Cache Invalidation
|
||||
|
||||
**Caches MUST subscribe to relevant broker events.**
|
||||
|
||||
### Current Bug Pattern
|
||||
|
||||
```typescript
|
||||
// BAD: Only clears on team switch
|
||||
constructor() {
|
||||
teamChange$.subscribe(() => {
|
||||
this.memoizedPermissions.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// GOOD: Also clear on role changes
|
||||
constructor() {
|
||||
teamChange$.subscribe(() => this.clearCache());
|
||||
BusTeamRoleChange.subscribe(() => this.clearCache()); // ← ADD THIS
|
||||
BusTeamMemberRoleChange.subscribe(() => this.clearCache()); // ← AND THIS
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist: Before Making Changes
|
||||
|
||||
### For ANY state change:
|
||||
|
||||
1. [ ] Does this change affect other services?
|
||||
2. [ ] Is there a broker event for this? If not, should there be?
|
||||
3. [ ] Are all relevant services subscribed to the event?
|
||||
|
||||
### For blocking-related changes:
|
||||
|
||||
1. [ ] Is blocking checked on all relevant ACTIONS (not just permissions)?
|
||||
2. [ ] What happens if block is created DURING an action (e.g., mid-call)?
|
||||
3. [ ] Are broker events emitted for blocking changes?
|
||||
4. [ ] Do voice services subscribe to `BusUserBlockChange`?
|
||||
|
||||
### For permission/cache changes:
|
||||
|
||||
1. [ ] What events should invalidate this cache?
|
||||
2. [ ] Is the cache subscribed to all relevant broker events?
|
||||
3. [ ] What's the TTL? Is stale data acceptable?
|
||||
|
||||
## Files Quick Reference
|
||||
|
||||
| File | Key Functions | Known Issues |
|
||||
|------|---------------|--------------|
|
||||
| `user-block.service.ts` | `blockUser()`, `unblockUser()` | Missing voice cleanup on block |
|
||||
| `voice-channel.service.ts` | `answerDmCall()`, `startDmCall()` | Missing blocking check on answer |
|
||||
| `team.service.ts` | `getPermission()`, `clearCache()` | Cache doesn't subscribe to role events |
|
||||
| `roles.controller.ts` | `createRole()`, `deleteRole()` | Missing broker events |
|
||||
| `chat.service.ts` | `getChannelPermission()` | **Reference implementation** (correct) |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
1. **Fixing in ONE place** - If blocking is checked in `startDmCall()`, it should also be in `answerDmCall()`
|
||||
2. **Changing permissions** - Don't change `voiceListen: true` to `voiceListen: !isBlocked`. Use action checks instead.
|
||||
3. **Forgetting broker events** - Every CRUD operation on roles/permissions should emit an event
|
||||
4. **Assuming cache is fresh** - If an operation can change state, subscribe to its event
|
||||
79
test/fixture/src/broker/events.ts
Normal file
79
test/fixture/src/broker/events.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Broker Event Bus System
|
||||
*
|
||||
* All state changes that affect multiple services should emit broker events.
|
||||
* Services subscribe to these events to maintain consistency.
|
||||
*/
|
||||
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
// ============================================================================
|
||||
// User Events
|
||||
// ============================================================================
|
||||
|
||||
export interface UserBlockEvent {
|
||||
sourceUserId: string;
|
||||
targetUserId: string;
|
||||
blocked: boolean; // true = blocked, false = unblocked
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const BusUserBlockChange = new Subject<UserBlockEvent>();
|
||||
|
||||
// ============================================================================
|
||||
// Team Events
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamRoleEvent {
|
||||
teamId: string;
|
||||
roleId: string;
|
||||
action: 'created' | 'updated' | 'deleted';
|
||||
flags?: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TeamMemberRoleEvent {
|
||||
teamId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
action: 'assigned' | 'removed';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const BusTeamRoleChange = new Subject<TeamRoleEvent>();
|
||||
export const BusTeamMemberRoleChange = new Subject<TeamMemberRoleEvent>();
|
||||
|
||||
// ============================================================================
|
||||
// Voice Events
|
||||
// ============================================================================
|
||||
|
||||
export interface VoiceParticipantEvent {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
action: 'joined' | 'left' | 'kicked';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface DmCallEvent {
|
||||
callId: string;
|
||||
callerId: string;
|
||||
calleeId: string;
|
||||
state: 'ringing' | 'active' | 'ended' | 'declined';
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const BusVoiceParticipant = new Subject<VoiceParticipantEvent>();
|
||||
export const BusDmCall = new Subject<DmCallEvent>();
|
||||
|
||||
// ============================================================================
|
||||
// Channel Events
|
||||
// ============================================================================
|
||||
|
||||
export interface ChannelMemberEvent {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
hidden: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export const BusChannelMember = new Subject<ChannelMemberEvent>();
|
||||
99
test/fixture/src/controllers/roles.controller.ts
Normal file
99
test/fixture/src/controllers/roles.controller.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Roles Controller
|
||||
*
|
||||
* REST API for managing team roles.
|
||||
*/
|
||||
|
||||
import { BusTeamRoleChange } from '../broker/events';
|
||||
|
||||
interface TeamRole {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
flags: number;
|
||||
}
|
||||
|
||||
// In-memory store
|
||||
const roles: TeamRole[] = [];
|
||||
|
||||
export class RolesController {
|
||||
/**
|
||||
* Create a new role.
|
||||
*
|
||||
* BUG: Doesn't emit BusTeamRoleChange event!
|
||||
* Clients won't know a new role was created.
|
||||
*/
|
||||
async createRole(teamId: string, name: string, flags: number): Promise<TeamRole> {
|
||||
const role: TeamRole = {
|
||||
id: `role_${Date.now()}`,
|
||||
teamId,
|
||||
name,
|
||||
flags,
|
||||
};
|
||||
roles.push(role);
|
||||
|
||||
// BUG: Missing broker event!
|
||||
// Should emit:
|
||||
// BusTeamRoleChange.next({
|
||||
// teamId,
|
||||
// roleId: role.id,
|
||||
// action: 'created',
|
||||
// flags,
|
||||
// timestamp: new Date(),
|
||||
// });
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing role.
|
||||
*
|
||||
* Emits broker event correctly (this one is fine).
|
||||
*/
|
||||
async updateRole(teamId: string, roleId: string, updates: Partial<TeamRole>): Promise<TeamRole> {
|
||||
const role = roles.find(r => r.id === roleId && r.teamId === teamId);
|
||||
if (!role) throw new Error('Role not found');
|
||||
|
||||
Object.assign(role, updates);
|
||||
|
||||
// This one correctly emits the event
|
||||
BusTeamRoleChange.next({
|
||||
teamId,
|
||||
roleId: role.id,
|
||||
action: 'updated',
|
||||
flags: role.flags,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role.
|
||||
*
|
||||
* BUG: Doesn't emit BusTeamRoleChange event!
|
||||
* Clients won't know the role was deleted, will have stale data.
|
||||
*/
|
||||
async deleteRole(teamId: string, roleId: string): Promise<void> {
|
||||
const index = roles.findIndex(r => r.id === roleId && r.teamId === teamId);
|
||||
if (index === -1) throw new Error('Role not found');
|
||||
|
||||
roles.splice(index, 1);
|
||||
|
||||
// BUG: Missing broker event!
|
||||
// Should emit:
|
||||
// BusTeamRoleChange.next({
|
||||
// teamId,
|
||||
// roleId,
|
||||
// action: 'deleted',
|
||||
// timestamp: new Date(),
|
||||
// });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles for a team.
|
||||
*/
|
||||
async getRoles(teamId: string): Promise<TeamRole[]> {
|
||||
return roles.filter(r => r.teamId === teamId);
|
||||
}
|
||||
}
|
||||
122
test/fixture/src/services/chat.service.ts
Normal file
122
test/fixture/src/services/chat.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Chat Service
|
||||
*
|
||||
* Manages chat channels and permissions.
|
||||
* This shows the CORRECT pattern for handling blocking in chat.
|
||||
*/
|
||||
|
||||
import { UserBlockService } from './user-block.service';
|
||||
|
||||
interface ChannelPermission {
|
||||
permission: 'read' | 'write' | 'admin' | 'denied';
|
||||
voiceListen: boolean;
|
||||
voiceTalk: boolean;
|
||||
voiceWebcam: boolean;
|
||||
voiceScreenshare: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
id: string;
|
||||
type: 'dm' | 'project';
|
||||
userA?: string; // For DM channels
|
||||
userB?: string; // For DM channels
|
||||
projectId?: string; // For project channels
|
||||
}
|
||||
|
||||
export class ChatService {
|
||||
constructor(private userBlockService: UserBlockService) {}
|
||||
|
||||
/**
|
||||
* Get permission for a channel.
|
||||
*
|
||||
* For DM channels:
|
||||
* - Blocking returns 'read' permission (can see messages, can't send)
|
||||
* - This is the CORRECT pattern: permission = visibility, not action validation
|
||||
*
|
||||
* For voice permissions in DM:
|
||||
* - voiceListen, voiceTalk, etc. are always TRUE for DM channels
|
||||
* - This is INTENTIONAL: voice blocking is handled by action checks, not permissions
|
||||
*
|
||||
* Pattern: Permission controls VISIBILITY; Actions have SEPARATE blocking checks.
|
||||
* See chat.sendMessage() for how blocking is enforced on actions.
|
||||
*/
|
||||
async getChannelPermission(
|
||||
userId: string,
|
||||
channel: Channel
|
||||
): Promise<ChannelPermission> {
|
||||
if (channel.type === 'dm' && channel.userA && channel.userB) {
|
||||
const otherUser = channel.userA === userId ? channel.userB : channel.userA;
|
||||
|
||||
// Check blocking status
|
||||
const isBlockingOut = await this.userBlockService.isBlocking(userId, otherUser);
|
||||
const isBlockingInc = await this.userBlockService.isBlocking(otherUser, userId);
|
||||
|
||||
// Return 'read' permission for blocked DMs (can see, can't send)
|
||||
// This is the correct pattern - permission controls visibility
|
||||
if (isBlockingOut) {
|
||||
return {
|
||||
permission: 'read',
|
||||
reason: 'block-user',
|
||||
// Voice permissions are always true for DM - blocking is checked on actions
|
||||
voiceListen: true,
|
||||
voiceTalk: true,
|
||||
voiceWebcam: true,
|
||||
voiceScreenshare: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (isBlockingInc) {
|
||||
return {
|
||||
permission: 'read',
|
||||
reason: 'blocked-by-user',
|
||||
voiceListen: true,
|
||||
voiceTalk: true,
|
||||
voiceWebcam: true,
|
||||
voiceScreenshare: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Normal DM permission
|
||||
return {
|
||||
permission: 'write',
|
||||
voiceListen: true,
|
||||
voiceTalk: true,
|
||||
voiceWebcam: true,
|
||||
voiceScreenshare: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Project channel - normal permission flow
|
||||
return {
|
||||
permission: 'write',
|
||||
voiceListen: true,
|
||||
voiceTalk: true,
|
||||
voiceWebcam: true,
|
||||
voiceScreenshare: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a channel.
|
||||
*
|
||||
* This is the CORRECT pattern for blocking enforcement:
|
||||
* - Check blocking SEPARATELY from permission
|
||||
* - Permission controls visibility; this check controls action
|
||||
*/
|
||||
async sendMessage(userId: string, channel: Channel, content: string): Promise<void> {
|
||||
// Separate blocking check for the ACTION (not permission)
|
||||
if (channel.type === 'dm' && channel.userA && channel.userB) {
|
||||
const isBlocked = await this.userBlockService.isBlockingEitherWay(
|
||||
channel.userA,
|
||||
channel.userB
|
||||
);
|
||||
if (isBlocked) {
|
||||
throw new Error('You cannot send messages to this user');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the message...
|
||||
console.log(`[chat] ${userId} -> ${channel.id}: ${content}`);
|
||||
}
|
||||
}
|
||||
102
test/fixture/src/services/team.service.ts
Normal file
102
test/fixture/src/services/team.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Team Service (Client-side)
|
||||
*
|
||||
* Manages team state including permissions and role caching.
|
||||
*/
|
||||
|
||||
import { BusTeamRoleChange, BusTeamMemberRoleChange } from '../broker/events';
|
||||
|
||||
interface Permission {
|
||||
permission: 'read' | 'write' | 'admin' | 'denied';
|
||||
voiceListen: boolean;
|
||||
voiceTalk: boolean;
|
||||
voiceWebcam: boolean;
|
||||
voiceScreenshare: boolean;
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission cache for computed permissions.
|
||||
*
|
||||
* BUG: This cache only clears on team switch, not on role changes!
|
||||
* When a user's roles change, their cached permissions become stale.
|
||||
*/
|
||||
const memoizedPermissions = new Map<string, Permission>();
|
||||
|
||||
export class TeamService {
|
||||
private currentTeam: Team | null = null;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to team changes to clear cache
|
||||
// BUG: Only clears on team SWITCH, not on role updates!
|
||||
this.setupTeamChangeSubscription();
|
||||
|
||||
// BUG: Missing subscription to role changes!
|
||||
// Should subscribe to BusTeamRoleChange and BusTeamMemberRoleChange
|
||||
// and clear the cache when roles change.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached permission for a project.
|
||||
*/
|
||||
getPermission(projectId: string): Permission | undefined {
|
||||
return memoizedPermissions.get(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached permission for a project.
|
||||
*/
|
||||
setPermission(projectId: string, permission: Permission): void {
|
||||
memoizedPermissions.set(projectId, permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear permission cache.
|
||||
*
|
||||
* Called when active team changes.
|
||||
* BUG: Should also be called when roles change!
|
||||
*/
|
||||
clearPermissionCache(): void {
|
||||
memoizedPermissions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different team.
|
||||
*/
|
||||
setActiveTeam(team: Team): void {
|
||||
this.currentTeam = team;
|
||||
this.clearPermissionCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup subscription to team changes.
|
||||
*
|
||||
* BUG: Only subscribes to team SWITCH, not to:
|
||||
* - BusTeamRoleChange (role created/updated/deleted)
|
||||
* - BusTeamMemberRoleChange (user role assigned/removed)
|
||||
*/
|
||||
private setupTeamChangeSubscription(): void {
|
||||
// This would normally be an observable subscription
|
||||
// For now, we just clear on setActiveTeam()
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX: Should add these subscriptions:
|
||||
*
|
||||
* BusTeamRoleChange.subscribe(event => {
|
||||
* if (event.teamId === this.currentTeam?.id) {
|
||||
* this.clearPermissionCache();
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* BusTeamMemberRoleChange.subscribe(event => {
|
||||
* if (event.teamId === this.currentTeam?.id) {
|
||||
* this.clearPermissionCache();
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
}
|
||||
117
test/fixture/src/services/user-block.service.ts
Normal file
117
test/fixture/src/services/user-block.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* User Block Service
|
||||
*
|
||||
* Handles blocking/unblocking between users.
|
||||
* Blocking affects:
|
||||
* - DM visibility
|
||||
* - Voice call permissions
|
||||
* - Feed following
|
||||
*/
|
||||
|
||||
import { BusUserBlockChange } from '../broker/events';
|
||||
|
||||
interface UserBlockRecord {
|
||||
sourceUserId: string;
|
||||
targetUserId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// In-memory store for testing
|
||||
const blocks: UserBlockRecord[] = [];
|
||||
|
||||
export class UserBlockService {
|
||||
/**
|
||||
* Block a user.
|
||||
*
|
||||
* When a user is blocked:
|
||||
* 1. They can no longer send DMs
|
||||
* 2. They are unfollowed from feeds
|
||||
* 3. DM channel becomes read-only
|
||||
*
|
||||
* BUG: Missing voice call cleanup!
|
||||
* - Should end any active DM call between these users
|
||||
* - Should kick from shared voice channels
|
||||
*/
|
||||
async blockUser(sourceUserId: string, targetUserId: string): Promise<void> {
|
||||
// Check if already blocked
|
||||
const existing = blocks.find(
|
||||
b => b.sourceUserId === sourceUserId && b.targetUserId === targetUserId
|
||||
);
|
||||
if (existing) return;
|
||||
|
||||
// Create block record
|
||||
const block: UserBlockRecord = {
|
||||
sourceUserId,
|
||||
targetUserId,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
blocks.push(block);
|
||||
|
||||
// Unfollow in both directions
|
||||
await this.unfollowUser(sourceUserId, targetUserId);
|
||||
await this.unfollowUser(targetUserId, sourceUserId);
|
||||
|
||||
// Emit broker event
|
||||
BusUserBlockChange.next({
|
||||
sourceUserId,
|
||||
targetUserId,
|
||||
blocked: true,
|
||||
timestamp: block.createdAt,
|
||||
});
|
||||
|
||||
// BUG: No voice cleanup here!
|
||||
// Should call: voiceChannelService.endDmCallBetweenUsers(sourceUserId, targetUserId)
|
||||
// Should call: voiceChannelService.kickFromSharedChannels(sourceUserId, targetUserId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock a user.
|
||||
*/
|
||||
async unblockUser(sourceUserId: string, targetUserId: string): Promise<void> {
|
||||
const index = blocks.findIndex(
|
||||
b => b.sourceUserId === sourceUserId && b.targetUserId === targetUserId
|
||||
);
|
||||
if (index === -1) return;
|
||||
|
||||
blocks.splice(index, 1);
|
||||
|
||||
// Unhide DM channel
|
||||
await this.unhideDmChannel(sourceUserId, targetUserId);
|
||||
|
||||
// Emit broker event
|
||||
BusUserBlockChange.next({
|
||||
sourceUserId,
|
||||
targetUserId,
|
||||
blocked: false,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if either user has blocked the other.
|
||||
*/
|
||||
async isBlockingEitherWay(userA: string, userB: string): Promise<boolean> {
|
||||
return blocks.some(
|
||||
b =>
|
||||
(b.sourceUserId === userA && b.targetUserId === userB) ||
|
||||
(b.sourceUserId === userB && b.targetUserId === userA)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if source has blocked target.
|
||||
*/
|
||||
async isBlocking(sourceUserId: string, targetUserId: string): Promise<boolean> {
|
||||
return blocks.some(
|
||||
b => b.sourceUserId === sourceUserId && b.targetUserId === targetUserId
|
||||
);
|
||||
}
|
||||
|
||||
private async unfollowUser(userId: string, targetId: string): Promise<void> {
|
||||
// Unfollow logic...
|
||||
}
|
||||
|
||||
private async unhideDmChannel(userA: string, userB: string): Promise<void> {
|
||||
// Unhide DM channel logic...
|
||||
}
|
||||
}
|
||||
220
test/fixture/src/services/voice-channel.service.ts
Normal file
220
test/fixture/src/services/voice-channel.service.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Voice Channel Service
|
||||
*
|
||||
* Manages voice channel state, participants, and DM calls.
|
||||
*/
|
||||
|
||||
import { BusVoiceParticipant, BusDmCall, BusUserBlockChange } from '../broker/events';
|
||||
import { UserBlockService } from './user-block.service';
|
||||
|
||||
interface VoiceParticipant {
|
||||
channelId: string;
|
||||
odlUserId: string;
|
||||
userId: string;
|
||||
joinedAt: Date;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
}
|
||||
|
||||
interface DmCall {
|
||||
callId: string;
|
||||
callerId: string;
|
||||
calleeId: string;
|
||||
channelId: string;
|
||||
state: 'ringing' | 'active' | 'ended' | 'declined';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// In-memory stores
|
||||
const participants: VoiceParticipant[] = [];
|
||||
const dmCalls: DmCall[] = [];
|
||||
|
||||
export class VoiceChannelService {
|
||||
constructor(private userBlockService: UserBlockService) {}
|
||||
|
||||
/**
|
||||
* Join a voice channel.
|
||||
*
|
||||
* For DM channels, checks blocking before allowing join.
|
||||
*/
|
||||
async joinVoiceChannel(
|
||||
userId: string,
|
||||
channelId: string,
|
||||
channelType: 'dm' | 'project'
|
||||
): Promise<void> {
|
||||
// For DM channels, check blocking
|
||||
if (channelType === 'dm') {
|
||||
const otherUserId = this.getOtherDmUser(channelId, userId);
|
||||
const isBlocked = await this.userBlockService.isBlockingEitherWay(userId, otherUserId);
|
||||
if (isBlocked) {
|
||||
throw new Error('Cannot join voice - user is blocked');
|
||||
}
|
||||
}
|
||||
|
||||
const participant: VoiceParticipant = {
|
||||
channelId,
|
||||
odlUserId: `odl_${userId}`,
|
||||
userId,
|
||||
joinedAt: new Date(),
|
||||
muted: false,
|
||||
deafened: false,
|
||||
};
|
||||
participants.push(participant);
|
||||
|
||||
BusVoiceParticipant.next({
|
||||
channelId,
|
||||
userId,
|
||||
action: 'joined',
|
||||
timestamp: participant.joinedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a DM call.
|
||||
*
|
||||
* Checks blocking before creating the call.
|
||||
*/
|
||||
async startDmCall(callerId: string, calleeId: string): Promise<DmCall> {
|
||||
// Check blocking
|
||||
const isBlocked = await this.userBlockService.isBlockingEitherWay(callerId, calleeId);
|
||||
if (isBlocked) {
|
||||
throw new Error('Cannot start call - user is blocked');
|
||||
}
|
||||
|
||||
const call: DmCall = {
|
||||
callId: `call_${Date.now()}`,
|
||||
callerId,
|
||||
calleeId,
|
||||
channelId: `dm_${callerId}_${calleeId}`,
|
||||
state: 'ringing',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
dmCalls.push(call);
|
||||
|
||||
BusDmCall.next({
|
||||
callId: call.callId,
|
||||
callerId,
|
||||
calleeId,
|
||||
state: 'ringing',
|
||||
timestamp: call.createdAt,
|
||||
});
|
||||
|
||||
// Notify callee
|
||||
this.notifyDmCall(call);
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a DM call.
|
||||
*
|
||||
* BUG: Missing blocking check!
|
||||
* If block is created after call starts but before answer,
|
||||
* the callee can still answer.
|
||||
*/
|
||||
async answerDmCall(callId: string, userId: string): Promise<void> {
|
||||
const call = dmCalls.find(c => c.callId === callId);
|
||||
if (!call) throw new Error('Call not found');
|
||||
if (call.calleeId !== userId) throw new Error('Not the callee');
|
||||
if (call.state !== 'ringing') throw new Error('Call is not ringing');
|
||||
|
||||
// BUG: No blocking check here!
|
||||
// Should check: await this.userBlockService.isBlockingEitherWay(call.callerId, call.calleeId)
|
||||
|
||||
call.state = 'active';
|
||||
|
||||
BusDmCall.next({
|
||||
callId: call.callId,
|
||||
callerId: call.callerId,
|
||||
calleeId: call.calleeId,
|
||||
state: 'active',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a DM call.
|
||||
*
|
||||
* BUG: Missing blocking check!
|
||||
*/
|
||||
async declineDmCall(callId: string, userId: string): Promise<void> {
|
||||
const call = dmCalls.find(c => c.callId === callId);
|
||||
if (!call) throw new Error('Call not found');
|
||||
if (call.calleeId !== userId) throw new Error('Not the callee');
|
||||
|
||||
// BUG: No blocking check here either!
|
||||
|
||||
call.state = 'declined';
|
||||
|
||||
BusDmCall.next({
|
||||
callId: call.callId,
|
||||
callerId: call.callerId,
|
||||
calleeId: call.calleeId,
|
||||
state: 'declined',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End a DM call between two users.
|
||||
*
|
||||
* Used when block is created to clean up active calls.
|
||||
*/
|
||||
async endDmCallBetweenUsers(userA: string, userB: string): Promise<void> {
|
||||
const call = dmCalls.find(
|
||||
c =>
|
||||
(c.callerId === userA && c.calleeId === userB) ||
|
||||
(c.callerId === userB && c.calleeId === userA)
|
||||
);
|
||||
if (call && call.state !== 'ended') {
|
||||
call.state = 'ended';
|
||||
BusDmCall.next({
|
||||
callId: call.callId,
|
||||
callerId: call.callerId,
|
||||
calleeId: call.calleeId,
|
||||
state: 'ended',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick a user from a voice channel.
|
||||
*/
|
||||
async leaveChannel(userId: string, channelId?: string): Promise<void> {
|
||||
const index = participants.findIndex(
|
||||
p => p.userId === userId && (!channelId || p.channelId === channelId)
|
||||
);
|
||||
if (index !== -1) {
|
||||
const participant = participants[index];
|
||||
participants.splice(index, 1);
|
||||
|
||||
BusVoiceParticipant.next({
|
||||
channelId: participant.channelId,
|
||||
userId,
|
||||
action: 'left',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify callee of incoming DM call.
|
||||
*
|
||||
* BUG: Doesn't filter for blocking!
|
||||
* Blocked users still receive call notifications.
|
||||
*/
|
||||
private notifyDmCall(call: DmCall): void {
|
||||
// BUG: Should check blocking before notifying
|
||||
// if (await this.userBlockService.isBlockingEitherWay(call.callerId, call.calleeId)) return;
|
||||
|
||||
// Send notification to callee...
|
||||
console.log(`[notify] ${call.calleeId}: Incoming call from ${call.callerId}`);
|
||||
}
|
||||
|
||||
private getOtherDmUser(channelId: string, userId: string): string {
|
||||
// Parse DM channel ID to get the other user
|
||||
const parts = channelId.replace('dm_', '').split('_');
|
||||
return parts.find(id => id !== userId) || '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user