# PropertyLab Sales Agent mobile API

This is the client contract for the PropertyLab Sales Agent Flutter app. The
app uses only the petaV3 host and the endpoints below. All JSON requests send
`Accept: application/json`; authenticated requests also send
`Authorization: Bearer <token>`.

The mobile client should use the direct `/agent-api/*` path (for example,
`POST /agent-api/agent-auth/token`). Equivalent `/api/*` paths remain for
existing clients, but can collide with legacy Dingo routing on older
deployments.

The server derives the user and salesperson identity from the JWT. A client
must never send or trust an Admin ID or an internal numeric Lead ID. Public
Lead identity is `lead_uuid`; mobile interaction identity is a client-generated
UUID stored by the server as `agent_call_events.external_id` with the
`flutter:` prefix.

## Authentication and refresh

### `POST /api/agent-auth/token`

Request:

```json
{
  "email": "agent@example.test",
  "password": "example-password"
}
```

Success is `200`:

```json
{
  "data": {
    "token": "<jwt>",
    "expires_in": 3600,
    "account_scope": "11111111-1111-4111-8111-111111111111"
  }
}
```

`account_scope` is the authenticated account's stable, lowercase public UUID.
Treat it as an opaque owner namespace for local credentials and data; it is
server-derived and must never be supplied by the client.

Invalid credentials, a non-staff account, or an inactive account return `401`
with the same generic message. A valid staff login without an Admin row
returns `422`. Login can return `429` when rate-limited.

### `POST /api/agent-auth/refresh`

Send the current bearer token and an empty body. This route accepts an expired
access token only while it remains inside the configured refresh window.
Success is the same `data.token` / `data.expires_in` / `data.account_scope`
shape as login. The refreshed scope must match the stored credential owner.
Refresh rotates the token: replace the stored token atomically and do not reuse
the old one.

On any authenticated endpoint, handle `401` as follows:

1. Attempt one refresh with the current token.
2. If refresh returns `200`, durably replace the token and retry the original
   request once with the new token.
3. If refresh returns `401`, clear credentials and require sign-in. Do not
   loop refresh or retry indefinitely.

Role removal, account deactivation, a missing Admin row, a missing/invalid
token, or a token outside the refresh window all make refresh return `401`.

## Lead search and selection

All Lead endpoints require a staff JWT. The all-customer search and individual
lookup additionally require the dedicated Agent App Lead search permission;
missing that permission returns `403`.

### `GET /api/agent-leads/assigned?cursor={cursor}`

Returns only live customer Leads whose `assigned_admin_id` belongs to the
authenticated Agent. The client cannot request another Agent's assignments,
and this endpoint does not require the all-customer search permission. Pages
are capped at 25 rows; follow the opaque, user-bound `meta.next_cursor` until
it is `null`. The response fields match the search response below.

### `GET /api/agent-leads?q={query}&cursor={cursor}`

Searches approved live customer Leads by case-insensitive name or email and by
normalized phone variants. Text needs at least two useful characters; phone
search needs at least four digits. Invalid queries or cursors return `422`.
The server caps each page at 25 rows. Follow `meta.next_cursor` until it is
`null`; treat the cursor as opaque and bound to the authenticated user and
query.

```json
{
  "data": [
    {
      "assigned_salesperson": "Sales Agent",
      "display_name": "Example Buyer",
      "email": "buyer@example.test",
      "lead_status": "New",
      "lead_uuid": "11111111-1111-4111-8111-111111111111",
      "primary_phone": "60123456789",
      "updated_at": "2026-08-23T10:00:00+08:00"
    }
  ],
  "meta": { "next_cursor": null }
}
```

### `GET /api/agent-leads/{lead_uuid}`

Returns the same approved Lead fields under `data`. An ineligible, removed, or
unknown Lead returns `404`. Select and persist the returned `lead_uuid` before
creating an interaction; do not infer an internal ID from any other response.

## Badge first-bind and list

### `POST /api/agent-badges/bind`

```json
{ "badge_sn": "C8478C5BA8E1" }
```

The serial is exactly 12 hexadecimal characters; input is normalized to
uppercase. Only pre-registered, active, unowned BLE inventory can first-bind.
A repeat by the current owner is safe and returns `200` with
`binding_status: "already_bound"`; a successful first-bind returns
`binding_status: "bound"`.

```json
{
  "data": {
    "badge_sn": "C8478C5BA8E1",
    "label": "Pilot badge",
    "is_active": true,
    "binding_status": "bound"
  }
}
```

Errors are `404 badge_not_found`, `409 badge_inactive`, and
`409 badge_already_bound` when another agent owns the badge. Malformed input
returns `422`. The mobile API intentionally has no transfer or unbind route.

