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

243 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 found** → `updateCall`: increment `missedcallcount`, update `startedAt` to latest timestamp
- **No match** → `createCall`:
```graphql
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:
```graphql
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.