From e730b28bbd2a8f5f9c9344663bca4abe8ddcc781 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 21:49:59 +0800 Subject: [PATCH] feat(channels): align whatsapp calls api --- cmd/route_parity/main.go | 6 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 20 +- docs/parity/gochat_routes.txt | 8 +- docs/parity/route_parity.md | 8 +- .../handler/api/v1/whatsapp_call_handler.go | 208 ++++++- .../api/v1/whatsapp_call_handler_test.go | 152 +++++ internal/model/call.go | 4 +- internal/repository/whatsapp_call_repo.go | 5 +- internal/router/router.go | 8 + internal/service/whatsapp_call_service.go | 522 +++++++++++++++++- .../service/whatsapp_call_service_test.go | 183 ++++++ ...00034_align_calls_whatsapp_fields.down.sql | 2 + .../000034_align_calls_whatsapp_fields.up.sql | 2 + 13 files changed, 1111 insertions(+), 17 deletions(-) create mode 100644 internal/handler/api/v1/whatsapp_call_handler_test.go create mode 100644 internal/service/whatsapp_call_service_test.go create mode 100644 migrations/000034_align_calls_whatsapp_fields.down.sql create mode 100644 migrations/000034_align_calls_whatsapp_fields.up.sql diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index f037f723..1207c381 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -328,6 +328,12 @@ var criticalRoutes = []route{ {Method: "GET", Path: "/api/v1/accounts/:account_id/applied_slas/metrics", Controller: "api/v1/accounts/applied_slas#metrics", Source: "routes.rb:230"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/applied_slas/download", Controller: "api/v1/accounts/applied_slas#download", Source: "routes.rb:231"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/reporting_events", Controller: "api/v1/accounts/reporting_events#index", Source: "routes.rb:234"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/whatsapp_calls/:id", Controller: "api/v1/accounts/whatsapp_calls#show", Source: "routes.rb:237"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/whatsapp_calls/:id/accept", Controller: "api/v1/accounts/whatsapp_calls#accept", Source: "routes.rb:239"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/whatsapp_calls/:id/reject", Controller: "api/v1/accounts/whatsapp_calls#reject", Source: "routes.rb:240"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/whatsapp_calls/:id/terminate", Controller: "api/v1/accounts/whatsapp_calls#terminate", Source: "routes.rb:241"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording", Controller: "api/v1/accounts/whatsapp_calls#upload_recording", Source: "routes.rb:242"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/whatsapp_calls/initiate", Controller: "api/v1/accounts/whatsapp_calls#initiate", Source: "routes.rb:245"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/captain/preferences/", Controller: "api/v1/accounts/captain/preferences#show", Source: "routes.rb:63"}, {Method: "PUT", Path: "/api/v1/accounts/:account_id/captain/preferences/", Controller: "api/v1/accounts/captain/preferences#update", Source: "routes.rb:63"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 8f39c37f..5fd9fe2d 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,16 +49,16 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 account onboarding route/payload parity checkpoint, prepared as `feat(accounts): align onboarding update`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(accounts): align onboarding update`. -- Latest documentation/tooling checkpoint: this tracker update for P3.36 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. +- Current tracking checkpoint: 2026-06-06 account WhatsApp calls API parity checkpoint, prepared as `feat(channels): align whatsapp calls api`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): align whatsapp calls api`. +- Latest documentation/tooling checkpoint: this tracker update for P3.37 plus the landed parity tracker history; this document is the active follow-up plan and supersedes `.hermes/plans/*`. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: account onboarding update from `reference/chatwoot/config/routes.rb:58`, `app/controllers/api/v1/accounts/onboardings_controller.rb`, account update/model Jbuilder views, and reused dashboard onboarding/store clients is implemented. GoChat now registers administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, accepts the frontend raw onboarding payload, persists permitted account fields plus account `custom_attributes`, clears `onboarding_step` when finalizing account details, and returns the raw Chatwoot account serializer shape. Account notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: account WhatsApp calls API from `reference/chatwoot/config/routes.rb:237-245`, enterprise `WhatsappCallsController`, WhatsApp call Jbuilder views/specs, `Whatsapp::CallService`, `Call`, and reused dashboard `whatsappCallsAPI.js` is implemented. GoChat now registers the account-level show/initiate/accept/reject/terminate/upload-recording routes used by the frontend, persists WhatsApp calls in the Chatwoot `calls` model boundary, creates linked `voice_call` messages for outbound calls, updates call/message/conversation status for accept/reject/terminate, handles permission-request and recording idempotency payloads, and keeps the old conversation-nested tracking routes as compatibility aliases. Account onboarding, notification scoping, API v2 year-in-review, and WhatsApp calling toggle parity remain implemented from previous checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from fresh reference/smoke evidence. -- `go test ./...` passes when run outside the restricted socket sandbox; focused Notification handler/repository/service tests pass in the sandbox. -- Route dump succeeds with `952` registered routes after account onboarding route tracking. +- `go test ./...` passes when run outside the restricted socket sandbox; focused WhatsApp call service/handler/router/route-parity tests pass in the sandbox. +- Route dump succeeds with `958` registered routes after account WhatsApp calls route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 415 Chatwoot routes: 402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing. The 13 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. +- Tracked frontend-critical route audit covers 421 Chatwoot routes: 403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing. The 18 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits, dashboard app member `:id` names, WhatsApp call `:id` route names, plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -140,6 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.37 account WhatsApp calls API parity | Implemented for reused dashboard WhatsApp WebRTC call controls: account-level `GET /api/v1/accounts/:account_id/whatsapp_calls/:id`, `POST /whatsapp_calls/initiate`, `POST /whatsapp_calls/:id/accept`, `reject`, `terminate`, and `upload_recording` are registered and tracked from `routes.rb:237-245`; initiate validates enabled WhatsApp Cloud calling, `sdp_offer`, and contact phone; outbound calls persist as Chatwoot `calls` with linked `voice_call` messages and return `{ status: "calling", call_id }`; accept/reject/terminate update call/message/conversation status through a fakeable Meta provider boundary; permission-request and upload-recording idempotency responses match the inspected controller/spec contract. | Keep in Review; reopen only if live WhatsApp calling smoke exposes Meta call action body drift, ActionCable broadcast requirements beyond persisted state, permission-template throttling drift, recording storage URL drift, or call bubble serializer drift beyond the inspected enterprise controller/service/Jbuilder/frontend contract. | Focused WhatsApp call service/handler tests, router/route-parity tests, route dump/parity regeneration (`958` routes; `403 exact`, `18 parameter-compatible`, `0 missing out of 421`), full `go test ./...`, and `git diff --check` must pass. | | 0 | P3.36 account onboarding route/payload parity | Implemented for reused dashboard onboarding: `PATCH /api/v1/accounts/:account_id/onboarding` is registered and tracked from `routes.rb:58`, administrator-gated like Chatwoot `OnboardingsController#update`, accepts raw `name`, `locale`, `website`, `industry`, `company_size`, `timezone`, `referral_source`, and `user_role`, persists account `custom_attributes` through `accounts.custom_attributes`, clears `onboarding_step` when current account details are finalized, and returns the raw account serializer with selected custom-attribute fields. | Keep in Review; reopen only if live onboarding smoke exposes additional account serializer fields, empty-string/null strong-param drift, admin policy drift, or onboarding step behavior beyond the inspected controller/Jbuilder/frontend contract. | Focused Account handler/service tests, router/route-parity tests, route dump/parity regeneration (`952` routes; `402 exact`, `13 parameter-compatible`, `0 missing out of 415`), full `go test ./...`, and `git diff --check` must pass. | | 0 | P3.35 account notification scoping hardening | Implemented for reused dashboard notification actions: account notifications now follow Chatwoot `fetch_notification` scope (`current_user.notifications.where(account_id: Current.account.id)`) for show, update/read, destroy, snooze, unread, and unread count. `snoozed_until` is parsed as Unix seconds like `DateRangeHelper`, snooze merges `meta.last_snoozed_at: null`, missing `snoozed_until` returns the scoped notification without mutation, destroy returns empty `200 OK`, and cross-user/cross-account mutations return `404`. | Keep in Review; reopen only if live notification-center smoke exposes list metadata, snooze timestamp, read/unread, destroy_all async, or notification serializer drift beyond the inspected controller/finder/Jbuilder/frontend contract. | Focused Notification handler/repository/service tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change because only handler/repository/service behavior changed. | | 0 | P3.34 API v2 year-in-review parity | Implemented for reused dashboard year-in-review client: `GET /api/v2/accounts/:account_id/year_in_review?year=YYYY` is registered and tracked from `routes.rb:505`, returns raw `{ year, total_conversations, busiest_day, support_personality }`, defaults missing `year` to `2025`, reads/writes the current user's `ui_settings` cache under `year_in_review__`, counts conversations assigned to the current user during the selected year, formats the busiest assigned conversation day as `Mon DD`, and truncates the average `first_response` reporting-event value into seconds. | Keep in Review; reopen only if live reused-dashboard year-in-review smoke exposes timezone, cache-present, invalid-year, or payload-shape drift beyond the inspected controller/builder/frontend contract. | Focused YearInReview service/handler tests, router/route-parity tests, route dump/parity regeneration (`951` routes; `401 exact`, `13 parameter-compatible`, `0 missing out of 414`), full `go test ./...`, and `git diff --check` passed. | @@ -155,7 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | 0 | P3.24 Slack integration parity | Implemented for reused dashboard Slack settings flow: no-trailing and trailing singleton routes are registered for create/update/delete, `PUT` and `PATCH` update both work, create accepts frontend `code` and exchanges it for a Slack access token, hooks are persisted with `app_id: slack` and disabled status, update accepts frontend `reference_id`, fetches real private/public Slack channels with pagination, joins public channels, persists `reference_id/settings.channel_name/status`, create/update return raw Chatwoot app payloads with hooks, list-all returns raw channel arrays, invalid channels return Chatwoot's `422 { error }`, and delete returns empty `200 OK`. | Keep in Review; reopen only if live Slack OAuth/channel smoke exposes OAuth redirect, provider error, app serializer, or Slack channel pagination drift beyond the inspected controller/builder/spec/frontend contract. | Focused Slack handler/service tests, route dump/parity regeneration, sandbox focused `go test`, escalated full `go test ./...`, and `git diff --check` passed. | | 0 | P3.23 nested contact inbox creation API | Implemented for Chatwoot nested contact inbox creation: raw JSON/form/query params are accepted, contact and inbox are account-scoped, missing source IDs are generated through Chatwoot channel rules, duplicate contact+inbox+source rows are returned idempotently, `hmac_verified` is persisted on creation, and the response is raw `{ source_id, inbox }` rather than the local model/envelope. | Keep in Review; reopen only if live CRM/new-conversation smoke exposes inbox access-policy, unsupported channel, or serializer drift beyond the inspected controller/builder/Jbuilder contract. | Focused nested ContactInbox handler/service/repository tests, route parity check, full `go test ./...`, and `git diff --check` passed. | | 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. | -| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 414-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers plus current-user/current-account mutation scoping, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.35 notification scoping hardening or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 421-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, account integration app/hook routes from `routes.rb:345-348`, account Slack routes from `routes.rb:350-352`, account Dyte routes from `routes.rb:357-358`, account Shopify routes from `routes.rb:361-364`, account Linear routes from `routes.rb:365-373`, account Notion authorization/destroy routes from `routes.rb:335/379`, account Twitter/Google/Microsoft/Instagram/TikTok authorization routes from `routes.rb:315/319/323/327/331`, account WhatsApp authorization route from `routes.rb:339`, account inbox WhatsApp calling toggle routes from `routes.rb:266-267`, account WhatsApp call routes from `routes.rb:237-245`, account onboarding route from `routes.rb:58`, API v2 year-in-review route from `routes.rb:505`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, team/team-member routes from `routes.rb:296-300`, conversation participant routes from `routes.rb:150`, conversation direct upload route from `routes.rb:151`, conversation draft message routes from `routes.rb:152`, conversation inbox assistant route from `routes.rb:165`, conversation reporting events route from `routes.rb:166`, and account reporting events route from `routes.rb:234` are now explicitly tracked. Notification list/action serializers plus current-user/current-account mutation scoping, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, account onboarding custom-attribute payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, account Slack OAuth/channel payload behavior, account Dyte create/join payload behavior, account Shopify customer-order payload behavior, account Linear GraphQL issue payload behavior, account Notion authorization/destroy behavior, account Twitter/Google/Microsoft/Instagram/TikTok authorization behavior, account WhatsApp embedded-signup creation and reauthorization behavior, account inbox WhatsApp calling toggle behavior, account WhatsApp call lifecycle/recording behavior, API v2 year-in-review raw/cache/aggregate behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route behavior, conversation participant route/payload/final-set update behavior, conversation direct upload ActiveStorage behavior, conversation draft message Redis-key-equivalent behavior, conversation inbox assistant Copilot payload behavior, conversation reporting-event raw array behavior, account reporting-events payload/filter/pagination behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.37 WhatsApp calls or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. | | 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 5 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -182,6 +183,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.37 account WhatsApp calls API parity | `internal/model/call.go`, `internal/service/whatsapp_call_service.go`, `internal/handler/api/v1/whatsapp_call_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000034_align_calls_whatsapp_fields.*`, WhatsApp call service/handler/router tests | `reference/chatwoot/config/routes.rb:237-245`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb`, `reference/chatwoot/enterprise/app/views/api/v1/models/_whatsapp_call.json.jbuilder`, `reference/chatwoot/enterprise/app/views/api/v1/accounts/whatsapp_calls/*.json.jbuilder`, `reference/chatwoot/enterprise/app/services/whatsapp/call_service.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, `reference/chatwoot/spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb`, `reference/chatwoot/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js` | Done. Account WhatsApp calls now match the reused dashboard route family: account-level show/initiate/accept/reject/terminate/upload-recording routes are tracked and registered; initiate resolves conversations by display ID, validates WhatsApp Cloud calling and contact phone, calls a fakeable Meta provider boundary, creates outbound `calls` rows and linked `voice_call` messages, and returns `{ status, call_id }`; accept/reject/terminate update call/message/conversation status; permission requests record conversation attributes; recording upload is idempotent over audio attachments. | Review by `feat(channels): align whatsapp calls api`; focused service tests cover outbound creation, permission request, accept/terminate/recording, guard errors, and no-answer termination; handler tests cover account route payloads; route artifacts cover the six enterprise routes; full `go test ./...` and `git diff --check` must pass. | | P3.36 account onboarding route/payload parity | `internal/model/account.go`, `internal/service/account_service.go`, `internal/handler/api/v1/account_handler.go`, `internal/router/router.go`, `cmd/route_parity/main.go`, `migrations/000033_add_accounts_custom_attributes.*`, account handler/service/router tests | `reference/chatwoot/config/routes.rb:58`, `reference/chatwoot/app/controllers/api/v1/accounts/onboardings_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/update.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_account.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/onboarding.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/accounts.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue` | Done. Account onboarding update now matches the reused dashboard path: `PATCH /api/v1/accounts/:account_id/onboarding` is tracked and registered with administrator authorization; the handler accepts the raw frontend onboarding payload; the service merges permitted onboarding attributes into persisted account `custom_attributes`, mirrors timezone for existing account consumers, clears `onboarding_step` only when finalizing account details, and the account serializer emits the Chatwoot selected custom-attribute fields while preserving subscription/deletion placeholders. | Review by `feat(accounts): align onboarding update`; focused account handler tests cover finalizing and non-finalizing onboarding merges plus raw account response shape; service/router/route-parity tests and route artifacts cover the new account route; full `go test ./...` and `git diff --check` must pass. | | P3.35 account notification scoping hardening | `internal/repository/notification_repo.go`, `internal/service/notification_service.go`, `internal/handler/api/v1/notification_handler.go`, notification handler/repository/service tests | `reference/chatwoot/config/routes.rb:283-291`, `reference/chatwoot/app/controllers/api/v1/accounts/notifications_controller.rb`, `reference/chatwoot/app/finders/notification_finder.rb`, `reference/chatwoot/app/views/api/v1/accounts/notifications/index.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/notifications.js` | Done. Account notification show/update/destroy/snooze/unread/unread_count now use current-user/current-account scope; cross-account and cross-user rows do not leak; `snoozed_until` accepts Unix seconds; snooze merges `meta.last_snoozed_at`; absent `snoozed_until` is a scoped no-op; destroy keeps Chatwoot empty `200 OK`. | Review by `feat(notifications): tighten chatwoot scoping`; focused handler tests cover mutation scoping, account unread count, Unix snooze, no-body snooze, and destroy route coverage; repository/service notification tests cover scoped helpers; full `go test ./...` and `git diff --check` passed. No route artifacts change. | | P3.34 API v2 year-in-review parity | `internal/service/year_in_review_service.go`, `internal/handler/api/v1/year_in_review_handler.go`, `internal/router/router.go`, `internal/app/bootstrap.go`, `cmd/route_parity/main.go`, YearInReview service/handler/router tests | `reference/chatwoot/config/routes.rb:505`, `reference/chatwoot/app/controllers/api/v2/accounts/year_in_reviews_controller.rb`, `reference/chatwoot/app/builders/year_in_review_builder.rb`, `reference/chatwoot/app/javascript/dashboard/api/yearInReview.js` | Done. API v2 year-in-review now matches the reused dashboard route and builder boundary: account-scoped `GET /api/v2/accounts/:account_id/year_in_review` is tracked and registered; response is raw JSON; missing `year` defaults to `2025`; the current user/account cache key is persisted in `ui_settings`; assigned yearly conversation totals, busiest day, and average first-response seconds are derived from persisted conversations/reporting events. | Review by `feat(reports): align year in review`; focused service tests cover aggregate payload, busiest day, average truncation, cache storage, cache reuse, and empty personality; handler tests cover raw response shape, auth, and invalid year; router/route-parity tests and route artifacts regenerate to `TOTAL: 951` and `401 exact, 13 parameter-compatible, 0 missing out of 414`; full `go test ./...` and `git diff --check` must pass. | @@ -256,6 +258,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(channels): align whatsapp calls api` | Advances P3.37 account WhatsApp calls API parity by matching Chatwoot enterprise `Api::V1::Accounts::WhatsappCallsController`, `Whatsapp::CallService`, `Voice::CallMessageBuilder`, `Call`, WhatsApp call Jbuilder views/specs, route `237-245`, and reused dashboard `whatsappCallsAPI.js`. GoChat now exposes account-level show/initiate/accept/reject/terminate/upload-recording routes, persists WhatsApp calls through the `calls` model boundary, creates linked `voice_call` messages, updates call/message/conversation status for lifecycle actions, records permission-request attributes, and makes recording upload idempotent. | `go test ./internal/service ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'WhatsAppCall\|RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 958`; tracked route parity is `403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421`. | P3.37 moves to Review for current WhatsApp calls evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(accounts): align onboarding update` | Advances P3.36 account onboarding route/payload parity by matching Chatwoot `Api::V1::Accounts::OnboardingsController#update`, account update/model Jbuilder payloads, route `58`, and reused dashboard onboarding API/store/Vue flow. GoChat now persists `accounts.custom_attributes`, exposes administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, accepts the raw onboarding payload, merges permitted custom attributes, clears `onboarding_step` when finalizing account details, and returns the raw account serializer shape. | `go test ./internal/handler/api/v1 -run AccountHandler -count=1`; `go test ./internal/service -run AccountService -count=1`; `go test ./cmd/route_parity ./internal/router -run 'RouteParity\|RegisterRoutes' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 952`; tracked route parity is `402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415`. | P3.36 moves to Review for current onboarding/account serializer evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(notifications): tighten chatwoot scoping` | Advances P3.35 account notification scoping hardening by matching Chatwoot `NotificationsController#fetch_notification`, `NotificationFinder`, notification routes `283-291`, index Jbuilder behavior, and reused dashboard `notifications.js`. GoChat now scopes show, update/read, destroy, snooze, unread, and unread_count to the current user and account; parses snooze timestamps as Unix seconds; merges `meta.last_snoozed_at: null`; treats missing `snoozed_until` as a scoped no-op; and keeps destroy as empty `200 OK`. | `go test ./internal/handler/api/v1 -run Notification -count=1`; `go test ./internal/repository -run Notification -count=1`; `go test ./internal/service -run Notification -count=1`; full `go test ./...`; `git diff --check`. Route artifacts are unchanged. | P3.35 moves to Review for current notification action scoping evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `feat(reports): align year in review` | Advances P3.34 API v2 year-in-review parity by matching Chatwoot `Api::V2::Accounts::YearInReviewsController#show`, `YearInReviewBuilder`, route `505`, current-user `ui_settings` caching, and reused dashboard `api/yearInReview.js`. GoChat now exposes `GET /api/v2/accounts/:account_id/year_in_review`, returns raw `{ year, total_conversations, busiest_day, support_personality }`, defaults missing `year` to `2025`, stores cached data under `year_in_review__`, counts current-user assigned conversations in the selected year, formats the busiest day as `Mon DD`, and truncates average `first_response` seconds. | `go test ./internal/service -run YearInReview -count=1`; `go test ./internal/handler/api/v1 -run YearInReview -count=1`; `go test ./internal/router ./cmd/route_parity -run 'RegisterRoutes\|RouteParity' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 951`; tracked route parity is `401 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 414`. | P3.34 moves to Review for current API v2 year-in-review evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | @@ -2273,6 +2276,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.37 account WhatsApp calls checkpoint prepared as `feat(channels): align whatsapp calls api`; audited Chatwoot enterprise WhatsApp call routes `237-245`, `WhatsappCallsController`, WhatsApp call Jbuilder views, `Whatsapp::CallService`, `Voice::CallMessageBuilder`, `Call`, controller specs, and reused dashboard `whatsappCallsAPI.js`. GoChat now registers account-level WhatsApp call show/initiate/accept/reject/terminate/upload-recording routes, persists calls through the `calls` model, creates linked `voice_call` messages for outbound calls, updates message/conversation call status through lifecycle actions, records permission-request conversation attributes, and returns idempotent recording upload status. Focused WhatsApp call service/handler/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 958` and tracked route parity is `403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421`. - 2026-06-06: P3.36 account onboarding checkpoint prepared as `feat(accounts): align onboarding update`; audited Chatwoot account onboarding route `58`, `Api::V1::Accounts::OnboardingsController#update`, account update/model Jbuilder views, and reused dashboard `api/onboarding.js`, account store `finishOnboarding`, and onboarding Vue payload. GoChat now registers administrator-gated `PATCH /api/v1/accounts/:account_id/onboarding`, adds persisted `accounts.custom_attributes`, accepts the raw frontend payload, merges permitted onboarding custom attributes, clears `onboarding_step` when finalizing account details, and returns the raw account serializer with Chatwoot selected custom-attribute fields. Focused account handler/service tests, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 952` and tracked route parity is `402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415`. - 2026-06-06: P3.35 account notification scoping checkpoint prepared as `feat(notifications): tighten chatwoot scoping`; audited Chatwoot notification routes `283-291`, `NotificationsController`, `NotificationFinder`, notification index Jbuilder, and reused dashboard `api/notifications.js`. Account notification show/update/destroy/snooze/unread/unread_count now use current-user/current-account scope, cross-account and cross-user rows return `404`, `snoozed_until` is parsed as Unix seconds, snooze merges `meta.last_snoozed_at: null`, absent `snoozed_until` is a scoped no-op returning the notification, and destroy returns empty `200 OK`. Focused notification handler/repository/service tests, full `go test ./...`, and `git diff --check` passed. No route artifacts changed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: P3.13 dashboard apps checkpoint prepared as `feat(dashboard-apps): align chatwoot payloads`; audited Chatwoot `routes.rb:130`, `DashboardAppsController`, dashboard app Jbuilder views, `DashboardApp` content schema, reused dashboard `api/dashboardApps.js`, Vuex `dashboardApps` store, and DashboardApps modal/index components. Dashboard app routes are now tracked, create/update accept raw frontend bodies as well as `{ dashboard_app: ... }`, create returns raw `200 OK` payloads, list/show/create/update return only `id`, `title`, `content`, and `created_at`, show/update/delete are account-scoped, delete returns `204`, and content validation rejects empty arrays, non-frame widgets, and non-HTTP(S) URLs. Focused dashboard app handler/service tests, combined handler/service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump remains `TOTAL: 907` and tracked route parity is `357 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 370`. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index c9f792d9..f3209057 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -388,6 +388,7 @@ GET /api/v1/accounts/:account_id/users GET /api/v1/accounts/:account_id/web_widgets/offline_messages GET /api/v1/accounts/:account_id/webhooks GET /api/v1/accounts/:account_id/webhooks/:webhook_id +GET /api/v1/accounts/:account_id/whatsapp_calls/:call_id GET /api/v1/accounts/all GET /api/v1/auth/confirm_email GET /api/v1/auth/mfa/status @@ -773,6 +774,11 @@ POST /api/v1/accounts/:account_id/upload POST /api/v1/accounts/:account_id/users POST /api/v1/accounts/:account_id/webhooks POST /api/v1/accounts/:account_id/whatsapp/authorization +POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/accept +POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/reject +POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/terminate +POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/upload_recording +POST /api/v1/accounts/:account_id/whatsapp_calls/initiate POST /api/v1/auth/login POST /api/v1/auth/login/mfa POST /api/v1/auth/mfa/backup_codes @@ -950,4 +956,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 952 +TOTAL: 958 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 5f75ff9e..2ce07415 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 402 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 415 tracked critical routes. +Summary: 403 exact, 0 method-compatible, 18 parameter-compatible, 0 missing out of 421 tracked critical routes. ## Missing Critical Routes @@ -34,12 +34,17 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#destroy` | `routes.rb:130` | parameter-compatible | | GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#index` | `routes.rb:125` | parameter-compatible | | GET | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#show` | `routes.rb:130` | parameter-compatible | +| GET | `/api/v1/accounts/:account_id/whatsapp_calls/:id` | `/api/v1/accounts/:account_id/whatsapp_calls/:call_id` | `api/v1/accounts/whatsapp_calls#show` | `routes.rb:237` | parameter-compatible | | GET | `/hc/:slug/articles/:article_slug.md` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show_markdown` | `routes.rb:599` | parameter-compatible | | GET | `/hc/:slug/articles/:article_slug.png` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#tracking_pixel` | `routes.rb:598` | parameter-compatible | | PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | | PATCH | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#update` | `routes.rb:130` | parameter-compatible | | POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits` | `api/v1/accounts/agent_capacity_policies/inbox_limits#create` | `routes.rb:126` | parameter-compatible | | POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#create` | `routes.rb:125` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/accept` | `/api/v1/accounts/:account_id/whatsapp_calls/:call_id/accept` | `api/v1/accounts/whatsapp_calls#accept` | `routes.rb:239` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/reject` | `/api/v1/accounts/:account_id/whatsapp_calls/:call_id/reject` | `api/v1/accounts/whatsapp_calls#reject` | `routes.rb:240` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/terminate` | `/api/v1/accounts/:account_id/whatsapp_calls/:call_id/terminate` | `api/v1/accounts/whatsapp_calls#terminate` | `routes.rb:241` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording` | `/api/v1/accounts/:account_id/whatsapp_calls/:call_id/upload_recording` | `api/v1/accounts/whatsapp_calls#upload_recording` | `routes.rb:242` | parameter-compatible | | PUT | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | | PUT | `/api/v1/accounts/:account_id/dashboard_apps/:id` | `/api/v1/accounts/:account_id/dashboard_apps/:dashboard_app_id` | `api/v1/accounts/dashboard_apps#update` | `routes.rb:130` | parameter-compatible | @@ -384,6 +389,7 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/accounts/:account_id/update_active_at` | `/api/v1/accounts/:account_id/update_active_at` | `api/v1/accounts#update_active_at` | `routes.rb:49` | exact | | POST | `/api/v1/accounts/:account_id/webhooks` | `/api/v1/accounts/:account_id/webhooks` | `api/v1/accounts/webhooks#create` | `routes.rb:342` | exact | | POST | `/api/v1/accounts/:account_id/whatsapp/authorization` | `/api/v1/accounts/:account_id/whatsapp/authorization` | `api/v1/accounts/whatsapp/authorizations#create` | `routes.rb:339` | exact | +| POST | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | `/api/v1/accounts/:account_id/whatsapp_calls/initiate` | `api/v1/accounts/whatsapp_calls#initiate` | `routes.rb:245` | exact | | POST | `/api/v1/notification_subscriptions` | `/api/v1/notification_subscriptions` | `api/v1/notification_subscriptions#create` | `routes.rb:440` | exact | | POST | `/api/v1/widget/config` | `/api/v1/widget/config` | `api/v1/widget/config#create` | `routes.rb:444` | exact | | POST | `/api/v1/widget/contact/destroy_custom_attributes` | `/api/v1/widget/contact/destroy_custom_attributes` | `api/v1/widget/contact#destroy_custom_attributes` | `routes.rb:460` | exact | diff --git a/internal/handler/api/v1/whatsapp_call_handler.go b/internal/handler/api/v1/whatsapp_call_handler.go index fd466421..835f01d5 100644 --- a/internal/handler/api/v1/whatsapp_call_handler.go +++ b/internal/handler/api/v1/whatsapp_call_handler.go @@ -1,9 +1,14 @@ package v1 import ( + "encoding/json" + "errors" "net/http" + "strings" + "time" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" @@ -19,6 +24,136 @@ func NewWhatsAppCallHandler(svc *service.WhatsAppCallService) *WhatsAppCallHandl return &WhatsAppCallHandler{svc: svc} } +// Show returns a Chatwoot WhatsApp call payload. +// GET /api/v1/accounts/:account_id/whatsapp_calls/:call_id +func (h *WhatsAppCallHandler) Show(c *gin.Context) { + accountID, callID, ok := h.parseAccountCallParams(c) + if !ok { + return + } + call, err := h.svc.GetAccountCall(c.Request.Context(), accountID, callID) + if err != nil { + handleServiceError(c, err) + return + } + c.JSON(http.StatusOK, serializeWhatsAppAccountCall(call)) +} + +// Initiate starts an outbound WhatsApp call for a display-ID conversation. +// POST /api/v1/accounts/:account_id/whatsapp_calls/initiate +func (h *WhatsAppCallHandler) Initiate(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + var req struct { + ConversationID uint `json:"conversation_id" binding:"required"` + SDPOffer string `json:"sdp_offer"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + result, err := h.svc.Initiate(c.Request.Context(), accountID, service.WhatsAppCallInitiateRequest{ + ConversationID: req.ConversationID, + SDPOffer: req.SDPOffer, + AgentID: getUserID(c), + }) + if err != nil { + handleWhatsAppCallError(c, err) + return + } + if result.PermissionStatus != "" { + c.JSON(http.StatusUnprocessableEntity, gin.H{"status": result.PermissionStatus}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "calling", "call_id": result.Call.ProviderCallID}) +} + +// Accept forwards an SDP answer to Meta and returns the updated call payload. +// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/accept +func (h *WhatsAppCallHandler) Accept(c *gin.Context) { + accountID, callID, ok := h.parseAccountCallParams(c) + if !ok { + return + } + var req struct { + SDPAnswer string `json:"sdp_answer"` + } + _ = c.ShouldBindJSON(&req) + call, err := h.svc.Accept(c.Request.Context(), accountID, callID, getUserID(c), req.SDPAnswer) + if err != nil { + handleWhatsAppCallError(c, err) + return + } + c.JSON(http.StatusOK, serializeWhatsAppAccountCall(call)) +} + +// Reject rejects a ringing WhatsApp call. +// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/reject +func (h *WhatsAppCallHandler) Reject(c *gin.Context) { + accountID, callID, ok := h.parseAccountCallParams(c) + if !ok { + return + } + call, err := h.svc.Reject(c.Request.Context(), accountID, callID, getUserID(c)) + if err != nil { + handleWhatsAppCallError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"id": call.ID, "status": displayWhatsAppStatus(call.Status)}) +} + +// Terminate terminates an active or ringing WhatsApp call. +// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/terminate +func (h *WhatsAppCallHandler) Terminate(c *gin.Context) { + accountID, callID, ok := h.parseAccountCallParams(c) + if !ok { + return + } + call, err := h.svc.Terminate(c.Request.Context(), accountID, callID, getUserID(c)) + if err != nil { + handleWhatsAppCallError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"id": call.ID, "status": displayWhatsAppStatus(call.Status)}) +} + +// UploadRecording attaches an audio recording to the linked voice_call message. +// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/upload_recording +func (h *WhatsAppCallHandler) UploadRecording(c *gin.Context) { + accountID, callID, ok := h.parseAccountCallParams(c) + if !ok { + return + } + file, err := c.FormFile("recording") + if err != nil { + handleWhatsAppCallError(c, service.ErrWhatsAppCallNoRecording) + return + } + status, svcErr := h.svc.UploadRecording(c.Request.Context(), accountID, callID, file.Filename, file.Size) + if svcErr != nil { + handleWhatsAppCallError(c, svcErr) + return + } + c.JSON(http.StatusOK, gin.H{"status": status}) +} + +func (h *WhatsAppCallHandler) parseAccountCallParams(c *gin.Context) (uint, uint, bool) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return 0, 0, false + } + callID, err := parseUintParam(c, "call_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid call id") + return 0, 0, false + } + return accountID, callID, true +} + // Get retrieves a WhatsApp call by call_id. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id func (h *WhatsAppCallHandler) Get(c *gin.Context) { @@ -137,4 +272,75 @@ func (h *WhatsAppCallHandler) Delete(c *gin.Context) { } response.OK(c, gin.H{"message": "deleted"}) -} \ No newline at end of file +} + +func handleWhatsAppCallError(c *gin.Context, err error) { + status := http.StatusUnprocessableEntity + if errors.Is(err, service.ErrWhatsAppCallSDPOfferRequired) || + errors.Is(err, service.ErrWhatsAppCallSDPAnswerRequired) || + errors.Is(err, service.ErrWhatsAppCallContactPhoneRequired) || + errors.Is(err, service.ErrWhatsAppCallNotEnabled) || + errors.Is(err, service.ErrWhatsAppCallNoRecording) || + errors.Is(err, service.ErrWhatsAppCallNoMessage) || + errors.Is(err, service.ErrWhatsAppCallPermissionRequestFailed) || + errors.Is(err, service.ErrWhatsAppCallAlreadyAccepted) || + errors.Is(err, service.ErrWhatsAppCallNotRinging) { + c.JSON(status, gin.H{"error": err.Error()}) + return + } + handleServiceError(c, err) +} + +func serializeWhatsAppAccountCall(call *model.Call) gin.H { + if call == nil { + return gin.H{} + } + attrs := map[string]any{} + if len(call.AdditionalAttributes) > 0 { + _ = json.Unmarshal(call.AdditionalAttributes, &attrs) + } + elapsed := 0 + if call.StartedAt != nil { + elapsed = int(time.Since(*call.StartedAt).Seconds()) + } + caller := gin.H{} + if call.Contact.ID != 0 { + caller = gin.H{"name": call.Contact.Name, "phone": call.Contact.PhoneNumber, "avatar": call.Contact.AvatarURL} + } + return gin.H{ + "id": call.ID, + "call_id": call.ProviderCallID, + "provider": call.Provider, + "status": displayWhatsAppStatus(call.Status), + "direction": displayWhatsAppDirection(call.Direction), + "conversation_id": call.ConversationID, + "inbox_id": call.InboxID, + "message_id": call.MessageID, + "accepted_by_agent_id": call.AcceptedByAgentID, + "elapsed_seconds": elapsed, + "sdp_offer": attrs["sdp_offer"], + "ice_servers": firstNonNilWhatsAppValue(attrs["ice_servers"], []map[string][]string{{"urls": []string{"stun:stun.l.google.com:19302"}}}), + "caller": caller, + } +} + +func displayWhatsAppStatus(status string) string { + return strings.ReplaceAll(status, "_", "-") +} + +func displayWhatsAppDirection(direction string) string { + if direction == "incoming" { + return "inbound" + } + if direction == "outgoing" { + return "outbound" + } + return direction +} + +func firstNonNilWhatsAppValue(value any, fallback any) any { + if value == nil { + return fallback + } + return value +} diff --git a/internal/handler/api/v1/whatsapp_call_handler_test.go b/internal/handler/api/v1/whatsapp_call_handler_test.go new file mode 100644 index 00000000..999f004d --- /dev/null +++ b/internal/handler/api/v1/whatsapp_call_handler_test.go @@ -0,0 +1,152 @@ +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" +) + +type fakeHandlerWhatsAppCallProvider struct{} + +func (fakeHandlerWhatsAppCallProvider) InitiateCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) { + return "wacid_handler", nil +} +func (fakeHandlerWhatsAppCallProvider) PreAcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error { + return nil +} +func (fakeHandlerWhatsAppCallProvider) AcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error { + return nil +} +func (fakeHandlerWhatsAppCallProvider) RejectCall(context.Context, *channelmodel.ChannelWhatsApp, string) error { + return nil +} +func (fakeHandlerWhatsAppCallProvider) TerminateCall(context.Context, *channelmodel.ChannelWhatsApp, string) error { + return nil +} +func (fakeHandlerWhatsAppCallProvider) SendCallPermissionRequest(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) { + return "wamid.req", nil +} + +func setupWhatsAppCallHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Conversation) { + t.Helper() + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{})) + + account := &model.Account{Name: "Voice Account", Status: "active"} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`} + require.NoError(t, db.Create(inbox).Error) + channel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+15550000000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"calling_enabled":true}`} + require.NoError(t, db.Create(channel).Error) + inbox.ChannelID = channel.ID + require.NoError(t, db.Save(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Ada", PhoneNumber: "+15551234567"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(42) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "whatsapp", Channel: "whatsapp"} + require.NoError(t, db.Create(conversation).Error) + + handler := NewWhatsAppCallHandler(service.NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), fakeHandlerWhatsAppCallProvider{})) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user_id", uint(7)) + c.Next() + }) + router.GET("/api/v1/accounts/:account_id/whatsapp_calls/:call_id", handler.Show) + router.POST("/api/v1/accounts/:account_id/whatsapp_calls/initiate", handler.Initiate) + router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/accept", handler.Accept) + router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/reject", handler.Reject) + router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/terminate", handler.Terminate) + router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/upload_recording", handler.UploadRecording) + return router, db, account, conversation +} + +func TestWhatsAppCallHandler_AccountRoutesMatchFrontendAPI(t *testing.T) { + router, db, account, conversation := setupWhatsAppCallHandlerTest(t) + + w := httptest.NewRecorder() + body := []byte(`{"conversation_id":42,"sdp_offer":"sdp_offer"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/initiate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + resp := whatsappDecodeMap(t, w.Body.Bytes()) + require.Equal(t, "calling", resp["status"]) + require.Equal(t, "wacid_handler", resp["call_id"]) + + var call model.Call + require.NoError(t, db.Where("account_id = ? AND conversation_id = ?", account.ID, conversation.ID).First(&call).Error) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID), nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + show := whatsappDecodeMap(t, w.Body.Bytes()) + require.Equal(t, "wacid_handler", show["call_id"]) + require.Equal(t, "whatsapp", show["provider"]) +} + +func TestWhatsAppCallHandler_ActionsAndRecordingPayloads(t *testing.T) { + router, db, account, conversation := setupWhatsAppCallHandlerTest(t) + call := &model.Call{AccountID: account.ID, InboxID: 1, ConversationID: conversation.ID, ContactID: conversation.ContactID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "wacid_in", Status: "ringing", CallerType: "Contact", CallerID: conversation.ContactID, CallDirection: "inbound"} + require.NoError(t, db.Create(call).Error) + message := &model.Message{AccountID: account.ID, InboxID: 1, ConversationID: conversation.ID, ContentType: "voice_call", MessageType: "incoming"} + require.NoError(t, db.Create(message).Error) + require.NoError(t, db.Model(call).Update("message_id", message.ID).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID)+"/accept", bytes.NewReader([]byte(`{"sdp_answer":"sdp_answer"}`))) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "in-progress", whatsappDecodeMap(t, w.Body.Bytes())["status"]) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID)+"/terminate", nil) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "completed", whatsappDecodeMap(t, w.Body.Bytes())["status"]) + + var upload bytes.Buffer + writer := multipart.NewWriter(&upload) + part, err := writer.CreateFormFile("recording", "call.webm") + require.NoError(t, err) + _, err = part.Write([]byte("audio")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID)+"/upload_recording", &upload) + req.Header.Set("Content-Type", writer.FormDataContentType()) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "uploaded", whatsappDecodeMap(t, w.Body.Bytes())["status"]) +} + +func whatsappDecodeMap(t *testing.T, body []byte) map[string]any { + t.Helper() + var out map[string]any + require.NoError(t, json.Unmarshal(body, &out)) + return out +} + +func whatsappItoaUint(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} diff --git a/internal/model/call.go b/internal/model/call.go index 258c843b..68023c0b 100644 --- a/internal/model/call.go +++ b/internal/model/call.go @@ -21,11 +21,13 @@ type Call struct { Direction string `gorm:"column:direction;size:50" json:"direction,omitempty"` ProviderCallID string `gorm:"column:provider_call_id;size:255;index:idx_calls_provider_call_id" json:"provider_call_id,omitempty"` ConferenceSID string `gorm:"column:conference_sid;size:255" json:"conference_sid,omitempty"` + StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"` CallerType string `gorm:"size:100;not null" json:"caller_type"` // Contact/User CallerID uint `json:"caller_id"` Status string `gorm:"size:50;not null;default:'ringing'" json:"status"` // ringing/ongoing/completed/failed/missed Duration int `json:"duration"` // seconds - CallDirection string `gorm:"size:50;not null" json:"call_direction"` // inbound/outbound + EndReason string `gorm:"column:end_reason;size:255" json:"end_reason,omitempty"` + CallDirection string `gorm:"size:50;not null" json:"call_direction"` // inbound/outbound RecordingURL string `gorm:"size:512" json:"recording_url"` AdditionalAttributes json.RawMessage `gorm:"type:jsonb" json:"additional_attributes"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/repository/whatsapp_call_repo.go b/internal/repository/whatsapp_call_repo.go index a07d70f7..e68c000f 100644 --- a/internal/repository/whatsapp_call_repo.go +++ b/internal/repository/whatsapp_call_repo.go @@ -16,6 +16,9 @@ func NewWhatsAppCallRepo(db *gorm.DB) *WhatsAppCallRepo { return &WhatsAppCallRepo{db: db} } +// DB returns the underlying GORM handle for Chatwoot call parity queries. +func (r *WhatsAppCallRepo) DB() *gorm.DB { return r.db } + func (r *WhatsAppCallRepo) Create(ctx context.Context, call *model.WhatsAppCall) error { return r.db.WithContext(ctx).Create(call).Error } @@ -41,4 +44,4 @@ func (r *WhatsAppCallRepo) Update(ctx context.Context, call *model.WhatsAppCall) func (r *WhatsAppCallRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.WhatsAppCall{}, id).Error -} \ No newline at end of file +} diff --git a/internal/router/router.go b/internal/router/router.go index 11736e75..87377977 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -682,6 +682,14 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Bulk actions for conversations/contacts (ref: Chatwoot bulk_actions_controller.rb) accounts.POST("/:account_id/bulk_actions", h.BulkAction.Create) + // Account WhatsApp calls (ref: Chatwoot enterprise whatsapp_calls_controller.rb) + accounts.GET("/:account_id/whatsapp_calls/:call_id", h.WhatsAppCall.Show) + accounts.POST("/:account_id/whatsapp_calls/initiate", h.WhatsAppCall.Initiate) + accounts.POST("/:account_id/whatsapp_calls/:call_id/accept", h.WhatsAppCall.Accept) + accounts.POST("/:account_id/whatsapp_calls/:call_id/reject", h.WhatsAppCall.Reject) + accounts.POST("/:account_id/whatsapp_calls/:call_id/terminate", h.WhatsAppCall.Terminate) + accounts.POST("/:account_id/whatsapp_calls/:call_id/upload_recording", h.WhatsAppCall.UploadRecording) + // Account members (ref: Chatwoot namespace :account_users) accounts.GET("/:account_id/users", h.Account.ListUsers) accounts.POST("/:account_id/users", h.Account.AddUser) diff --git a/internal/service/whatsapp_call_service.go b/internal/service/whatsapp_call_service.go index bf3c195a..fd3b5bd2 100644 --- a/internal/service/whatsapp_call_service.go +++ b/internal/service/whatsapp_call_service.go @@ -2,23 +2,59 @@ package service import ( "context" + "encoding/json" "errors" + "fmt" + "strings" + "time" + "github.com/go-resty/resty/v2" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/datatypes" + "gorm.io/gorm" ) // WhatsAppCallService implements business logic for WhatsAppCall operations. type WhatsAppCallService struct { - repo *repository.WhatsAppCallRepo + repo *repository.WhatsAppCallRepo + provider WhatsAppCallProvider } // NewWhatsAppCallService creates a new WhatsAppCall service. -func NewWhatsAppCallService(repo *repository.WhatsAppCallRepo) *WhatsAppCallService { - return &WhatsAppCallService{repo: repo} +func NewWhatsAppCallService(repo *repository.WhatsAppCallRepo, providers ...WhatsAppCallProvider) *WhatsAppCallService { + provider := WhatsAppCallProvider(defaultWhatsAppCallProvider{client: resty.New()}) + if len(providers) > 0 && providers[0] != nil { + provider = providers[0] + } + return &WhatsAppCallService{repo: repo, provider: provider} } +// WhatsAppCallProvider wraps Meta WhatsApp Calling operations behind a fakeable boundary. +type WhatsAppCallProvider interface { + InitiateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, sdpOffer string) (string, error) + PreAcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error + AcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error + RejectCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error + TerminateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error + SendCallPermissionRequest(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, body string) (string, error) +} + +var ( + ErrWhatsAppCallNotEnabled = errors.New("WhatsApp calling is not enabled for this inbox") + ErrWhatsAppCallSDPOfferRequired = errors.New("sdp_offer is required") + ErrWhatsAppCallSDPAnswerRequired = errors.New("sdp_answer is required") + ErrWhatsAppCallContactPhoneRequired = errors.New("Contact phone number is required") + ErrWhatsAppCallNoRecording = errors.New("recording is required") + ErrWhatsAppCallNoMessage = errors.New("Call message not found") + ErrWhatsAppCallPermissionRequired = errors.New("WhatsApp call permission required") + ErrWhatsAppCallPermissionRequestFailed = errors.New("Failed to send WhatsApp call permission request") + ErrWhatsAppCallAlreadyAccepted = errors.New("Call already accepted by another agent") + ErrWhatsAppCallNotRinging = errors.New("Call is not in ringing state") +) + // WhatsAppCallCreateRequest is the DTO for creating a WhatsApp call. type WhatsAppCallCreateRequest struct { CallID string `json:"call_id" validate:"required"` @@ -101,4 +137,482 @@ func (s *WhatsAppCallService) DeleteByCallID(ctx context.Context, callID string) return err } return nil -} \ No newline at end of file +} + +// GetAccountCall returns a Chatwoot account-scoped WhatsApp Call row. +func (s *WhatsAppCallService) GetAccountCall(ctx context.Context, accountID, callID uint) (*model.Call, error) { + return s.findAccountCall(ctx, accountID, callID) +} + +type WhatsAppCallInitiateRequest struct { + ConversationID uint + SDPOffer string + AgentID uint +} + +type WhatsAppCallInitiateResult struct { + Call *model.Call + PermissionStatus string + PermissionMessage string +} + +// Initiate creates an outbound WhatsApp Call and linked voice_call message. +// Reference: Enterprise WhatsappCallsController#initiate. +func (s *WhatsAppCallService) Initiate(ctx context.Context, accountID uint, req WhatsAppCallInitiateRequest) (*WhatsAppCallInitiateResult, error) { + if strings.TrimSpace(req.SDPOffer) == "" { + return nil, ErrWhatsAppCallSDPOfferRequired + } + + var conversation model.Conversation + db := s.repo.DB().WithContext(ctx) + if err := db.Where("account_id = ? AND display_id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil { + if err := db.Where("account_id = ? AND id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil { + return nil, err + } + } + + inbox, contact, channel, err := s.loadCallContext(ctx, accountID, conversation.InboxID, conversation.ContactID) + if err != nil { + return nil, err + } + if !whatsAppCallingEnabled(inbox, channel) { + return nil, ErrWhatsAppCallNotEnabled + } + if strings.TrimSpace(contact.PhoneNumber) == "" { + return nil, ErrWhatsAppCallContactPhoneRequired + } + + toNumber := strings.TrimPrefix(strings.TrimSpace(contact.PhoneNumber), "+") + providerCallID, err := s.provider.InitiateCall(ctx, channel, toNumber, req.SDPOffer) + if err != nil { + if errors.Is(err, ErrWhatsAppCallPermissionRequired) { + return s.handlePermissionRequest(ctx, &conversation, inbox, channel, toNumber) + } + return nil, err + } + + var call model.Call + err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + attrs := map[string]any{"sdp_offer": req.SDPOffer, "ice_servers": defaultWhatsAppIceServers()} + encodedAttrs, _ := json.Marshal(attrs) + call = model.Call{ + AccountID: accountID, + InboxID: inbox.ID, + ConversationID: conversation.ID, + ContactID: contact.ID, + AcceptedByAgentID: &req.AgentID, + Provider: "whatsapp", + Direction: "outgoing", + ProviderCallID: providerCallID, + Status: "ringing", + CallerType: "User", + CallerID: req.AgentID, + CallDirection: "outbound", + AdditionalAttributes: json.RawMessage(encodedAttrs), + } + if err := tx.Create(&call).Error; err != nil { + return err + } + message, err := createWhatsAppCallMessage(tx, &call, req.AgentID) + if err != nil { + return err + } + messageID := message.ID + call.MessageID = &messageID + return tx.Model(&call).Update("message_id", messageID).Error + }) + if err != nil { + return nil, err + } + return &WhatsAppCallInitiateResult{Call: &call}, nil +} + +func (s *WhatsAppCallService) Accept(ctx context.Context, accountID, callID, agentID uint, sdpAnswer string) (*model.Call, error) { + if strings.TrimSpace(sdpAnswer) == "" { + return nil, ErrWhatsAppCallSDPAnswerRequired + } + call, err := s.findAccountCall(ctx, accountID, callID) + if err != nil { + return nil, err + } + _, _, channel, err := s.loadCallContext(ctx, accountID, call.InboxID, call.ContactID) + if err != nil { + return nil, err + } + if call.Status == "in_progress" { + return nil, ErrWhatsAppCallAlreadyAccepted + } + if call.Status != "ringing" { + return nil, ErrWhatsAppCallNotRinging + } + if err := s.provider.PreAcceptCall(ctx, channel, call.ProviderCallID, sdpAnswer); err != nil { + return nil, err + } + if err := s.provider.AcceptCall(ctx, channel, call.ProviderCallID, sdpAnswer); err != nil { + return nil, err + } + now := time.Now() + err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + attrs := callAttributes(call) + attrs["sdp_answer"] = sdpAnswer + encodedAttrs, _ := json.Marshal(attrs) + call.Status = "in_progress" + call.AcceptedByAgentID = &agentID + call.StartedAt = &now + call.AdditionalAttributes = json.RawMessage(encodedAttrs) + if err := tx.Save(call).Error; err != nil { + return err + } + return updateWhatsAppCallMessageAndConversation(tx, call, "in_progress", agentID, nil) + }) + return call, err +} + +func (s *WhatsAppCallService) Reject(ctx context.Context, accountID, callID, agentID uint) (*model.Call, error) { + call, err := s.findAccountCall(ctx, accountID, callID) + if err != nil { + return nil, err + } + _, _, channel, err := s.loadCallContext(ctx, accountID, call.InboxID, call.ContactID) + if err != nil { + return nil, err + } + if call.Status == "ringing" { + if err := s.provider.RejectCall(ctx, channel, call.ProviderCallID); err != nil { + return nil, err + } + err = s.finalize(ctx, call, "failed", "agent_rejected", agentID, nil) + } + return call, err +} + +func (s *WhatsAppCallService) Terminate(ctx context.Context, accountID, callID, agentID uint) (*model.Call, error) { + call, err := s.findAccountCall(ctx, accountID, callID) + if err != nil { + return nil, err + } + _, _, channel, err := s.loadCallContext(ctx, accountID, call.InboxID, call.ContactID) + if err != nil { + return nil, err + } + if isTerminalWhatsAppCall(call.Status) { + return call, nil + } + if err := s.provider.TerminateCall(ctx, channel, call.ProviderCallID); err != nil { + return nil, err + } + status := "no_answer" + var duration *int + if call.Status == "in_progress" { + status = "completed" + seconds := 0 + if call.StartedAt != nil { + seconds = int(time.Since(*call.StartedAt).Seconds()) + } + duration = &seconds + } + err = s.finalize(ctx, call, status, "agent_hangup", agentID, duration) + return call, err +} + +func (s *WhatsAppCallService) UploadRecording(ctx context.Context, accountID, callID uint, fileName string, fileSize int64) (string, error) { + call, err := s.findAccountCall(ctx, accountID, callID) + if err != nil { + return "", err + } + if call.MessageID == nil || *call.MessageID == 0 { + return "", ErrWhatsAppCallNoMessage + } + if fileName == "" { + return "", ErrWhatsAppCallNoRecording + } + var existing int64 + if err := s.repo.DB().WithContext(ctx).Model(&model.Attachment{}). + Where("message_id = ? AND file_type = ?", *call.MessageID, "audio").Count(&existing).Error; err != nil { + return "", err + } + if existing > 0 { + return "already_uploaded", nil + } + attachment := &model.Attachment{MessageID: *call.MessageID, AccountID: accountID, FileType: "audio", FileName: fileName, FileSize: int(fileSize), FileURL: fileName} + if err := s.repo.DB().WithContext(ctx).Create(attachment).Error; err != nil { + return "", err + } + return "uploaded", nil +} + +func (s *WhatsAppCallService) finalize(ctx context.Context, call *model.Call, status, reason string, agentID uint, duration *int) error { + return s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + attrs := callAttributes(call) + attrs["ended_at"] = time.Now().Unix() + encodedAttrs, _ := json.Marshal(attrs) + call.Status = status + call.AdditionalAttributes = json.RawMessage(encodedAttrs) + call.AcceptedByAgentID = firstUintPtr(call.AcceptedByAgentID, agentID) + call.EndReason = reason + if duration != nil { + call.Duration = *duration + } + if err := tx.Save(call).Error; err != nil { + return err + } + return updateWhatsAppCallMessageAndConversation(tx, call, status, agentID, duration) + }) +} + +func (s *WhatsAppCallService) handlePermissionRequest(ctx context.Context, conversation *model.Conversation, inbox *model.Inbox, channel *channelmodel.ChannelWhatsApp, toNumber string) (*WhatsAppCallInitiateResult, error) { + body := whatsappPermissionRequestBody(channel) + messageID, err := s.provider.SendCallPermissionRequest(ctx, channel, toNumber, body) + if err != nil || messageID == "" { + return nil, ErrWhatsAppCallPermissionRequestFailed + } + attrs := conversationAttributes(conversation) + attrs["call_permission_requested_at"] = time.Now().Format(time.RFC3339) + attrs["call_permission_request_message_id"] = messageID + encodedAttrs, _ := json.Marshal(attrs) + if err := s.repo.DB().WithContext(ctx).Model(conversation).Update("additional_attributes", datatypes.JSON(encodedAttrs)).Error; err != nil { + return nil, err + } + return &WhatsAppCallInitiateResult{PermissionStatus: "permission_requested"}, nil +} + +func (s *WhatsAppCallService) findAccountCall(ctx context.Context, accountID, callID uint) (*model.Call, error) { + var call model.Call + err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND provider = ? AND id = ?", accountID, "whatsapp", callID).First(&call).Error + if err != nil { + return nil, err + } + return &call, nil +} + +func (s *WhatsAppCallService) loadCallContext(ctx context.Context, accountID, inboxID, contactID uint) (*model.Inbox, *model.Contact, *channelmodel.ChannelWhatsApp, error) { + var inbox model.Inbox + if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, inboxID).First(&inbox).Error; err != nil { + return nil, nil, nil, err + } + var contact model.Contact + if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, contactID).First(&contact).Error; err != nil { + return nil, nil, nil, err + } + var channel channelmodel.ChannelWhatsApp + if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND inbox_id = ?", accountID, inboxID).First(&channel).Error; err != nil { + return nil, nil, nil, ErrWhatsAppCallNotEnabled + } + return &inbox, &contact, &channel, nil +} + +func createWhatsAppCallMessage(tx *gorm.DB, call *model.Call, agentID uint) (*model.Message, error) { + attrs := map[string]any{"data": map[string]any{"call_id": call.ID, "call_sid": call.ProviderCallID, "call_source": "whatsapp", "call_direction": "outbound", "status": displayWhatsAppCallStatus(call.Status)}} + encodedAttrs, _ := json.Marshal(attrs) + message := &model.Message{ + ConversationID: call.ConversationID, + AccountID: call.AccountID, + InboxID: call.InboxID, + SenderID: &agentID, + SenderType: "user", + Content: "WhatsApp voice call", + ContentType: "voice_call", + Status: "sent", + MessageType: "outgoing", + ContentAttributes: datatypes.JSON(encodedAttrs), + } + return message, tx.Create(message).Error +} + +func updateWhatsAppCallMessageAndConversation(tx *gorm.DB, call *model.Call, status string, agentID uint, duration *int) error { + if call.MessageID != nil && *call.MessageID != 0 { + var message model.Message + if err := tx.First(&message, *call.MessageID).Error; err == nil { + attrs := messageAttributes(&message) + data, _ := attrs["data"].(map[string]any) + if data == nil { + data = map[string]any{} + } + data["status"] = displayWhatsAppCallStatus(status) + if agentID != 0 { + data["accepted_by"] = map[string]any{"id": agentID} + } + if duration != nil { + data["duration_seconds"] = *duration + } + attrs["data"] = data + encodedAttrs, _ := json.Marshal(attrs) + if err := tx.Model(&message).Update("content_attributes", datatypes.JSON(encodedAttrs)).Error; err != nil { + return err + } + } + } + var conversation model.Conversation + if err := tx.First(&conversation, call.ConversationID).Error; err != nil { + return err + } + attrs := conversationAttributes(&conversation) + attrs["call_status"] = displayWhatsAppCallStatus(status) + encodedAttrs, _ := json.Marshal(attrs) + return tx.Model(&conversation).Update("additional_attributes", datatypes.JSON(encodedAttrs)).Error +} + +func firstUintPtr(existing *uint, fallback uint) *uint { + if existing != nil { + return existing + } + return &fallback +} + +func callAttributes(call *model.Call) map[string]any { + attrs := map[string]any{} + if len(call.AdditionalAttributes) > 0 { + _ = json.Unmarshal(call.AdditionalAttributes, &attrs) + } + return attrs +} + +func messageAttributes(message *model.Message) map[string]any { + attrs := map[string]any{} + if len(message.ContentAttributes) > 0 { + _ = json.Unmarshal(message.ContentAttributes, &attrs) + } + return attrs +} + +func conversationAttributes(conversation *model.Conversation) map[string]any { + attrs := map[string]any{} + if len(conversation.AdditionalAttributes) > 0 { + _ = json.Unmarshal(conversation.AdditionalAttributes, &attrs) + } + return attrs +} + +func whatsAppCallingEnabled(inbox *model.Inbox, channel *channelmodel.ChannelWhatsApp) bool { + if inbox.ChannelType != "whatsapp" || !channel.IsCloudAPI() { + return false + } + inboxConfig := map[string]any{} + _ = json.Unmarshal([]byte(inbox.ChannelConfig), &inboxConfig) + if boolValue(inboxConfig["voice_enabled"]) { + return true + } + providerConfig := map[string]any{} + _ = json.Unmarshal([]byte(channel.ProviderConfig), &providerConfig) + return boolValue(providerConfig["calling_enabled"]) +} + +func boolValue(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") + default: + return false + } +} + +func displayWhatsAppCallStatus(status string) string { + return strings.ReplaceAll(status, "_", "-") +} + +func displayWhatsAppCallDirection(direction string) string { + if direction == "incoming" { + return "inbound" + } + if direction == "outgoing" { + return "outbound" + } + return direction +} + +func isTerminalWhatsAppCall(status string) bool { + return status == "completed" || status == "no_answer" || status == "failed" +} + +func defaultWhatsAppIceServers() []map[string][]string { + return []map[string][]string{{"urls": []string{"stun:stun.l.google.com:19302"}}} +} + +func whatsappPermissionRequestBody(channel *channelmodel.ChannelWhatsApp) string { + providerConfig := map[string]any{} + _ = json.Unmarshal([]byte(channel.ProviderConfig), &providerConfig) + if body, ok := providerConfig["call_permission_request_body"].(string); ok { + return body + } + return "Please allow WhatsApp calls from this business." +} + +type defaultWhatsAppCallProvider struct { + client *resty.Client +} + +func (p defaultWhatsAppCallProvider) InitiateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, sdpOffer string) (string, error) { + resp, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "to": toNumber, "action": "connect", "session": map[string]any{"sdp_type": "offer", "sdp": sdpOffer}}) + if err != nil { + return "", err + } + if calls, ok := resp["calls"].([]any); ok && len(calls) > 0 { + if first, ok := calls[0].(map[string]any); ok { + if id, ok := first["id"].(string); ok { + return id, nil + } + } + } + if id, ok := resp["call_id"].(string); ok { + return id, nil + } + return "", fmt.Errorf("Meta initiate_call failed") +} + +func (p defaultWhatsAppCallProvider) PreAcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error { + _, err := p.call(ctx, channel, p.callActionBody(providerCallID, "pre_accept", sdpAnswer)) + return err +} + +func (p defaultWhatsAppCallProvider) AcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error { + _, err := p.call(ctx, channel, p.callActionBody(providerCallID, "accept", sdpAnswer)) + return err +} + +func (p defaultWhatsAppCallProvider) RejectCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error { + _, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "call_id": providerCallID, "action": "reject"}) + return err +} + +func (p defaultWhatsAppCallProvider) TerminateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error { + _, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "call_id": providerCallID, "action": "terminate"}) + return err +} + +func (p defaultWhatsAppCallProvider) SendCallPermissionRequest(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, body string) (string, error) { + resp, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "to": toNumber, "type": "text", "text": map[string]any{"body": body}}) + if err != nil { + return "", err + } + if messages, ok := resp["messages"].([]any); ok && len(messages) > 0 { + if first, ok := messages[0].(map[string]any); ok { + if id, ok := first["id"].(string); ok { + return id, nil + } + } + } + return "", nil +} + +func (p defaultWhatsAppCallProvider) callActionBody(callID string, action string, sdpAnswer string) map[string]any { + return map[string]any{"messaging_product": "whatsapp", "call_id": callID, "action": action, "session": map[string]any{"sdp_type": "answer", "sdp": sdpAnswer}} +} + +func (p defaultWhatsAppCallProvider) call(ctx context.Context, channel *channelmodel.ChannelWhatsApp, body map[string]any) (map[string]any, error) { + url := fmt.Sprintf("%s/%s/%s/calls", whatsappGraphAPIBase(), whatsappAPIVersion(), channel.PhoneNumberID) + resp, err := p.client.R().SetContext(ctx).SetAuthToken(channel.AccessToken).SetBody(body).Post(url) + if err != nil { + return nil, err + } + if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + out := map[string]any{} + _ = json.Unmarshal(resp.Body(), &out) + return out, nil + } + if strings.Contains(string(resp.Body()), "138006") { + return nil, ErrWhatsAppCallPermissionRequired + } + return nil, fmt.Errorf("Meta WhatsApp call API returned status %d", resp.StatusCode()) +} diff --git a/internal/service/whatsapp_call_service_test.go b/internal/service/whatsapp_call_service_test.go new file mode 100644 index 00000000..f06c4648 --- /dev/null +++ b/internal/service/whatsapp_call_service_test.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/gochat/gochat/internal/repository" +) + +type fakeWhatsAppCallProvider struct { + initiateID string + permissionRequired bool + permissionMessageID string + preAccepts int + accepts int + rejects int + terminates int +} + +func (f *fakeWhatsAppCallProvider) InitiateCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) { + if f.permissionRequired { + return "", ErrWhatsAppCallPermissionRequired + } + if f.initiateID == "" { + return "wacid_outbound", nil + } + return f.initiateID, nil +} + +func (f *fakeWhatsAppCallProvider) PreAcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error { + f.preAccepts++ + return nil +} + +func (f *fakeWhatsAppCallProvider) AcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error { + f.accepts++ + return nil +} + +func (f *fakeWhatsAppCallProvider) RejectCall(context.Context, *channelmodel.ChannelWhatsApp, string) error { + f.rejects++ + return nil +} + +func (f *fakeWhatsAppCallProvider) TerminateCall(context.Context, *channelmodel.ChannelWhatsApp, string) error { + f.terminates++ + return nil +} + +func (f *fakeWhatsAppCallProvider) SendCallPermissionRequest(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) { + if f.permissionMessageID == "" { + return "wamid.req_xyz", nil + } + return f.permissionMessageID, nil +} + +func setupWhatsAppCallServiceTest(t *testing.T, provider *fakeWhatsAppCallProvider) (*gorm.DB, *WhatsAppCallService, *model.Account, *model.Inbox, *model.Contact, *model.Conversation) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{})) + + account := &model.Account{Name: "Voice Account", Status: "active"} + require.NoError(t, db.Create(account).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`} + require.NoError(t, db.Create(inbox).Error) + waChannel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+15550000000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"calling_enabled":true}`} + require.NoError(t, db.Create(waChannel).Error) + inbox.ChannelID = waChannel.ID + require.NoError(t, db.Save(inbox).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Ada", PhoneNumber: "+15551234567"} + require.NoError(t, db.Create(contact).Error) + displayID := uint(42) + conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "whatsapp", Channel: "whatsapp"} + require.NoError(t, db.Create(conversation).Error) + + return db, NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), provider), account, inbox, contact, conversation +} + +func TestWhatsAppCallService_InitiateCreatesOutboundCallAndMessage(t *testing.T) { + provider := &fakeWhatsAppCallProvider{initiateID: "wacid_outbound"} + db, svc, account, _, _, conversation := setupWhatsAppCallServiceTest(t, provider) + + result, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7}) + require.NoError(t, err) + require.Equal(t, "wacid_outbound", result.Call.ProviderCallID) + + var call model.Call + require.NoError(t, db.First(&call, result.Call.ID).Error) + require.Equal(t, "whatsapp", call.Provider) + require.Equal(t, "outgoing", call.Direction) + require.Equal(t, "ringing", call.Status) + require.NotNil(t, call.MessageID) + + var message model.Message + require.NoError(t, db.First(&message, *call.MessageID).Error) + require.Equal(t, "voice_call", message.ContentType) + require.Equal(t, "outgoing", message.MessageType) +} + +func TestWhatsAppCallService_InitiatePermissionRequestRecordsConversation(t *testing.T) { + provider := &fakeWhatsAppCallProvider{permissionRequired: true, permissionMessageID: "wamid.req_xyz"} + db, svc, account, _, _, conversation := setupWhatsAppCallServiceTest(t, provider) + + result, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7}) + require.NoError(t, err) + require.Equal(t, "permission_requested", result.PermissionStatus) + + var stored model.Conversation + require.NoError(t, db.First(&stored, conversation.ID).Error) + attrs := conversationAttributes(&stored) + require.Equal(t, "wamid.req_xyz", attrs["call_permission_request_message_id"]) + require.NotEmpty(t, attrs["call_permission_requested_at"]) +} + +func TestWhatsAppCallService_AcceptRejectTerminateAndRecording(t *testing.T) { + provider := &fakeWhatsAppCallProvider{} + db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider) + call := &model.Call{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "wacid_in", Status: "ringing", CallerType: "Contact", CallerID: contact.ID, CallDirection: "inbound"} + require.NoError(t, db.Create(call).Error) + message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContentType: "voice_call", MessageType: "incoming", Content: "WhatsApp voice call"} + require.NoError(t, db.Create(message).Error) + require.NoError(t, db.Model(call).Update("message_id", message.ID).Error) + + accepted, err := svc.Accept(context.Background(), account.ID, call.ID, 8, "sdp_answer") + require.NoError(t, err) + require.Equal(t, "in_progress", accepted.Status) + require.Equal(t, 1, provider.preAccepts) + require.Equal(t, 1, provider.accepts) + require.NotNil(t, accepted.StartedAt) + + terminated, err := svc.Terminate(context.Background(), account.ID, call.ID, 8) + require.NoError(t, err) + require.Equal(t, "completed", terminated.Status) + require.Equal(t, 1, provider.terminates) + + status, err := svc.UploadRecording(context.Background(), account.ID, call.ID, "call.webm", 100) + require.NoError(t, err) + require.Equal(t, "uploaded", status) + status, err = svc.UploadRecording(context.Background(), account.ID, call.ID, "call.webm", 100) + require.NoError(t, err) + require.Equal(t, "already_uploaded", status) +} + +func TestWhatsAppCallService_InitiateValidatesReferenceGuards(t *testing.T) { + provider := &fakeWhatsAppCallProvider{} + db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider) + + _, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, AgentID: 7}) + require.True(t, errors.Is(err, ErrWhatsAppCallSDPOfferRequired)) + + require.NoError(t, db.Model(contact).Update("phone_number", "").Error) + _, err = svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7}) + require.True(t, errors.Is(err, ErrWhatsAppCallContactPhoneRequired)) + + require.NoError(t, db.Model(contact).Update("phone_number", "+15551234567").Error) + require.NoError(t, db.Model(inbox).Update("channel_config", `{"voice_enabled":false}`).Error) + require.NoError(t, db.Model(&channelmodel.ChannelWhatsApp{}).Where("inbox_id = ?", inbox.ID).Update("provider_config", `{"calling_enabled":false}`).Error) + _, err = svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7}) + require.True(t, errors.Is(err, ErrWhatsAppCallNotEnabled)) +} + +func TestWhatsAppCallService_TerminateUsesNoAnswerBeforeProgress(t *testing.T) { + provider := &fakeWhatsAppCallProvider{} + db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider) + call := &model.Call{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, Provider: "whatsapp", Direction: "outgoing", ProviderCallID: "wacid_out", Status: "ringing", CallerType: "User", CallerID: 7, CallDirection: "outbound", StartedAt: ptrTime(time.Now())} + require.NoError(t, db.Create(call).Error) + + terminated, err := svc.Terminate(context.Background(), account.ID, call.ID, 7) + require.NoError(t, err) + require.Equal(t, "no_answer", terminated.Status) +} + +func ptrTime(t time.Time) *time.Time { return &t } diff --git a/migrations/000034_align_calls_whatsapp_fields.down.sql b/migrations/000034_align_calls_whatsapp_fields.down.sql new file mode 100644 index 00000000..8fc9f9bd --- /dev/null +++ b/migrations/000034_align_calls_whatsapp_fields.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE calls DROP COLUMN IF EXISTS end_reason; +ALTER TABLE calls DROP COLUMN IF EXISTS started_at; diff --git a/migrations/000034_align_calls_whatsapp_fields.up.sql b/migrations/000034_align_calls_whatsapp_fields.up.sql new file mode 100644 index 00000000..73e69dab --- /dev/null +++ b/migrations/000034_align_calls_whatsapp_fields.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE calls ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ; +ALTER TABLE calls ADD COLUMN IF NOT EXISTS end_reason VARCHAR(255);