feat(assignment-policies): align chatwoot payloads
This commit is contained in:
@@ -44,17 +44,17 @@ Hermes task landing checklist:
|
||||
| Automation rule CRUD, listener triggers, condition/action execution, external webhook/email actions | B9, P5.4 | Review. Rule payloads, trigger coverage, execution logs, retryable external actions, durable team email, and enterprise `add_sla` action are tracked. | Reopen from fresh reference evidence for unsupported action params, trigger events, or failed B12 automation smoke. |
|
||||
| Macro CRUD and execute side effects | B9, P5.5 | Review. Macro serializers, permissions, action params, and display-ID conversation execution are tracked. | Reopen from macro frontend smoke failures or new reference action semantics. |
|
||||
| CSAT account reports, public submit/update, resolved-conversation sends, downloads, channel templates | B8, P5.6 | Review. CSAT response/report payloads, public lock behavior, resolve-triggered survey send, CSV download, and queued channel templates are tracked. | Reopen from CSAT live smoke failures, provider-template status drift, or new reference survey settings. |
|
||||
| Enterprise feature set excluding SSO/SAML/LDAP/OIDC | B7-B11, B12 | Review/Doing. SLA, audit, custom roles, limits/capacity, Captain/Copilot, CSAT, automation/macros, and assignment policies have explicit tracker rows. | Reopen or split only from reused frontend failures or current `reference/chatwoot` evidence. |
|
||||
| Enterprise feature set excluding SSO/SAML/LDAP/OIDC | B7-B11, B12 | Review. SLA, audit, custom roles, limits/capacity, Captain/Copilot, CSAT, automation/macros, and assignment policies have explicit tracker rows and current implementation checkpoints. | Reopen or split only from reused frontend failures or current `reference/chatwoot` evidence. |
|
||||
| Reused Chatwoot frontend verification | B12, Phase 7 | Review. API, browser, enterprise API, and enterprise browser smoke harnesses are checked in; live execution remains environment-dependent. | Run when PostgreSQL, Redis, Meilisearch, GoChat, Vite, and Chrome are available; every failure becomes a named slice. |
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(channels): expose oauth callbacks`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): expose oauth callbacks`.
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(assignment-policies): align chatwoot payloads`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(assignment-policies): align chatwoot payloads`.
|
||||
- Latest documentation/tooling checkpoint: `docs: land full parity tracking plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`; root integration callback drift is implemented for `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, and `routes.rb:630/634/654`; root channel callback drift is implemented for `Twitter::CallbacksController`, `Google::CallbacksController`, `Microsoft::CallbacksController`, `Instagram::CallbacksController`, `Tiktok::CallbacksController`, and `routes.rb:626/649-652`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, `/twilio/voice/recording_status/:phone`, `/linear/callback`, `/shopify/callback`, `/notion/callback`, `/twitter/callback`, `/google/callback`, `/microsoft/callback`, `/instagram/callback`, and `/tiktok/callback`. Linear, Shopify, and Notion callbacks verify account state, exchange OAuth codes through provider token endpoints, persist Chatwoot-style account integration hooks, and redirect to reused frontend integration settings; Shopify account integration auth now returns the Chatwoot `redirect_url` authorization payload instead of creating credentials directly. Google and Microsoft root callbacks exchange OAuth codes, decode ID-token mailbox identity, create/update email channel inboxes, and redirect to settings or agents. Instagram, TikTok, and Twitter root callbacks create/update channel inboxes, preserve Chatwoot error redirects to new-inbox pages, and route successful new inboxes to agents. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is Phase 6 placeholder burn-down, B12 live smoke finding, or the next route/controller/serializer drift from fresh reference evidence.
|
||||
- Worktree status at this implementation checkpoint: Chatwoot assignment policy drift from `routes.rb:306-313`, account assignment policy controllers, nested policy inbox controllers, inbox assignment policy controllers, Jbuilder views, and the reused frontend `assignmentPolicies` API/store is now implemented. GoChat account `/assignment_policies` list/show/create/update/delete now returns Chatwoot raw arrays/objects and empty `200 OK` deletes, accepts both raw and nested `{ assignment_policy: ... }` bodies, persists Chatwoot assignment policy fields, exposes assigned inbox counts, and aligns inbox policy binding/list/delete payloads. P2/P3 dashboard deep-link route drift, callback route drift, public help-center route/payload drift, CRM/inbox/settings payload drift, enterprise SLA/Audit/CustomRole/AgentCapacity/Captain/Copilot/CSAT/InboxLimit/automation/macro work, and P5 durable job work through P5.13b remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. Next active implementation slice is Phase 6 placeholder burn-down, B12 live smoke finding, or the next route/controller/serializer drift from fresh reference evidence.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 900` after adding Chatwoot root Twitter, Google, Microsoft, Instagram, and TikTok callback routes.
|
||||
- Route dump succeeds with `TOTAL: 906` after adding Chatwoot no-trailing-slash assignment policy routes.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
- Tracked frontend-critical route audit covers 362 Chatwoot routes: 353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits 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.
|
||||
@@ -127,7 +127,7 @@ Open work after the current checkpoint:
|
||||
| Phase 1 | Meilisearch search engine | Review | B6 payload parity, optional live gate, and DB-fallback hardening are implemented; an actual live Meilisearch run is optional and environment-dependent |
|
||||
| Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback |
|
||||
| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based |
|
||||
| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke harnesses exist and optional live runs can expose follow-up slices |
|
||||
| Phase 4 | Enterprise feature completion | Review | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke harnesses exist and optional live runs can expose follow-up slices |
|
||||
| Phase 5 | Background jobs and integrations | Review | P5.1/P5.2/P5.3/P5.4/P5.5/P5.6/P5.7/P5.8/P5.9/P5.10/P5.11/P5.12 durable worker, event dispatch, search indexing, automation delivery, macro, CSAT survey/template, SLA scan, contact export, inbound webhook persistence, outbound/provider delivery status, Captain document sync/crawl/response/embedding/Copilot/conversation responses, conversation maintenance, message status update, account bulk-action cores, P5.13 analytics rollups/timeseries, and B9.3 delayed-action reference closure are in Review; further work should be named drift from smoke/reference evidence |
|
||||
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
|
||||
| Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent |
|
||||
@@ -138,7 +138,7 @@ This table is the shortest authoritative handoff view. If an older lower section
|
||||
|
||||
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 362-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`, 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`, and account integration app/hook routes from `routes.rb:345-348` 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, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, 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 or Phase 6 placeholder burn-down. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 362-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`, and account integration app/hook routes from `routes.rb:345-348` 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, 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 or Phase 6 placeholder burn-down. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. |
|
||||
| 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. |
|
||||
| 3 | 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. |
|
||||
| 4 | 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. |
|
||||
@@ -172,6 +172,7 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
| 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. |
|
||||
| P6 message retry status parity | `internal/service/message_service.go`, `internal/handler/api/v1/message_handler.go`, message service/handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations/messages_controller.rb`, `app/views/api/v1/accounts/conversations/messages/retry.json.jbuilder`, dashboard `api/inbox/message.js` | Account message retry now follows Chatwoot: status resets to `sent`, `content_attributes` is cleared to `{}`, response returns the message serializer, and the existing durable SendReplyJob path is queued when a worker pool is configured. | Review by `feat(messages): align retry status parity`; focused retry service/handler tests, combined handler/service tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P4.9 assignment policies payload/binding parity | `internal/router/router.go`, `internal/handler/api/v1/assignment_policy_handler.go`, `internal/service/assignment_policy_service.go`, `internal/repository/assignment_policy_repo.go`, `internal/model/assignment_policy.go`, assignment policy handler/service/repository/router tests | `reference/chatwoot/config/routes.rb:306-313`, `reference/chatwoot/app/controllers/api/v1/accounts/assignment_policies_controller.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/assignment_policies/inboxes_controller.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/inboxes/assignment_policies_controller.rb`, assignment policy Jbuilder views, dashboard `api/assignmentPolicies.js`, `store/modules/assignmentPolicies.js` | Account assignment policies now use the Chatwoot model fields and response contracts: list returns a raw array, show/create/update return raw policy objects, delete returns empty `200 OK`, raw and `{ assignment_policy: ... }` request bodies are accepted, inbox assignment policy GET/POST/DELETE returns raw policy/empty `200 OK`, and policy inbox lists return `{ inboxes: [...] }`. | Review by `feat(assignment-policies): align chatwoot payloads`; focused AssignmentPolicy handler tests, service/repository/router/app/migrate/route-parity packages, escalated full `go test ./...`, route dump `TOTAL: 906`, tracked route parity `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`, and `git diff --check` passed. |
|
||||
| Phase 2/3 drift audit | `cmd/route_parity`, `docs/parity/*`, serializer tests | `reference/chatwoot/config/routes.rb`, controller Jbuilder views, reused frontend API clients | Convert any smoke/reference mismatch into a named route, controller, or serializer slice. Static route extraction remains acceptable until Ruby/Bundler is available. | Regenerated route parity shows 0 missing tracked frontend routes; new serializer fixtures cover the drift. |
|
||||
| Phase 6 placeholder burn-down | Account/contact/conversation/message/inbox handlers and services | Matching reference controllers/Jbuilder views plus reused frontend screens | Re-run placeholder audit and assign every frontend-reachable stub to a specific owner. Burn down the highest-impact stubs before broad feature expansion. | `rg` placeholder audit is recorded here; no reused-frontend critical path is ownerless. |
|
||||
| B12 live smoke | `scripts/parity_frontend_smoke.sh`, `docs/parity/frontend_smoke_report.md`, `cmd/gochat` | Reused `reference/chatwoot` Vite frontend, dashboard route/API clients | Run optional live API/browser/enterprise smoke with PostgreSQL, Redis, Meilisearch, GoChat, Vite, and Chrome. Convert failures into named rows above. | Smoke report records command, environment, pass/fail, artifacts, and linked follow-up owners. |
|
||||
@@ -207,6 +208,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(assignment-policies): align chatwoot payloads` | Advances P4.9/Phase 2/3 assignment policy parity by matching Chatwoot `AssignmentPoliciesController`, `AssignmentPolicies::InboxesController`, `Inboxes::AssignmentPoliciesController`, Jbuilder views, frontend `assignmentPolicies` API/store usage, and `routes.rb:306-313`. GoChat now registers no-trailing-slash Chatwoot assignment policy routes, persists Chatwoot assignment policy fields, returns raw array/object payloads, accepts raw and nested request bodies, exposes assigned inbox counts, replaces inbox policy associations, lists policy inboxes as `{ inboxes: [...] }`, and returns empty `200 OK` deletes. | `go test ./internal/handler/api/v1 -run AssignmentPolicy -count=1`; `go test ./internal/service ./internal/repository ./internal/router ./internal/app ./cmd/migrate ./cmd/route_parity -count=1`; sandboxed full `go test ./...` failed only on local socket restrictions, escalated full `go test ./...` passed; `git diff --check`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 906`; tracked route parity remains `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Assignment policies move to Review for current reference evidence; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed route/controller/serializer drift. |
|
||||
| `feat(channels): expose oauth callbacks` | Advances Phase 2/3 root channel callback parity by matching Chatwoot `Twitter::CallbacksController#show`, `Google::CallbacksController#show`, `Microsoft::CallbacksController#show`, `Instagram::CallbacksController#show`, `Tiktok::CallbacksController#show`, and `routes.rb:626/649-652`. GoChat now registers `/twitter/callback`, `/google/callback`, `/microsoft/callback`, `/instagram/callback`, and `/tiktok/callback`; Google/Microsoft callbacks exchange OAuth codes and create/update email inboxes from ID-token mailbox identity; Instagram/TikTok/Twitter callbacks create/update channel inboxes and preserve Chatwoot success/error redirect paths for reused frontend onboarding/settings flows. | `go test ./internal/router -run 'ChannelCallbacks\|IntegrationCallbacks\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 900`; tracked route parity is `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Root channel callbacks move to Review for current reference evidence; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed route/controller/serializer drift. |
|
||||
| `feat(integrations): expose oauth callbacks` | Advances Phase 2/3 root integration callback parity by matching Chatwoot `Linear::CallbacksController#show`, `Shopify::CallbacksController#show`, `Notion::CallbacksController#show`, account Shopify auth behavior, and `routes.rb:630/634/654`. GoChat now registers `/linear/callback`, `/shopify/callback`, and `/notion/callback`; verifies account state, exchanges OAuth codes through configurable provider token endpoints, persists account integration hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify auth `redirect_url` authorization payloads. | `go test ./internal/router -run 'IntegrationCallbacks\|RegisterRoutes' -count=1`; `go test ./internal/service -run ShopifyIntegrationService -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 895`; tracked route parity is `348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357`. | Linear/Shopify/Notion root callbacks move to Review for current reference evidence; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(twilio): expose root callback` | Advances Phase 2/3 Twilio ingress parity by matching Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and `routes.rb:639-640`. GoChat now registers the exact `/twilio/callback` route, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound callbacks by `To` or `MessagingServiceSid`, normalizes phone numbers to stored E.164 channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and durable persister boundary. | `go test ./internal/handler/webhook -run 'TwilioCallback\|TwilioWebhook\|TwilioDeliveryStatus' -count=1`; `go test ./internal/router -run 'RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 892`; tracked route parity is `345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354`. | Twilio root callback ingress moves to Review for current reference evidence; continue Phase 2/3 callback route audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
@@ -539,7 +541,7 @@ B5.5c AgentCapacityPolicy / InboxCapacityLimit parity contract:
|
||||
| Policy request/response | Enterprise controller accepts `{ agent_capacity_policy: { name, description, exclusion_rules } }`; index/show/create/update return raw policy JSON, destroy is `head :ok`; `assignment_logic` is not a permitted Chatwoot field. | Go response helpers wrap policy payloads in `{ success, data }` or pagination meta, and service creation currently requires `assignment_logic`. | Add raw Chatwoot serializers, accept wrapped and flat bodies where useful, make `assignment_logic` optional/defaulted for storage compatibility, and keep update wrapper-compatible. |
|
||||
| Policy serializer | `_agent_capacity_policy.json.jbuilder` emits `id`, `name`, `description`, `exclusion_rules`, Unix `created_at`, Unix `updated_at`, `account_id`, `assigned_agent_count`, and `inbox_capacity_limits`. | Repository does not preload `InboxCapacityLimits`; response exposes model timestamps and local fields directly; assigned count is not serialized in Chatwoot shape. | Add a serializer boundary that preloads capacity limits, counts assigned users through account users, and emits Unix timestamps plus `inbox_capacity_limits`. |
|
||||
| Inbox capacity limits | Nested `inbox_limits#create/update/destroy` lives under a current account policy, validates current-account inbox, prevents duplicate inbox assignment, requires `conversation_limit >= 0`, create returns raw limit JSON, update also emits `inbox_name`, destroy is `204 No Content`. | Existing `InboxCapacityLimit` model exists, but no nested handler/service/repo flow is wired; old `InboxLimit` model/handler has a different path and shape. | Add repo/service methods for create/update/delete under policy/account scope, duplicate and negative-limit errors, raw create/update serializers, and 204 destroy. |
|
||||
| Enforcement | Capacity policies and inbox limits must feed assignment and auto-assignment limits before the feature can leave Review. | B5.5c API/data parity is done; assignment enforcement is still in P4.4/P4.9. | Wire manual and automatic assignment enforcement in the B7/P4.4 capacity checkpoint. |
|
||||
| Enforcement | Capacity policies and inbox limits must feed assignment and auto-assignment limits before the feature can leave Review. | B5.5c API/data parity and B7 assignment enforcement are done; P4.9 now covers Chatwoot assignment policy payload/binding parity. | Keep manual and automatic assignment enforcement covered by B7 regression tests; reopen only from smoke/reference drift. |
|
||||
| Route artifacts | Adding nested Chatwoot routes changes Go route inventory. | Done by `b197e54`; route dump is `TOTAL: 829` and route parity tracks 274 routes with 0 missing. | Keep regenerated after any later route change. |
|
||||
|
||||
B5.5c implementation checkpoints:
|
||||
@@ -1395,7 +1397,7 @@ This is the ordered queue for the next implementation slices. Do not skip the ro
|
||||
| Q4 | Start serializer parity fixtures for auth/session, conversations/messages, contacts/companies, inboxes, notifications, and search. | Phase 3 | Each area has at least one reference fixture and Go response test. | Doing |
|
||||
| Q5 | Review Meilisearch document shape and endpoint payloads against Chatwoot frontend consumers. | Phase 1 and Phase 3 | Search remains Meilisearch-first and payload mismatches are fixed or tracked. | Review |
|
||||
| Q6 | Implement provider-specific webhook ingress for Chatwoot public webhook paths. | Phase 6 | Generic webhook placeholder no longer masks provider gaps; Telegram, LINE, SMS/Twilio, WhatsApp, Instagram/Twitter/TikTok routes resolve and verify like Chatwoot where supported. | Done |
|
||||
| Q7 | Burn down enterprise gaps in this order: SLA, assignment policy and capacity, CSAT, automation/macros, Audit, CustomRole, InboxLimit, Captain/Copilot. | Phase 4 and Phase 5 | Each feature passes route, persistence, auth, side-effect, response, and test checks. | Doing |
|
||||
| Q7 | Burn down enterprise gaps in this order: SLA, assignment policy and capacity, CSAT, automation/macros, Audit, CustomRole, InboxLimit, Captain/Copilot. | Phase 4 and Phase 5 | Each feature passes route, persistence, auth, side-effect, response, and test checks. | Review |
|
||||
| Q8 | Add frontend smoke harness using the reused Chatwoot frontend once core API flows boot end-to-end. | Phase 7 | Login, inbox list, conversation list/detail, message send, contact view, widget init/message, public CSAT, and key enterprise routes have repeatable API/browser smoke commands. | Review |
|
||||
|
||||
## Current Decision Ledger
|
||||
@@ -1449,7 +1451,7 @@ Remaining slice landing plan:
|
||||
| Slice | First implementation checkpoint | Follow-up checkpoints | Done classification rule |
|
||||
| --- | --- | --- | --- |
|
||||
| B6 | Compare current Meilisearch document fields and global/entity search payloads against Chatwoot frontend consumers. | Add optional live Meilisearch integration gate, tighten account-scoped filters, and document DB fallback as development-only. | Done only after mocked tests and live-shape review prove search payloads work without DB-only assumptions. |
|
||||
| B7 | Done: assignment capacity enforcement, SLA policy CRUD payload parity, applied-SLA conversation persistence, idempotent SLA breach lifecycle, applied-SLA report/list payload parity, and SLA miss notification fan-out. | Capacity-aware assignment policy binding review and durable SLA job scheduling remain broader Phase 5/P4.9 follow-ups, not hidden SLA gaps. | Review after `d23f3f0`; move to Done only after assignment policy binding review is either implemented or split out of B7. |
|
||||
| B7 | Done: assignment capacity enforcement, assignment policy CRUD/inbox binding payload parity, SLA policy CRUD payload parity, applied-SLA conversation persistence, idempotent SLA breach lifecycle, applied-SLA report/list payload parity, and SLA miss notification fan-out. | Durable SLA job scheduling remains Phase 5; future assignment policy work should come only from B12 smoke or fresh reference evidence. | Review after `feat(assignment-policies): align chatwoot payloads`; move to Done only after B12 confirms enterprise assignment/SLA screens or any smoke gaps are split into owned follow-ups. |
|
||||
| B8 | Done: account-side CSAT list/metrics/review-note payload parity, public submit/update idempotency, resolve-triggered generic survey message send, and CSV download filters/columns. | Channel-specific WhatsApp/Twilio template send hooks and durable queueing are tracked under Phase 5. | Review after `b36cf07`; move to Done only after channel-specific send hooks are either implemented or formally split as external integration scope. |
|
||||
| B9 | Done: B9.1 automation rule CRUD/listener/log/external-action parity, B9.2 macro frontend CRUD/execute side effects, and B9.3 delayed-action reference closure plus team email/SLA action parity. | Deeper macro attachment/file parity remains B9.4; future delayed automation action work requires new reference or smoke evidence. | Review after `feat(automation): close delayed action parity`; move to Done only after B9.4 is implemented or formally split out. |
|
||||
| B10 | Done: audit list payload, audit writer boundary for representative mutating core resources, CustomRole permission-key parity, AccountUser permission resolution, admin gates, delete nullification, and account-level InboxLimit enforcement in inbox/channel creation paths. | Frontend smoke coverage for enterprise settings remains B12, not a hidden B10 blocker. | Review after B10.4; move to Done only after reused frontend smoke confirms audit/custom-role/limit settings flows or any smoke gaps are split into owned follow-ups. |
|
||||
@@ -1620,7 +1622,7 @@ New route groups tracked in this slice:
|
||||
| Area | Chatwoot source | Current Go surface observed | Expected action |
|
||||
| --- | --- | --- | --- |
|
||||
| Captain/Copilot | `reference/chatwoot/config/routes.rb:62-89` | Many `/api/v1/accounts/:account_id/captain/...` routes already existed. | Added tracked critical set; route parity is exact. |
|
||||
| Assignment policies | `reference/chatwoot/config/routes.rb:306-313` | `/assignment_policies` and local `/assignment_policies_v2` routes exist. | Added tracked route set and aligned singular inbox assignment delete route. |
|
||||
| Assignment policies | `reference/chatwoot/config/routes.rb:306-313` | Chatwoot `/assignment_policies` and inbox assignment policy routes now exist with no-trailing-slash aliases; local `/assignment_policies_v2` remains for compatibility. | Tracked route set is 0 missing and payload/binding behavior is in Review by `feat(assignment-policies): align chatwoot payloads`. |
|
||||
| Widget API | `reference/chatwoot/config/routes.rb:442-472` | GoChat exposed many routes only under `/widget`. | Added `/api/v1/widget` aliases; behavior parity remains tracked under Phase 3/6. |
|
||||
| Public API | `reference/chatwoot/config/routes.rb:569-585` | GoChat had public CSAT conversation routes. | Added public inbox/contact/conversation/message route surface and Chatwoot public CSAT path. |
|
||||
| Reports v2 | `reference/chatwoot/config/routes.rb:479-513` | GoChat had report-style routes under `/api/v1/accounts`. | Added exact v2 `/api/v2/accounts/:account_id/...` report paths. |
|
||||
@@ -1716,7 +1718,7 @@ Included checklist:
|
||||
- [x] Audit logs and admin-readable audit endpoints.
|
||||
- [x] Custom roles and permission checks.
|
||||
- [x] Agent capacity and assignment limits.
|
||||
- [ ] Assignment policies and auto-assignment compatibility.
|
||||
- [x] Assignment policies and auto-assignment compatibility.
|
||||
- [x] Captain/Copilot assistant, custom tools, scenarios, documents, responses, inbox bindings, preferences, Copilot threads/messages, tasks, and stream disabled-state compatibility. Provider-specific LLM depth remains feature-gated and B12/Phase 5 can name any deeper frontend or dispatch gaps.
|
||||
- [x] CSAT survey response flow, metrics, filters, and review notes.
|
||||
- [x] Inbox limits and account/inbox usage enforcement.
|
||||
@@ -1739,14 +1741,14 @@ Enterprise tracking table:
|
||||
| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, Captain preferences through B11.3a, Captain playground through B11.3b, document sync gates through B11.3c, task/tool-call payload persistence through B11.3d, stream/realtime fallback payloads through B11.3e, and durable P5.11 Captain/Copilot response jobs are aligned. | Review |
|
||||
| P4.7 | CSAT | `internal/csat/*`, `internal/automation/csat_survey_*`, `internal/handler/api/v1/csat_*`, `internal/service/csat_metrics_service.go` | Account-side list/metrics/review-note payloads, public update depth, resolve-triggered generic survey send, download CSV, and channel-specific template job hooks are aligned. | Review |
|
||||
| P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD/listener/log/external-action delivery, delayed-action reference closure, team-email and SLA actions, and macro CRUD/display-ID execution parity are implemented; deeper macro attachment/file parity remains B9.4. | Review |
|
||||
| P4.9 | Assignment policies | `internal/autoassignment/*`, `internal/automation/agent_bot_rule_listener.go` | Match Chatwoot assignment policy behavior and availability/capacity rules. | Doing |
|
||||
| P4.9 | Assignment policies | `internal/model/assignment_policy.go`, `internal/repository/assignment_policy_repo.go`, `internal/service/assignment_policy_service.go`, `internal/handler/api/v1/assignment_policy_handler.go`, `internal/router/router.go`, plus legacy `internal/autoassignment/*` | Chatwoot assignment policy CRUD, raw serializers, request wrappers, inbox policy binding/list/delete, assigned inbox counts, and no-trailing-slash routes are implemented; keep in Review until B12 proves the reused enterprise settings flow or opens named drift. | Review |
|
||||
|
||||
Enterprise work package breakdown:
|
||||
|
||||
| Package | Subtasks | Must verify | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| SLA | Policy CRUD parity, conversation SLA assignment, first-response/next-response/resolution timers, business-hours handling, breach events, notifications. | Policy payloads, applied-SLA attach, seconds-based thresholds, core state transitions, breach idempotency, report payloads, reference `only_during_business_hours` timing, and notification fan-out are covered. Durable scheduling remains Phase 5. | Review |
|
||||
| Assignment and capacity | Assignment policy CRUD, inbox policy binding, round-robin/availability/capacity selection, manual assignment limits, fallback behavior. | Manual and automatic assignment respect policy, availability, team/inbox membership, and limits. | Review |
|
||||
| Assignment and capacity | Assignment policy CRUD, inbox policy binding, round-robin/availability/capacity selection, manual assignment limits, fallback behavior. | Assignment policy API and inbox bindings match Chatwoot payloads by `feat(assignment-policies): align chatwoot payloads`; manual and automatic assignment respect availability, team/inbox membership, and limits by `a98dc2c`. | Review |
|
||||
| CSAT account/public side | Survey send on resolve, response list, metrics, filters, downloads, review notes, resend/idempotency, public lock. | Account report list/metrics/review-note payload fixtures are covered by `f441680`; public object-shaped submit/update, one-response-per-message idempotency, and resolve-triggered generic survey message send are covered by `ef3a909`; download CSV is covered by `b36cf07`. | Review |
|
||||
| Automation rules | CRUD payload parity, condition/action parity, event listener coverage, delayed actions, execution logs, no-stop-on-match behavior, webhook, transcript, team-email, and SLA actions. | B9.1a CRUD, B9.1b listener coverage, B9.1c execution outcome logging, B9.1d retryable webhook/email transcript delivery, and B9.3 current-reference delayed-action closure plus team-email/SLA action coverage are covered. | Review |
|
||||
| Macros | Macro CRUD, availability by account/user, execute side effects, validation, audit/log output. | CRUD payloads, visibility/authorization, display-ID execution, labels/status/messages/private notes, and empty execute/delete responses are covered by `feat(macros): align chatwoot macro payloads`; deeper attachment files and durable queue remain follow-ups. | Review |
|
||||
@@ -1763,7 +1765,7 @@ Enterprise acceptance gates:
|
||||
| Audit | Mutating account resources emit audit records with actor, IP, request UUID, auditable type/id, associated account, and changes; admin list pagination matches Chatwoot. | `docs/requirements/M11-enterprise-features.md` |
|
||||
| CustomRole | Permission keys match Chatwoot; `AccountUser` permission resolution honors custom roles; deleting a role nullifies users; admin-only policy is enforced. | `docs/requirements/M1-accounts-and-users.md`, `docs/requirements/M11-enterprise-features.md` |
|
||||
| AgentCapacity and InboxLimit | Assignment and auto-assignment respect per-inbox conversation limits; account/inbox limits are enforced in create paths and surfaced to frontend. | `docs/requirements/M1-accounts-and-users.md`, `docs/requirements/M5-team-and-assignment.md` |
|
||||
| Assignment policies | V2 route and payload shape match Chatwoot; policy selection is applied during manual and automatic assignment; availability and capacity interact correctly. | `reference/chatwoot/config/routes.rb:306-313` |
|
||||
| Assignment policies | Chatwoot assignment policy route and payload shapes match the reused frontend; inbox policy binding/list/delete is implemented; policy selection, availability, and capacity interact correctly or are reopened from B12/reference evidence. | `reference/chatwoot/config/routes.rb:306-313` |
|
||||
| CSAT | Resolve event sends survey once; widget/WhatsApp/Twilio paths are modeled where supported; public submit/update honors lock window; metrics and download filters match Chatwoot. | `.hermes/plans/2026-05-24-automation-macro-csat.md`, `docs/requirements/M7-reporting-and-csat.md` |
|
||||
| Automation and macros | Conditions/actions match Chatwoot; macro execute side effects are real; execution logs and async webhook/email transcript actions are durable and retryable. | `.hermes/plans/2026-05-24-automation-macro-csat.md`, `docs/requirements/M6-automation-and-templates.md` |
|
||||
| Captain/Copilot | Assistant, tools, documents, responses, scenarios, inbox bindings, copilot threads/messages, tasks, streaming, and LLM/tool-call behavior are implemented or explicitly feature-gated. | `docs/requirements/M10-captain-and-copilot.md` |
|
||||
@@ -2359,3 +2361,4 @@ Verification milestone gates:
|
||||
- 2026-06-06: Twilio root callback checkpoint prepared as `feat(twilio): expose root callback`; audited Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and routes `639-640`. GoChat now exposes `/twilio/callback`, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound events by `To` or `MessagingServiceSid`, normalizes phone numbers to the stored Twilio channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and persister. Focused webhook/router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: Root integration callback checkpoint prepared as `feat(integrations): expose oauth callbacks`; audited Chatwoot `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, Shopify account auth, and routes `630/634/654`. GoChat now exposes `/linear/callback`, `/shopify/callback`, and `/notion/callback`, verifies state, exchanges OAuth codes via configurable token endpoints, persists account hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify account auth `redirect_url`. Focused router/service/route-parity tests and route dump/parity regeneration passed; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke.
|
||||
- 2026-06-06: Root channel callback checkpoint prepared as `feat(channels): expose oauth callbacks`; audited Chatwoot `Twitter::CallbacksController`, `Google::CallbacksController`, `Microsoft::CallbacksController`, `Instagram::CallbacksController`, `Tiktok::CallbacksController`, shared OAuth callback behavior, and routes `626/649-652`. GoChat now exposes `/twitter/callback`, `/google/callback`, `/microsoft/callback`, `/instagram/callback`, and `/tiktok/callback`; callbacks exchange OAuth tokens through configurable provider endpoints, create/update email/social channel inboxes, and redirect successful or failed flows to Chatwoot dashboard inbox agents/settings/new-inbox paths. Focused channel callback/router/route-parity tests and route dump/parity regeneration passed; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed route/controller/serializer drift.
|
||||
- 2026-06-06: Assignment policy payload checkpoint prepared as `feat(assignment-policies): align chatwoot payloads`; audited Chatwoot account assignment policy controllers, nested assignment-policy inbox controllers, inbox assignment policy controllers, Jbuilder views, reused dashboard assignment policy API/store, and routes `306-313`. GoChat now returns raw Chatwoot assignment policy arrays/objects, accepts raw and nested request bodies, persists Chatwoot policy fields, exposes assigned inbox counts, replaces inbox policy associations, lists policy inboxes as `{ inboxes: [...] }`, and returns empty `200 OK` deletes. Focused AssignmentPolicy tests, service/repository/router/app/migrate/route-parity packages, escalated full `go test ./...`, route dump `TOTAL: 906`, route parity `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`, and `git diff --check` passed; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed drift.
|
||||
|
||||
@@ -52,6 +52,7 @@ DELETE /api/v1/accounts/:account_id/google_channels/:google_id
|
||||
DELETE /api/v1/accounts/:account_id/hooks/:id
|
||||
DELETE /api/v1/accounts/:account_id/inbox_members/
|
||||
DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id
|
||||
DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy
|
||||
DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/
|
||||
DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/:policy_id
|
||||
DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy_v2/
|
||||
@@ -138,6 +139,7 @@ GET /api/v1/accounts/:account_id/applied_slas
|
||||
GET /api/v1/accounts/:account_id/applied_slas/download
|
||||
GET /api/v1/accounts/:account_id/applied_slas/metrics
|
||||
GET /api/v1/accounts/:account_id/assignable_agents
|
||||
GET /api/v1/accounts/:account_id/assignment_policies
|
||||
GET /api/v1/accounts/:account_id/assignment_policies/
|
||||
GET /api/v1/accounts/:account_id/assignment_policies/:policy_id
|
||||
GET /api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes
|
||||
@@ -249,6 +251,7 @@ GET /api/v1/accounts/:account_id/inboxes/
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id/agent_bot
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignable_agents
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy_v2/
|
||||
GET /api/v1/accounts/:account_id/inboxes/:inbox_id/campaigns
|
||||
@@ -485,6 +488,7 @@ PATCH /api/v1/accounts/:account_id/agent_bots/:agent_bot_id
|
||||
PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id
|
||||
PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id
|
||||
PATCH /api/v1/accounts/:account_id/agents/:agent_id
|
||||
PATCH /api/v1/accounts/:account_id/assignment_policies/:policy_id
|
||||
PATCH /api/v1/accounts/:account_id/campaigns/:campaign_id
|
||||
PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id
|
||||
PATCH /api/v1/accounts/:account_id/companies/:company_id
|
||||
@@ -552,6 +556,7 @@ POST /api/v1/accounts/:account_id/agents
|
||||
POST /api/v1/accounts/:account_id/agents/bulk_assign
|
||||
POST /api/v1/accounts/:account_id/agents/bulk_create
|
||||
POST /api/v1/accounts/:account_id/agents/bulk_unassign
|
||||
POST /api/v1/accounts/:account_id/assignment_policies
|
||||
POST /api/v1/accounts/:account_id/assignment_policies/
|
||||
POST /api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes
|
||||
POST /api/v1/accounts/:account_id/assignment_policies_v2
|
||||
@@ -655,6 +660,7 @@ POST /api/v1/accounts/:account_id/hooks/
|
||||
POST /api/v1/accounts/:account_id/hooks/:id/process_event
|
||||
POST /api/v1/accounts/:account_id/inbox_members/
|
||||
POST /api/v1/accounts/:account_id/inboxes/
|
||||
POST /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy
|
||||
POST /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy/
|
||||
POST /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy_v2/
|
||||
POST /api/v1/accounts/:account_id/inboxes/:inbox_id/csat_template
|
||||
@@ -898,4 +904,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: 900
|
||||
TOTAL: 906
|
||||
|
||||
+4
-1
@@ -209,7 +209,10 @@ func autoMigrate(db *gorm.DB) error {
|
||||
&model.SlaPolicyInbox{},
|
||||
&model.AppliedSLA{},
|
||||
&model.SlaEvent{},
|
||||
// M11: Assignment Policy V2 model — enhanced with Type field + inbox join
|
||||
// Chatwoot assignment policies + inbox association.
|
||||
&model.AssignmentPolicy{},
|
||||
&model.InboxAssignmentPolicy{},
|
||||
// Legacy enhanced assignment policy aliases kept for local compatibility.
|
||||
&model.AssignmentPolicyV2{},
|
||||
&model.AssignmentPolicyInbox{},
|
||||
// Enterprise: Audit log model (read-only log of account actions)
|
||||
|
||||
@@ -17,6 +17,25 @@ type AssignmentPolicyHandler struct {
|
||||
svc *service.AssignmentPolicyService
|
||||
}
|
||||
|
||||
// ListAccountPolicies returns all assignment policies for the account.
|
||||
// GET /api/v1/accounts/:id/assignment_policies
|
||||
func (h *AssignmentPolicyHandler) ListAccountPolicies(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
|
||||
policies, svcErr := h.svc.ListAccountPolicies(c.Request.Context(), accountID)
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("List assignment policies for account %d: %v", accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, policies)
|
||||
}
|
||||
|
||||
// NewAssignmentPolicyHandler creates a new AssignmentPolicy handler.
|
||||
func NewAssignmentPolicyHandler(svc *service.AssignmentPolicyService) *AssignmentPolicyHandler {
|
||||
return &AssignmentPolicyHandler{svc: svc}
|
||||
@@ -31,14 +50,29 @@ func (h *AssignmentPolicyHandler) GetAccountPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
policy, svcErr := h.svc.GetAccountPolicy(c.Request.Context(), accountID)
|
||||
var policyID uint
|
||||
if raw := c.Param("policy_id"); raw != "" {
|
||||
id, err := strconv.ParseUint(raw, 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid policy ID")
|
||||
return
|
||||
}
|
||||
policyID = uint(id)
|
||||
}
|
||||
|
||||
policy, svcErr := h.svc.GetAccountPolicy(c.Request.Context(), accountID, policyID)
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("Get assignment policy for account %d: %v", accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, policy)
|
||||
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// CreateAccountPolicy creates the account-level assignment policy.
|
||||
@@ -50,20 +84,25 @@ func (h *AssignmentPolicyHandler) CreateAccountPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreatePolicyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req, err := bindAssignmentPolicyCreate(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
policy, svcErr := h.svc.CreateAccountPolicy(c.Request.Context(), accountID, req)
|
||||
policy, svcErr := h.svc.CreateAccountPolicy(c.Request.Context(), accountID, *req)
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("Create assignment policy for account %d: %v", accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, policy)
|
||||
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// UpdateAccountPolicy updates the account-level assignment policy.
|
||||
@@ -81,20 +120,25 @@ func (h *AssignmentPolicyHandler) UpdateAccountPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.UpdatePolicyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req, err := bindAssignmentPolicyUpdate(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
policy, svcErr := h.svc.UpdateAccountPolicy(c.Request.Context(), uint(id), accountID, req)
|
||||
policy, svcErr := h.svc.UpdateAccountPolicy(c.Request.Context(), uint(id), accountID, *req)
|
||||
if svcErr != nil {
|
||||
applogger.L().Errorf("Update assignment policy %d for account %d: %v", id, accountID, svcErr)
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, policy)
|
||||
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// DeleteAccountPolicy deletes the account-level assignment policy.
|
||||
@@ -118,7 +162,7 @@ func (h *AssignmentPolicyHandler) DeleteAccountPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetInboxPolicy returns the inbox-level assignment policy override.
|
||||
@@ -143,7 +187,86 @@ func (h *AssignmentPolicyHandler) GetInboxPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, policy)
|
||||
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// ListPolicyInboxes returns Chatwoot `{ inboxes: [...] }` payload for a policy.
|
||||
// GET /api/v1/accounts/:id/assignment_policies/:policy_id/inboxes
|
||||
func (h *AssignmentPolicyHandler) ListPolicyInboxes(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
policyID, err := strconv.ParseUint(c.Param("policy_id"), 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid policy ID")
|
||||
return
|
||||
}
|
||||
|
||||
inboxes, svcErr := h.svc.ListPolicyInboxes(c.Request.Context(), accountID, uint(policyID))
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"inboxes": inboxes})
|
||||
}
|
||||
|
||||
// AddPolicyInbox associates an inbox with a policy through the nested policy route.
|
||||
// POST /api/v1/accounts/:id/assignment_policies/:policy_id/inboxes
|
||||
func (h *AssignmentPolicyHandler) AddPolicyInbox(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
policyID, err := strconv.ParseUint(c.Param("policy_id"), 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid policy ID")
|
||||
return
|
||||
}
|
||||
var req service.CreateInboxPolicyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
req.AssignmentPolicyID = uint(policyID)
|
||||
policy, svcErr := h.svc.CreateInboxPolicy(c.Request.Context(), accountID, req)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// RemovePolicyInbox removes the current policy association from an inbox.
|
||||
// DELETE /api/v1/accounts/:id/assignment_policies/:policy_id/inboxes/:inbox_id
|
||||
func (h *AssignmentPolicyHandler) RemovePolicyInbox(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
inboxID, err := strconv.ParseUint(c.Param("inbox_id"), 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid inbox ID")
|
||||
return
|
||||
}
|
||||
if svcErr := h.svc.DeleteInboxPolicy(c.Request.Context(), uint(inboxID), accountID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// CreateInboxPolicy creates an inbox-level assignment policy override.
|
||||
@@ -163,7 +286,7 @@ func (h *AssignmentPolicyHandler) CreateInboxPolicy(c *gin.Context) {
|
||||
|
||||
var req service.CreateInboxPolicyRequest
|
||||
req.InboxID = uint(inboxID) // Set from URL param
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := bindAssignmentPolicyBody(c, &req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -175,7 +298,12 @@ func (h *AssignmentPolicyHandler) CreateInboxPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, policy)
|
||||
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// UpdateInboxPolicy updates an inbox-level assignment policy override.
|
||||
@@ -230,5 +358,52 @@ func (h *AssignmentPolicyHandler) DeleteInboxPolicy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.NoContent(c)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// DeleteCurrentInboxPolicy removes the policy association for the URL inbox.
|
||||
// DELETE /api/v1/accounts/:id/inboxes/:inbox_id/assignment_policy
|
||||
func (h *AssignmentPolicyHandler) DeleteCurrentInboxPolicy(c *gin.Context) {
|
||||
accountID := getAccountID(c)
|
||||
if accountID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
|
||||
return
|
||||
}
|
||||
inboxID, err := strconv.ParseUint(c.Param("inbox_id"), 10, 32)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid inbox ID")
|
||||
return
|
||||
}
|
||||
if svcErr := h.svc.DeleteInboxPolicy(c.Request.Context(), uint(inboxID), accountID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func bindAssignmentPolicyCreate(c *gin.Context) (*service.CreatePolicyRequest, error) {
|
||||
var req service.CreatePolicyRequest
|
||||
if err := bindChatwootPayload(c, "assignment_policy", &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func bindAssignmentPolicyUpdate(c *gin.Context) (*service.UpdatePolicyRequest, error) {
|
||||
var req service.UpdatePolicyRequest
|
||||
if err := bindChatwootPayload(c, "assignment_policy", &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func bindAssignmentPolicyBody(c *gin.Context, req *service.CreateInboxPolicyRequest) error {
|
||||
var body struct {
|
||||
AssignmentPolicyID uint `json:"assignment_policy_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
req.AssignmentPolicyID = body.AssignmentPolicyID
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ func (s *AssignmentPolicyHandlerTestSuite) SetupSuite() {
|
||||
// AutoMigrate all required models
|
||||
err = db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&autoassignment.AssignmentPolicy{},
|
||||
&autoassignment.InboxAssignmentPolicy{},
|
||||
&model.Inbox{},
|
||||
&model.AssignmentPolicy{},
|
||||
&model.InboxAssignmentPolicy{},
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
|
||||
@@ -76,6 +77,14 @@ func (s *AssignmentPolicyHandlerTestSuite) SetupSuite() {
|
||||
rg.POST("/assignment_policy", s.handler.CreateAccountPolicy)
|
||||
rg.PUT("/assignment_policy/:policy_id", s.handler.UpdateAccountPolicy)
|
||||
rg.DELETE("/assignment_policy/:policy_id", s.handler.DeleteAccountPolicy)
|
||||
rg.GET("/assignment_policies", s.handler.ListAccountPolicies)
|
||||
rg.POST("/assignment_policies", s.handler.CreateAccountPolicy)
|
||||
rg.GET("/assignment_policies/:policy_id", s.handler.GetAccountPolicy)
|
||||
rg.PATCH("/assignment_policies/:policy_id", s.handler.UpdateAccountPolicy)
|
||||
rg.GET("/assignment_policies/:policy_id/inboxes", s.handler.ListPolicyInboxes)
|
||||
rg.POST("/inboxes/:inbox_id/assignment_policy", s.handler.CreateInboxPolicy)
|
||||
rg.GET("/inboxes/:inbox_id/assignment_policy", s.handler.GetInboxPolicy)
|
||||
rg.DELETE("/inboxes/:inbox_id/assignment_policy", s.handler.DeleteCurrentInboxPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,12 +115,14 @@ func TestAssignmentPolicyHandlerTestSuite(t *testing.T) {
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() {
|
||||
// Create a policy via the service (since the POST endpoint also tests creation, use DB directly for setup)
|
||||
policy := &autoassignment.AssignmentPolicy{
|
||||
policy := &model.AssignmentPolicy{
|
||||
AccountID: 1,
|
||||
Policy: autoassignment.PolicyRoundRobin,
|
||||
Name: "Default",
|
||||
AssignmentOrder: 0,
|
||||
ConversationPriority: 1,
|
||||
FairDistributionLimit: 5,
|
||||
FairDistributionWindow: 300,
|
||||
Active: true,
|
||||
Enabled: true,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(policy).Error)
|
||||
|
||||
@@ -123,11 +134,9 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() {
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Equal(s.T(), float64(1), data["account_id"])
|
||||
assert.Equal(s.T(), "round_robin", data["policy"])
|
||||
assert.Equal(s.T(), "Default", resp["name"])
|
||||
assert.Equal(s.T(), float64(1), resp["conversation_priority"])
|
||||
assert.Equal(s.T(), true, resp["enabled"])
|
||||
}
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_NotFound() {
|
||||
@@ -163,23 +172,19 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Unauthorized() {
|
||||
// ==================== CreateAccountPolicy ====================
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() {
|
||||
body := `{"policy":"round_robin","fair_distribution_limit":5,"fair_distribution_window":300,"active":true}`
|
||||
body := `{"name":"Default","assignment_order":0,"conversation_priority":1,"fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusCreated, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Equal(s.T(), float64(1), data["account_id"])
|
||||
assert.Equal(s.T(), "round_robin", data["policy"])
|
||||
assert.Equal(s.T(), float64(5), data["fair_distribution_limit"])
|
||||
assert.Equal(s.T(), float64(300), data["fair_distribution_window"])
|
||||
assert.Equal(s.T(), "Default", resp["name"])
|
||||
assert.Equal(s.T(), float64(5), resp["fair_distribution_limit"])
|
||||
assert.Equal(s.T(), float64(300), resp["fair_distribution_window"])
|
||||
}
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_InvalidJSON() {
|
||||
@@ -218,7 +223,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Unauthorized(
|
||||
unauthRouter := gin.New()
|
||||
unauthRouter.POST("/api/v1/accounts/:account_id/assignment_policy", s.handler.CreateAccountPolicy)
|
||||
|
||||
body := `{"policy":"round_robin"}`
|
||||
body := `{"name":"Default"}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/assignment_policy", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -231,16 +236,16 @@ func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Unauthorized(
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() {
|
||||
// Create a policy first via DB
|
||||
policy := &autoassignment.AssignmentPolicy{
|
||||
policy := &model.AssignmentPolicy{
|
||||
AccountID: 1,
|
||||
Policy: autoassignment.PolicyRoundRobin,
|
||||
Name: "Default",
|
||||
FairDistributionLimit: 5,
|
||||
FairDistributionWindow: 300,
|
||||
Active: true,
|
||||
Enabled: true,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(policy).Error)
|
||||
|
||||
body := `{"policy":"longest_waiting","fair_distribution_limit":10}`
|
||||
body := `{"name":"Priority","fair_distribution_limit":10}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -250,15 +255,12 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() {
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.True(s.T(), resp["success"].(bool))
|
||||
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "longest_waiting", data["policy"])
|
||||
assert.Equal(s.T(), float64(10), data["fair_distribution_limit"])
|
||||
assert.Equal(s.T(), "Priority", resp["name"])
|
||||
assert.Equal(s.T(), float64(10), resp["fair_distribution_limit"])
|
||||
}
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_NotFound() {
|
||||
body := `{"policy":"round_robin"}`
|
||||
body := `{"name":"Default"}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/9999", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -281,12 +283,12 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_InvalidJSON()
|
||||
// ==================== DeleteAccountPolicy ====================
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_Success() {
|
||||
policy := &autoassignment.AssignmentPolicy{
|
||||
policy := &model.AssignmentPolicy{
|
||||
AccountID: 1,
|
||||
Policy: autoassignment.PolicyRoundRobin,
|
||||
Name: "Default",
|
||||
FairDistributionLimit: 5,
|
||||
FairDistributionWindow: 300,
|
||||
Active: true,
|
||||
Enabled: true,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(policy).Error)
|
||||
|
||||
@@ -294,7 +296,58 @@ func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_Success() {
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusNoContent, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestPluralAssignmentPolicies_ChatwootPayloads() {
|
||||
policy := &model.AssignmentPolicy{AccountID: 1, Name: "Balanced", Description: "Route VIP first", AssignmentOrder: 1, ConversationPriority: 1, FairDistributionLimit: 20, FairDistributionWindow: 1800, Enabled: true}
|
||||
s.Require().NoError(s.db.Create(policy).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/assignment_policies", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var list []map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &list))
|
||||
s.Require().Len(list, 1)
|
||||
assert.Equal(s.T(), "Balanced", list[0]["name"])
|
||||
assert.Contains(s.T(), list[0], "assigned_inbox_count")
|
||||
}
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestInboxAssignmentPolicy_ChatwootRoutes() {
|
||||
policy := &model.AssignmentPolicy{AccountID: 1, Name: "Default", Enabled: true}
|
||||
s.Require().NoError(s.db.Create(policy).Error)
|
||||
inbox := &model.Inbox{AccountID: 1, Name: "Support", ChannelType: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(inbox).Error)
|
||||
|
||||
body := `{"assignment_policy_id":` + uintToStr(policy.ID) + `}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var show map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &show))
|
||||
assert.Equal(s.T(), "Default", show["name"])
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/api/v1/accounts/1/assignment_policies/"+uintToStr(policy.ID)+"/inboxes", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var inboxes map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &inboxes))
|
||||
s.Require().Len(inboxes["inboxes"], 1)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_NotFound() {
|
||||
@@ -317,4 +370,4 @@ func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_NotFound() {
|
||||
// uintToStr converts a uint to string for URL path construction.
|
||||
func uintToStr(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/autoassignment"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
// AssignmentPolicyRepo implements GORM repository for AssignmentPolicy.
|
||||
@@ -20,13 +20,13 @@ func NewAssignmentPolicyRepo(db *gorm.DB) *AssignmentPolicyRepo {
|
||||
}
|
||||
|
||||
// Create creates a new assignment policy.
|
||||
func (r *AssignmentPolicyRepo) Create(ctx context.Context, policy *autoassignment.AssignmentPolicy) error {
|
||||
func (r *AssignmentPolicyRepo) Create(ctx context.Context, policy *model.AssignmentPolicy) error {
|
||||
return r.db.WithContext(ctx).Create(policy).Error
|
||||
}
|
||||
|
||||
// FindByID retrieves an assignment policy by primary key.
|
||||
func (r *AssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*autoassignment.AssignmentPolicy, error) {
|
||||
var policy autoassignment.AssignmentPolicy
|
||||
func (r *AssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*model.AssignmentPolicy, error) {
|
||||
var policy model.AssignmentPolicy
|
||||
if err := r.db.WithContext(ctx).First(&policy, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -34,15 +34,28 @@ func (r *AssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*autoassi
|
||||
}
|
||||
|
||||
// GetByID retrieves an assignment policy by primary key (alias for FindByID matching service expectations).
|
||||
func (r *AssignmentPolicyRepo) GetByID(ctx context.Context, id uint) (*autoassignment.AssignmentPolicy, error) {
|
||||
func (r *AssignmentPolicyRepo) GetByID(ctx context.Context, id uint) (*model.AssignmentPolicy, error) {
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// FindByAccount retrieves the active assignment policy for an account.
|
||||
func (r *AssignmentPolicyRepo) FindByAccount(ctx context.Context, accountID uint) (*autoassignment.AssignmentPolicy, error) {
|
||||
var policy autoassignment.AssignmentPolicy
|
||||
// FindByAccount retrieves all assignment policies for an account.
|
||||
func (r *AssignmentPolicyRepo) FindByAccount(ctx context.Context, accountID uint) ([]model.AssignmentPolicy, error) {
|
||||
var policies []model.AssignmentPolicy
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("account_id = ? AND active = ?", accountID, true).
|
||||
Where("account_id = ?", accountID).
|
||||
Order("id ASC").
|
||||
Find(&policies).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policies, nil
|
||||
|
||||
}
|
||||
|
||||
// FindByAccountAndID retrieves an account-scoped assignment policy.
|
||||
func (r *AssignmentPolicyRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.AssignmentPolicy, error) {
|
||||
var policy model.AssignmentPolicy
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("account_id = ? AND id = ?", accountID, id).
|
||||
First(&policy).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,13 +63,24 @@ func (r *AssignmentPolicyRepo) FindByAccount(ctx context.Context, accountID uint
|
||||
}
|
||||
|
||||
// Update updates an assignment policy.
|
||||
func (r *AssignmentPolicyRepo) Update(ctx context.Context, policy *autoassignment.AssignmentPolicy) error {
|
||||
func (r *AssignmentPolicyRepo) Update(ctx context.Context, policy *model.AssignmentPolicy) error {
|
||||
return r.db.WithContext(ctx).Save(policy).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes an assignment policy.
|
||||
func (r *AssignmentPolicyRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&autoassignment.AssignmentPolicy{}, id).Error
|
||||
return r.db.WithContext(ctx).Delete(&model.AssignmentPolicy{}, id).Error
|
||||
|
||||
}
|
||||
|
||||
// CountAssignedInboxes returns how many inboxes use an assignment policy.
|
||||
func (r *AssignmentPolicyRepo) CountAssignedInboxes(ctx context.Context, policyID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.InboxAssignmentPolicy{}).
|
||||
Where("assignment_policy_id = ?", policyID).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// InboxAssignmentPolicyRepo implements GORM repository for InboxAssignmentPolicy.
|
||||
@@ -71,13 +95,13 @@ func NewInboxAssignmentPolicyRepo(db *gorm.DB) *InboxAssignmentPolicyRepo {
|
||||
}
|
||||
|
||||
// Create creates a new inbox assignment policy.
|
||||
func (r *InboxAssignmentPolicyRepo) Create(ctx context.Context, policy *autoassignment.InboxAssignmentPolicy) error {
|
||||
func (r *InboxAssignmentPolicyRepo) Create(ctx context.Context, policy *model.InboxAssignmentPolicy) error {
|
||||
return r.db.WithContext(ctx).Create(policy).Error
|
||||
}
|
||||
|
||||
// FindByID retrieves an inbox assignment policy by primary key.
|
||||
func (r *InboxAssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*autoassignment.InboxAssignmentPolicy, error) {
|
||||
var policy autoassignment.InboxAssignmentPolicy
|
||||
func (r *InboxAssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*model.InboxAssignmentPolicy, error) {
|
||||
var policy model.InboxAssignmentPolicy
|
||||
if err := r.db.WithContext(ctx).First(&policy, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -85,22 +109,70 @@ func (r *InboxAssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*aut
|
||||
}
|
||||
|
||||
// FindByInbox retrieves the active assignment policy override for an inbox.
|
||||
func (r *InboxAssignmentPolicyRepo) FindByInbox(ctx context.Context, accountID, inboxID uint) (*autoassignment.InboxAssignmentPolicy, error) {
|
||||
var policy autoassignment.InboxAssignmentPolicy
|
||||
func (r *InboxAssignmentPolicyRepo) FindByInbox(ctx context.Context, accountID, inboxID uint) (*model.InboxAssignmentPolicy, error) {
|
||||
var policy model.InboxAssignmentPolicy
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("account_id = ? AND inbox_id = ? AND active = ?", accountID, inboxID, true).
|
||||
Joins("JOIN assignment_policies ON assignment_policies.id = inbox_assignment_policies.assignment_policy_id").
|
||||
Where("assignment_policies.account_id = ? AND inbox_assignment_policies.inbox_id = ?", accountID, inboxID).
|
||||
First(&policy).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// FindPolicyByInbox retrieves the assignment policy associated with an inbox.
|
||||
func (r *InboxAssignmentPolicyRepo) FindPolicyByInbox(ctx context.Context, accountID, inboxID uint) (*model.AssignmentPolicy, error) {
|
||||
var policy model.AssignmentPolicy
|
||||
if err := r.db.WithContext(ctx).
|
||||
Joins("JOIN inbox_assignment_policies ON inbox_assignment_policies.assignment_policy_id = assignment_policies.id").
|
||||
Where("assignment_policies.account_id = ? AND inbox_assignment_policies.inbox_id = ?", accountID, inboxID).
|
||||
First(&policy).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// FindInboxesByPolicy retrieves inboxes linked to an account-scoped policy.
|
||||
func (r *InboxAssignmentPolicyRepo) FindInboxesByPolicy(ctx context.Context, accountID, policyID uint) ([]model.Inbox, error) {
|
||||
var inboxes []model.Inbox
|
||||
err := r.db.WithContext(ctx).
|
||||
Joins("JOIN inbox_assignment_policies ON inbox_assignment_policies.inbox_id = inboxes.id").
|
||||
Joins("JOIN assignment_policies ON assignment_policies.id = inbox_assignment_policies.assignment_policy_id").
|
||||
Where("assignment_policies.account_id = ? AND assignment_policies.id = ?", accountID, policyID).
|
||||
Order("inboxes.id ASC").
|
||||
Find(&inboxes).Error
|
||||
return inboxes, err
|
||||
}
|
||||
|
||||
// ReplaceForInbox attaches one policy to an inbox, replacing an old association.
|
||||
func (r *InboxAssignmentPolicyRepo) ReplaceForInbox(ctx context.Context, inboxID, policyID uint) (*model.InboxAssignmentPolicy, error) {
|
||||
var policy model.InboxAssignmentPolicy
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("inbox_id = ?", inboxID).Delete(&model.InboxAssignmentPolicy{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
policy = model.InboxAssignmentPolicy{InboxID: inboxID, AssignmentPolicyID: policyID}
|
||||
return tx.Create(&policy).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// DeleteByInbox removes an inbox assignment policy association.
|
||||
func (r *InboxAssignmentPolicyRepo) DeleteByInbox(ctx context.Context, inboxID uint) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("inbox_id = ?", inboxID).
|
||||
Delete(&model.InboxAssignmentPolicy{}).Error
|
||||
}
|
||||
|
||||
// Update updates an inbox assignment policy.
|
||||
func (r *InboxAssignmentPolicyRepo) Update(ctx context.Context, policy *autoassignment.InboxAssignmentPolicy) error {
|
||||
func (r *InboxAssignmentPolicyRepo) Update(ctx context.Context, policy *model.InboxAssignmentPolicy) error {
|
||||
return r.db.WithContext(ctx).Save(policy).Error
|
||||
}
|
||||
|
||||
// Delete soft-deletes an inbox assignment policy.
|
||||
func (r *InboxAssignmentPolicyRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&autoassignment.InboxAssignmentPolicy{}, id).Error
|
||||
return r.db.WithContext(ctx).Delete(&model.InboxAssignmentPolicy{}, id).Error
|
||||
}
|
||||
|
||||
@@ -1683,23 +1683,29 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
// Reference: Chatwoot resources :assignment_policies
|
||||
assignmentPolicies := accountScoped.Group("/assignment_policies")
|
||||
{
|
||||
assignmentPolicies.GET("/", h.AssignmentPolicy.GetAccountPolicy)
|
||||
assignmentPolicies.GET("", h.AssignmentPolicy.ListAccountPolicies)
|
||||
assignmentPolicies.GET("/", h.AssignmentPolicy.ListAccountPolicies)
|
||||
assignmentPolicies.POST("", h.AssignmentPolicy.CreateAccountPolicy)
|
||||
assignmentPolicies.POST("/", h.AssignmentPolicy.CreateAccountPolicy)
|
||||
assignmentPolicies.GET("/:policy_id", h.AssignmentPolicy.GetAccountPolicy)
|
||||
assignmentPolicies.PUT("/:policy_id", h.AssignmentPolicy.UpdateAccountPolicy)
|
||||
assignmentPolicies.PATCH("/:policy_id", h.AssignmentPolicy.UpdateAccountPolicy)
|
||||
assignmentPolicies.DELETE("/:policy_id", h.AssignmentPolicy.DeleteAccountPolicy)
|
||||
// Nested inboxes (Chatwoot: resources :assignment_policy_inboxes)
|
||||
assignmentPolicies.GET("/:policy_id/inboxes", h.AssignmentPolicy.GetInboxPolicy)
|
||||
assignmentPolicies.POST("/:policy_id/inboxes", h.AssignmentPolicy.CreateInboxPolicy)
|
||||
assignmentPolicies.DELETE("/:policy_id/inboxes/:inbox_id", h.AssignmentPolicy.DeleteInboxPolicy)
|
||||
assignmentPolicies.GET("/:policy_id/inboxes", h.AssignmentPolicy.ListPolicyInboxes)
|
||||
assignmentPolicies.POST("/:policy_id/inboxes", h.AssignmentPolicy.AddPolicyInbox)
|
||||
assignmentPolicies.DELETE("/:policy_id/inboxes/:inbox_id", h.AssignmentPolicy.RemovePolicyInbox)
|
||||
}
|
||||
|
||||
// Inbox-level assignment policy overrides (nested under inboxes)
|
||||
inboxAssignment := accountScoped.Group("/inboxes/:inbox_id/assignment_policy")
|
||||
{
|
||||
inboxAssignment.GET("/", h.AssignmentPolicyV2.GetInboxPolicy)
|
||||
inboxAssignment.POST("/", h.AssignmentPolicyV2.SetInboxPolicy)
|
||||
inboxAssignment.DELETE("/", h.AssignmentPolicyV2.DeleteInboxPolicy)
|
||||
inboxAssignment.GET("", h.AssignmentPolicy.GetInboxPolicy)
|
||||
inboxAssignment.GET("/", h.AssignmentPolicy.GetInboxPolicy)
|
||||
inboxAssignment.POST("", h.AssignmentPolicy.CreateInboxPolicy)
|
||||
inboxAssignment.POST("/", h.AssignmentPolicy.CreateInboxPolicy)
|
||||
inboxAssignment.DELETE("", h.AssignmentPolicy.DeleteCurrentInboxPolicy)
|
||||
inboxAssignment.DELETE("/", h.AssignmentPolicy.DeleteCurrentInboxPolicy)
|
||||
inboxAssignment.PUT("/:policy_id", h.AssignmentPolicy.UpdateInboxPolicy)
|
||||
inboxAssignment.DELETE("/:policy_id", h.AssignmentPolicy.DeleteInboxPolicy)
|
||||
}
|
||||
|
||||
@@ -2,22 +2,28 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/autoassignment"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AssignmentPolicyService implements business logic for AssignmentPolicy operations.
|
||||
// Reference: Chatwoot app/controllers/api/v1/assignment_policies_controller.rb
|
||||
type AssignmentPolicyService struct {
|
||||
policyRepo *repository.AssignmentPolicyRepo
|
||||
inboxPolicyRepo *repository.InboxAssignmentPolicyRepo
|
||||
assignSvc *autoassignment.AssignmentService
|
||||
policyRepo *repository.AssignmentPolicyRepo
|
||||
inboxPolicyRepo *repository.InboxAssignmentPolicyRepo
|
||||
assignSvc *autoassignment.AssignmentService
|
||||
}
|
||||
|
||||
type AssignmentPolicyPayload map[string]any
|
||||
|
||||
// NewAssignmentPolicyService creates a new AssignmentPolicy service.
|
||||
func NewAssignmentPolicyService(
|
||||
policyRepo *repository.AssignmentPolicyRepo,
|
||||
@@ -29,73 +35,105 @@ func NewAssignmentPolicyService(
|
||||
|
||||
// CreatePolicyRequest is the DTO for creating an assignment policy.
|
||||
type CreatePolicyRequest struct {
|
||||
Policy string `json:"policy" validate:"required,oneof=round_robin longest_waiting lowest_load"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AssignmentOrder int `json:"assignment_order,omitempty"`
|
||||
ConversationPriority int `json:"conversation_priority,omitempty"`
|
||||
FairDistributionLimit int `json:"fair_distribution_limit,omitempty"`
|
||||
FairDistributionWindow int `json:"fair_distribution_window,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UpdatePolicyRequest is the DTO for updating an assignment policy.
|
||||
type UpdatePolicyRequest struct {
|
||||
Policy string `json:"policy,omitempty" validate:"omitempty,oneof=round_robin longest_waiting lowest_load"`
|
||||
FairDistributionLimit int `json:"fair_distribution_limit,omitempty"`
|
||||
FairDistributionWindow int `json:"fair_distribution_window,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AssignmentOrder *int `json:"assignment_order,omitempty"`
|
||||
ConversationPriority *int `json:"conversation_priority,omitempty"`
|
||||
FairDistributionLimit *int `json:"fair_distribution_limit,omitempty"`
|
||||
FairDistributionWindow *int `json:"fair_distribution_window,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// CreateInboxPolicyRequest is the DTO for creating an inbox assignment policy.
|
||||
type CreateInboxPolicyRequest struct {
|
||||
InboxID uint `json:"inbox_id" validate:"required"`
|
||||
Policy string `json:"policy" validate:"required,oneof=round_robin longest_waiting lowest_load"`
|
||||
FairDistributionLimit int `json:"fair_distribution_limit,omitempty"`
|
||||
FairDistributionWindow int `json:"fair_distribution_window,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
InboxID uint `json:"inbox_id" validate:"required"`
|
||||
AssignmentPolicyID uint `json:"assignment_policy_id" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateInboxPolicyRequest is the DTO for updating an inbox assignment policy.
|
||||
type UpdateInboxPolicyRequest struct {
|
||||
Policy string `json:"policy,omitempty" validate:"omitempty,oneof=round_robin longest_waiting lowest_load"`
|
||||
FairDistributionLimit int `json:"fair_distribution_limit,omitempty"`
|
||||
FairDistributionWindow int `json:"fair_distribution_window,omitempty"`
|
||||
Active *bool `json:"active,omitempty"`
|
||||
AssignmentPolicyID uint `json:"assignment_policy_id" validate:"required"`
|
||||
}
|
||||
|
||||
// GetAccountPolicy retrieves the account-level assignment policy.
|
||||
func (s *AssignmentPolicyService) GetAccountPolicy(ctx context.Context, accountID uint) (*autoassignment.AssignmentPolicy, error) {
|
||||
policy, err := s.policyRepo.FindByAccount(ctx, accountID)
|
||||
// ListAccountPolicies retrieves all account-scoped assignment policies.
|
||||
func (s *AssignmentPolicyService) ListAccountPolicies(ctx context.Context, accountID uint) ([]AssignmentPolicyPayload, error) {
|
||||
policies, err := s.policyRepo.FindByAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list assignment policies: %w", err)
|
||||
}
|
||||
|
||||
items := make([]AssignmentPolicyPayload, 0, len(policies))
|
||||
for i := range policies {
|
||||
payload, err := s.SerializePolicy(ctx, &policies[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, payload)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetAccountPolicy retrieves an account-scoped assignment policy by ID.
|
||||
func (s *AssignmentPolicyService) GetAccountPolicy(ctx context.Context, accountID uint, policyID ...uint) (*model.AssignmentPolicy, error) {
|
||||
if len(policyID) > 0 && policyID[0] != 0 {
|
||||
policy, err := s.policyRepo.FindByAccountAndID(ctx, accountID, policyID[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assignment policy not found: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
policies, err := s.policyRepo.FindByAccount(ctx, accountID)
|
||||
if err != nil || len(policies) == 0 {
|
||||
if err == nil {
|
||||
err = gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("assignment policy not found: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
return &policies[0], nil
|
||||
}
|
||||
|
||||
// CreateAccountPolicy creates the account-level assignment policy.
|
||||
func (s *AssignmentPolicyService) CreateAccountPolicy(ctx context.Context, accountID uint, req CreatePolicyRequest) (*autoassignment.AssignmentPolicy, error) {
|
||||
func (s *AssignmentPolicyService) CreateAccountPolicy(ctx context.Context, accountID uint, req CreatePolicyRequest) (*model.AssignmentPolicy, error) {
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
active := true
|
||||
if req.Active != nil {
|
||||
active = *req.Active
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
limit := 5
|
||||
limit := 100
|
||||
if req.FairDistributionLimit > 0 {
|
||||
limit = req.FairDistributionLimit
|
||||
}
|
||||
|
||||
window := 300
|
||||
window := 3600
|
||||
if req.FairDistributionWindow > 0 {
|
||||
window = req.FairDistributionWindow
|
||||
}
|
||||
|
||||
policy := &autoassignment.AssignmentPolicy{
|
||||
policy := &model.AssignmentPolicy{
|
||||
AccountID: accountID,
|
||||
Policy: autoassignment.AssignmentPolicyType(req.Policy),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
AssignmentOrder: req.AssignmentOrder,
|
||||
ConversationPriority: req.ConversationPriority,
|
||||
FairDistributionLimit: limit,
|
||||
FairDistributionWindow: window,
|
||||
Active: active,
|
||||
Enabled: enabled,
|
||||
}
|
||||
|
||||
if err := s.policyRepo.Create(ctx, policy); err != nil {
|
||||
@@ -106,30 +144,34 @@ func (s *AssignmentPolicyService) CreateAccountPolicy(ctx context.Context, accou
|
||||
}
|
||||
|
||||
// UpdateAccountPolicy updates the account-level assignment policy.
|
||||
func (s *AssignmentPolicyService) UpdateAccountPolicy(ctx context.Context, id, accountID uint, req UpdatePolicyRequest) (*autoassignment.AssignmentPolicy, error) {
|
||||
func (s *AssignmentPolicyService) UpdateAccountPolicy(ctx context.Context, id, accountID uint, req UpdatePolicyRequest) (*model.AssignmentPolicy, error) {
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policy, err := s.policyRepo.FindByID(ctx, id)
|
||||
policy, err := s.policyRepo.FindByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assignment policy not found: %w", err)
|
||||
}
|
||||
if policy.AccountID != accountID {
|
||||
return nil, fmt.Errorf("assignment policy does not belong to account")
|
||||
}
|
||||
|
||||
if req.Policy != "" {
|
||||
policy.Policy = autoassignment.AssignmentPolicyType(req.Policy)
|
||||
if req.Name != "" {
|
||||
policy.Name = req.Name
|
||||
}
|
||||
if req.FairDistributionLimit > 0 {
|
||||
policy.FairDistributionLimit = req.FairDistributionLimit
|
||||
policy.Description = req.Description
|
||||
if req.AssignmentOrder != nil {
|
||||
policy.AssignmentOrder = *req.AssignmentOrder
|
||||
}
|
||||
if req.FairDistributionWindow > 0 {
|
||||
policy.FairDistributionWindow = req.FairDistributionWindow
|
||||
if req.ConversationPriority != nil {
|
||||
policy.ConversationPriority = *req.ConversationPriority
|
||||
}
|
||||
if req.Active != nil {
|
||||
policy.Active = *req.Active
|
||||
if req.FairDistributionLimit != nil {
|
||||
policy.FairDistributionLimit = *req.FairDistributionLimit
|
||||
}
|
||||
if req.FairDistributionWindow != nil {
|
||||
policy.FairDistributionWindow = *req.FairDistributionWindow
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
policy.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
if err := s.policyRepo.Update(ctx, policy); err != nil {
|
||||
@@ -141,13 +183,10 @@ func (s *AssignmentPolicyService) UpdateAccountPolicy(ctx context.Context, id, a
|
||||
|
||||
// DeleteAccountPolicy deletes the account-level assignment policy.
|
||||
func (s *AssignmentPolicyService) DeleteAccountPolicy(ctx context.Context, id, accountID uint) error {
|
||||
policy, err := s.policyRepo.FindByID(ctx, id)
|
||||
_, err := s.policyRepo.FindByAccountAndID(ctx, accountID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("assignment policy not found: %w", err)
|
||||
}
|
||||
if policy.AccountID != accountID {
|
||||
return fmt.Errorf("assignment policy does not belong to account")
|
||||
}
|
||||
|
||||
if err := s.policyRepo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to delete assignment policy: %w", err)
|
||||
@@ -155,102 +194,90 @@ func (s *AssignmentPolicyService) DeleteAccountPolicy(ctx context.Context, id, a
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInboxPolicy retrieves the inbox-level assignment policy override.
|
||||
func (s *AssignmentPolicyService) GetInboxPolicy(ctx context.Context, accountID, inboxID uint) (*autoassignment.InboxAssignmentPolicy, error) {
|
||||
policy, err := s.inboxPolicyRepo.FindByInbox(ctx, accountID, inboxID)
|
||||
// GetInboxPolicy retrieves the assignment policy associated with an inbox.
|
||||
func (s *AssignmentPolicyService) GetInboxPolicy(ctx context.Context, accountID, inboxID uint) (*model.AssignmentPolicy, error) {
|
||||
policy, err := s.inboxPolicyRepo.FindPolicyByInbox(ctx, accountID, inboxID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inbox assignment policy not found: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// ListPolicyInboxes retrieves inboxes associated with an assignment policy.
|
||||
func (s *AssignmentPolicyService) ListPolicyInboxes(ctx context.Context, accountID, policyID uint) ([]model.Inbox, error) {
|
||||
if _, err := s.policyRepo.FindByAccountAndID(ctx, accountID, policyID); err != nil {
|
||||
return nil, fmt.Errorf("assignment policy not found: %w", err)
|
||||
}
|
||||
return s.inboxPolicyRepo.FindInboxesByPolicy(ctx, accountID, policyID)
|
||||
}
|
||||
|
||||
// CreateInboxPolicy creates an inbox-level assignment policy override.
|
||||
func (s *AssignmentPolicyService) CreateInboxPolicy(ctx context.Context, accountID uint, req CreateInboxPolicyRequest) (*autoassignment.InboxAssignmentPolicy, error) {
|
||||
func (s *AssignmentPolicyService) CreateInboxPolicy(ctx context.Context, accountID uint, req CreateInboxPolicyRequest) (*model.AssignmentPolicy, error) {
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
active := true
|
||||
if req.Active != nil {
|
||||
active = *req.Active
|
||||
if _, err := s.policyRepo.FindByAccountAndID(ctx, accountID, req.AssignmentPolicyID); err != nil {
|
||||
return nil, fmt.Errorf("assignment policy not found: %w", err)
|
||||
}
|
||||
|
||||
limit := 5
|
||||
if req.FairDistributionLimit > 0 {
|
||||
limit = req.FairDistributionLimit
|
||||
if _, err := s.inboxPolicyRepo.ReplaceForInbox(ctx, req.InboxID, req.AssignmentPolicyID); err != nil {
|
||||
return nil, fmt.Errorf("create inbox assignment policy: %w", err)
|
||||
}
|
||||
|
||||
window := 300
|
||||
if req.FairDistributionWindow > 0 {
|
||||
window = req.FairDistributionWindow
|
||||
}
|
||||
|
||||
policy := &autoassignment.InboxAssignmentPolicy{
|
||||
AccountID: accountID,
|
||||
InboxID: req.InboxID,
|
||||
Policy: autoassignment.AssignmentPolicyType(req.Policy),
|
||||
FairDistributionLimit: limit,
|
||||
FairDistributionWindow: window,
|
||||
Active: active,
|
||||
}
|
||||
|
||||
if err := s.inboxPolicyRepo.Create(ctx, policy); err != nil {
|
||||
applogger.L().Errorf("failed to create inbox assignment policy: %v", err)
|
||||
return nil, fmt.Errorf("failed to create inbox assignment policy: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
return s.GetInboxPolicy(ctx, accountID, req.InboxID)
|
||||
}
|
||||
|
||||
// UpdateInboxPolicy updates an inbox-level assignment policy override.
|
||||
func (s *AssignmentPolicyService) UpdateInboxPolicy(ctx context.Context, id, accountID uint, req UpdateInboxPolicyRequest) (*autoassignment.InboxAssignmentPolicy, error) {
|
||||
func (s *AssignmentPolicyService) UpdateInboxPolicy(ctx context.Context, id, accountID uint, req UpdateInboxPolicyRequest) (*model.AssignmentPolicy, error) {
|
||||
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policy, err := s.inboxPolicyRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inbox assignment policy not found: %w", err)
|
||||
}
|
||||
if policy.AccountID != accountID {
|
||||
return nil, fmt.Errorf("inbox assignment policy does not belong to account")
|
||||
}
|
||||
|
||||
if req.Policy != "" {
|
||||
policy.Policy = autoassignment.AssignmentPolicyType(req.Policy)
|
||||
}
|
||||
if req.FairDistributionLimit > 0 {
|
||||
policy.FairDistributionLimit = req.FairDistributionLimit
|
||||
}
|
||||
if req.FairDistributionWindow > 0 {
|
||||
policy.FairDistributionWindow = req.FairDistributionWindow
|
||||
}
|
||||
if req.Active != nil {
|
||||
policy.Active = *req.Active
|
||||
}
|
||||
|
||||
if err := s.inboxPolicyRepo.Update(ctx, policy); err != nil {
|
||||
applogger.L().Errorf("failed to update inbox assignment policy: %v", err)
|
||||
return nil, fmt.Errorf("failed to update inbox assignment policy: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
return s.CreateInboxPolicy(ctx, accountID, CreateInboxPolicyRequest{InboxID: id, AssignmentPolicyID: req.AssignmentPolicyID})
|
||||
}
|
||||
|
||||
// DeleteInboxPolicy deletes an inbox-level assignment policy override.
|
||||
func (s *AssignmentPolicyService) DeleteInboxPolicy(ctx context.Context, id, accountID uint) error {
|
||||
policy, err := s.inboxPolicyRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if _, err := s.inboxPolicyRepo.FindPolicyByInbox(ctx, accountID, id); err != nil {
|
||||
return fmt.Errorf("inbox assignment policy not found: %w", err)
|
||||
}
|
||||
if policy.AccountID != accountID {
|
||||
return fmt.Errorf("inbox assignment policy does not belong to account")
|
||||
}
|
||||
|
||||
if err := s.inboxPolicyRepo.Delete(ctx, id); err != nil {
|
||||
if err := s.inboxPolicyRepo.DeleteByInbox(ctx, id); err != nil {
|
||||
return fmt.Errorf("failed to delete inbox assignment policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SerializePolicy returns Chatwoot assignment_policy partial shape.
|
||||
func (s *AssignmentPolicyService) SerializePolicy(ctx context.Context, policy *model.AssignmentPolicy) (AssignmentPolicyPayload, error) {
|
||||
count, err := s.policyRepo.CountAssignedInboxes(ctx, policy.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count assigned inboxes: %w", err)
|
||||
}
|
||||
return AssignmentPolicyPayload{
|
||||
"id": policy.ID,
|
||||
"name": policy.Name,
|
||||
"description": policy.Description,
|
||||
"assignment_order": policy.AssignmentOrder,
|
||||
"conversation_priority": policy.ConversationPriority,
|
||||
"fair_distribution_limit": policy.FairDistributionLimit,
|
||||
"fair_distribution_window": policy.FairDistributionWindow,
|
||||
"enabled": policy.Enabled,
|
||||
"assigned_inbox_count": count,
|
||||
"created_at": unixSeconds(policy.CreatedAt),
|
||||
"updated_at": unixSeconds(policy.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unixSeconds(value time.Time) int64 {
|
||||
if value.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return value.Unix()
|
||||
}
|
||||
|
||||
func IsAssignmentPolicyNotFound(err error) bool {
|
||||
return err != nil && (errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, gorm.ErrInvalidDB))
|
||||
}
|
||||
|
||||
// AssignConversation assigns a specific conversation to an agent using auto-assignment.
|
||||
func (s *AssignmentPolicyService) AssignConversation(ctx context.Context, conversationID, inboxID, accountID uint) (uint, error) {
|
||||
agentID, err := s.assignSvc.AssignConversation(ctx, conversationID, inboxID, accountID)
|
||||
|
||||
Reference in New Issue
Block a user