# WhatsApp — sales app adapter

## What it does

Serves the authenticated Flutter sales app from the existing WhatsApp inbox.
This adapter exposes individual Cloud API and QR/Bridge conversations, text
replies, customer/channel context and permission-scoped account-manager filters.
It does not create a separate messaging store or change web inbox behavior.

## How it works

- `GET /agent-api/whatsapp/conversations` retains `limit` (1–200), `search`
  (up to 100 characters) and `meta.has_more`. Optional filters intersect:
  `assigned_to=me|<user UUID>` and `channel_type=cloud_api|bridge`.
- `ListAgentWhatsappRequest` validates inputs. `AgentWhatsappQuery` starts from
  `AgentInbox::conversations`, so filters never replace `LeadVisibility` or
  WhatsApp access checks. Unknown/out-of-scope owner UUIDs return 403; malformed
  values return 422. Omitting filters means all **accessible**, not all company
  conversations.
- `meta.supports_filters=true` advertises the capability. `meta.assignees` contains
  only UUID/name pairs for account managers assigned to Leads visible to this
  caller. This reflects `Lead.assigned_admin_id` (a User ID), not conversation
  routing ownership or future multi-person Caller/Closer roles.
- List and detail conversation objects add `channel_type`, `channel_phone`,
  `avatar_url`, `lead_uuid`, `lead_status` and nullable `assigned_to:{uuid,name}`.
  Missing data stays null. Credentials/provider configuration are never included.
- Existing detail pagination, text-send idempotency, dispatch-time authorization,
  blocked-contact checks and provider-specific reply restrictions remain in place.
  Cloud replies use server time and the last inbound message's 24-hour window.
- Roll out the backend before enabling filters in the app. Older clients ignore
  additive fields; the updated app hides controls on older backends and rejects
  an unacknowledged filtered response rather than presenting it as filtered.
- Not implemented here: template sending, multi-person
  assignments, or changes to the web role/ownership model.

### Unread, needs-reply and phone notifications (September 16)

**Inbox state.** List and detail conversation objects add `unread_count`,
`needs_reply` and `last_inbound_at`. Both states come from
[`InboxState`](/src/Whatsapp/Support/InboxState.php), which the web inbox also
uses, so a phone badge and a web chip cannot disagree:

- `unread_count` — inbound messages newer than the **team-global**
  `last_read_at` (all of them when never read). Deleted messages never count.
  Opening a thread in either inbox clears it for everyone.
- `needs_reply` — the customer's latest inbound has no **confirmed** outbound
  reply (status sent / delivered / read) at or after it. A reply that is still
  queued, failed or deleted leaves it `true`; an AI auto-reply that was sent
  answers it. The web-only *Needs review* concept is not exposed here.

`GET /agent-api/whatsapp/conversations` accepts `unread=1` and `needs_reply=1`
(booleans; `true`/`false` strings are rejected with 422). They intersect with
`assigned_to`, `channel_type` and `search` and start from
`AgentInbox::conversations`, so they can never widen visibility.
`meta.supports_state_filters=true` advertises the capability and
`meta.counts: {unread, needs_reply}` counts conversations in the selected scope
(`assigned_to` + `channel_type`), ignoring the state toggles and search, so the
tab badge does not change when a chip is tapped.

`POST /agent-api/whatsapp/conversations/{uuid}/read` behaves like opening the
thread on the web: stamps `last_read_at`, queues `MarkWhatsAppRead` (customer
blue ticks) and broadcasts `WhatsAppConversationRead`. It is scoped by
`AgentInbox` (404 outside scope) and returns
`{uuid, unread_count, needs_reply, last_read_at}`. `GET` thread reads never mark
read, so the app's polling cannot send receipts.

**Push token lifecycle.** `POST /agent-api/agent-app/installations` accepts
`platform=android` with `tech.propertylab.agent` or `platform=ios` with
`tech.propertylab.salesagent` — each platform only with its own **production**
identity; pilot IDs are refused so a pilot token can never enter production
pushes. The token stays encrypted at rest (`encrypted` cast) and hidden from
responses. Re-registering replaces the token (FCM refresh). When an installation
is taken over by a different account without a new token, the old token is
cleared rather than delivering to the new account. `DELETE
/agent-api/agent-app/installations/{installationUuid}/push-token` (204, owner-scoped,
idempotent) stops delivery before logout / account switching; the installation
row stays for version tracking. FCM `UNREGISTERED` / `SENDER_ID_MISMATCH` clears
the token unless the device registered a replacement in the meantime.

