# Inbound Call Processing

Both **answered** and **missed** calls use a single-job pipeline: the job acts
as a thin wrapper (lock + idempotency + retries) and delegates all business logic
to the service layer.

---

## Direct Calls

Inbound calls that ring an extension directly (no queue) are processed by a
separate service. The call type is classified from the **CDR call path**, not
from the destination extension (both call types can end on the same extension):

| Call type | CDR call path |
|-----------|---------------|
| Queue call    | `dcontext = ext-queues`      and `lastapp = Queue` |
| Direct call   | `dcontext = followme-check`  and `lastapp = Dial` |
|               | (also accepts `dial-with-exten` — the generic FreePBX direct-dial context; this dialplan dials extensions through the follow-me mechanism) |

The service skips any call whose CDR contains a queue leg, so the existing
queue/inbound pipeline is never affected.

### Flow

```
agihangupaction  (routes/console.php — dcontext=dial-with-exten && lastapp=Dial)
    └─→ ProcessDirectCallJob::dispatch($linkedid)  [+5 s delay]
            ├─ SET NX lock  "call-direct-lock:{linkedid}"  (5 min)
            ├─ Guard: skip if InboundReport already exists
            └─→ DirectCallsService::processSingleDirectCall($linkedid)
                    ├─ Query 1: cdr  WHERE linkedid  (all legs — classify path)
                    ├─ Single-pass PHP: ringtime, talktime, user_id, caller_number
                    └─ DELETE stale row → INSERT inbound_reports
```

Both answered and missed direct calls flow through the same job; the service
decides the `call_status` from the CDR disposition.

### Files

| File | Role |
|------|------|
| `app/Jobs/ProcessDirectCallJob.php` | Thin wrapper — lock, idempotency, retries |
| `Modules/Reporting/Services/DirectCallsService.php` | All business logic (`processSingleDirectCall`) |
| `routes/console.php` | Routes direct calls to `ProcessDirectCallJob` in `agihangupaction` |

---

## Answered Calls

### Flow

```
agihangupaction  (routes/console.php)
    └─→ ProcessInboundCallJob::dispatch($linkedid)  [+5 s delay]
            ├─ SET NX lock  "call-inbound-lock:{linkedid}"  (5 min)
            ├─ Guard: skip if InboundReport already exists
            └─→ AnsweredCallsService::processSingleAnsweredCall($linkedid)
                    ├─ Query 1: cdr  WHERE linkedid  (all legs — callids, src/did, transfers)
                    ├─ Query 2: queuelog  WHERE callid IN (...)  (ENTER/COMPLETE/RING)
                    ├─ Query 3: queues_config  (queue description — PK lookup)
                    ├─ Query 4: surveys  (customer choice)
                    ├─ Single-pass PHP: ringtime, talktime, user_id, caller_number
                    └─ DELETE stale row → INSERT inbound_reports
```

### Redis Keys

| Key | TTL | Purpose |
|-----|-----|---------|
| `call-inbound-lock:{linkedid}` | 5 min | Prevents duplicate processing |

### Files

| File | Role |
|------|------|
| `app/Jobs/ProcessInboundCallJob.php` | Thin wrapper — lock, idempotency, retries |
| `Modules/Reporting/Services/AnsweredCallsService.php` | All business logic (`processSingleAnsweredCall`) |
| `routes/console.php` | Dispatches `ProcessInboundCallJob` on `ANSWERED` disposition |

---

## Missed Calls

### Flow

```
agihangupaction  (routes/console.php)
    ├─ Redis::setex("missed-calls:seen:{linkedid}", 1800, 1)
    ├─→ PrepareMissedCallDataJob::dispatch($linkedid)
    │       ├─ SET NX lock  "missed-call-prep-lock:{linkedid}"  (5 min)
    │       ├─ Guard: skip if InboundReport already exists
    │       └─→ MissedCallsService::processSingleMissedCall($linkedid)
    │               ├─ Query 1: cdr  WHERE linkedid  (all legs — callids, src/did)
    │               ├─ Query 2: queuelog  WHERE callid IN (...)  (ENTER/ABANDON/RINGNOANSWER)
    │               │           also guards against COMPLETECALLER/COMPLETEAGENT
    │               ├─ Query 3: queues_config  (queue description — PK lookup)
    │               ├─ Single-pass PHP: ringtime, final event
    │               └─ DELETE stale row → INSERT inbound_reports
    └─→ AnalyzeMissedCalls::analyzeSingleCall($linkedid)  (ring-detail status update)
```

### Redis Keys

| Key | TTL | Purpose |
|-----|-----|---------|
| `missed-calls:seen:{linkedid}` | 30 min | Deduplication in `AnalyzeMissedCalls` cron |
| `missed-call-prep-lock:{linkedid}` | 5 min | Prevents duplicate processing |

### Files

| File | Role |
|------|------|
| `app/Jobs/PrepareMissedCallDataJob.php` | Thin wrapper — lock, idempotency, retries |
| `Modules/Reporting/Services/MissedCallsService.php` | All business logic (`processSingleMissedCall`) |
| `app/Console/Commands/AnalyzeMissedCalls.php` | Ring-detail status analysis |
| `routes/console.php` | Dispatches `PrepareMissedCallDataJob` on non-ANSWERED disposition |

---

## Shared Principles

- **SET NX lock** prevents double-processing if a job is retried or re-queued.
- **Idempotency check** (`InboundReport::where('linkedid')->exists()`) skips calls already written.
- **No Redis payload** — all data is fetched fresh by the service; Redis is used only for locking.
- **Single-pass loops** — CDR and queuelog analysis each run in one `foreach`; no Collection chains.
- **Lock release on failure** — the lock is explicitly deleted before re-throwing so retries can proceed.
