Skip to main content

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

  1. Materialize Access Permissions: Store pre-computed permitted groups and users in two dedicated tables: call_groups and call_users.
  2. Centralized Call & Segment Creation: Consolidate call and leg lifecycle management into a single service and permission resolution pipeline.
  3. 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.
  4. 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[] and callUsers 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: @@unique prevents duplicate records during concurrent segments and supports PostgreSQL ON 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 EventPermitted Groups Added to call_groupsPermitted Users Added to call_users
Initial Call Created (DID / Queue)Call's initial group_idIf outbound or originated by user, the originating user_id.
Segment Created with Agent (user_id)All groups the agent belongs to via UserGroupThe agent user_id marked as is_participant = true.
Segment Created with Queue (group_id)The queue's group_idAll members of this group with AccessLevel.group.
Call Transferred to Another Agent/GroupThe new group is appended to call_groupsThe 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

  1. L1 In-Memory User-to-Group & Role Cache (in Node.js Process):

    • Stores userId -> [groupIds] and groupId -> [userIds] in memory maps.
    • Updated via Redis PubSub invalidation when user.group changes occur.
    • Cost: 0 network queries during live call ticks. Resolves permissions in $< 0.1\text{ ms}$.
  2. 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.
  3. 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 })
      ]);

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:

  1. Admins / Supervisors (AccessLevel.all): Subscribed to calls:all.
  2. Group Dashboards (AccessLevel.group): Subscribed to calls:group:{groupId}.
  3. Individual Agents (AccessLevel.own): Subscribed to calls:user:{userId}.

6.2 Latency Budget Breakdown

Processing StepTarget BudgetMechanism
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

  1. Phase 1: Database Migration:
    • Create call_groups and call_users tables via Prisma migration.
    • Run a background backfill script for active and recent historical calls.
  2. Phase 2: In-Memory / Redis Caching Layer:
    • Implement UserGroupCache with Redis PubSub listeners for user/group membership changes.
  3. Phase 3: Centralized Call Engine:
    • Build call-engine methods encapsulating call/segment creation and permission assignment.
  4. Phase 4: Service Refactoring:
  5. 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.