Files
helix-engage/docs/superpowers/specs/2026-03-22-phase2-missed-call-queue-login-redesign.md
saridsa2 744a91a1ff feat: Phase 2 — missed call queue, login redesign, button fix
- Missed call queue with FIFO auto-assignment, dedup, SLA tracking
- Status sub-tabs (Pending/Attempted/Completed/Invalid) in worklist
- missedCallId passed through disposition flow for callback tracking
- Login page redesigned: centered white card on blue background
- Disposition button changed to content-width
- NavAccountCard popover close fix on menu item click

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:16:53 +05:30

12 KiB
Raw Blame History

Phase 2: Missed Call Queue + Login Redesign + Button Fix

Date: 2026-03-22 PRD Reference: US 7 (Missed Call Queue), Login Page Redesign, Button Width Fix Branch: dev


1. Missed Call Queue (US 7)

1.1 Data Model

The existing Call entity on the Fortytwo platform is extended with 4 custom fields (already added via admin portal):

GraphQL Field Name DB Column Type Purpose
callbackstatus callbackstatus SELECT Lifecycle: PENDING_CALLBACK, CALLBACK_ATTEMPTED, CALLBACK_COMPLETED, INVALID, WRONG_NUMBER
callsourcenumber callsourcenumber TEXT Which DID/branch the patient called
missedcallcount missedcallcount NUMBER Dedup counter — same number calling multiple times before callback
callbackattemptedat callbackattemptedat DATE_TIME Timestamp of first callback attempt

Important: Custom fields use all-lowercase GraphQL names (not camelCase). Verified via introspection and mutation test on staging.

Existing fields used:

  • callStatus: MISSED — identifies missed calls
  • agentName — tracks which agent is assigned
  • disposition — records callback outcome
  • callerNumber — caller's phone (PHONES type, accessed as callerNumber { primaryPhoneNumber })
  • startedAt — when the call was missed
  • leadId — linked lead (if matched)

1.2 Sidecar: Missed Queue Service

Extend the existing src/worklist/ module (already handles missed call data and is registered in app.module.ts).

New files:

  • src/worklist/missed-queue.service.ts — Queue logic (ingestion, dedup, assignment)

Modified files:

  • src/worklist/worklist.controller.ts — Add missed queue endpoints
  • src/worklist/worklist.module.ts — Register MissedQueueService

Auth model:

  • GET /api/missed-queue and PATCH /api/missed-queue/:id/status — use agent's forwarded auth token (same as existing worklist endpoints)
  • Ingestion timer and auto-assignment — use server API key (PLATFORM_API_KEY) since these run without a user request

Endpoints

Method Path Purpose
GET /api/missed-queue Returns missed calls for current agent, grouped by callbackstatus
POST /api/missed-queue/ingest Polls Ozonetel abandonCalls, deduplicates, writes to platform
PATCH /api/missed-queue/:id/status Updates callbackstatus on a Call record
POST /api/missed-queue/assign Assigns oldest unassigned PENDING_CALLBACK call to an agent

Ingestion Flow (runs every 30s via setInterval on service init)

  1. Call OzonetelAgentService.getAbandonCalls() with fromTime/toTime limited to the last 5 minutes (the method already supports these parameters). This prevents re-processing the entire day's abandon calls on service restart.
  2. Normalize caller phone numbers to +91XXXXXXXXXX format before any query or write (Ozonetel may return numbers in varying formats like 009919876543210 or 9876543210).
  3. For each abandoned call:
    • Extract callerID (phone number, normalized) and did (source number)
    • Query platform: calls(filter: { callerNumber: { primaryPhoneNumber: { eq: "<normalized_number>" } }, callbackstatus: { eq: PENDING_CALLBACK } })
    • Match foundupdateCall: increment missedcallcount, update startedAt to latest timestamp
    • No matchcreateCall:
      mutation { createCall(data: {
        callStatus: MISSED,
        direction: INBOUND,
        callerNumber: { primaryPhoneNumber: "<normalized_number>", primaryPhoneCallingCode: "+91" },
        callsourcenumber: "<DID>",
        callbackstatus: PENDING_CALLBACK,
        missedcallcount: 1,
        startedAt: "<timestamp>"
      }) { id } }
      
  4. Track ingested Ozonetel monitorUCID values in a Set to avoid re-processing within the same poll cycle

