diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index c9348f88..541bf860 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,10 +49,10 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(profile): expose hmac identifier`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(profile): expose hmac identifier`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(account-users): align inviter id`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(account-users): align inviter id`. - Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: record placeholder smoke audit`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P3.1 profile `_user` serializer drift from `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `user_spec.rb`, and reused frontend `scriptHelpers.js` is implemented. Profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is absent and expose the Chatwoot-compatible SHA-256 HMAC of the user email when the installation config is present. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active implementation slice is B12 live smoke finding, remaining P3.2 invitations drift from fresh reference evidence, or another route/controller/serializer drift opened from current `reference/chatwoot` inspection. +- Worktree status at this implementation checkpoint: P3.2 account-user invitation column drift from Chatwoot `account_users.inviter_id`, `AgentBuilder`, `_user.json.jbuilder`, and enterprise audit metadata is implemented. GoChat now persists AccountUser invitation source through the Chatwoot `inviter_id` column/JSON name, migrates existing `invited_by` values forward, keeps agent serializers from leaking inviter fields, and preserves profile `inviter_id` output through the existing `_user` serializer. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active implementation slice is B12 live smoke finding, remaining P3.2 invitations drift from fresh reference evidence, or another route/controller/serializer drift opened from current `reference/chatwoot` inspection. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 905` after removing the non-Chatwoot `GET /platform/api/v1/users/:id/token` route and keeping the Chatwoot `GET /platform/api/v1/users/:id/login` route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -209,6 +209,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(account-users): align inviter id` | Advances P3.2 invitation/account-user parity by matching Chatwoot `account_users.inviter_id`, `AgentBuilder#create_account_user`, `app/views/api/v1/models/_user.json.jbuilder`, and enterprise `Audit::AccountUser`. GoChat now maps `model.AccountUser.InvitedBy` to the Chatwoot `inviter_id` column and JSON name, emits account-user lifecycle payloads with `inviter_id`, adds migration `000031` to copy existing `invited_by` values forward, and keeps account agent serializers free of inviter fields. | `go test ./internal/model -run AccountUserDefaultValues -count=1`; `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestCreateAgent' -count=1`; `go test ./internal/service ./internal/repository -run 'AccountUser\|Agent' -count=1`; `go test ./cmd/migrate ./internal/app -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | AccountUser inviter persistence moves to Review for current reference evidence; continue remaining P3.2 invitation mail/confirmation drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | | `feat(profile): expose hmac identifier` | Advances P3.1 profile `_user` serializer parity by matching Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. GoChat profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is not configured and include the SHA-256 HMAC of the user email keyed by that installation config when present. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestGet' -count=1`; `go test ./internal/service -run Profile -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | Profile `_user` HMAC field moves to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | | `feat(platform): align user payloads` | Advances P3.2 platform user parity by matching Chatwoot `Platform::Api::V1::UsersController`, `app/views/platform/api/v1/models/_user.json.jbuilder`, `users/token.json.jbuilder`, and platform routes. GoChat now returns raw user/token/login payloads instead of local envelopes, creates or reuses users by email with confirmed active status, idempotently grants platform-app permission, generates/reuses personal access tokens, merges `custom_attributes` on update, returns empty `200 OK` deletes, registers `GET /platform/api/v1/users/:id/login`, and removes the non-reference `GET /platform/api/v1/users/:id/token` route from the dump. | `go test ./internal/handler/api/v1 -run PlatformUser -count=1`; `go test ./internal/service -run PlatformUser -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`; full `go test ./...`; `git diff --check`. Route dump is `TOTAL: 905`; tracked route parity remains `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Platform users move to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | | `feat(agents): align serializer fields` | Advances P3.2 agents serializer parity by matching Chatwoot `app/views/api/v1/models/_agent.json.jbuilder`. GoChat account agent list/create/show/update payloads now return only `id`, `account_id`, `availability_status`, `auto_offline`, `confirmed`, `email`, `provider`, `available_name`, optional `custom_attributes`, `name`, `role`, `thumbnail`, and enterprise `custom_role_id`; local `invited_by` and `account_user_id` fields are no longer emitted. | `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestCreateAgent\|TestAgentHandlerSuite/TestListAfterCreate\|TestAgentHandlerSuite/TestGetAgent' -count=1`; `go test ./internal/handler/api/v1 -run AgentHandler -count=1`; `go test ./internal/service ./internal/repository ./internal/router -run Agent -count=1`; sandboxed full `go test ./...` failed only on local socket/miniredis/httptest restrictions; escalated full `go test ./...` passed; `git diff --check`. | Agent serializer field shape moves to Review for current reference evidence; continue remaining P3.2 account users/invitations drift or B12 live smoke. | @@ -1676,7 +1677,7 @@ 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. | Review; profile account permission arrays, conditional `hmac_identifier`, and user notification-settings raw payloads now match Chatwoot role/custom-role semantics, widget script helper, and dashboard settings store expectations. | -| 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, platform user raw serializer/create-login-token/update-delete semantics, agents `_agent` serializer fields, `order_by_full_name` ordering with Chatwoot no-pagination index behavior, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline`/validation semantics now have focused Chatwoot-style coverage. | +| 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, platform user raw serializer/create-login-token/update-delete semantics, AccountUser `inviter_id` persistence/audit naming, agents `_agent` serializer fields, `order_by_full_name` ordering with Chatwoot no-pagination index behavior, agents bulk invite best-effort behavior, and agent update PATCH/`auto_offline`/validation 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, avatars, and shared attachments. | Doing; company multipart avatar create/update and fixed 25-row company pagination now match the reused dashboard companies store/API path. | @@ -2198,6 +2199,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.2 account-user inviter checkpoint prepared as `feat(account-users): align inviter id`; audited Chatwoot `AccountUser` schema, `AgentBuilder#create_account_user`, `api/v1/models/_user.json.jbuilder`, and enterprise `Audit::AccountUser`. GoChat now persists invitation source through the Chatwoot `inviter_id` column/JSON name instead of the local `invited_by` column, copies existing `invited_by` values forward in migration `000031`, emits account-user lifecycle events with `inviter_id`, and verifies agent creation stores the current user as inviter without leaking inviter fields through the `_agent` serializer. Focused model/agent/service/repository/migration tests, full `go test ./...`, and `git diff --check` passed. Continue remaining P3.2 invitation mail/confirmation drift or B12 live smoke. - 2026-06-06: P3.1 profile HMAC checkpoint prepared as `feat(profile): expose hmac identifier`; audited Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. Profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is absent and expose `OpenSSL::HMAC.hexdigest('sha256', key, email)` compatible values when the installation config exists, letting the reused widget script helper send `identifier_hash` without adapter code. Focused profile handler tests, service compile test, full `go test ./...`, and `git diff --check` passed. Continue remaining P3.2 invitations drift or B12 live smoke. - 2026-06-06: P3.2 platform user checkpoint prepared as `feat(platform): align user payloads`; audited Chatwoot `Platform::Api::V1::UsersController`, `platform/api/v1/models/_user.json.jbuilder`, `users/token.json.jbuilder`, and platform user routes. Platform user show/create/update now return raw Chatwoot user payloads, create reuses existing users by email and creates the platform-app permissible idempotently, access tokens are generated/reused for user payloads, `GET /users/:id/login` returns a Chatwoot-shaped URL payload, `POST /users/:id/token` returns `{ access_token, expiry, user }`, update merges `custom_attributes`, and destroy returns empty `200 OK`. The stale non-reference `GET /platform/api/v1/users/:id/token` route was removed from the dump, leaving route dump `TOTAL: 905` and tracked route parity at `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. Focused platform handler/service/router tests, route parity test/generation, full `go test ./...`, and `git diff --check` passed; continue remaining P3.2 invitations drift or B12 live smoke. - 2026-06-06: P3.5 company avatar form/pagination checkpoint prepared as `feat(companies): align avatar form payloads`; audited Chatwoot enterprise `CompaniesController`, company Jbuilder views, reused dashboard `api/companies.js`, and Pinia `stores/companies.js`. Company create/update now accepts nested multipart `company[...]` form payloads generated when the frontend includes an avatar, maps `company[avatar]` into the existing serialized `avatar_url` boundary, preserves nested multipart `company[custom_attributes][...]`, and makes company list/search/contact list/search use Chatwoot's fixed `RESULTS_PER_PAGE = 25` instead of local `per_page` overrides. Focused company handler tests, focused company service tests, full `go test ./...`, and `git diff --check` passed; no route artifacts changed. diff --git a/internal/handler/api/v1/agent_handler_test.go b/internal/handler/api/v1/agent_handler_test.go index 66ab93fa..04b1d121 100644 --- a/internal/handler/api/v1/agent_handler_test.go +++ b/internal/handler/api/v1/agent_handler_test.go @@ -180,7 +180,12 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() { assert.Equal(s.T(), float64(7), data["custom_role_id"]) assert.Contains(s.T(), data, "confirmed") assert.NotContains(s.T(), data, "invited_by") + assert.NotContains(s.T(), data, "inviter_id") assert.NotContains(s.T(), data, "account_user_id") + + var membership model.AccountUser + s.Require().NoError(s.db.Where("account_id = ? AND user_id = ?", s.account.ID, uint(data["id"].(float64))).First(&membership).Error) + assert.Equal(s.T(), s.user.ID, membership.InvitedBy) } func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() { diff --git a/internal/model/account_user.go b/internal/model/account_user.go index e9437838..1d2f1c08 100644 --- a/internal/model/account_user.go +++ b/internal/model/account_user.go @@ -24,7 +24,7 @@ type AccountUser struct { Availability string `gorm:"size:50;default:'offline'" json:"availability"` // online/offline/busy AutoOffline bool `gorm:"default:true" json:"auto_offline"` // auto-offline when idle ActiveAt *time.Time `json:"active_at,omitempty"` // last active timestamp in this account - InvitedBy uint `gorm:"default:0" json:"invited_by"` // UserID of the user who invited this member + InvitedBy uint `gorm:"column:inviter_id;default:0" json:"inviter_id"` // Chatwoot: inviter_id of the user who invited this member CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` diff --git a/internal/model/constraints_test.go b/internal/model/constraints_test.go index bfbbade5..c0b0d201 100644 --- a/internal/model/constraints_test.go +++ b/internal/model/constraints_test.go @@ -126,6 +126,7 @@ func TestAccountUserDefaultValues(t *testing.T) { assert.Equal(t, "agent", fetched.Role, "Role should default to 'agent'") assert.Equal(t, "offline", fetched.Availability, "Availability should default to 'offline'") assert.Equal(t, uint(0), fetched.InvitedBy, "InvitedBy should default to 0") + assert.True(t, db.Migrator().HasColumn(&model.AccountUser{}, "inviter_id"), "AccountUser should use Chatwoot inviter_id column") } // TestCustomRoleDefaultValues verifies CustomRole model default field values. diff --git a/internal/service/account_user_service.go b/internal/service/account_user_service.go index f9a1117d..80818aa6 100644 --- a/internal/service/account_user_service.go +++ b/internal/service/account_user_service.go @@ -21,11 +21,11 @@ import ( // after_save (update_presence_in_redis on availability change), // validates user_id uniqueness scoped to account_id type AccountUserService struct { - repo *repository.AccountUserRepo - notificationRepo *repository.NotificationSettingRepo - accountRepo *repository.AccountRepo - userRepo *repository.UserRepo - eventBus *pubsub.EventBus + repo *repository.AccountUserRepo + notificationRepo *repository.NotificationSettingRepo + accountRepo *repository.AccountRepo + userRepo *repository.UserRepo + eventBus *pubsub.EventBus } // NewAccountUserService creates a new AccountUser service. @@ -78,10 +78,10 @@ func (s *AccountUserService) AddUserToAccount(ctx context.Context, accountID uin } au := &model.AccountUser{ - AccountID: accountID, - UserID: req.UserID, - Role: req.Role, - InvitedBy: req.InviterID, + AccountID: accountID, + UserID: req.UserID, + Role: req.Role, + InvitedBy: req.InviterID, Availability: "offline", // 1:1 Chatwoot: default availability AutoOffline: true, // 1:1 Chatwoot: default auto_offline } @@ -104,7 +104,7 @@ func (s *AccountUserService) AddUserToAccount(ctx context.Context, accountID uin "account_id": accountID, "user_id": req.UserID, "role": req.Role, - "invited_by": req.InviterID, + "inviter_id": req.InviterID, }) if err := s.eventBus.Publish(pubsub.TopicAccountUserCreated, payload); err != nil { applogger.L().Errorf("Failed to publish AccountUserCreated event: %v", err) @@ -211,8 +211,8 @@ func (s *AccountUserService) createDefaultNotificationSetting(ctx context.Contex // 1:1 Chatwoot: email_conversation_assignment = bit 1 → 2 // push_conversation_assignment = bit 1 → 2 setting := &model.NotificationSetting{ - UserID: userID, - AccountID: accountID, + UserID: userID, + AccountID: accountID, EmailFlags: 2, // bit 1 = email_conversation_assignment PushFlags: 2, // bit 1 = push_conversation_assignment } @@ -234,4 +234,4 @@ func (s *AccountUserService) SetAutoOffline(ctx context.Context, accountID, user // FindOnlineAgents returns all online agents for an account. func (s *AccountUserService) FindOnlineAgents(ctx context.Context, accountID uint) ([]model.AccountUser, error) { return s.repo.FindOnlineAgentsByAccount(ctx, accountID) -} \ No newline at end of file +} diff --git a/migrations/000031_align_account_users_inviter_id.down.sql b/migrations/000031_align_account_users_inviter_id.down.sql new file mode 100644 index 00000000..8aa098c0 --- /dev/null +++ b/migrations/000031_align_account_users_inviter_id.down.sql @@ -0,0 +1,8 @@ +-- Revert Chatwoot inviter_id alignment for account_users. + +UPDATE account_users +SET invited_by = inviter_id +WHERE inviter_id IS NOT NULL + AND inviter_id <> 0; + +ALTER TABLE account_users DROP COLUMN IF EXISTS inviter_id; diff --git a/migrations/000031_align_account_users_inviter_id.up.sql b/migrations/000031_align_account_users_inviter_id.up.sql new file mode 100644 index 00000000..9527fde6 --- /dev/null +++ b/migrations/000031_align_account_users_inviter_id.up.sql @@ -0,0 +1,9 @@ +-- Align account_users inviter column with Chatwoot's schema. + +ALTER TABLE account_users ADD COLUMN IF NOT EXISTS inviter_id INTEGER DEFAULT 0; + +UPDATE account_users +SET inviter_id = invited_by +WHERE inviter_id = 0 + AND invited_by IS NOT NULL + AND invited_by <> 0;