> ## Documentation Index
> Fetch the complete documentation index at: https://internal.mechzie.in/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Voice Calls

> In-job audio calls using 100ms rooms with FCM signaling — ringing, accept, decline, cancel, end, and missed-call handling

## Overview

MechZie audio calls use **100ms** for the actual audio room and a custom **FCM + Redis signaling layer** for ringing. 100ms has no concept of "ringing" — it only knows join/leave room. The signaling layer bridges that gap.

<Info>
  **Who can call whom?** Either party (customer or mechanic) can initiate a call on a job once it is in `accepted`, `en_route`, `arrived`, `in_progress`, `completed`, or `disputed` status.
</Info>

## Architecture

```
Either party taps Call
        │
        ▼
POST /call/initiate ──► API creates 100ms room (if needed)
                    ──► Generates single-use nonce (Redis, 5 min TTL)
                    ──► Schedules BullMQ timeout job (30 s)
                    ──► Sends data-only FCM to receiver's device
                    ──► Returns HMS token + room ID to caller
                        │
                        ▼
              Receiver's phone wakes up
              (Flutter background handler fires)
              Shows native incoming call UI
                        │
              ┌─────────┴─────────┐
              │                   │
         Tap Accept           Tap Decline
              │                   │
              ▼                   ▼
   POST /call/accept      POST /call/decline
   (nonce exchange)        ──► socket call:declined
   ──► HMS token                 to caller
   ──► joins 100ms room
              │
              │  (call in progress)
              │
   Either party hangs up
              │
              ▼
   POST /call/end ──► clears Redis active state
                  ──► emits call:ended to both parties
```

<Warning>
  FCM payloads are **data-only** (no `notification` key). This ensures Flutter's background message handler fires even when the app is killed. Adding a `notification` key will break call wake-up on Android when the app is backgrounded.
</Warning>

## Call Flow — Step by Step

### 1. Caller initiates

```bash theme={null}
POST /api/v1/jobs/{jobId}/call/initiate
Authorization: Bearer ACCESS_TOKEN
```

**Response `201`:**

```json theme={null}
{
  "data": {
    "hms_room_id": "64b1a2c3d4e5f6a7b8c9d0e1",
    "auth_token": "eyJhbGci...",
    "role": "mechanic"
  }
}
```

The caller immediately uses `hms_room_id` + `auth_token` to join the 100ms room and wait. The other party's phone rings via FCM.

***

### 2. Receiver's FCM payload

The receiver's Flutter background handler receives:

```json theme={null}
{
  "type": "incoming_call",
  "job_id": "uuid-of-job",
  "caller_role": "mechanic",
  "caller_name": "Ravi Kumar",
  "nonce": "a1b2c3d4e5f6..."
}
```

<Warning>
  **Store the nonce in memory only** — never log or persist it. It is single-use and expires in 5 minutes.
</Warning>

***

### 3. Receiver accepts

```bash theme={null}
POST /api/v1/jobs/{jobId}/call/accept
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{ "nonce": "a1b2c3d4e5f6..." }
```

**Response `200`:**

```json theme={null}
{
  "data": {
    "hms_room_id": "64b1a2c3d4e5f6a7b8c9d0e1",
    "auth_token": "eyJhbGci...",
    "role": "customer"
  }
}
```

The nonce is consumed (single-use). A fresh HMS token is generated for the receiver. The caller receives a `call:accepted` Socket.io event. Both parties are now in the 100ms room.

***

### 4. Receiver declines

```bash theme={null}
POST /api/v1/jobs/{jobId}/call/decline
Authorization: Bearer ACCESS_TOKEN
```

**Response `200`:**

```json theme={null}
{ "data": { "status": "declined" } }
```

Caller receives `call:declined` on `user:{callerId}` Socket.io room.

***

### 5. Caller cancels (before answer)

```bash theme={null}
POST /api/v1/jobs/{jobId}/call/cancel
Authorization: Bearer ACCESS_TOKEN
```

**Response `200`:**

```json theme={null}
{ "data": { "status": "cancelled" } }
```

A cancellation FCM is sent to dismiss the receiver's native call UI. Caller receives no socket event (they initiated the cancel).

***

### 6. Either party ends the call (hangup)

Call this from `leaveCall()` in both Flutter apps whenever a party hangs up:

```bash theme={null}
POST /api/v1/jobs/{jobId}/call/end
Authorization: Bearer ACCESS_TOKEN
```

**Response `200`:**

```json theme={null}
{ "data": { "status": "ended" } }
```

The active call state is cleared from Redis immediately. Both parties receive a `call:ended` Socket.io event so each app can return to its idle UI. **Idempotent** — safe to call multiple times (no-ops if call already ended).

<Info>
  Always call `/call/end` from your `leaveCall()` handler regardless of who initiates the hangup. If both parties call it simultaneously, only the first clears Redis; the second is a silent no-op.
</Info>

***

### 7. Missed call (timeout)

If the receiver doesn't answer within **30 seconds**, a BullMQ worker fires:

* Deletes the ringing state from Redis
* Emits `call:missed` to the caller on `user:{callerId}`
* Writes a `call_missed` audit entry to `job_messages`

***

## Socket.io Events (Call Signaling)

Listen on your connected Socket.io instance. All events are delivered to the `user:{userId}` room (auto-joined on connect).

### `call:accepted`