Auto-Assignment (triggered on two events)

Assignment fires when an agent becomes available via either path:

  1. Disposition submission (POST /api/ozonetel/dispose): After an agent completes a call and submits disposition, they become Ready. This is the primary trigger — most "agent available" transitions happen here.
  2. Manual state change (POST /api/ozonetel/agent-state): When an agent manually toggles to Ready via AgentStatusToggle.

In both cases, call MissedQueueService.assignNext(agentName):

  1. Query platform: oldest Call with callbackstatus: PENDING_CALLBACK and agentName is null/empty, ordered by startedAt: AscNullsLast
  2. If found → updateCall setting agentName to the available agent
  3. Use optimistic concurrency: if the update fails (another agent claimed it first), retry with the next oldest call
  4. Return assigned call to frontend (so it can surface at top of worklist)

Note on race conditions: Since this is a single-instance sidecar, a simple in-memory mutex around the assignment query+update is sufficient to prevent two simultaneous Ready events from claiming the same call.

Status Transitions

Trigger From Status To Status Additional Updates
Agent clicks call-back PENDING_CALLBACK CALLBACK_ATTEMPTED Set callbackattemptedat
Disposition: APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED CALLBACK_ATTEMPTED CALLBACK_COMPLETED
Disposition: NO_ANSWER (after max retries) CALLBACK_ATTEMPTED CALLBACK_ATTEMPTED Stays attempted, agent can retry
Disposition: WRONG_NUMBER CALLBACK_ATTEMPTED WRONG_NUMBER
Agent marks invalid Any INVALID

1.3 Sidecar: Worklist Update

Update WorklistService.getMissedCalls() to include the new fields in the query:

calls(first: 20, filter: {
  agentName: { eq: "<agent>" },
  callStatus: { eq: MISSED },
  callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] }
}, orderBy: [{ startedAt: AscNullsLast }]) {
  edges { node {
    id name createdAt
    direction callStatus agentName
    callerNumber { primaryPhoneNumber }
    startedAt endedAt durationSec
    disposition leadId
    callbackstatus callsourcenumber missedcallcount callbackattemptedat
  } }
}

1.4 Frontend: Worklist Panel Changes

src/hooks/use-worklist.ts:

  • Add callbackstatus, callsourcenumber, missedcallcount, callbackattemptedat to MissedCall type
  • Transform data from sidecar response (fields are already lowercase, minimal mapping needed)

src/components/call-desk/worklist-panel.tsx:

Replace the flat "Missed" tab with status sub-tabs:

[All] [Missed] [Callbacks] [Follow-ups] [Leads]
         │
         └── [Pending | Attempted | Completed | Invalid]

Pending sub-tab (default view):

  • FIFO ordered (oldest first, matching AscNullsLast sort)
  • Row content: caller phone, time since missed, missed call count badge (shown if >1), call source number, SLA color indicator
  • SLA thresholds: green (<15 min), orange (1530 min), red (>30 min) — existing logic
  • Click-to-call → triggers callback, sidecar auto-transitions to CALLBACK_ATTEMPTED

Attempted sub-tab:

  • Calls where agent tried calling back but no final resolution yet
  • Row content: caller phone, time since first attempt (callbackattemptedat), last disposition
  • Click-to-call for retry

Completed / Invalid sub-tabs:

  • Read-only history of resolved missed calls
  • Shows: caller phone, final disposition, resolution timestamp

Assignment notification: When auto-assigned, the missed call appears at top of the worklist with a highlighted "Missed Call" badge. A toast notification alerts the agent.

1.5 Frontend: Post-Callback Status Update

When an agent clicks call-back on a missed call:

  1. Frontend calls PATCH /api/missed-queue/:id/status with { status: 'CALLBACK_ATTEMPTED' }
  2. Normal outbound call flow begins via SIP
  3. After call ends → disposition form → disposition submitted → sidecar maps disposition to final callbackstatus and updates platform

