From 0fb4e6c5009e8d65d4a9890254a872e5fedafa59 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 02:07:09 +0800 Subject: [PATCH] feat(profile): align account permissions --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 12 ++++--- .../handler/api/v1/profile_handler_test.go | 35 +++++++++++++++++-- internal/service/profile_service.go | 5 +-- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index be038efd..8aea7343 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after `fbb405a feat(agents): align list ordering`, with this implementation checkpoint prepared as `feat(platform): align account user membership`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(platform): align account user membership`. +- Current tracking checkpoint: 2026-06-06 after `706c706 feat(platform): align account user membership`, with this implementation checkpoint prepared as `feat(profile): align account permissions`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(profile): align account permissions`. - Latest documentation-only checkpoint: `8b378c7 docs: refresh parity tracker baseline`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P3.2 platform account-user membership drift is closed: `POST /platform/api/v1/accounts/:account_id/account_users` now follows Chatwoot `find_or_initialize_by(user_id).update!`, returns raw `AccountUser` JSON with `200 OK`, updates duplicate memberships instead of failing, and `DELETE /account_users/destroy` accepts `user_id` as the Chatwoot collection destroy route while returning empty `200 OK`. The preceding agents list ordering, update-route/`auto_offline`, agents bulk invite, B10.3 CustomRole frontend update, and Phase 6 contacts pagination/active payload drifts are closed. Conversation transcript and custom-attribute response drifts are already closed. 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 the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: Profile account permission drift is closed for reused route guards and dashboard permission helpers: profile account entries now return Chatwoot `AccountUser#permissions` for ordinary roles (`["administrator"]` or `["agent"]`) and continue to return enterprise custom-role permissions plus `custom_role` when `custom_role_id` is present. The preceding platform account-user membership, agents list ordering, update-route/`auto_offline`, agents bulk invite, B10.3 CustomRole frontend update, and Phase 6 contacts pagination/active payload drifts are closed. Conversation transcript and custom-attribute response drifts are already closed. 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 the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 840` after adding the Chatwoot platform account-user collection destroy route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -143,6 +143,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(profile): align account permissions` | Advances P3.1/P3.2 profile and permission parity by matching Chatwoot `api/v1/models/_user.json.jbuilder`, `AccountUser#permissions`, and the enterprise custom-role prepend used by dashboard route guards. Profile account entries now expose ordinary role permissions as `["administrator"]` or `["agent"]`, and custom-role memberships expose the role's permission keys plus `custom_role` while retaining nested `custom_role` metadata. | `go test ./internal/handler/api/v1 -run ProfileHandler -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 840`. | Continue P3.2 permissions/invitations drift or the next Phase 6 reference/smoke finding. | | `feat(platform): align account user membership` | Advances P3.2 platform account-user parity by matching `Platform::Api::V1::AccountUsersController`. Platform account-user index/create now return raw AccountUser JSON instead of the local `{ success, data }` envelope, create uses Chatwoot `find_or_initialize_by(user_id).update!` semantics so duplicate membership requests update role instead of failing, and the Chatwoot collection route `DELETE /platform/api/v1/accounts/:account_id/account_users/destroy?user_id=...` is registered while the legacy `/:user_id` alias remains. | `go test ./internal/handler/api/v1 -run PlatformAccountUser -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./internal/handler/api/v1 ./internal/repository ./internal/router -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 840`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | Continue P3.2 permissions/invitations drift or the next Phase 6 reference/smoke finding. | | `feat(agents): align list ordering` | Advances P3.2 agents/settings parity by matching Chatwoot `AgentsController#index`, which renders `Current.account.users.order_by_full_name`. Account agent lists now order by `lower(users.name) ASC` before pagination while preserving account-user role, availability, auto-offline, inviter, account-user ID, and custom-role metadata. | `go test ./internal/handler/api/v1 -run AgentHandler -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 839`. | Continue P3.2 account users/invitations/permissions drift or the next Phase 6 reference/smoke finding. | | `feat(agents): align update route semantics` | Advances P3.2 agents/settings parity by aligning update with Chatwoot `AgentsController#update` and the reused dashboard `ApiClient.update` path. Agent update now exposes `PATCH /api/v1/accounts/:account_id/agents/:agent_id` alongside the existing `PUT` alias, accepts nested `{ agent: ... }` payloads, preserves `auto_offline` when the field is omitted, and still applies explicit `auto_offline: false` like Rails strong params with `compact`. | `go test ./internal/handler/api/v1 -run AgentHandler -count=1`; `go test ./internal/service -run Agent -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 839`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | Continue P3.2 account users/invitations/permissions drift or the next Phase 6 reference/smoke finding. | @@ -1561,8 +1562,8 @@ Frontend-critical API groups to audit first: | ID | Area | Scope | Status | | --- | --- | --- | --- | -| P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Done | -| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, platform account-user raw/idempotent membership, agents `order_by_full_name` index ordering, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline` presence semantics now have focused Chatwoot-style coverage. | +| P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Done; profile account permission arrays now match Chatwoot role/custom-role semantics for dashboard route guards. | +| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, profile account permission arrays, platform account-user raw/idempotent membership, agents `order_by_full_name` index ordering, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline` presence semantics now have focused Chatwoot-style coverage. | | P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Review; assignable-agent payload drift closed, broader inbox serializer drift remains evidence-driven. | | P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review | | P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, and shared attachments. | Doing | @@ -2076,6 +2077,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.1/P3.2 profile permissions checkpoint prepared as `feat(profile): align account permissions`; audited Chatwoot `_user.json.jbuilder`, `AccountUser#permissions`, enterprise `Enterprise::AccountUser`, and dashboard `permissionsHelper.js`. Profile account entries now expose `["administrator"]`/`["agent"]` for ordinary roles and custom-role permission keys plus `custom_role` for custom-role memberships, preserving nested custom-role metadata for the reused frontend. Focused profile tests, combined handler/service/repository tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 permissions/invitations drift next. - 2026-06-06: P3.2 platform account-user checkpoint prepared as `feat(platform): align account user membership`; audited Chatwoot `Platform::Api::V1::AccountUsersController` and platform routes. Platform account-user index/create now return raw AccountUser JSON, create updates existing memberships instead of failing duplicates, the user-permissible extra gate was removed to match the account-scoped reference controller, and collection destroy is available as `DELETE /account_users/destroy` with `user_id`. Focused platform account-user tests, route generation/parity, combined handler/repository/router tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 permissions/invitations drift next. - 2026-06-06: P3.2 agents index-order checkpoint prepared as `feat(agents): align list ordering`; audited Chatwoot `AgentsController#index` and `User.order_by_full_name`. Account agent lists now join users and order by `lower(users.name) ASC` before pagination, while preserving account-user metadata in the Chatwoot agent serializer. Focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 account users/invitations drift next. - 2026-06-06: P3.2 agents update-route checkpoint prepared as `feat(agents): align update route semantics`; audited Chatwoot `AgentsController#update`, dashboard `agents.js`, and shared `ApiClient.update`. Agent update now exposes frontend-used `PATCH /api/v1/accounts/:account_id/agents/:agent_id` alongside the existing `PUT` alias, preserves existing `auto_offline` when omitted from the request, and still applies explicit `auto_offline: false` like Rails strong params with `compact`. Focused handler/service tests, route generation/parity, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 account users/invitations drift next. diff --git a/internal/handler/api/v1/profile_handler_test.go b/internal/handler/api/v1/profile_handler_test.go index 2e22cf12..629f1050 100644 --- a/internal/handler/api/v1/profile_handler_test.go +++ b/internal/handler/api/v1/profile_handler_test.go @@ -56,6 +56,7 @@ func (s *ProfileHandlerTestSuite) SetupSuite() { &model.Account{}, &model.User{}, &model.AccountUser{}, + &model.CustomRole{}, &model.AccessToken{}, )) s.db = db @@ -140,9 +141,12 @@ func (s *ProfileHandlerTestSuite) SetupTest() { "pubsub_token": "pubsub-profile-user", }) s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).Updates(map[string]interface{}{ - "availability": "offline", - "auto_offline": true, + "role": "administrator", + "custom_role_id": 0, + "availability": "offline", + "auto_offline": true, }) + s.db.Unscoped().Where("account_id = ?", s.accountID).Delete(&model.CustomRole{}) s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{}) s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error) } @@ -187,9 +191,36 @@ func (s *ProfileHandlerTestSuite) TestGet_Success() { assert.Equal(s.T(), "offline", account["availability"]) assert.Equal(s.T(), "offline", account["availability_status"]) assert.Equal(s.T(), true, account["auto_offline"]) + assert.Equal(s.T(), []interface{}{"administrator"}, account["permissions"]) } } +func (s *ProfileHandlerTestSuite) TestGet_CustomRolePermissions() { + role := &model.CustomRole{AccountID: s.accountID, Name: "Support Lead"} + s.Require().NoError(role.SetPermissionKeys([]model.PermissionDimension{ + model.DimensionConversationManage, + model.DimensionContactManage, + })) + s.Require().NoError(s.db.Create(role).Error) + s.Require().NoError(s.db.Model(&model.AccountUser{}). + Where("account_id = ? AND user_id = ?", s.accountID, s.userID). + Updates(map[string]interface{}{"role": "agent", "custom_role_id": role.ID}).Error) + + req, _ := http.NewRequest("GET", "/api/v1/profile", nil) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + payload := s.decodeProfileBody(w) + account := s.firstAccountFromProfile(payload) + assert.Equal(s.T(), "agent", account["role"]) + assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage", "custom_role"}, account["permissions"]) + assert.Equal(s.T(), float64(role.ID), account["custom_role_id"]) + customRole := account["custom_role"].(map[string]interface{}) + assert.Equal(s.T(), "Support Lead", customRole["name"]) + assert.Equal(s.T(), []interface{}{"conversation_manage", "contact_manage"}, customRole["permissions"]) +} + func (s *ProfileHandlerTestSuite) TestGet_Unauthorized() { // Create router without auth middleware — user_id will be 0 r := gin.New() diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index be57ad45..e4ad81a7 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -407,7 +407,8 @@ func profileAccountResponse(accountUser model.AccountUser) ProfileAccountRespons activeAt := timeStringPtr(accountUser.ActiveAt) availability := defaultString(accountUser.Availability, "offline") status := defaultString(accountUser.Account.Status, "active") - permissions := []string{} + role := defaultString(accountUser.Role, "agent") + permissions := []string{role} var customRole any var customRoleID *uint if accountUser.CustomRole != nil && accountUser.CustomRoleID > 0 { @@ -434,7 +435,7 @@ func profileAccountResponse(accountUser model.AccountUser) ProfileAccountRespons Status: status, OnboardingStep: accountUser.Account.OnboardingStep, ActiveAt: activeAt, - Role: accountUser.Role, + Role: role, Permissions: permissions, Availability: availability, AvailabilityStatus: availability,