```dart theme={null}
socket.on('call:accepted', (data) {
  // data = { "job_id": "uuid" }
  // Receiver joined — call is live, both parties in 100ms room
  updateCallUI(CallState.connected);
});
```

***

### `call:declined`

```dart theme={null}
socket.on('call:declined', (data) {
  // data = { "job_id": "uuid" }
  endCallScreen();
  showSnackbar('Call declined');
});
```

***

### `call:missed`

```dart theme={null}
socket.on('call:missed', (data) {
  // data = { "job_id": "uuid" }
  // Fired 30s after initiate if no answer
  endCallScreen();
  showSnackbar('No answer');
});
```

***

### `call:ended`

Fired to **both parties** when either one calls `/call/end`. Use this to return your UI to idle when the remote party hangs up first.

```dart theme={null}
socket.on('call:ended', (data) {
  // data = { "job_id": "uuid" }
  _hms.leaveRoom();   // leave 100ms room if still in it
  updateCallUI(CallState.idle);
});
```

***

### `call:cancelled` (FCM only)

Delivered as a data-only FCM message (not a Socket.io event) so it wakes the app even when killed:

```json theme={null}
{
  "type": "call_cancelled",
  "job_id": "uuid-of-job"
}
```

```dart theme={null}
// In your FirebaseMessaging.onBackgroundMessage handler:
if (message.data['type'] == 'call_cancelled') {
  dismissIncomingCallScreen(message.data['job_id']);
}
```

***

## Flutter Integration Example

```dart theme={null}
class CallService {
  final SocketService _socket;
  final HmsService _hms;

  CallService(this._socket, this._hms);

  /// Caller: initiate a call
  Future<void> initiateCall(String jobId) async {
    final res = await api.post('/jobs/$jobId/call/initiate');
    final data = res.data['data'];

    // Join 100ms room immediately and wait for other party
    await _hms.joinRoom(
      roomId: data['hms_room_id'],
      authToken: data['auth_token'],
    );

    // Listen for signaling results
    _socket.socket.once('call:accepted', (_) {
      // Other party joined — call is live
      updateUI(CallState.connected);
    });
    _socket.socket.once('call:missed', (_) async {
      await leaveCall(jobId);
    });
    _socket.socket.once('call:declined', (_) async {
      await leaveCall(jobId);
    });
    _socket.socket.once('call:ended', (_) async {
      // Remote party hung up — clean up local state
      await _hms.leaveRoom();
      updateUI(CallState.idle);
    });
  }

  /// Receiver: accept via nonce (called from FCM handler)
  Future<void> acceptCall(String jobId, String nonce) async {
    final res = await api.post(
      '/jobs/$jobId/call/accept',
      data: {'nonce': nonce},
    );
    final data = res.data['data'];

    await _hms.joinRoom(
      roomId: data['hms_room_id'],
      authToken: data['auth_token'],
    );

    // Listen for remote hangup once connected
    _socket.socket.once('call:ended', (_) async {
      await _hms.leaveRoom();
      updateUI(CallState.idle);
    });

    updateUI(CallState.connected);
  }

  /// Receiver: decline
  Future<void> declineCall(String jobId) async {
    await api.post('/jobs/$jobId/call/decline');
    updateUI(CallState.idle);
  }

  /// Caller: cancel before answered
  Future<void> cancelCall(String jobId) async {
    _socket.socket.off('call:accepted');
    _socket.socket.off('call:missed');
    _socket.socket.off('call:declined');
    _socket.socket.off('call:ended');
    await api.post('/jobs/$jobId/call/cancel');
    await _hms.leaveRoom();
    updateUI(CallState.idle);
  }

  /// Either party: hang up during an active call
  /// Call this whenever the local party taps the hang-up button.
  Future<void> leaveCall(String jobId) async {
    _socket.socket.off('call:ended'); // avoid self-loop
    await api.post('/jobs/$jobId/call/end');
    await _hms.leaveRoom();
    updateUI(CallState.idle);
  }
}
```

***

## Error Codes

| HTTP  | Code        | Meaning                                                            |
| ----- | ----------- | ------------------------------------------------------------------ |
| `409` | `CONFLICT`  | Call already ringing or active for this job                        |
| `403` | `FORBIDDEN` | Invalid or expired nonce (accept), or not the caller (cancel)      |
| `404` | `NOT_FOUND` | Job not found                                                      |
| `403` | `FORBIDDEN` | Job status doesn't allow calls                                     |
| `409` | `CONFLICT`  | Call no longer ringing when accept is sent (timed out / cancelled) |

***

## Redis State Machine

| Key                    | TTL   | Purpose                                                   |
| ---------------------- | ----- | --------------------------------------------------------- |
| `call:ringing:{jobId}` | 35 s  | Ringing state — caller, nonce, BullMQ job ID              |
| `call:active:{jobId}`  | 4 h   | Active call state — set on accept, cleared by `/call/end` |
| `call:nonce:{nonce}`   | 5 min | Single-use nonce → validates accept request               |

The 35 s TTL on `call:ringing` is a safety net — the BullMQ timeout fires at 30 s and cleans up Redis itself. The extra 5 s prevents a race where Redis expiry fires before the worker.

The 4 h TTL on `call:active` is a **safety net only** — `/call/end` clears it immediately on hangup. The TTL prevents the key from persisting indefinitely if both clients crash without calling the endpoint.