This integrates with the existing ActiveCallCard disposition flow. The frontend must pass the missed Call record ID as missedCallId in the disposition request body so the sidecar can look up and update the callbackstatus. The dispose endpoint currently receives { ucid, disposition, callerPhone, direction, durationSec, leadId, notes } — add missedCallId?: string as an optional field. When present, the sidecar updates the corresponding Call record's callbackstatus based on disposition mapping:

  • APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED → CALLBACK_COMPLETED
  • WRONG_NUMBER → WRONG_NUMBER
  • NO_ANSWER → stays CALLBACK_ATTEMPTED (agent can retry)

2. Login Page Redesign

Current State

Split-panel layout: 60% blue left panel with marketing feature cards (Unified Lead Inbox, Campaign Intelligence, Speed to Contact) + 40% white right panel with login form.

Target State

  • Full blue background using bg-brand-section (existing brand blue token)
  • Centered white card (~420px max-width, rounded-xl, shadow-xl)
  • Inside the card:
    • Helix Engage logo (prominent, centered)
    • "Global Hospital" subtitle
    • Google sign-in button with "OR CONTINUE WITH" divider
    • Email input
    • Password input with eye toggle
    • Remember me checkbox + Forgot password link (same row)
    • Sign in button (full-width within card — standard for login forms)
  • Footer: subtle "Powered by FortyTwo" text below the card
  • No left panel, no marketing copy, no feature cards
  • Mobile: card fills screen width with padding

File Changes

  • src/pages/login.tsx — restructure layout, remove left panel, center card

3. Button Width Fix

Problem

Buttons in call desk inline forms (disposition, appointment, enquiry, transfer) use w-full, spanning the entire container width. This looks awkward in wide panels.

Fix

Change buttons in these forms from w-full to w-auto with right-aligned layout (flex justify-end gap-3).

Scope

Login page buttons stay w-full (narrow container, standard practice).

Affected Files

  • src/components/call-desk/disposition-form.tsx — Save Disposition button (confirmed w-full)
  • Other call desk form buttons (appointment, enquiry, transfer) — verify at implementation time, may already be content-width

Technical Notes

GraphQL Field Naming

Custom fields added via admin portal use all-lowercase GraphQL names:

  • callbackstatus (not callbackStatus)
  • callsourcenumber (not callSourceNumber)
  • missedcallcount (not missedCallCount)
  • callbackattemptedat (not callbackAttemptedAt)

Managed (app-defined) fields retain camelCase (callStatus, agentName, etc.).

Verified on Staging

  • Queries: calls(first: 2) { edges { node { callbackstatus callsourcenumber missedcallcount callbackattemptedat } } }
  • Mutations: updateCall(id: "...", data: { callbackstatus: PENDING_CALLBACK, missedcallcount: 1 })
  • Staging DB: fortytwo_staging, workspace schema: workspace_3x7sonctrktrxft4b0bwuc26x, table: _call

Dedup Strategy

Deduplication is by caller phone number against PENDING_CALLBACK records. Once a missed call transitions to any other status, a new missed call from the same number creates a fresh record. This prevents stale dedup.

Ozonetel Ingestion Idempotency

Each poll queries only the last 5 minutes via fromTime/toTime parameters, preventing full-day reprocessing on restart. Within a poll cycle, processed monitorUCID values are tracked in a Set<string> to avoid duplicates. The platform dedup query (phone number + PENDING_CALLBACK) provides a second safety net.

Phone Number Normalization

All phone numbers are normalized to +91XXXXXXXXXX format before writes and queries. Ozonetel may return numbers as 009919876543210, 919876543210, or 9876543210 — strip leading 0091/91/0 prefixes, then prepend +91.

Edge Cases

  • Multiple DIDs: If a caller dials branch A, then branch B before callback, the records merge (count incremented). The callsourcenumber updates to the latest branch. This is intentional — the callback is to the patient, not the branch.
  • Agent goes offline after assignment: Assigned missed calls stay with the agent. No automatic requeue. Supervisors can manually reassign in Phase 3.
  • Ingestion poll interval: 30s, configurable via MISSED_QUEUE_POLL_INTERVAL_MS env var.