Technical Design Document: Centralized Call Lifecycle & Permission Materialization
1. Executive Summary & Problem Context
In high-concurrency environments (1,000 concurrent calls, ~83 segments/s, 60s avg call duration), determining call visibility dynamically via real-time table joins across calls, call_segments, userGroups, and roles leads to database bottlenecks, query latency spikes, and unauthorized data leakage in WebSocket broadcasts. Furthermore, call and segment creation logic is fragmented across multiple services (activecalls.service.js, livecalls.ser.js, calls.service.js, and campaign services).
Core Objectives
- Materialize Access Permissions: Store pre-computed permitted groups and users in two dedicated tables:
call_groupsandcall_users. - Centralized Call & Segment Creation: Consolidate call and leg lifecycle management into a single service and permission resolution pipeline.
- High-Throughput Architecture: Support 1,000 active concurrent calls (~16.7 call creations/s, ~83.3 segment creations/s) with O(1) in-memory membership lookups and batched database writes.
- Sub-1-Second Realtime Latency: Deliver call updates to dashboards within 1 second using permission-scoped WebSocket topic dispatching.
2. Database Changes (Prisma & PostgreSQL)
Two new relational tables materialize permissions directly associated with each call.
2.1 Entity Relationship Diagram
2.2 Prisma Schema Additions (schema.prisma)
model CallGroup {
id BigInt @id @default(autoincrement())
call_id Int
group_id Int
reason String? // e.g., "INITIAL_QUEUE", "SEGMENT_TRANSFER", "AGENT_MEMBERSHIP"
created_at DateTime @default(now()) @db.Timestamptz(3)
call Call @relation(fields: [call_id], references: [id], onDelete: Cascade)
group Group @relation(fields: [group_id], references: [id], onDelete: Cascade)
@@unique([call_id, group_id], map: "uq_call_groups_call_group")
@@index([group_id, call_id], map: "idx_call_groups_group_call")
@@map("call_groups")
}
model CallUser {
id BigInt @id @default(autoincrement())
call_id Int
user_id Int
is_participant Boolean @default(false) // true if agent was a direct leg participant
access_reason String? // e.g., "PARTICIPANT", "GROUP_MEMBER", "ORIGINATOR"
created_at DateTime @default(now()) @db.Timestamptz(3)
call Call @relation(fields: [call_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@unique([call_id, user_id], map: "uq_call_users_call_user")
@@index([user_id, call_id], map: "idx_call_users_user_call")
@@map("call_users")
}
Add the corresponding reverse relations:
- In
model Call:callGroups CallGroup[]andcallUsers CallUser[] - In
model Group:callGroups CallGroup[] - In
model User:callUsers CallUser[]
2.3 Indexing & Performance Design
- Covering Composite Indices:
(group_id, call_id)and(user_id, call_id)enable fast Index-Only Scans when querying call lists for a specific user or group filter. ON DELETE CASCADE: Automatically cleans up permission rows when a call record is purged.- Idempotent Ingestion:
@@uniqueprevents duplicate records during concurrent segments and supports PostgreSQLON CONFLICT DO NOTHING(skipDuplicates: true).
3. Permission Derivation Engine
When a call or segment is created or updated, permissions are resolved dynamically and expanded cumulatively.
3.1 Permission Expansion Rules
| Source Event | Permitted Groups Added to call_groups | Permitted Users Added to call_users |
|---|---|---|
| Initial Call Created (DID / Queue) | Call's initial group_id | If outbound or originated by user, the originating user_id. |
Segment Created with Agent (user_id) | All groups the agent belongs to via UserGroup | The agent user_id marked as is_participant = true. |
Segment Created with Queue (group_id) | The queue's group_id | All members of this group with AccessLevel.group. |
| Call Transferred to Another Agent/Group | The new group is appended to call_groups | The new agent & target group members appended to call_users. |
[!IMPORTANT] Cumulative Permissions: As calls transition across IVRs, queues, and multiple agents, permissions are additive. Participants from earlier legs retain access to the complete call history, and new assignees gain access from their point of involvement.
4. Centralized Call & Segment Creation Architecture
Instead of writing directly to prisma.call and prisma.callSegment in polling loops or webhook handlers, all operations route through a centralized service: call-engine.service.js (or centralized actions within calls.service.js).
4.1 System Architecture
4.2 Unified Action Contracts
// Centralized call creation / state change
await broker.call("call-engine.processCallEvent", {
telephonyCallId: "00000A12BC89",
tcxCallId: 1042,
status: "talking",
callType: "inbound",
hotline: "+18005550199",
groupId: 12, // Queue or Initial Group
startTime: new Date()
});
// Centralized segment creation
await broker.call("call-engine.createSegment", {
callId: 4501,
telephonyConnectionId: "leg_99812",
status: "talking",
userId: 84, // Agent
groupId: 12, // Queue/Group
sourceNumber: "+1555123456",
destinationNumber: "101",
startTime: new Date()
});
5. High-Throughput & Caching Strategy (1000 Concurrent Calls)
5.1 Volume & Throughput Calculation
- Concurrent Active Calls: 1,000 calls
- Average Duration: 60 seconds
- Call Turnover:
1,000 / 60 ≈ 16.7 calls/sec created & terminated - Segments Turnover:
16.7 × 5 = 83.3 segments/sec - Permission Writes: Each segment adds 1-2 groups and 1-10 users. At peak, this represents 200–500 relational permission rows/sec.
5.2 Two-Tier Caching Architecture
-
L1 In-Memory User-to-Group & Role Cache (in Node.js Process):
- Stores
userId -> [groupIds]andgroupId -> [userIds]in memory maps. - Updated via Redis PubSub invalidation when
user.groupchanges occur. - Cost: 0 network queries during live call ticks. Resolves permissions in $< 0.1\text{ ms}$.
- Stores
-
L2 Redis Active Call & Permission Cache:
HSET active_call:{callId}storing state, active segments, and permitted group/user ID sets.- TTL: 20 minutes from last activity.
- Allows instant dashboard hydration without querying PostgreSQL.
-
Database Write Optimization:
- Write Coalescing: Collect updates over 200ms windows or use PostgreSQL
createMany({ skipDuplicates: true }). - Single Transaction Execution:
await prisma.$transaction([prisma.call.upsert(...),prisma.callSegment.create(...),prisma.callGroup.createMany({ data: groupEntries, skipDuplicates: true }),prisma.callUser.createMany({ data: userEntries, skipDuplicates: true })]);
- Write Coalescing: Collect updates over 200ms windows or use PostgreSQL
6. Sub-1-Second Dashboard Latency & WebSocket Delivery
6.1 Permission-Scoped WebSocket Topics
Currently, updates broadcast globally to topic "calls" via callBroadcast.util.js, causing overhead and permission leakage.
We switch to scoped channel delivery:
- Admins / Supervisors (
AccessLevel.all): Subscribed tocalls:all. - Group Dashboards (
AccessLevel.group): Subscribed tocalls:group:{groupId}. - Individual Agents (
AccessLevel.own): Subscribed tocalls:user:{userId}.
6.2 Latency Budget Breakdown
| Processing Step | Target Budget | Mechanism |
|---|---|---|
| 3CX Poll / Webhook Intake | $50\text{ ms}$ | Non-blocking queue intake |
| State Diff & Dedup | $5\text{ ms}$ | In-memory key comparison |
| Permission Resolution | $< 2\text{ ms}$ | L1 In-memory Map lookup |
| Database Transaction | $25\text{ ms}$ | Batched createMany with indexed constraints |
| Redis Cache Update | $3\text{ ms}$ | Pipeline write |
| WebSocket Dispatch | $15\text{ ms}$ | Direct broadcast to matching connection rooms |
| Total End-to-End Latency | $\approx 100\text{ ms}$ | Well within the 1,000 ms SLA |
7. Execution Sequence & Activity Flow
7.1 Call Leg Ingestion & Permission Sequence
7.2 Segment Lifecycle & Permission Expansion Activity
8. Rollout & Migration Plan
- Phase 1: Database Migration:
- Create
call_groupsandcall_userstables via Prisma migration. - Run a background backfill script for active and recent historical calls.
- Create
- Phase 2: In-Memory / Redis Caching Layer:
- Implement
UserGroupCachewith Redis PubSub listeners for user/group membership changes.
- Implement
- Phase 3: Centralized Call Engine:
- Build
call-enginemethods encapsulating call/segment creation and permission assignment.
- Build
- Phase 4: Service Refactoring:
- Update activecalls.service.js and livecalls.ser.js to delegate creation to the new engine.
- Introduce scoped WebSocket topics (
calls:group:{id},calls:user:{id}) in websocket.service.js alongside legacy topics during transition.
- Phase 5: Load & Latency Validation:
- Benchmark with 1,000 simulated concurrent calls using the test suite to verify Postgres connection pool utilization and sub-1-second UI propagation.