**Who is notified.** `ProcessInboundWhatsAppWebhook::considerAgentAppPush` queues
`SendWhatsappInboundPushNotifications` (message id only) for each **live 1:1
customer message** on a Cloud API or QR/Bridge line, after the contact is linked
to its Lead. Outbound and AI replies, history backfill, reactions, calls, system
rows, revoked/deleted messages, groups and sandbox threads never notify; status,
edit and media-download events do not go through this path at all.
[`WhatsappInboundPushNotifier`](/src/AgentApp/Services/WhatsappInboundPushNotifier.php)
resolves recipients at send time:

1. people **responsible** for the Lead — `leads.assigned_admin_id` plus every
   project role holder (Caller, Closer, …) via `AgentLeadAssignments::assignedTo`;
2. who still pass `AgentInbox` (active staff role, WhatsApp permission,
   `LeadVisibility`, channel/chat type) for that conversation;
3. who have an installation with a live token (deduplicated per device token).

Company-wide viewers and team leads are **not** notified about Leads they do not
hold, and a conversation without a linked Lead notifies nobody (it still shows as
unread to whoever can see it). Widening the audience is a product decision.

**Payload and idempotency.** The FCM message is always the generic
`New WhatsApp message` / `Open WhatsApp to view the message`, with data
`{type: whatsapp_inbound, conversation_uuid}` only — no body, phone number, name
or attachment URL. Android uses channel `whatsapp_messages` and a per-conversation
tag; iOS uses `apns-collapse-id`, so a newer alert replaces an older one for the
same thread. `agent_app_push_deliveries` holds one row per
(kind, message, installation) and a conditional `pending → sending` claim, so a
replayed webhook, a retried job or two workers never notify a device twice
(at most once). Any send error (an FCM status such as `UNAVAILABLE`, a
connection error, a Google OAuth token failure) releases the claim for a retry
after 30 s and 120 s, then marks it failed, so no device is left stuck in
`sending`. The ledger and logs hold ids, statuses, error codes and exception
class names only — never tokens or content.

**Deploy before shipping the app build.**

1. Run the migration (`agent_app_push_deliveries`).
2. `FIREBASE_PROJECT_ID` and `FIREBASE_SERVICE_ACCOUNT_PATH` (JSON outside git,
   readable by the queue worker) must point at the Firebase project that holds
   **both** the Android app `tech.propertylab.agent` and the iOS app
   `tech.propertylab.salesagent`, with an APNs auth key uploaded for the iOS app.
   Without them, jobs log `WhatsApp push skipped because Firebase is not
   configured.` and send nothing; in-app polling keeps working.
3. Restart queue workers (`supervisorctl restart`, not only `queue:restart`).
4. Verify with an approved internal test Lead only — never a real customer.

### Mobile attachment uploads

- Conversations advertise `supports_media=true`. `POST
  /agent-api/whatsapp/conversations/{uuid}/media` accepts multipart `file`,
  `request_uuid` and optional `caption`. The mobile request extends the web
  `SendMediaRequest`: JPEG/PNG up to 5 MB, supported audio/video up to 16 MB,
  supported documents up to 25 MB. Audio captions, arbitrary voice flags and
  reply-to fields are rejected. Audio files are ordinary audio attachments,
  not microphone-recorded OGG/Opus push-to-talk messages.
- The server hashes bytes, sniffed MIME, sanitized display filename and caption
  into the send identity. A UUID replay must match the conversation, actor and
  digest; changed payloads, cross-text/media reuse and deleted rows return 409.
  An already accepted upload can be acknowledged even after the reply window
  closes; a new send still requires an open window and current access.
