feat(conversations): scope filter permissions

This commit is contained in:
2026-06-07 06:20:37 +08:00
parent ee9477ae58
commit b87120f690
3 changed files with 89 additions and 7 deletions
+9 -5
View File
@@ -49,12 +49,12 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 P3.72 account enabled-features serialization parity, prepared as `feat(accounts): serialize enabled features`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(accounts): serialize enabled features`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `enabled_features` account payload parity. No route artifacts change.
- Current tracking checkpoint: 2026-06-07 P3.73 conversation filter permission parity, prepared as `feat(conversations): scope filter permissions`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): scope filter permissions`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot `Conversations::PermissionFilterService` parity for the advanced conversation filter path. No route artifacts change.
- Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker.
- Worktree status at this implementation checkpoint: account serialization now returns Chatwoot-style `enabled_features`: default enabled `config/features.yml` flags are present for empty GoChat accounts, explicit true flags such as `crm_v2` are included, and false flags are omitted so reused frontend feature gates behave like `Account#enabled_features`. Existing P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, P3.69 contact index/filter/export resolved-scope parity, P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, import paths creating unexpected contact types, the next reused-frontend mismatch, or B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
- Worktree status at this implementation checkpoint: advanced conversation filtering now applies the same inbox visibility gate as Chatwoot `Conversations::PermissionFilterService`: administrators see all account conversations, agents see only conversations from inboxes they are assigned to, and account users missing from the account see no filter results. Existing P3.72 account enabled-feature serialization parity, P3.71 contact referer filter parity, P5.8c contact import search indexing, P3.70 global contact search resolved-scope parity, P3.69 contact index/filter/export resolved-scope parity, P3.68 contact filter payload parity, P3.64-P3.67 conversation filter payload parity, P3.63 search message-order parity, P3.62 trimmed search payload fields, P3.61 sender-name, P3.60 numeric message-type parity, and prior report/profile/channel/contact checkpoints remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Next executable implementation checkpoint: continue Phase 2/3 drift audit for message filters, import paths creating unexpected contact types, the next reused-frontend mismatch, or B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes.
- `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full.
- Route dump succeeds with `967` registered routes after profile MFA route tracking.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
@@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section
| Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- |
| 0 | P3.73 conversation filter permission parity | Implemented for reused dashboard advanced filters: `POST /conversations/filter` now scopes results and meta counts through Chatwoot-style inbox visibility before applying payload/status/label filters. Administrators see all account conversations; agents see only assigned inbox conversations. | Keep in Review; reopen from B12 conversation filter smoke or fresh reference evidence for enterprise custom-role overrides beyond the current non-SSO scope. | Focused conversation service filter permission tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.72 account enabled-features serialization parity | Implemented for reused frontend account feature gates: GoChat account payloads now serialize `features` like Chatwoot `Account#enabled_features`, seeding default enabled flags from `config/features.yml`, including explicit true flags such as `crm_v2`, and omitting false flags. | Keep in Review; reopen from B12 account/CRM smoke or fresh reference evidence for installation-config default feature drift, cloud-only feature filtering, or bitfield migration needs. | Focused account handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P3.71 contact referer filter parity | Implemented for reused CRM contact advanced filters: GoChat now accepts frontend `referer` payload conditions and filters `contacts.additional_attributes.referer` with equality and containment operators. | Keep in Review; reopen from B12 CRM filter smoke or fresh reference/frontend evidence for exact referer location drift, link case-sensitivity expectations, or export filter reuse gaps. | Focused contact filter handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
| 0 | P5.8c contact import search indexing parity | Implemented for imported contact rows: CSV imports now call the contact search indexer after successful create and after successful duplicate merge/update, so Meilisearch documents stay current for email, phone number, identifier, and `contact_type` fields used by contact/global search. | Keep in Review; reopen from B12 CRM/search smoke or fresh reference evidence for label-aware contact search documents, failed-label partial updates, or durable import worker replay drift. | Focused contact import service tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. |
@@ -236,6 +237,7 @@ These rows are the executable development plan from this point forward. A checkp
| ID | Owner files | Reference files | Work to land | Exit gate |
| --- | --- | --- | --- | --- |
| P3.73 conversation filter permission parity | `internal/service/conversation_service.go`, `internal/service/conversation_service_test.go` | `reference/chatwoot/app/services/conversations/filter_service.rb`, `reference/chatwoot/app/services/conversations/permission_filter_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#filter` | Advanced conversation filters now apply the Chatwoot permission base relation before payload/status/label filters. Non-admin agents are restricted to conversations whose inbox is in their account-scoped assigned inboxes; administrators keep full account visibility; missing account memberships return an empty result set. | Review by `feat(conversations): scope filter permissions`; focused service tests assert agent filtering hides unassigned-inbox matches while administrators still see all matching inboxes; full `go test ./...` and `git diff --check`. No route artifacts change. |
| P3.72 account enabled-features serialization parity | `internal/handler/api/v1/account_handler.go`, `internal/handler/api/v1/account_handler_test.go` | `reference/chatwoot/app/models/concerns/featurable.rb`, `reference/chatwoot/app/views/api/v1/models/_account.json.jbuilder`, `reference/chatwoot/config/features.yml`, `reference/chatwoot/app/javascript/dashboard/store/modules/accounts.js` | Account show/list payloads now serialize `features` as Chatwoot `enabled_features` instead of raw GoChat `feature_flags`: default enabled flags are present for empty accounts, explicit true flags like `crm_v2` are included, and false flags are omitted so reused frontend feature gates see the same truthy-key contract. | Review by `feat(accounts): serialize enabled features`; focused handler tests assert default enabled flags, explicit `crm_v2`, and false-flag omission; full `go test ./...` and `git diff --check`. No route artifacts change. |
| P3.71 contact referer filter parity | `internal/repository/contact_repo.go`, `internal/handler/api/v1/contact_handler_crud_test.go` | `reference/chatwoot/app/javascript/dashboard/components-next/filter/contactProvider.js`, `reference/chatwoot/app/javascript/dashboard/components-next/filter/helper/filterHelper.js`, `reference/chatwoot/app/services/contacts/filter_service.rb`, `reference/chatwoot/lib/filters/filter_keys.yml` | Contact advanced filters now accept the reused frontend's built-in `referer` contact filter and evaluate it against `contacts.additional_attributes.referer` with `equal_to`, `not_equal_to`, `contains`, and `does_not_contain`. This closes the prior frontend/reference drift where the current dashboard exposed `referer` even though the backend filter key list does not declare it under contacts. | Review by `feat(contacts): filter by referer`; focused handler tests assert `referer contains` matches only contacts whose additional attributes carry the referer URL; full `go test ./...` and `git diff --check`. No route artifacts change. |
| P5.8c contact import search indexing parity | `internal/service/contact_service.go`, `internal/service/contact_service_g3_test.go` | `reference/chatwoot/app/controllers/api/v1/accounts/contacts_controller.rb#import`, `reference/chatwoot/app/jobs/data_import_job.rb`, `reference/chatwoot/app/services/data_import/contact_manager.rb`, P3.70 Meilisearch contact document contract | Imported contacts now refresh the contact search index after successful CSV-created rows and after successful duplicate merge/update rows. This keeps Meilisearch contact documents current for identity fields and `contact_type`, which are used by global contact search resolved-scope filters. | Review by `feat(contacts): index imported contacts`; focused import tests assert imported create and merge paths call `IndexContact`; full `go test ./...` and `git diff --check`. No route artifacts change. |
@@ -334,6 +336,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(conversations): scope filter permissions` | Advances P3.73 with Chatwoot `Conversations::PermissionFilterService` parity for `POST /conversations/filter`. Advanced filters now apply assigned-inbox visibility before filter payloads and meta counts, so non-admin agents cannot see conversations from inboxes they do not belong to while administrators retain account-wide visibility. | `go test ./internal/service -run 'TestConversationService_Filter_(RestrictsAgentToAssignedInboxes|AllowsAdministratorAllInboxes)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.73 to Review; continue Phase 2/3 drift audit for message filters, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(accounts): serialize enabled features` | Advances P3.72 with Chatwoot account `enabled_features` serialization parity. GoChat account payloads now seed default enabled features from the local reference `config/features.yml`, include explicit true flags such as `crm_v2`, and omit false flags instead of exposing raw `feature_flags` to reused dashboard feature gates. | `go test ./internal/handler/api/v1 -run 'AccountHandlerSuite/TestGet_(FeaturesUseChatwootEnabledFeatures|DefaultFeaturesMatchChatwootDefaults)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.72 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(contacts): filter by referer` | Advances P3.71 with reused CRM contact filter parity for the frontend-exposed `referer` attribute. Contact advanced filters now evaluate `referer` against `contacts.additional_attributes.referer` with equality and containment operators instead of rejecting the current dashboard payload as an unknown attribute. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUDTestSuite/TestFilter_ContactRefererFromFrontendProvider\|ContactHandlerCRUDTestSuite/TestFilter_ChatwootPayloadStandardAndAdditionalAttributes' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.71 to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, CRM v2 feature-flag serialization, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(contacts): index imported contacts` | Advances P5.8c with contact import search-index parity. CSV import create and duplicate-merge/update paths now call the contact search indexer after the row and labels are successfully persisted, keeping Meilisearch contact documents current for identity fields and `contact_type` changes used by P3.70 resolved-scope search. | `go test ./internal/service -run 'TestContactService_ImportCSV_(ImportsValidRows\|MergesDuplicateEmail)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P5.8c to Review; continue Phase 2/3 drift audit for message filters, conversation permission-filter depth, CRM v2 feature-flag serialization, label-aware contact search documents, Phase 6 placeholder audit, or B12 live smoke. |
@@ -2673,3 +2676,4 @@ Verification milestone gates:
- 2026-06-07: P5.8c contact import search indexing checkpoint prepared as `feat(contacts): index imported contacts`; audited the contact import worker path and the P3.70 Meilisearch contact document contract. GoChat CSV import now reindexes contacts after successful imported creates and duplicate merges, keeping search documents current for identity fields and `contact_type`. Focused import service tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.71 contact referer filter checkpoint prepared as `feat(contacts): filter by referer`; audited reused `contactProvider.js`, `filterHelper.js`, `Contacts::FilterService`, and `filter_keys.yml`. GoChat contact advanced filters now accept the frontend-exposed `referer` attribute and match it against `contacts.additional_attributes.referer`, so CRM filter payloads using `contains` no longer fail as unknown attributes. Focused contact filter tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.72 account enabled-features serialization checkpoint prepared as `feat(accounts): serialize enabled features`; audited Chatwoot `Featurable#enabled_features`, account model serializer, `config/features.yml`, and reused dashboard account feature getter. GoChat account payloads now return Chatwoot-style truthy `features`: default enabled flags are present for empty accounts, explicit true flags such as `crm_v2` are included, and false flags are omitted. Focused account handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.73 conversation filter permission checkpoint prepared as `feat(conversations): scope filter permissions`; audited Chatwoot `Conversations::FilterService`, `Conversations::PermissionFilterService`, and the reused `POST /conversations/filter` path. GoChat advanced conversation filters now apply assigned-inbox visibility for non-admin agents before payload/status/label filters and meta counts, while administrators keep full account visibility. Focused conversation service permission tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change.
+34 -2
View File
@@ -747,7 +747,12 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
var conversations []model.Conversation
query := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
query := s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
var err error
query, err = s.applyConversationPermissionFilter(ctx, accountID, userID, query)
if err != nil {
return nil, err
}
if len(params.Payload) > 0 {
advancedQuery, err := s.applyConversationFilterPayload(ctx, query, accountID, params.Payload)
if err != nil {
@@ -877,7 +882,7 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
return nil, err
}
err := query.Offset(offset).Limit(limit).Order(orderClause).Find(&conversations).Error
err = query.Offset(offset).Limit(limit).Order(orderClause).Find(&conversations).Error
if err != nil {
return nil, err
}
@@ -893,6 +898,33 @@ func (s *ConversationService) Filter(ctx context.Context, accountID uint, userID
}, nil
}
func (s *ConversationService) applyConversationPermissionFilter(ctx context.Context, accountID, userID uint, query *gorm.DB) (*gorm.DB, error) {
if userID == 0 {
return query, nil
}
var accountUser model.AccountUser
err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return query.Where("1 = 0"), nil
}
if err != nil {
return query, err
}
if accountUser.IsAdministrator() {
return query, nil
}
inboxIDs, err := s.visibleUnreadCountInboxIDs(ctx, accountID, userID)
if err != nil {
return query, err
}
if len(inboxIDs) == 0 {
return query.Where("1 = 0"), nil
}
return query.Where("conversations.inbox_id IN ?", inboxIDs), nil
}
func (s *ConversationService) applyConversationFilterPayload(ctx context.Context, query *gorm.DB, accountID uint, payload []ConversationFilterCondition) (*gorm.DB, error) {
clauses := make([]string, 0, len(payload)*2)
args := make([]any, 0, len(payload))
@@ -332,6 +332,52 @@ func TestConversationService_GetMeta_FiltersByLabelAndVisibility(t *testing.T) {
assert.Equal(t, int64(1), meta.MineCount)
}
func TestConversationService_Filter_RestrictsAgentToAssignedInboxes(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
visibleInbox := createConversationServiceTestInbox(t, db, account.ID)
hiddenInbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
agent := &model.User{Name: "Filter Agent", Email: "filter-agent@example.com"}
require.NoError(t, db.Create(agent).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: agent.ID, Role: "agent"}).Error)
require.NoError(t, db.Create(&model.InboxMember{InboxID: visibleInbox.ID, UserID: agent.ID}).Error)
visible := createConversationServiceTestConversation(t, db, account.ID, visibleInbox.ID, contact.ID, "open")
require.NoError(t, db.Model(visible).Update("labels", "vip").Error)
hidden := createConversationServiceTestConversation(t, db, account.ID, hiddenInbox.ID, contact.ID, "open")
require.NoError(t, db.Model(hidden).Update("labels", "vip").Error)
result, err := svc.Filter(context.Background(), account.ID, agent.ID, FilterParams{Labels: "vip", AssigneeType: "all"}, 0, 25)
require.NoError(t, err)
require.Len(t, result.Conversations, 1)
assert.Equal(t, visible.ID, result.Conversations[0].ID)
assert.Equal(t, int64(1), result.Count.AllCount)
}
func TestConversationService_Filter_AllowsAdministratorAllInboxes(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inboxA := createConversationServiceTestInbox(t, db, account.ID)
inboxB := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
admin := &model.User{Name: "Filter Admin", Email: "filter-admin@example.com"}
require.NoError(t, db.Create(admin).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: admin.ID, Role: "administrator"}).Error)
first := createConversationServiceTestConversation(t, db, account.ID, inboxA.ID, contact.ID, "open")
require.NoError(t, db.Model(first).Update("labels", "vip").Error)
second := createConversationServiceTestConversation(t, db, account.ID, inboxB.ID, contact.ID, "open")
require.NoError(t, db.Model(second).Update("labels", "vip").Error)
result, err := svc.Filter(context.Background(), account.ID, admin.ID, FilterParams{Labels: "vip", AssigneeType: "all"}, 0, 25)
require.NoError(t, err)
assert.Len(t, result.Conversations, 2)
assert.Equal(t, int64(2), result.Count.AllCount)
}
// ========== MarkUnread Tests ==========
func TestConversationService_MarkUnread(t *testing.T) {