feat(conversations): align participant payloads
This commit is contained in:
@@ -91,6 +91,11 @@ var criticalRoutes = []route{
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/unread_counts", Controller: "api/v1/accounts/conversations/unread_counts#index", Source: "routes.rb:138"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/filter", Controller: "api/v1/accounts/conversations#filter", Source: "routes.rb:139"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/assignments", Controller: "api/v1/accounts/conversations/assignments#create", Source: "routes.rb:148"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#show", Source: "routes.rb:150"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#create", Source: "routes.rb:150"},
|
||||
{Method: "PATCH", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#update", Source: "routes.rb:150"},
|
||||
{Method: "PUT", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#update", Source: "routes.rb:150"},
|
||||
{Method: "DELETE", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/participants", Controller: "api/v1/accounts/conversations/participants#destroy", Source: "routes.rb:150"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/labels", Controller: "api/v1/accounts/conversations/labels#create", Source: "routes.rb:149"},
|
||||
{Method: "GET", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/labels", Controller: "api/v1/accounts/conversations/labels#index", Source: "routes.rb:149"},
|
||||
{Method: "POST", Path: "/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status", Controller: "api/v1/accounts/conversations#toggle_status", Source: "routes.rb:158"},
|
||||
|
||||
@@ -49,15 +49,15 @@ Hermes task landing checklist:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 teams frontend route checkpoint, prepared as `feat(teams): align frontend update routes`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(teams): align frontend update routes`.
|
||||
- Current tracking checkpoint: 2026-06-06 conversation participants checkpoint, prepared as `feat(conversations): align participant payloads`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align participant payloads`.
|
||||
- Latest documentation/tooling checkpoint: this tracker landing update plus `e8d08bb docs: track canned response parity`; 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: teams from `reference/chatwoot/config/routes.rb:296-300` and reused dashboard `api/teams.js` are implemented for the frontend route shape. `PATCH /api/v1/accounts/:account_id/teams/:team_id` now reaches the normal update action used by `TeamsAPI.update`, and no-trailing-slash aliases are registered for frontend team index/create plus team member list/create/update/delete calls. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- Worktree status at this implementation checkpoint: conversation participants from `reference/chatwoot/config/routes.rb:150`, `ParticipantsController`, participant Jbuilder views, and reused dashboard `api/inbox/conversation.js` are implemented for the frontend route and payload shape. `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants` are now tracked and routed without the trailing slash, participant create/update return raw Chatwoot agent arrays, update treats `user_ids` as the final participant set, and destroy returns an empty `200 OK`. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 919` after adding frontend teams/team-members aliases and `PATCH` team update.
|
||||
- Route dump succeeds with `TOTAL: 925` after adding frontend conversation participant aliases and the singleton `PUT/DELETE` participant routes.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 374 Chatwoot routes: 361 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 379 Chatwoot routes: 366 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.
|
||||
- `/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,7 +140,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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 374-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 Dyte routes from `routes.rb:357-358`, dashboard app routes from `routes.rb:130`, canned response routes from `routes.rb:114`, notification subscription routes from `routes.rb:440`, and team/team-member routes from `routes.rb:296-300` are now explicitly tracked. Notification list/action serializers, 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 Dyte create/join payload behavior, dashboard app raw payload/serializer behavior, canned response raw payload/search/delete behavior, notification subscription payload behavior, team update/frontend route 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 teams route parity 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 379-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 Dyte routes from `routes.rb:357-358`, 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`, and conversation participant routes from `routes.rb:150` are now explicitly tracked. Notification list/action serializers, 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 Dyte create/join payload 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, 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 conversation participant parity 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. |
|
||||
@@ -150,10 +150,11 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
| 9 | P3.14 canned responses API | Implemented for the reused dashboard canned-response settings flow: raw frontend bodies and Rails-style wrappers are accepted, no-trailing-slash frontend aliases and `PATCH` update are registered, list/search return raw arrays, create/update/show return raw Chatwoot fields, search uses `search=` and Chatwoot ranking, update/delete are account-scoped, and delete returns empty `200 OK`. | Keep in Review; reopen only if live settings smoke exposes validation/error wording or non-frontend compatibility alias drift beyond the current reference contract. | Focused canned handler/service tests, combined handler/canned/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 10 | P3.15 notification subscriptions API | Implemented for the reused dashboard push helper: user-scoped singular `POST/DELETE /api/v1/notification_subscriptions` is tracked, raw frontend bodies plus `{ notification_subscription: ... }` wrappers are accepted, browser push identifiers derive from `subscription_attributes.endpoint`, FCM identifiers derive from `device_id`/`push_token`, existing identifiers move/update to the current user, create returns raw Chatwoot subscription JSON with string `subscription_type`, and destroy returns empty `200 OK` whether or not a matching `push_token` exists. | Keep in Review; reopen only if live browser push smoke exposes service-worker registration or mobile FCM attribute drift beyond the current reference builder/controller contract. | Focused notification subscription handler tests, service/repository/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 11 | P3.16 teams frontend routes | Implemented for reused dashboard team settings: no-trailing-slash `GET/POST /teams` and `GET/POST/PATCH/DELETE /teams/:team_id/team_members` aliases are registered, and frontend `PATCH /teams/:team_id` update now reaches the Chatwoot team serializer response. | Keep in Review; reopen only if live team settings smoke exposes team show/store payload drift or team-member authorization/status-code differences beyond the inspected controller/Jbuilder contract. | Focused TeamHandler PATCH test, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 12 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 13 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
|
||||
| 14 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. |
|
||||
| 15 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. |
|
||||
| 12 | P3.17 conversation participants API | Implemented for reused dashboard conversation sidebar calls: singleton `GET/POST/PATCH/PUT/DELETE /conversations/:conversation_id/participants` routes are tracked and registered without the trailing slash, list/create/update return raw Chatwoot agent arrays, create accepts `user_ids`, update treats `user_ids` as the final participant set, and destroy returns empty `200 OK`. | Keep in Review; reopen only if live conversation sidebar smoke exposes participant authorization or agent serializer drift beyond the inspected controller/Jbuilder contract. | Focused ConversationParticipant handler/service tests, router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 13 | P6.8 contact outbound voice call | Implemented for the reused dashboard route with Twilio voice-enabled inbox validation, assigned-inbox check, open-conversation reuse, ContactInbox/conversation/call/message persistence, and Chatwoot response shape. | Keep in Review; reopen only if live smoke exposes provider initiation/status-update drift beyond the fakeable persisted boundary. | Focused contact call tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| 14 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. |
|
||||
| 15 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. |
|
||||
| 16 | P5.13 reports/analytics | P5.13a derives visible report aggregates from persisted rows; P5.13b adds lazy rollup freshness, durable day rollup jobs, and `/reports` metric timeseries. | Keep report drift closed as frontend smoke or reference inspection exposes additional metrics. | Report fixtures verify timeseries values, cache/freshness behavior, and no hidden placeholder JSON. |
|
||||
|
||||
## Open Checkpoint Contracts
|
||||
|
||||
@@ -181,6 +182,7 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
| P3.14 canned responses API parity | `internal/router/router.go`, `internal/handler/api/v1/canned_response_handler.go`, `internal/canned/service.go`, `internal/canned/model.go`, canned handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:114`, `reference/chatwoot/app/controllers/api/v1/accounts/canned_responses_controller.rb`, `reference/chatwoot/app/models/canned_response.rb`, `reference/chatwoot/db/schema.rb`, `reference/chatwoot/app/javascript/dashboard/api/cannedResponse.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/cannedResponse.js` | Canned responses now match the reused dashboard API: frontend `PATCH /api/v1/accounts/:account_id/canned_responses/:id` and no-trailing-slash index/create aliases are registered; the tracked critical route set includes Rails `PATCH`; create/update accept raw frontend bodies plus Rails-style `{ canned_response: ... }`; list/search return raw arrays; `search=` filters and ranks by short-code prefix, short-code contains, then content contains; create/update/show return raw objects with `id`, `account_id`, `short_code`, `content`, `created_at`, and `updated_at`; update/delete are account-scoped; delete returns empty `200 OK`; legacy `q` and `/search` remain only compatibility aliases. | Review by `feat(canned-responses): align chatwoot payloads`; focused canned handler tests cover list/search/raw create/PATCH update/delete/cross-account `404`, service tests cover account-scoped get/update/delete and ranking, route dump/parity regenerated to `TOTAL: 910` and `358 exact, 13 parameter-compatible, 0 missing out of 371`, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.15 notification subscriptions API parity | `internal/router/router.go`, `internal/handler/api/v1/notification_subscription_handler.go`, `internal/service/notification_subscription_service.go`, `internal/repository/notification_subscription_repo.go`, notification subscription handler tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:440`, `reference/chatwoot/app/controllers/api/v1/notification_subscriptions_controller.rb`, `reference/chatwoot/app/builders/notification_subscription_builder.rb`, `reference/chatwoot/app/models/notification_subscription.rb`, `reference/chatwoot/db/schema.rb`, dashboard `api/notificationSubscription.js`, `helper/pushHelper.js` | Notification subscriptions now match the reused dashboard push registration boundary: singular user-scoped `POST/DELETE /api/v1/notification_subscriptions` routes are tracked; create accepts raw frontend bodies plus Rails-style wrappers; browser push identifiers derive from `endpoint`; FCM identifiers derive from `device_id` or `push_token`; duplicate identifiers update/move to the current user; create serializes raw Chatwoot subscription fields with string enum values; and destroy deletes by `push_token`/endpoint while returning empty `200 OK` for missing matches. | Review by `feat(notifications): align subscription payloads`; focused notification subscription handler tests, combined handler/service/repository/router/route-parity tests, route dump/parity regeneration to `TOTAL: 912` and `360 exact, 13 parameter-compatible, 0 missing out of 373`, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.16 teams frontend route parity | `internal/router/router.go`, `internal/handler/api/v1/team_handler.go`, team handler/router tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:296-300`, `reference/chatwoot/app/controllers/api/v1/accounts/teams_controller.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/team_members_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/teams/*.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/team_members/*.json.jbuilder`, dashboard `api/teams.js`, `store/modules/teams/actions.js`, `store/modules/teamMembers.js` | Team routes now match reused dashboard call sites: no-trailing-slash `GET/POST /teams` aliases are registered for `CacheEnabledApiClient`, frontend `PATCH /teams/:team_id` update is registered alongside Rails `PUT`, and no-trailing-slash team-member list/create/update/delete aliases are registered for `TeamsAPI.getAgents/addAgents/updateAgents`. Team update continues to accept raw frontend bodies and `{ team: ... }` wrappers and returns raw Chatwoot team fields. | Review by `feat(teams): align frontend update routes`; focused TeamHandler PATCH test, router/route-parity tests, route dump/parity regeneration to `TOTAL: 919` and `361 exact, 13 parameter-compatible, 0 missing out of 374`, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.17 conversation participants API parity | `internal/router/router.go`, `internal/handler/api/v1/conversation_participant_handler.go`, `internal/service/conversation_participant_service.go`, `internal/repository/conversation_participant_repo.go`, conversation participant handler/service tests, `cmd/route_parity` | `reference/chatwoot/config/routes.rb:150`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations/participants_controller.rb`, `reference/chatwoot/app/views/api/v1/accounts/conversations/participants/*.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_agent.json.jbuilder`, dashboard `api/inbox/conversation.js` | Conversation participants now match the reused dashboard sidebar contract: singleton participant routes are tracked and registered without trailing slashes; trailing slash and legacy per-user routes remain compatibility aliases; list/create/update return raw agent arrays rendered through the Chatwoot agent serializer shape; create accepts frontend `user_ids` and legacy `user_id`; update treats `user_ids` as the final participant set, adding missing users and removing absent users; destroy accepts `{ user_ids: [...] }` and returns empty `200 OK`. | Review by `feat(conversations): align participant payloads`; focused ConversationParticipant handler/service tests, router/route-parity tests, route dump/parity regeneration to `TOTAL: 925` and `366 exact, 13 parameter-compatible, 0 missing out of 379`, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P6.8 contact outbound voice call parity | `internal/router/router.go`, `internal/handler/api/v1/contact_handler.go`, `internal/service/contact_service.go`, `internal/model/call.go`, contact handler tests | `reference/chatwoot/config/routes.rb:216`, `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb`, `reference/chatwoot/enterprise/app/services/voice/outbound_call_builder.rb`, `reference/chatwoot/enterprise/app/services/voice/call_message_builder.rb`, `reference/chatwoot/enterprise/app/models/call.rb`, dashboard `api/contacts.js`, `store/modules/contacts/actions.js`, `api/channel/voice/voiceAPIClient.js` | Contact outbound calls now match the Chatwoot enterprise route boundary: account contact lookup, current user's assigned `Channel::TwilioSms` inbox lookup, `voice_enabled` guard, phone-number guard, open display-ID conversation reuse only for same inbox/contact, new ContactInbox/open conversation creation when needed, persisted outgoing Twilio call metadata, linked `voice_call` message content attributes, and raw `{ conversation_id, inbox_id, call_sid, conference_sid }` response. | Review by `feat(contacts): initiate voice calls`; focused contact call tests cover success/reuse/resolved-hint ignored/no-phone/non-voice/unassigned cases; route dump/parity regenerated to `TOTAL: 861` and `299 exact, 7 parameter-compatible, 0 missing out of 306`; full `go test ./...` and `git diff --check` passed. |
|
||||
| P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. Empty/null JSON serializes as `{}`. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed. |
|
||||
@@ -222,6 +224,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(conversations): align participant payloads` | Advances P3.17 conversation participant parity by matching Chatwoot `Conversations::ParticipantsController`, participant Jbuilder views, the `_agent` serializer, routes `150`, and reused dashboard `api/inbox/conversation.js` calls. GoChat now registers and tracks singleton `GET/POST/PATCH/PUT/DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants` routes without trailing slashes, keeps trailing slash and per-user compatibility aliases, returns raw agent arrays instead of local `{ success, data }` envelopes, accepts frontend `user_ids`, treats update `user_ids` as the final participant set, and returns empty `200 OK` for participant destroy. | `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -run 'ConversationParticipant\|Router\|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: 925`; tracked route parity is `366 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 379`. | P3.17 moves to Review for current conversation sidebar participant evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(teams): align frontend update routes` | Advances P3.16 team route parity by matching Chatwoot `TeamsController`, `TeamMembersController`, their Jbuilder serializers, routes `296-300`, and reused dashboard `api/teams.js`/team store callers. GoChat now registers frontend no-trailing-slash aliases for team index/create and team-member list/create/update/delete, tracks the Rails `PATCH /api/v1/accounts/:account_id/teams/:team_id` route, routes frontend team updates through the existing Chatwoot-shaped update serializer, and keeps raw frontend bodies plus `{ team: ... }` wrappers accepted. | `go test ./internal/handler/api/v1 -run TeamHandler -count=1`; `go test ./internal/handler/api/v1 ./internal/router ./cmd/route_parity -run 'TeamHandler\|Router\|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: 919`; tracked route parity is `361 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 374`. | P3.16 moves to Review for current team settings route evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `feat(notifications): align subscription payloads` | Advances P3.15 notification subscription parity by matching Chatwoot `NotificationSubscriptionsController`, `NotificationSubscriptionBuilder`, the `NotificationSubscription` enum model, schema fields, and reused dashboard `pushHelper`/`notificationSubscription` API. GoChat now registers the singular user-scoped `DELETE /api/v1/notification_subscriptions`, tracks `POST/DELETE` route parity, accepts raw frontend bodies and Rails-style wrappers, derives browser push identifiers from `subscription_attributes.endpoint`, derives FCM identifiers from `device_id` or `push_token`, updates/moves duplicate identifiers to the current user, returns raw subscription objects with string `subscription_type`, and makes destroy an empty `200 OK` no-op for missing matches like the reference controller. | `go test ./internal/handler/api/v1 -run NotificationSubscription -count=1`; `go test ./internal/service ./internal/repository -run NotificationSubscription -count=1`; `go test ./internal/router ./cmd/route_parity -run 'Router\|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: 912`; tracked route parity is `360 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 373`. | P3.15 moves to Review for current browser push registration evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
| `4e0113a feat(canned-responses): align chatwoot payloads` | Advances P3.14 canned response parity by matching Chatwoot `CannedResponsesController`, `CannedResponse#order_by_search`, schema fields, and reused dashboard `cannedResponse` API/store. GoChat now registers frontend no-trailing-slash index/create aliases plus `PATCH` update, accepts raw frontend bodies and Rails-style wrappers, returns raw arrays/objects with Chatwoot fields instead of local `{ success, data }` envelopes, supports `search=` ranking by short-code prefix/contains/content, scopes show/update/delete by account, and returns empty `200 OK` deletes while keeping legacy `q` and `/search` as compatibility aliases only. | `go test ./internal/handler/api/v1 -run CannedResponse -count=1`; `go test ./internal/canned -count=1`; `go test ./internal/handler/api/v1 ./internal/canned ./internal/router ./cmd/route_parity -run 'CannedResponse\|Router\|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: 910`; tracked route parity is `358 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 371`. | P3.14 moves to Review for current canned response settings evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. |
|
||||
@@ -2405,3 +2408,4 @@ Verification milestone gates:
|
||||
- 2026-06-06: Documentation checkpoint prepared as `docs: record committed parity tracker`; confirms `4e0113a feat(canned-responses): align chatwoot payloads` is the current committed implementation baseline, marks this master plan as the landed tracker for known Hermes plans and user-confirmed scope, and keeps the next executable queue limited to Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. Verification for this docs-only checkpoint: `git diff --check`.
|
||||
- 2026-06-06: P3.15 notification subscription checkpoint prepared as `feat(notifications): align subscription payloads`; audited Chatwoot notification subscriptions controller, builder, model/schema, and reused dashboard push helper/API. GoChat now exposes the singular user-scoped `DELETE /api/v1/notification_subscriptions`, tracks `POST/DELETE` route parity, accepts raw and wrapped create bodies, derives identifiers from browser endpoints or FCM device/push tokens, updates/moves duplicate identifiers to the current user, returns raw subscription JSON with string enum values, and makes destroy an empty `200 OK` no-op for missing matches. Focused notification subscription handler tests, service/repository/router/route-parity tests, route dump/parity regeneration (`TOTAL: 912`, `360 exact`, `13 parameter-compatible`, `0 missing out of 373`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: P3.16 teams frontend route checkpoint prepared as `feat(teams): align frontend update routes`; audited Chatwoot teams/team-members controllers, Jbuilder serializers, routes `296-300`, and reused dashboard teams API/store callers. GoChat now exposes the frontend `PATCH /api/v1/accounts/:account_id/teams/:team_id` update path, registers no-trailing-slash team index/create aliases, and registers no-trailing-slash team-member list/create/update/delete aliases used by the dashboard. Focused TeamHandler PATCH test, router/route-parity tests, route dump/parity regeneration (`TOTAL: 919`, `361 exact`, `13 parameter-compatible`, `0 missing out of 374`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: P3.17 conversation participant checkpoint prepared as `feat(conversations): align participant payloads`; audited Chatwoot participant routes/controller/Jbuilder, `_agent` serializer, and reused dashboard conversation API calls. GoChat now exposes singleton no-trailing-slash participant routes, tracks `GET/POST/PATCH/PUT/DELETE /conversations/:conversation_id/participants`, returns raw agent arrays, accepts frontend `user_ids`, applies update as final-set synchronization, and returns empty `200 OK` destroy responses. Focused ConversationParticipant handler/service tests, router/route-parity tests, route dump/parity regeneration (`TOTAL: 925`, `366 exact`, `13 parameter-compatible`, `0 missing out of 379`), full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
|
||||
@@ -41,6 +41,7 @@ DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attrib
|
||||
DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id
|
||||
DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/labels/:tag_id
|
||||
DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id
|
||||
DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants/:user_id
|
||||
DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id
|
||||
DELETE /api/v1/accounts/:account_id/custom_attribute_definitions/:id
|
||||
@@ -221,6 +222,7 @@ GET /api/v1/accounts/:account_id/conversations/:conversation_id/labels
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/delivery_status/
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/participants/
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/
|
||||
GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id
|
||||
@@ -504,6 +506,7 @@ PATCH /api/v1/accounts/:account_id/conversations/:conversation_id
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/draft_messages/:draft_id
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/labels
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/participants/
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/participants/:user_id
|
||||
PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/priority
|
||||
@@ -644,6 +647,7 @@ POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/retry
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/translate
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/mute
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/participants/
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/toggle_priority
|
||||
POST /api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status
|
||||
@@ -864,6 +868,8 @@ PUT /api/v1/accounts/:account_id/contacts/:contact_id
|
||||
PUT /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id
|
||||
PUT /api/v1/accounts/:account_id/conversations/:conversation_id
|
||||
PUT /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id
|
||||
PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
PUT /api/v1/accounts/:account_id/conversations/:conversation_id/participants/
|
||||
PUT /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id
|
||||
PUT /api/v1/accounts/:account_id/custom_attribute_definitions/:id
|
||||
PUT /api/v1/accounts/:account_id/custom_filters/:id
|
||||
@@ -917,4 +923,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: 919
|
||||
TOTAL: 925
|
||||
|
||||
@@ -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: 361 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 374 tracked critical routes.
|
||||
Summary: 366 exact, 0 method-compatible, 13 parameter-compatible, 0 missing out of 379 tracked critical routes.
|
||||
|
||||
## Missing Critical Routes
|
||||
|
||||
@@ -68,6 +68,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| DELETE | `/api/v1/accounts/:account_id/contacts/:contact_id/avatar` | `/api/v1/accounts/:account_id/contacts/:contact_id/avatar` | `api/v1/accounts/contacts#avatar` | `routes.rb:208` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#destroy` | `routes.rb:134` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `api/v1/accounts/conversations/messages#destroy` | `routes.rb:142` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#destroy` | `routes.rb:150` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `api/v1/accounts/custom_attribute_definitions#destroy` | `routes.rb:250` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/custom_filters/:id` | `/api/v1/accounts/:account_id/custom_filters/:id` | `api/v1/accounts/custom_filters#destroy` | `routes.rb:251` | exact |
|
||||
| DELETE | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#destroy` | `routes.rb:277` | exact |
|
||||
@@ -145,6 +146,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#show` | `routes.rb:134` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/labels` | `/api/v1/accounts/:account_id/conversations/:conversation_id/labels` | `api/v1/accounts/conversations/labels#index` | `routes.rb:149` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `api/v1/accounts/conversations/messages#index` | `routes.rb:142` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#show` | `routes.rb:150` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/meta` | `/api/v1/accounts/:account_id/conversations/meta` | `api/v1/accounts/conversations#meta` | `routes.rb:136` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/search` | `/api/v1/accounts/:account_id/conversations/search` | `api/v1/accounts/conversations#search` | `routes.rb:137` | exact |
|
||||
| GET | `/api/v1/accounts/:account_id/conversations/unread_counts` | `/api/v1/accounts/:account_id/conversations/unread_counts` | `api/v1/accounts/conversations/unread_counts#index` | `routes.rb:138` | exact |
|
||||
@@ -249,6 +251,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/canned_responses/:id` | `/api/v1/accounts/:account_id/canned_responses/:id` | `api/v1/accounts/canned_responses#update` | `routes.rb:114` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#update` | `routes.rb:150` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `/api/v1/accounts/:account_id/integrations/hooks/:id` | `api/v1/accounts/integrations/hooks#update` | `routes.rb:346` | exact |
|
||||
| PATCH | `/api/v1/accounts/:account_id/portals/:portal_id` | `/api/v1/accounts/:account_id/portals/:portal_id` | `api/v1/accounts/portals#update` | `routes.rb:385` | exact |
|
||||
@@ -309,6 +312,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/` | `api/v1/accounts/conversations/messages#create` | `routes.rb:142` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/retry` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/retry` | `api/v1/accounts/conversations/messages#retry` | `routes.rb:145` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/translate` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id/translate` | `api/v1/accounts/conversations/messages#translate` | `routes.rb:144` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#create` | `routes.rb:150` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_priority` | `/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_priority` | `api/v1/accounts/conversations#toggle_priority` | `routes.rb:159` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status` | `/api/v1/accounts/:account_id/conversations/:conversation_id/toggle_status` | `api/v1/accounts/conversations#toggle_status` | `routes.rb:158` | exact |
|
||||
| POST | `/api/v1/accounts/:account_id/conversations/:conversation_id/transcript` | `/api/v1/accounts/:account_id/conversations/:conversation_id/transcript` | `api/v1/accounts/conversations#transcript` | `routes.rb:157` | exact |
|
||||
@@ -389,6 +393,7 @@ These routes exist with equivalent method and path shape but different parameter
|
||||
| PUT | `/api/v1/accounts/:account_id/contacts/:contact_id` | `/api/v1/accounts/:account_id/contacts/:contact_id` | `api/v1/accounts/contacts#update` | `routes.rb:197` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id` | `api/v1/accounts/conversations#update` | `routes.rb:134` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `/api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id` | `api/v1/accounts/conversations/messages#update` | `routes.rb:142` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `/api/v1/accounts/:account_id/conversations/:conversation_id/participants` | `api/v1/accounts/conversations/participants#update` | `routes.rb:150` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `/api/v1/accounts/:account_id/custom_attribute_definitions/:id` | `api/v1/accounts/custom_attribute_definitions#update` | `routes.rb:250` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/custom_filters/:id` | `/api/v1/accounts/:account_id/custom_filters/:id` | `api/v1/accounts/custom_filters#update` | `routes.rb:251` | exact |
|
||||
| PUT | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `/api/v1/accounts/:account_id/inboxes/:inbox_id` | `api/v1/accounts/inboxes#update` | `routes.rb:252` | exact |
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
@@ -40,7 +41,7 @@ func (h *ConversationParticipantHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, toInterfaceSlice(participants))
|
||||
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, participants))
|
||||
}
|
||||
|
||||
// Add adds a participant to a conversation.
|
||||
@@ -59,21 +60,27 @@ func (h *ConversationParticipantHandler) Add(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
UserID uint `json:"user_id"`
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
userIDs := participantRequestUserIDs(req.UserID, req.UserIDs)
|
||||
if len(userIDs) == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "user_ids is required")
|
||||
return
|
||||
}
|
||||
|
||||
participant, svcErr := h.participantSvc.Add(c.Request.Context(), accountID, conversationID, req.UserID, req.Role)
|
||||
participants, svcErr := h.participantSvc.AddMany(c.Request.Context(), accountID, conversationID, userIDs, req.Role)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, participant)
|
||||
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, participants))
|
||||
}
|
||||
|
||||
// Update updates a participant's role in a conversation.
|
||||
@@ -111,7 +118,7 @@ func (h *ConversationParticipantHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, participant)
|
||||
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, []model.ConversationParticipant{*participant}))
|
||||
}
|
||||
|
||||
// Remove removes a participant from a conversation.
|
||||
@@ -140,12 +147,45 @@ func (h *ConversationParticipantHandler) Remove(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, nil)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Destroy removes multiple participants from a conversation.
|
||||
// DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
func (h *ConversationParticipantHandler) Destroy(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
|
||||
conversationID, err := parseUintParam(c, "conversation_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
}
|
||||
if c.Request.ContentLength != 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if svcErr := h.participantSvc.RemoveMany(c.Request.Context(), accountID, conversationID, req.UserIDs); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// BatchUpdate adds and/or removes multiple participants in a single call.
|
||||
// PATCH /api/v1/accounts/:account_id/conversations/:conversation_id/participants
|
||||
// Accepts: { "user_ids": [1,2,3], "remove_user_ids": [4,5], "role": "participant" }
|
||||
// Accepts: { "user_ids": [1,2,3] } as the final participant set.
|
||||
// Reference: Chatwoot app/controllers/api/v1/conversations/participants_controller.rb#update
|
||||
func (h *ConversationParticipantHandler) BatchUpdate(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
@@ -161,20 +201,37 @@ func (h *ConversationParticipantHandler) BatchUpdate(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
RemoveUserIDs []uint `json:"remove_user_ids"`
|
||||
Role string `json:"role"`
|
||||
UserIDs []uint `json:"user_ids"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
participants, svcErr := h.participantSvc.BatchUpdate(c.Request.Context(), accountID, conversationID, req.UserIDs, req.RemoveUserIDs, req.Role)
|
||||
participants, svcErr := h.participantSvc.Replace(c.Request.Context(), accountID, conversationID, req.UserIDs, req.Role)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, toInterfaceSlice(participants))
|
||||
c.JSON(http.StatusOK, serializeConversationParticipantAgents(accountID, participants))
|
||||
}
|
||||
|
||||
func participantRequestUserIDs(userID uint, userIDs []uint) []uint {
|
||||
if len(userIDs) > 0 {
|
||||
return userIDs
|
||||
}
|
||||
if userID != 0 {
|
||||
return []uint{userID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeConversationParticipantAgents(accountID uint, participants []model.ConversationParticipant) []map[string]any {
|
||||
payload := make([]map[string]any, 0, len(participants))
|
||||
for i := range participants {
|
||||
payload = append(payload, serializeAgentUser(participants[i].User, accountID, "", "", false, 0))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -91,8 +91,13 @@ func (s *ConversationParticipantHandlerTestSuite) SetupSuite() {
|
||||
participants := convGroup.Group("/participants")
|
||||
{
|
||||
participants.GET("", handler.List)
|
||||
participants.GET("/", handler.List)
|
||||
participants.POST("", handler.Add)
|
||||
participants.POST("/", handler.Add)
|
||||
participants.PATCH("", handler.BatchUpdate)
|
||||
participants.PATCH("/", handler.BatchUpdate)
|
||||
participants.PUT("", handler.BatchUpdate)
|
||||
participants.DELETE("", handler.Destroy)
|
||||
participants.PATCH("/:user_id", handler.Update)
|
||||
participants.DELETE("/:user_id", handler.Remove)
|
||||
}
|
||||
@@ -102,8 +107,7 @@ func (s *ConversationParticipantHandlerTestSuite) SetupSuite() {
|
||||
|
||||
func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant() {
|
||||
body := map[string]interface{}{
|
||||
"user_id": s.testUser.ID,
|
||||
"role": "assignee",
|
||||
"user_ids": []uint{s.testUser.ID},
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
@@ -114,13 +118,13 @@ func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant() {
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
data, ok := resp["data"].([]interface{})
|
||||
if ok && len(data) > 0 {
|
||||
item := data[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), "assignee", item["role"])
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(s.T(), resp, 1)
|
||||
assert.Equal(s.T(), float64(s.testUser.ID), resp[0]["id"])
|
||||
assert.Equal(s.T(), s.testUser.Email, resp[0]["email"])
|
||||
assert.NotContains(s.T(), resp[0], "conversation_id")
|
||||
assert.NotContains(s.T(), resp[0], "user_id")
|
||||
}
|
||||
|
||||
func (s *ConversationParticipantHandlerTestSuite) Test_ListParticipants() {
|
||||
@@ -142,6 +146,10 @@ func (s *ConversationParticipantHandlerTestSuite) Test_ListParticipants() {
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp []map[string]interface{}
|
||||
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.NotEmpty(s.T(), resp)
|
||||
assert.NotContains(s.T(), resp[0], "data")
|
||||
}
|
||||
|
||||
func (s *ConversationParticipantHandlerTestSuite) Test_RemoveParticipant() {
|
||||
@@ -210,36 +218,62 @@ func (s *ConversationParticipantHandlerTestSuite) Test_AddParticipant_InvalidAcc
|
||||
// ========== BatchUpdate Handler Tests ==========
|
||||
|
||||
func (s *ConversationParticipantHandlerTestSuite) Test_BatchUpdate_AddAndRemove() {
|
||||
// Create a second user for testing batch add/remove
|
||||
user1 := &model.User{Name: "BatchUser1", Email: "batch1@test.com", Password: "hashed", Role: "agent", Active: true}
|
||||
s.Require().NoError(s.db.Create(user1).Error)
|
||||
user2 := &model.User{Name: "BatchUser2", Email: "batch2@test.com", Password: "hashed", Role: "agent", Active: true}
|
||||
s.Require().NoError(s.db.Create(user2).Error)
|
||||
conv := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(conv).Error)
|
||||
|
||||
// First add s.testUser as participant
|
||||
// First add user1 as participant.
|
||||
addBody := map[string]interface{}{
|
||||
"user_id": s.testUser.ID,
|
||||
"role": "assignee",
|
||||
"user_ids": []uint{user1.ID},
|
||||
}
|
||||
addBytes, _ := json.Marshal(addBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(addBytes))
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(addBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
// Now batch update: add user2, remove s.testUser
|
||||
// Chatwoot update treats user_ids as the final participant set.
|
||||
batchBody := map[string]interface{}{
|
||||
"user_ids": []uint{user2.ID},
|
||||
"removed_user_ids": []uint{s.testUser.ID},
|
||||
"user_ids": []uint{user2.ID},
|
||||
}
|
||||
batchBytes, _ := json.Marshal(batchBody)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/participants", bytes.NewReader(batchBytes))
|
||||
req, _ = http.NewRequest("PATCH", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(batchBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp []map[string]interface{}
|
||||
assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Len(s.T(), resp, 1)
|
||||
assert.Equal(s.T(), float64(user2.ID), resp[0]["id"])
|
||||
|
||||
var count int64
|
||||
s.Require().NoError(s.db.Model(&model.ConversationParticipant{}).Where("conversation_id = ? AND user_id = ?", conv.ID, user1.ID).Count(&count).Error)
|
||||
assert.Equal(s.T(), int64(0), count)
|
||||
}
|
||||
|
||||
func (s *ConversationParticipantHandlerTestSuite) Test_DestroyParticipantsRawPayload() {
|
||||
user := &model.User{Name: "DestroyUser", Email: "destroy-participant@test.com", Password: "hashed", Role: "agent", Active: true}
|
||||
s.Require().NoError(s.db.Create(user).Error)
|
||||
conv := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testConv.InboxID, ContactID: s.testConv.ContactID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(conv).Error)
|
||||
s.Require().NoError(s.db.Create(&model.ConversationParticipant{AccountID: s.testAccount.ID, ConversationID: conv.ID, UserID: user.ID}).Error)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"user_ids": []uint{user.ID}})
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/"+strconv.FormatUint(uint64(s.testAccount.ID), 10)+"/conversations/"+strconv.FormatUint(uint64(conv.ID), 10)+"/participants", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
}
|
||||
|
||||
func (s *ConversationParticipantHandlerTestSuite) Test_BatchUpdate_InvalidAccountID() {
|
||||
|
||||
@@ -21,14 +21,20 @@ func NewConversationParticipantRepo(db *gorm.DB) *ConversationParticipantRepo {
|
||||
// FindByConversationID retrieves all participants for a conversation.
|
||||
func (r *ConversationParticipantRepo) FindByConversationID(ctx context.Context, conversationID uint) ([]model.ConversationParticipant, error) {
|
||||
var participants []model.ConversationParticipant
|
||||
err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Find(&participants).Error
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("conversation_id = ?", conversationID).
|
||||
Find(&participants).Error
|
||||
return participants, err
|
||||
}
|
||||
|
||||
// FindByConversationAndUserID retrieves a specific participant.
|
||||
func (r *ConversationParticipantRepo) FindByConversationAndUserID(ctx context.Context, conversationID, userID uint) (*model.ConversationParticipant, error) {
|
||||
var participant model.ConversationParticipant
|
||||
err := r.db.WithContext(ctx).Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&participant).Error
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||
First(&participant).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -983,9 +983,15 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Participants nested under conversation
|
||||
participants := conversations.Group("/:conversation_id/participants")
|
||||
{
|
||||
participants.GET("", h.ConversationParticipant.List)
|
||||
participants.GET("/", h.ConversationParticipant.List)
|
||||
participants.POST("", h.ConversationParticipant.Add)
|
||||
participants.POST("/", h.ConversationParticipant.Add)
|
||||
participants.PATCH("", h.ConversationParticipant.BatchUpdate)
|
||||
participants.PATCH("/", h.ConversationParticipant.BatchUpdate)
|
||||
participants.PUT("", h.ConversationParticipant.BatchUpdate)
|
||||
participants.PUT("/", h.ConversationParticipant.BatchUpdate)
|
||||
participants.DELETE("", h.ConversationParticipant.Destroy)
|
||||
participants.PATCH("/:user_id", h.ConversationParticipant.Update)
|
||||
participants.DELETE("/:user_id", h.ConversationParticipant.Remove)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// Reference: Chatwoot app/controllers/api/v1/accounts/conversations/participants_controller.rb
|
||||
// Chatwoot validates inbox access via assignable_agents before allowing participants.
|
||||
type ConversationParticipantService struct {
|
||||
repo *repository.ConversationParticipantRepo
|
||||
conversationRepo *repository.ConversationRepo
|
||||
repo *repository.ConversationParticipantRepo
|
||||
conversationRepo *repository.ConversationRepo
|
||||
assignableAgentSvc *AssignableAgentService
|
||||
}
|
||||
|
||||
@@ -40,36 +40,58 @@ func (s *ConversationParticipantService) List(ctx context.Context, accountID, co
|
||||
// Add adds a participant to a conversation.
|
||||
// Chatwoot: find_or_create_by(user_id), validates inbox access via assignable_agents
|
||||
func (s *ConversationParticipantService) Add(ctx context.Context, accountID, conversationID, userID uint, role string) (*model.ConversationParticipant, error) {
|
||||
participants, err := s.AddMany(ctx, accountID, conversationID, []uint{userID}, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(participants) > 0 {
|
||||
return &participants[0], nil
|
||||
}
|
||||
return s.repo.FindByConversationAndUserID(ctx, conversationID, userID)
|
||||
}
|
||||
|
||||
// AddMany adds the users missing from the participant set and returns only the newly added rows.
|
||||
// Chatwoot create action: participants_to_be_added_ids.map { find_or_create_by(user_id:) }.
|
||||
func (s *ConversationParticipantService) AddMany(ctx context.Context, accountID, conversationID uint, userIDs []uint, role string) ([]model.ConversationParticipant, error) {
|
||||
conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Chatwoot: validates :ensure_inbox_access — user must be in assignable_agents
|
||||
if s.assignableAgentSvc != nil {
|
||||
if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Chatwoot: before_validation :ensure_account_id — auto-sets account_id from conversation
|
||||
// find_or_create_by pattern
|
||||
existing, err := s.repo.FindByConversationAndUserID(ctx, conversationID, userID)
|
||||
if err == nil && existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
participant := &model.ConversationParticipant{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
}
|
||||
if err := s.repo.Create(ctx, participant); err != nil {
|
||||
applogger.L().Errorf("ConversationParticipantService.Add: %v", err)
|
||||
current, err := s.repo.FindByConversationID(ctx, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return participant, nil
|
||||
currentIDs := participantIDSet(current)
|
||||
|
||||
created := make([]model.ConversationParticipant, 0, len(userIDs))
|
||||
for _, userID := range uniqueParticipantUserIDs(userIDs) {
|
||||
if currentIDs[userID] {
|
||||
continue
|
||||
}
|
||||
if s.assignableAgentSvc != nil {
|
||||
if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
participant := &model.ConversationParticipant{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
}
|
||||
if err := s.repo.Create(ctx, participant); err != nil {
|
||||
applogger.L().Errorf("ConversationParticipantService.AddMany: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
reloaded, err := s.repo.FindByConversationAndUserID(ctx, conversationID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created = append(created, *reloaded)
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// Update updates a participant's role in a conversation.
|
||||
@@ -93,9 +115,21 @@ func (s *ConversationParticipantService) Remove(ctx context.Context, accountID,
|
||||
return s.repo.DeleteByConversationAndUserID(ctx, conversationID, userID)
|
||||
}
|
||||
|
||||
// RemoveMany removes multiple participants. Missing users are ignored, matching Chatwoot destroy.
|
||||
func (s *ConversationParticipantService) RemoveMany(ctx context.Context, accountID, conversationID uint, userIDs []uint) error {
|
||||
if _, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.BatchDelete(ctx, conversationID, uniqueParticipantUserIDs(userIDs))
|
||||
}
|
||||
|
||||
// BatchUpdate adds and removes participants in bulk.
|
||||
// Chatwoot: update action — adds via find_or_create_by, removes via destroy
|
||||
func (s *ConversationParticipantService) BatchUpdate(ctx context.Context, accountID, conversationID uint, addUserIDs []uint, removeUserIDs []uint, role string) ([]model.ConversationParticipant, error) {
|
||||
if len(removeUserIDs) == 0 {
|
||||
return s.Replace(ctx, accountID, conversationID, addUserIDs, role)
|
||||
}
|
||||
|
||||
conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -133,6 +167,84 @@ func (s *ConversationParticipantService) BatchUpdate(ctx context.Context, accoun
|
||||
return s.repo.FindByConversationID(ctx, conversationID)
|
||||
}
|
||||
|
||||
// Replace syncs participants to the supplied final user ID set.
|
||||
// Chatwoot update action: add ids not currently present and remove ids absent from params[:user_ids].
|
||||
func (s *ConversationParticipantService) Replace(ctx context.Context, accountID, conversationID uint, userIDs []uint, role string) ([]model.ConversationParticipant, error) {
|
||||
conversation, err := s.conversationRepo.FindByAccountAndID(ctx, accountID, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
current, err := s.repo.FindByConversationID(ctx, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentIDs := participantIDSet(current)
|
||||
targetIDs := participantIDSetFromIDs(uniqueParticipantUserIDs(userIDs))
|
||||
|
||||
for userID := range targetIDs {
|
||||
if currentIDs[userID] {
|
||||
continue
|
||||
}
|
||||
if s.assignableAgentSvc != nil {
|
||||
if err := s.validateInboxAccess(ctx, accountID, conversation.InboxID, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
participant := &model.ConversationParticipant{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversationID,
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
}
|
||||
if err := s.repo.Create(ctx, participant); err != nil {
|
||||
applogger.L().Errorf("Replace participants add user %d: %v", userID, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
removeIDs := make([]uint, 0)
|
||||
for userID := range currentIDs {
|
||||
if !targetIDs[userID] {
|
||||
removeIDs = append(removeIDs, userID)
|
||||
}
|
||||
}
|
||||
if err := s.repo.BatchDelete(ctx, conversationID, removeIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.FindByConversationID(ctx, conversationID)
|
||||
}
|
||||
|
||||
func uniqueParticipantUserIDs(ids []uint) []uint {
|
||||
seen := make(map[uint]bool, len(ids))
|
||||
unique := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
unique = append(unique, id)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func participantIDSet(participants []model.ConversationParticipant) map[uint]bool {
|
||||
ids := make(map[uint]bool, len(participants))
|
||||
for _, participant := range participants {
|
||||
ids[participant.UserID] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func participantIDSetFromIDs(userIDs []uint) map[uint]bool {
|
||||
ids := make(map[uint]bool, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
ids[userID] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// validateInboxAccess checks that a user has access to the conversation's inbox.
|
||||
// Chatwoot: errors.add(:user, 'must have inbox access') if conversation.inbox.assignable_agents.exclude?(user)
|
||||
func (s *ConversationParticipantService) validateInboxAccess(ctx context.Context, accountID, inboxID, userID uint) error {
|
||||
|
||||
Reference in New Issue
Block a user