- `MediaService` stores the binary before the transaction, using a server-derived
  extension. `AgentMediaReplyRepository` rechecks authorization under a conversation
  lock, delegates message/attachment creation to `WhatsappRepository`, and links
  the stored media atomically. Only then is `SendAgentWhatsAppReply` dispatched,
  retaining its dispatch-time access checks and existing provider delivery path.
- A confirmed concurrent replay cleans up only its unused new upload. An uncertain
  commit/storage failure can leave an orphan; it never deletes a potentially linked
  file. Cleanup errors log only a media ID, not content, filenames or credentials.
- Flutter keeps the pending UUID/caption/hash in account-scoped secure storage and
  a private non-backed-up cache copy of the selected file. Retry preserves the exact
  identity across reopening. OS cache eviction requires reselecting the identical
  file (hash checked); it does not silently start a new send. Progress distinguishes
  bytes uploaded from server acknowledgement/delivery.

### Microphone voice notes (disabled until deployment validation)

`supports_voice` is advertised only when `AGENT_APP_VOICE_NOTES_ENABLED=true`
and the configured ffmpeg executable is available. The default is **false**.
`AGENT_APP_VOICE_FFMPEG_BINARY` defaults to `ffmpeg`; an absolute executable
path is supported. Before enabling, verify **libopus** is installed under the
PHP-FPM user's environment, not just an interactive shell. The availability
field checks executable presence, not codec support.

The same media endpoint additionally accepts
`voice_format=pcm_s16le_16000_mono`, without a caption. This is raw signed
16-bit little-endian PCM, 16 kHz, mono: 32,000 bytes/second, 1–300 seconds,
at most 9,600,000 bytes. Odd byte counts, unsupported formats, captions and
oversize input return 422. It is not an endpoint for CAF/AAC/WAV files.

`AgentVoiceEncoder` forces the raw input format and a file/pipe protocol
allowlist; it does not probe playlists or user-supplied URLs. It uses one
encoding thread and a 60-second process timeout, producing mono 48 kHz
OGG/Opus at 32 kbps. Conversion failure stores no message and sends nothing.
The private stored media is `voice-note.ogg` / `audio/ogg`; attachment
`meta.voice=true` reaches the existing provider adapters. The original PCM
hash and format are part of retry identity; ordinary attachment digests are
unchanged. Accepted replays do not require conversion or an enabled voice flag.

Deployment checklist (not executed on production):

1. Deploy the backend adapter. Install/verify ffmpeg and libopus, set the binary
   path if needed, and allow the PHP process to execute it.
2. Ensure PHP upload/post limits and proxy request limits accept a 9.6 MB file
   plus multipart overhead; allow conversion time in request timeouts. Retain
   the existing authenticated send throttling and private media storage.
3. Enable the voice flag and rebuild Laravel config cache through the normal
   deployment workflow. No schema changes or new WhatsApp credentials.
   Run `php artisan agent-app:check-voice-notes` as the same OS user/container
   running PHP. It must exit 0 and print PASS. This converts one second of generated
   silence through the actual encoder, verifies OGG/Opus, removes the temporary
   probe, and sends no message. A passing CLI check does not verify proxy limits,
   PHP-FPM configuration, queue health or recipient playback.
4. Use approved test recipients to check actual iOS and Android recordings,
   Cloud API and QR/Bridge playback, permission denial, interruption, expiry,
   and uncertain-network retry. Automated tests do not prove provider delivery.

The app asks for microphone access only when Record voice note is tapped.
It cancels on backgrounding/interruption, checks Badge/reply access, and requires
a separate Send action. In-progress audio is bounded in memory; stopped audio
uses the account-scoped upload cache. If the OS evicts an **unconfirmed voice**
payload, it cannot be reconstructed by reselecting a user file. Keep its send
identity and query the original send result; do not automatically create a
replacement message. There is no microphone background mode, recording/body
logging, or automatic customer send.

### Send-result recovery (September 15)

`GET /agent-api/whatsapp/conversations/{uuid}/sends/{requestUuid}` is a read-only,
no-store lookup. Conversation responses advertise `supports_send_lookup: true`.
The caller must still be able to view the conversation, and the result must
belong to that conversation **and the original sending user**. A colleague's
send, even to a shared visible customer, is not a matching result.