### `GET /api/agent-badges`

Returns only the current salesperson's BLE badges as
`data[]` entries containing `badge_sn`, `label`, and `is_active`. It does not
expose ownership IDs.

## Interaction lifecycle

An interaction is the durable record of one selected Lead and one attempted
call. Generate one UUID once and retain it through every retry and upload.

### `POST /api/agent-call-interactions`

```json
{
  "interaction_uuid": "22222222-2222-4222-8222-222222222222",
  "lead_uuid": "11111111-1111-4111-8111-111111111111",
  "call_channel": "pstn",
  "badge_sn": "C8478C5BA8E1",
  "status": "preparing",
  "started_at": "2026-08-23T10:00:00+08:00"
}
```

`call_channel` is `pstn` or `whatsapp`. Initial status is `preparing` or
`waiting_for_badge_start`. The Lead must be in the allowed search scope and
the active badge must belong to the authenticated salesperson.

An initial `201`, or an idempotent `200` retry with matching immutable fields,
proves durable persistence for this exact UUID. The `200` response
must return the original `data.interaction_uuid`, `data.lead.lead_uuid`,
`data.call_channel`, `data.badge_sn`, and current `data.start_source`. Persistence
alone does not authorize a new recording start: the client must also inspect
`data.status`. Only `preparing` and `waiting_for_badge_start` authorize a new
badge capture. `recording` and `stopping` require resume or reconciliation;
they never authorize a new start. `queued`, `uploading`, `matched`, `filtered`,
`canceled`, `expired`, and `failed_permanent` never authorize a new start.
Handle those states according to the lifecycle below. Never replace the UUID
after an uncertain response; retry it first.

Response `data` contains:

```json
{
  "interaction_uuid": "22222222-2222-4222-8222-222222222222",
  "status": "preparing",
  "call_channel": "pstn",
  "badge_sn": "C8478C5BA8E1",
  "start_source": null,
  "stop_source": null,
  "started_at": "2026-08-23T10:00:00+08:00",
  "ended_at": null,
  "last_activity_at": "2026-08-23T10:00:00+08:00",
  "lease_expires_at": "2026-08-23T10:10:00+08:00",
  "lead": {
    "lead_uuid": "11111111-1111-4111-8111-111111111111",
    "display_name": "Example Buyer",
    "primary_phone": "60123456789"
  }
}
```

Create conflicts are:

- `409 active_interaction_exists`: another active capture exists. Resume the
  UUID in `existing_interaction_uuid`; do not create another recording.
- `409 interaction_immutable_mismatch`: the UUID exists with different
  immutable fields. Stop and preserve local state for operator resolution.
- `409 interaction_namespace_conflict` or `409 interaction_tombstone`: the UUID
  cannot be reused. Preserve evidence and use a genuinely new user action for
  any new interaction.

An unavailable badge returns `404 badge_not_found`; an unknown or ineligible
Lead returns `404`.

### `GET /api/agent-call-interactions/{interaction_uuid}`

Returns the same `data` shape for an interaction owned by the authenticated
salesperson. Unknown and other-owner UUIDs return `404`. Use this after an
uncertain transition or upload response to reconcile durable server state.

### `PATCH /api/agent-call-interactions/{interaction_uuid}`

Send a status transition, a heartbeat, or both. Client timestamps include an
offset.

```json
{ "status": "recording", "start_source": "app" }
```

```json
{ "heartbeat": true }
```

```json
{
  "status": "stopping",
  "stop_source": "device_button"
}
```

```json
{
  "status": "queued",
  "ended_at": "2026-08-23T10:03:00+08:00"
}
```

`start_source` is `app` or `device_button`. `stop_source` is `app`,
`device_button`, or `recovery`. Send heartbeats only while `recording` or
`stopping`. A same-state retry without a heartbeat is idempotent.

Allowed forward transitions are:

| From | To |
| --- | --- |
| `preparing` | `waiting_for_badge_start`, `recording`, `canceled`, `expired` |
| `waiting_for_badge_start` | `recording`, `canceled`, `expired` |
| `recording` | `stopping`, `queued`, `canceled`, `expired` |
| `stopping` | `queued`, `canceled`, `expired` |
| `queued` | `uploading`, `failed_permanent` |
| `uploading` | `failed_permanent` |

The server alone sets `matched` and `filtered`. Invalid transitions return
`409 invalid_interaction_transition`; attempting to submit a server-only
status fails validation with `422`. An expired recovery lease returns
`409 interaction_recovery_expired` with
`requires_unassigned_recovery: true`; preserve the audio for the unassigned
recovery flow. A successful transition returns `200` and the current `data`.

## Recording upload and durable acknowledgement

### `POST /api/agent-recordings`

Use `multipart/form-data`:

| Field | Contract |
| --- | --- |
| `audio` | YHY02 raw ADTS AAC file |
| `device_sn` | 12 hexadecimal characters |
| `file_name` | device-relative path, at most 80 characters |
| `started_at` | timestamp with offset |
| `duration_seconds` | integer from 0 through 86400 |
| `interaction_uuid` | the acknowledged interaction UUID |
| `audio_sha256` | lowercase or uppercase SHA-256 of the exact uploaded bytes |

For Flutter uploads, `interaction_uuid` and `audio_sha256` are a required
pair. Compute SHA-256 by streaming the final file that will be uploaded; do
not hash a re-encoded or partial buffer. The server streams its own hash and
byte count and rejects a mismatch with `422 audio_sha256_mismatch` before
durable linking.

The recording identity is the normalized `device_sn` plus `file_name`.
First success is `201`:

```json
{
  "success": true,
  "data": {
    "id": 123,
    "uuid": "33333333-3333-4333-8333-333333333333",
    "status": "stored",
    "duplicate": false
  }
}
```

An exact retry with the same interaction, recording identity, byte count, and
hash returns `200`, the same `data.id` / `data.uuid`, `status: "duplicate"`,
and `duplicate: true`. Changed content for the same recording identity returns
`409 recording_integrity_conflict`. Other `409` codes preserve the existing
link and must not be treated as upload success:

- `interaction_recording_conflict`: this interaction already owns another
  recording;
- `recording_interaction_conflict`: this recording already belongs to another
  interaction;
- `recording_owner_conflict` or `recording_lead_conflict`: stored attribution
  conflicts with the selected interaction;
- `interaction_link_forbidden` or `interaction_link_ineligible`: the
  authenticated interaction/badge/Lead/state is not linkable;
- `interaction_recovery_expired`: the recovery lease expired.

An owned UUID that does not exist returns `404`; an unowned badge returns
`403`; malformed multipart data returns `422`.

For a too-short clip, first success is `201` with `status: "filtered"`,
`reason: "too_short"`, and `duplicate: false`. This is a durable terminal
result: the server stores the recording identity, SHA-256, size, interaction
link, and Lead link while intentionally retaining no audio object or analysis
job. An exact retry returns `200`, `status: "filtered"`, and
`duplicate: true`. Never interpret `filtered` as a successful call match.

After any `201`/`200` response with `status` equal to `stored`, `duplicate`,
or `filtered`, the client must first durably commit the returned recording
`id`, `uuid`, status, and duplicate flag against the interaction UUID. Only
after that local commit may it delete the phone cache. The badge copy remains;
this API never requests deletion from the badge.

## Retry and rollout compatibility

- Retry transport failures and `5xx` with the same interaction UUID, recording
  identity, hash, and bytes. Reconcile uncertain interaction writes with the
  interaction `GET` endpoint.
- `409` means a durable semantic conflict, not a transient duplicate. Do not
  regenerate identity or delete local audio in response.
- `422` means validation or integrity failure. Correct the request; do not
  blindly retry it.
- During the legacy Kotlin compatibility window, recording uploads may omit
  both `interaction_uuid` and `audio_sha256`. They are accepted only when
  jointly absent. If either field is present, both are required and validated.
- New Flutter clients always send both integrity fields and use the explicit
  interaction endpoints. There is no fallback host, token, or mobile backend.


## Session report detail

`GET /agent-api/agent-recordings/{recording_uuid}/report` (also under `/api`).
Requires a staff JWT. Only the current agent's own non-deleted, non-ignored
recordings are readable; unknown/foreign recordings return 404. The public
recording UUID is the `data.uuid` returned by upload, not the local upload UUID.

```json
{"data":{"uuid":"11111111-1111-4111-8111-111111111111","processing_status":"complete","transcript":"Customer conversation","ai_analysis":{"summary":"Discussion summary"},"meeting_report":{"key_points":["Budget discussed"]},"recommendations":[{"body":"Send floor plan","priority":"high","action_type":"send_information","reason":null,"suggested_scheduled_for":null}]}}
```

`processing_status`: queued, transcribing, analyzing, complete, failed or
unavailable. Results can be null/empty while work is pending, or partly available
when it fails. Recommendations are typed action rows, falling back to legacy
next-step strings. The endpoint reads saved results only; refresh does not
trigger transcription or analysis. Responses are no-store. No migration needed.