Response: `data: {found: bool, removed: bool, message: object|null}`. A saved live
message uses the ordinary safe presenter (UUID, delivery status, attachments);
a soft-deleted send returns `found: true, removed: true, message: null`, with no
deleted body/attachments. No result is `found: false, removed: false, message: null`.
This never enqueues, re-encodes, re-sends or needs the phone's lost payload.
It still works after the Cloud API reply window closes or voice is disabled.

The App uses this when restoring pending text and opening/retrying a pending
attachment. `found` means persisted, **not delivered**: show the returned delivery
status. `found: false` can race a still-running original send and does not prove
non-delivery. Retain the same UUID/payload and allow checking again or an explicit
identical retry; never automatically issue a replacement UUID. If voice bytes
are gone and no server result exists, recovery must remain unresolved rather
than inventing audio or silently sending another voice note.

Voice checkpoint: the full Agent API suite passed 239 tests / 1,510 assertions;
the nine media tests passed again after process exception hardening. This includes
real ffmpeg OGG/Opus conversion, mono header and stored-size checks, replay after
flag/window changes, invalid inputs and conversion failure. The existing PHPUnit
XML schema deprecation remains. No live provider messages were sent.

Attachment validation: the full Agent API suite passes 236 tests / 1,480 assertions
on the isolated test DB. Six attachment tests cover replay, window/access checks,
MIME/size validation, deleted/cross-type UUIDs, image typing, and storage failure.

Validation on September 11: all 230 Agent API feature tests (1,446 assertions)
passed against an isolated scratch database with PHP 8.4 and a 512 MB process
memory limit. The existing PHPUnit XML schema produces a deprecation notice;
the default 128 MB limit was insufficient for the full recording-upload suite.
The new request/query classes pass Pint. No production data or provider calls
were used, and nothing has been deployed.


## Related files

- [Controller](/app/Http/Controllers/AgentApi/AgentWhatsappController.php)
- [Validation](/app/Http/Requests/AgentApi/ListAgentWhatsappRequest.php)
- [Query](/app/Http/Requests/AgentApi/AgentWhatsappQuery.php)
- [Scope and presenter](/src/Whatsapp/Support/AgentInbox.php)
- [Queued reply](/app/Jobs/Whatsapp/SendAgentWhatsAppReply.php)
- [Media controller](/app/Http/Controllers/AgentApi/AgentWhatsappMediaController.php)
- [Media validation](/app/Http/Requests/AgentApi/SendAgentWhatsappMediaRequest.php)
- [Media send transaction](/src/Whatsapp/Repositories/AgentMediaReplyRepository.php)
- [Media tests](/tests/Feature/AgentApi/AgentWhatsappMediaTest.php)
- [Voice encoder](/src/Whatsapp/Services/AgentVoiceEncoder.php)
- [API regression tests](/tests/Feature/AgentApi/AgentWhatsappTest.php)
- [Inbox state predicates](/src/Whatsapp/Support/InboxState.php)
- [Push notifier](/src/AgentApp/Services/WhatsappInboundPushNotifier.php)
- [Push job](/app/Jobs/AgentApp/SendWhatsappInboundPushNotifications.php)
- [FCM client](/src/AgentApp/Services/FirebaseCloudMessaging.php)
- [Delivery ledger](/src/AgentApp/AgentAppPushDelivery.php) and [repository](/src/AgentApp/Repositories/AgentAppPushDeliveryRepository.php)
- [Installations](/src/AgentApp/Repositories/AgentAppInstallationRepository.php) and [controller](/app/Http/Controllers/AgentApi/AgentAppInstallationsController.php)
- [Inbox state tests](/tests/Feature/AgentApi/AgentWhatsappInboxStateTest.php), [push tests](/tests/Feature/AgentApi/WhatsappInboundPushTest.php), [token tests](/tests/Feature/AgentApi/AgentAppPushRegistrationTest.php)
- [Parent WhatsApp module](/docs/modules_handbook/manage/messages/whatsapp/readMe.md)
- Flutter repository: `lib/features/whatsapp/` and `test/features/whatsapp/`.
