From 328d16c363e04ca914a553f9d8837f04c7938428 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 00:53:33 +0800 Subject: [PATCH] feat(custom-fields): align chatwoot saved view payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 10 +- internal/handler/api/v1/chatwoot_payload.go | 27 +++ .../v1/custom_attribute_definition_handler.go | 70 ++++++-- ...ustom_attribute_definition_handler_test.go | 48 ++--- .../handler/api/v1/custom_filter_handler.go | 71 +++++--- .../api/v1/custom_filter_handler_test.go | 30 +++- .../custom_attribute_definition_repo.go | 6 +- internal/repository/custom_filter_repo.go | 31 +++- .../custom_attribute_definition_service.go | 165 +++++++++++++++--- ...ustom_attribute_definition_service_test.go | 70 ++++---- .../service/custom_attribute_value_service.go | 17 +- internal/service/custom_filter_service.go | 63 ++++++- 12 files changed, 461 insertions(+), 147 deletions(-) create mode 100644 internal/handler/api/v1/chatwoot_payload.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 77008fc1..689a74eb 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 `8b378c7 docs: refresh parity tracker baseline`, with this implementation checkpoint prepared as `feat(labels): align chatwoot label payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(labels): align chatwoot label payloads`. +- Current tracking checkpoint: 2026-06-06 after `171cbd1 feat(labels): align chatwoot label payloads`, with this implementation checkpoint prepared as `feat(custom-fields): align chatwoot saved view payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(custom-fields): align chatwoot saved view payloads`. - 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.6 label CRUD now aligns Chatwoot account `/labels` list/create/show/update/delete payloads, `title/description/color/show_on_sidebar` persistence, nested frontend request bodies, legacy `/tags` compatibility, account-scoped lookup, empty `200 OK` delete, and label association cleanup/rename side effects. B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; 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 continued Phase 6 placeholder audit from fresh reference/smoke evidence, then the next P3.6 custom-attributes/custom-filters drift slice. +- Worktree status at this implementation checkpoint: P3.6 custom filters and custom attribute definitions now align the reused Chatwoot saved views/settings attributes frontend contract: `/custom_filters` list returns a raw array scoped by current user and `filter_type` with default `conversation`, create/update accept raw frontend bodies and nested Rails-style wrappers, mutation responses are raw model partials with Chatwoot fields only, and create/update status is `200 OK`; `/custom_attribute_definitions` list/show/create/update return raw Jbuilder-compatible payloads, accept raw frontend enum integers and string values, normalize stored `attribute_model` to Chatwoot `*_attribute` values, and keep delete as `204 No Content`. P3.6 label CRUD already aligns Chatwoot account `/labels` list/create/show/update/delete payloads, `title/description/color/show_on_sidebar` persistence, nested frontend request bodies, legacy `/tags` compatibility, account-scoped lookup, empty `200 OK` delete, and label association cleanup/rename side effects. B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; 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 continued Phase 6 placeholder audit from fresh reference/smoke evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 837` after adding Chatwoot auth reset/confirmation routes and `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments`. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -87,7 +87,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 critical set; notification list/action serializers, Devise password reset/confirmation payloads, CRM shared attachment payloads, account/settings payloads, assignable-agent payloads, and label CRUD payloads now match Chatwoot's frontend contract. | Continue Phase 6 placeholder audit, then open the next P3.6 custom-attributes/custom-filters drift slice from reference/smoke evidence. | 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 critical set; notification list/action serializers, Devise password reset/confirmation payloads, CRM shared attachment payloads, account/settings payloads, assignable-agent payloads, label CRUD payloads, custom filters, and custom attribute definitions now match the inspected Chatwoot frontend contract. | Continue Phase 6 placeholder audit, then open the next named serializer/controller drift slice from reference/smoke evidence. | 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; account/contact/conversation/message/inbox audit remains broad. | Continue fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. | | 4 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. | @@ -105,6 +105,7 @@ These rows are the executable development plan from this point forward. A checkp | P5.13a analytics placeholder burn-down | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, live/report handlers | Chatwoot `live_reports_controller.rb`, `reports_controller.rb`, `BotMetricsBuilder`, `InboxLabelMatrixBuilder`, `FirstResponseTimeDistributionBuilder`, `OutgoingMessagesCountBuilder` | Replace frontend-visible zero/empty placeholder responses for live conversations, grouped live conversations, bot summary/metrics, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message counts with persisted conversation/message/reporting-event queries. | Review by `feat(reports): derive analytics aggregates`; focused service/handler tests prove non-zero values from persisted rows and `rg` finds no placeholder TODOs in these methods. | | P5.13b scheduled/cached analytics | `internal/service/analytics_service.go`, `internal/service/analytics_query_helpers.go`, `internal/service/reporting_rollup_worker.go`, `internal/service/reporting_rollup_service.go`, report handlers/services, worker bootstrap | Chatwoot report controllers/services used by dashboard analytics, `Reports::DataSource`, reporting rollup/backfill jobs | Wire scheduled or lazy cached rollup freshness/idempotency and route `GET /reports` to metric timeseries instead of the summary handler. Define freshness rules for expensive rollups. | Review by `feat(reports): add analytics timeseries rollups`; report fixtures prove timeseries values are derived from persisted conversations/messages/reporting events, rollups refresh idempotently, durable `reporting:rollup_day` jobs replay, and hidden placeholder report JSON does not reappear. | | P3.6 label CRUD payload parity | `internal/handler/api/v1/label_handler.go`, `internal/service/tag_service.go`, `internal/model/tag.go`, `internal/repository/tag_repo.go`, migrations, label handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/labels_controller.rb`, `app/views/api/v1/accounts/labels/*.json.jbuilder`, `app/models/label.rb`, `db/schema.rb`, dashboard `api/labels.js`, `store/modules/labels.js` | Account `/labels` CRUD now returns Chatwoot `{ payload: [...] }` lists, raw mutation/show payloads, empty `200 OK` delete, `title/description/color/show_on_sidebar` persistence, title lowercasing/uniqueness, account scope, nested `{ label: ... }` request bodies, legacy `name` compatibility, default color, and local association cleanup/rename side effects. | Review by `feat(labels): align chatwoot label payloads`; focused handler/service/repository tests, combined handler/service/router tests, `go test ./cmd/migrate -count=1`, full `go test ./...`, and `git diff --check` passed. | +| P3.6 custom filters/custom attribute definitions payload parity | `internal/handler/api/v1/custom_filter_handler.go`, `internal/service/custom_filter_service.go`, `internal/repository/custom_filter_repo.go`, `internal/handler/api/v1/custom_attribute_definition_handler.go`, `internal/service/custom_attribute_definition_service.go`, `internal/service/custom_attribute_value_service.go`, handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/custom_filters_controller.rb`, `app/views/api/v1/accounts/custom_filters/*.json.jbuilder`, `app/views/api/v1/models/_custom_filter.json.jbuilder`, dashboard `api/customViews.js`, `store/modules/customViews.js`, `custom_attribute_definitions_controller.rb`, `_custom_attribute_definition.json.jbuilder`, dashboard `api/attributes.js`, `store/modules/attributes.js`, settings attributes screens/constants | `/custom_filters` and `/custom_attribute_definitions` now return raw Chatwoot arrays/objects instead of local envelopes, accept raw reused-frontend bodies plus Rails-style wrappers, use `200 OK` mutation responses, preserve `204` deletes, scope saved views by current user and default `filter_type=conversation`, normalize custom attribute enum integers to Chatwoot string values, and keep custom attribute value validation compatible with normalized `*_attribute` definitions plus legacy rows. | Review by `feat(custom-fields): align chatwoot saved view payloads`; focused handler/service/repository tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. | | 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. | @@ -140,6 +141,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(custom-fields): align chatwoot saved view payloads` | Advances P3.6 serializer/controller parity for saved views and settings attributes. Account `/custom_filters` now matches Chatwoot `CustomFiltersController`, `_custom_filter.json.jbuilder`, and dashboard `customViews.js`/store behavior: list returns a raw array, defaults to `filter_type=conversation`, scopes by current user, accepts raw frontend bodies plus nested `{ custom_filter }`, returns raw mutation payloads with no `account_id/user_id`, and uses `200 OK` for create/update plus `204` delete. Account `/custom_attribute_definitions` now matches `CustomAttributeDefinitionsController`, `_custom_attribute_definition.json.jbuilder`, dashboard `attributes.js`/store, and settings attribute enum constants: list/show/create/update return raw arrays/objects, create/update accept raw integer enum bodies plus wrappers, stored/serialized `attribute_model` values normalize to Chatwoot `conversation_attribute/contact_attribute/company_attribute`, and custom attribute value validation can find normalized and legacy definition rows. | `go test ./internal/handler/api/v1 -run 'CustomFilter\|CustomAttributeDefinition' -count=1`; `go test ./internal/service -run 'CustomFilter\|CustomAttributeDefinition' -count=1`; `go test ./internal/repository -run 'CustomFilter\|CustomAttributeDefinition' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | P3.6 custom filters/custom attribute definitions move to Review; continue Phase 6 placeholder audit from fresh reference/smoke evidence. | | `feat(labels): align chatwoot label payloads` | Advances P3.6 label CRUD parity by aligning account `/labels` with Chatwoot `LabelsController`, label Jbuilder views, `Label` model/schema, and dashboard labels API/store. List now returns `{ payload }`; create/show/update return raw label payloads with `id`, `title`, `description`, `color`, and `show_on_sidebar`; create/update accept nested `{ label }` bodies and legacy raw `name`; delete returns empty `200 OK`; title normalization, default color, account scope, persisted description/sidebar flags, and local conversation/contact association cleanup are covered. | `go test ./internal/handler/api/v1 -run LabelHandler -count=1`; `go test ./internal/service -run 'TagService|LabelService' -count=1`; `go test ./internal/repository -run TagRepo -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./cmd/migrate -count=1`; `go test ./...`; `git diff --check`. | P3.6 label CRUD moves to Review; continue Phase 6 placeholder audit, then custom attributes/custom filters drift. | | `docs: refresh parity tracker baseline` | Documentation-only checkpoint after `2ab7b58 feat(accounts): align account payloads`. Refreshes the Current Baseline to the committed account payload work, records the clean handoff state, and lands the next executable P3.6 label CRUD parity contract from direct Chatwoot reference/frontend inspection. | `git diff --check`. No Go code changed. | Start `feat(labels): align chatwoot label payloads`, then continue Phase 6 placeholder audit. | | `feat(accounts): align account payloads` | Advances Phase 2/3 account settings parity by aligning core reused-frontend account endpoints with Chatwoot `AccountsController`, `_account.json.jbuilder`, `show/update/create` views, dashboard `account.js`, and the account Vuex store. Account show/update/settings now return raw account objects instead of local `{ success, data }` envelopes, with `settings`, `custom_attributes`, `features`, `cache_keys`, `support_email`, `created_at`, locale/name/domain/status fields. Authenticated account creation accepts Chatwoot `account_name` and returns `{ data: { account_id } }` for the add-account modal. `update_active_at` now returns empty `200 OK`, and `cache_keys` now returns Chatwoot `{ cache_keys: { label, inbox, team } }` with zero-epoch defaults. | `go test ./internal/handler/api/v1 -run AccountHandler -count=1`; `go test ./internal/service -run AccountService -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | P3.2 remains Doing for account users/invitations/permissions drift; continue Phase 6 placeholder audit from fresh reference/smoke evidence. | diff --git a/internal/handler/api/v1/chatwoot_payload.go b/internal/handler/api/v1/chatwoot_payload.go new file mode 100644 index 00000000..4353d862 --- /dev/null +++ b/internal/handler/api/v1/chatwoot_payload.go @@ -0,0 +1,27 @@ +package v1 + +import ( + "encoding/json" + "fmt" + + "github.com/gin-gonic/gin" +) + +func bindChatwootPayload(c *gin.Context, wrapperKey string, target interface{}) error { + raw, err := c.GetRawData() + if err != nil { + return err + } + if len(raw) == 0 { + return fmt.Errorf("request body required") + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err == nil { + if wrapped, ok := envelope[wrapperKey]; ok { + raw = wrapped + } + } + + return json.Unmarshal(raw, target) +} diff --git a/internal/handler/api/v1/custom_attribute_definition_handler.go b/internal/handler/api/v1/custom_attribute_definition_handler.go index 6f069db1..4eef59ad 100644 --- a/internal/handler/api/v1/custom_attribute_definition_handler.go +++ b/internal/handler/api/v1/custom_attribute_definition_handler.go @@ -2,14 +2,55 @@ package v1 import ( "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" - "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) +type chatwootCustomAttributeDefinitionResponse struct { + ID uint `json:"id"` + AttributeDisplayName string `json:"attribute_display_name"` + AttributeDisplayType string `json:"attribute_display_type"` + AttributeDescription string `json:"attribute_description"` + AttributeKey string `json:"attribute_key"` + RegexPattern string `json:"regex_pattern"` + RegexCue string `json:"regex_cue"` + AttributeValues interface{} `json:"attribute_values"` + AttributeModel string `json:"attribute_model"` + DefaultValue interface{} `json:"default_value"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func serializeCustomAttributeDefinition(def *model.CustomAttributeDefinition) chatwootCustomAttributeDefinitionResponse { + return chatwootCustomAttributeDefinitionResponse{ + ID: def.ID, + AttributeDisplayName: def.AttributeDisplayName, + AttributeDisplayType: def.AttributeType, + AttributeDescription: def.Description, + AttributeKey: def.AttributeName, + RegexPattern: def.RegexPattern, + RegexCue: def.RegexCue, + AttributeValues: def.AttributeValues, + AttributeModel: def.AttributeModel, + DefaultValue: def.DefaultValue, + CreatedAt: def.CreatedAt, + UpdatedAt: def.UpdatedAt, + } +} + +func serializeCustomAttributeDefinitions(defs []model.CustomAttributeDefinition) []chatwootCustomAttributeDefinitionResponse { + items := make([]chatwootCustomAttributeDefinitionResponse, 0, len(defs)) + for i := range defs { + items = append(items, serializeCustomAttributeDefinition(&defs[i])) + } + return items +} + // CustomAttributeDefinitionHandler handles CRUD for custom attribute definitions. // Reference: Chatwoot app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb type CustomAttributeDefinitionHandler struct { @@ -32,15 +73,14 @@ func (h *CustomAttributeDefinitionHandler) List(c *gin.Context) { } attributeModel := c.Query("attribute_model") - page := pagination.Parse(c) - defs, total, svcErr := h.svc.List(c.Request.Context(), accountID, attributeModel, page.Offset, page.PerPage) + defs, _, svcErr := h.svc.List(c.Request.Context(), accountID, attributeModel, 0, 0) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OKWithMeta(c, defs, page.Page, page.PerPage, total) + c.JSON(http.StatusOK, serializeCustomAttributeDefinitions(defs)) } // Get retrieves a single custom attribute definition by ID. @@ -64,7 +104,7 @@ func (h *CustomAttributeDefinitionHandler) Get(c *gin.Context) { return } - response.OK(c, def) + c.JSON(http.StatusOK, serializeCustomAttributeDefinition(def)) } // Create creates a new custom attribute definition for an account. @@ -76,15 +116,11 @@ func (h *CustomAttributeDefinitionHandler) Create(c *gin.Context) { return } -// Chatwoot: params.require(:custom_attribute_definition) → {"custom_attribute_definition": {...}} - var wrapper struct { - CustomAttributeDefinition service.CreateCustomAttributeDefinitionRequest `json:"custom_attribute_definition"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.CreateCustomAttributeDefinitionRequest + if err := bindChatwootPayload(c, "custom_attribute_definition", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.CustomAttributeDefinition def, svcErr := h.svc.Create(c.Request.Context(), accountID, &req) if svcErr != nil { @@ -92,7 +128,7 @@ func (h *CustomAttributeDefinitionHandler) Create(c *gin.Context) { return } - response.Created(c, def) + c.JSON(http.StatusOK, serializeCustomAttributeDefinition(def)) } // Update modifies a custom attribute definition. @@ -110,15 +146,11 @@ func (h *CustomAttributeDefinitionHandler) Update(c *gin.Context) { return } - // Chatwoot: params.require(:custom_attribute_definition) → {"custom_attribute_definition": {...}} - var wrapper struct { - CustomAttributeDefinition service.UpdateCustomAttributeDefinitionRequest `json:"custom_attribute_definition"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.UpdateCustomAttributeDefinitionRequest + if err := bindChatwootPayload(c, "custom_attribute_definition", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.CustomAttributeDefinition def, svcErr := h.svc.Update(c.Request.Context(), accountID, id, &req) if svcErr != nil { @@ -126,7 +158,7 @@ func (h *CustomAttributeDefinitionHandler) Update(c *gin.Context) { return } - response.OK(c, def) + c.JSON(http.StatusOK, serializeCustomAttributeDefinition(def)) } // Delete soft-deletes a custom attribute definition. diff --git a/internal/handler/api/v1/custom_attribute_definition_handler_test.go b/internal/handler/api/v1/custom_attribute_definition_handler_test.go index 2818a2e0..7e35a7a6 100644 --- a/internal/handler/api/v1/custom_attribute_definition_handler_test.go +++ b/internal/handler/api/v1/custom_attribute_definition_handler_test.go @@ -82,7 +82,7 @@ func seedCustomAttrDef(t *testing.T, db *gorm.DB, accountID uint, name, displayN repo := repository.NewCustomAttributeDefinitionRepo(db) svc := service.NewCustomAttributeDefinitionService(repo) def, err := svc.Create(context.Background(), accountID, &service.CreateCustomAttributeDefinitionRequest{ -AttributeKey: name, + AttributeKey: name, AttributeDisplayName: displayName, AttributeDisplayType: attrType, AttributeModel: attrModel, @@ -111,12 +111,10 @@ func TestCustomAttributeDefinitionHandler_List(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) - var resp map[string]interface{} + var resp []map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) - assert.True(t, resp["success"].(bool)) - data := resp["data"].([]interface{}) - assert.Len(t, data, 2) + assert.Len(t, resp, 2) } func TestCustomAttributeDefinitionHandler_List_FilterByModel(t *testing.T) { @@ -128,16 +126,15 @@ func TestCustomAttributeDefinitionHandler_List_FilterByModel(t *testing.T) { r.GET("/api/v1/accounts/:account_id/custom_attribute_definitions", handler.List) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/custom_attribute_definitions?attribute_model=conversation", accountID), nil) + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/custom_attribute_definitions?attribute_model=conversation_attribute", accountID), nil) r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) - var resp map[string]interface{} + var resp []map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) - data := resp["data"].([]interface{}) - assert.Len(t, data, 1) + assert.Len(t, resp, 1) } func TestCustomAttributeDefinitionHandler_List_InvalidAccountID(t *testing.T) { @@ -170,7 +167,7 @@ func TestCustomAttributeDefinitionHandler_Get(t *testing.T) { var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) - assert.True(t, resp["success"].(bool)) + assert.Equal(t, "custom_priority_score", resp["attribute_key"]) } func TestCustomAttributeDefinitionHandler_Get_NotFound(t *testing.T) { @@ -204,27 +201,21 @@ func TestCustomAttributeDefinitionHandler_Create(t *testing.T) { r.POST("/api/v1/accounts/:account_id/custom_attribute_definitions", handler.Create) - body := service.CreateCustomAttributeDefinitionRequest{ - AttributeKey: "custom_priority_score", - AttributeDisplayName: "Custom Priority Score", - AttributeDisplayType: "number", - AttributeModel: "conversation", - } - bodyBytes := marshalNestedDef(body) + bodyBytes := []byte(`{"attribute_key":"custom_priority_score","attribute_display_name":"Custom Priority Score","attribute_description":"Score","attribute_display_type":1,"attribute_model":0}`) w := httptest.NewRecorder() req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/custom_attribute_definitions", accountID), bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - assert.Equal(t, http.StatusCreated, w.Code) + assert.Equal(t, http.StatusOK, w.Code) var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) - assert.True(t, resp["success"].(bool)) - data := resp["data"].(map[string]interface{}) - assert.Equal(t, "custom_priority_score", data["attribute_key"]) + assert.Equal(t, "custom_priority_score", resp["attribute_key"]) + assert.Equal(t, "number", resp["attribute_display_type"]) + assert.Equal(t, "conversation_attribute", resp["attribute_model"]) } func TestCustomAttributeDefinitionHandler_Create_InvalidType(t *testing.T) { @@ -233,9 +224,9 @@ func TestCustomAttributeDefinitionHandler_Create_InvalidType(t *testing.T) { r.POST("/api/v1/accounts/:account_id/custom_attribute_definitions", handler.Create) body := service.CreateCustomAttributeDefinitionRequest{ - AttributeKey: "custom_priority_score", + AttributeKey: "custom_priority_score", AttributeDisplayName: "Custom Priority Score", - AttributeDisplayType: "invalid_type", + AttributeDisplayType: "invalid_type", AttributeModel: "conversation", } bodyBytes := marshalNestedDef(body) @@ -254,9 +245,9 @@ func TestCustomAttributeDefinitionHandler_Create_InvalidAccountID(t *testing.T) r.POST("/api/v1/accounts/:account_id/custom_attribute_definitions", handler.Create) body := service.CreateCustomAttributeDefinitionRequest{ - AttributeKey: "custom_priority_score", + AttributeKey: "custom_priority_score", AttributeDisplayName: "Custom Priority Score", - AttributeDisplayType: "number", + AttributeDisplayType: "number", AttributeModel: "conversation", } bodyBytes := marshalNestedDef(body) @@ -293,7 +284,7 @@ func TestCustomAttributeDefinitionHandler_Update(t *testing.T) { body := service.UpdateCustomAttributeDefinitionRequest{ AttributeDisplayName: "Priority Level", - AttributeDescription: "Updated description", + AttributeDescription: "Updated description", } bodyBytes := marshalNestedDef(body) @@ -307,8 +298,7 @@ func TestCustomAttributeDefinitionHandler_Update(t *testing.T) { var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err) - data := resp["data"].(map[string]interface{}) - assert.Equal(t, "Priority Level", data["attribute_display_name"]) + assert.Equal(t, "Priority Level", resp["attribute_display_name"]) } func TestCustomAttributeDefinitionHandler_Update_NotFound(t *testing.T) { @@ -385,4 +375,4 @@ func TestCustomAttributeDefinitionHandler_Delete_InvalidAccountID(t *testing.T) r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/custom_filter_handler.go b/internal/handler/api/v1/custom_filter_handler.go index eefe2d7e..ccd1d542 100644 --- a/internal/handler/api/v1/custom_filter_handler.go +++ b/internal/handler/api/v1/custom_filter_handler.go @@ -2,14 +2,45 @@ package v1 import ( "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" - "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) +const defaultCustomFilterType = "conversation" + +type chatwootCustomFilterResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + FilterType string `json:"filter_type"` + Query interface{} `json:"query"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func serializeCustomFilter(filter *model.CustomFilter) chatwootCustomFilterResponse { + return chatwootCustomFilterResponse{ + ID: filter.ID, + Name: filter.Name, + FilterType: filter.FilterType, + Query: filter.Query, + CreatedAt: filter.CreatedAt, + UpdatedAt: filter.UpdatedAt, + } +} + +func serializeCustomFilters(filters []model.CustomFilter) []chatwootCustomFilterResponse { + items := make([]chatwootCustomFilterResponse, 0, len(filters)) + for i := range filters { + items = append(items, serializeCustomFilter(&filters[i])) + } + return items +} + // CustomFilterHandler handles CRUD for custom filters. // Reference: Chatwoot app/controllers/api/v1/accounts/custom_filters_controller.rb type CustomFilterHandler struct { @@ -23,7 +54,7 @@ func NewCustomFilterHandler(svc *service.CustomFilterService) *CustomFilterHandl // List retrieves all custom filters for an account. // GET /api/v1/accounts/:account_id/custom_filters -// Optional query param: type (conversation/contact/inbox) to filter by type. +// Optional query param: filter_type (conversation/contact/inbox) to filter by type. func (h *CustomFilterHandler) List(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { @@ -31,16 +62,16 @@ func (h *CustomFilterHandler) List(c *gin.Context) { return } - filterType := c.Query("type") - page := pagination.Parse(c) + filterType := c.DefaultQuery("filter_type", defaultCustomFilterType) + userID := getUserID(c) - filters, total, svcErr := h.svc.List(c.Request.Context(), accountID, filterType, page.Offset, page.PerPage) + filters, _, svcErr := h.svc.ListForUser(c.Request.Context(), accountID, userID, filterType, 0, 0) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OKWithMeta(c, filters, page.Page, page.PerPage, total) + c.JSON(http.StatusOK, serializeCustomFilters(filters)) } // Get retrieves a single custom filter by ID. @@ -58,13 +89,13 @@ func (h *CustomFilterHandler) Get(c *gin.Context) { return } - filter, svcErr := h.svc.Get(c.Request.Context(), accountID, id) + filter, svcErr := h.svc.GetForUser(c.Request.Context(), accountID, getUserID(c), id) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, filter) + c.JSON(http.StatusOK, serializeCustomFilter(filter)) } // Create creates a new custom filter for an account. @@ -78,15 +109,11 @@ func (h *CustomFilterHandler) Create(c *gin.Context) { userID := getUserID(c) - // Chatwoot: params.require(:custom_filter) → {"custom_filter": {...}} - var wrapper struct { - CustomFilter service.CreateCustomFilterRequest `json:"custom_filter"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.CreateCustomFilterRequest + if err := bindChatwootPayload(c, "custom_filter", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - req := wrapper.CustomFilter filter, svcErr := h.svc.Create(c.Request.Context(), accountID, userID, &req) if svcErr != nil { @@ -94,7 +121,7 @@ func (h *CustomFilterHandler) Create(c *gin.Context) { return } - response.Created(c, filter) + c.JSON(http.StatusOK, serializeCustomFilter(filter)) } // Update modifies a custom filter. @@ -112,23 +139,19 @@ func (h *CustomFilterHandler) Update(c *gin.Context) { return } - // Chatwoot: params.require(:custom_filter) → {"custom_filter": {...}} - var wrapper struct { - CustomFilter service.UpdateCustomFilterRequest `json:"custom_filter"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.UpdateCustomFilterRequest + if err := bindChatwootPayload(c, "custom_filter", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - req := wrapper.CustomFilter - filter, svcErr := h.svc.Update(c.Request.Context(), accountID, id, &req) + filter, svcErr := h.svc.UpdateForUser(c.Request.Context(), accountID, getUserID(c), id, &req) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, filter) + c.JSON(http.StatusOK, serializeCustomFilter(filter)) } // Delete soft-deletes a custom filter. @@ -146,7 +169,7 @@ func (h *CustomFilterHandler) Delete(c *gin.Context) { return } - svcErr := h.svc.Delete(c.Request.Context(), accountID, id) + svcErr := h.svc.DeleteForUser(c.Request.Context(), accountID, getUserID(c), id) if svcErr != nil { handleServiceError(c, svcErr) return diff --git a/internal/handler/api/v1/custom_filter_handler_test.go b/internal/handler/api/v1/custom_filter_handler_test.go index 7ac84711..ad1139f7 100644 --- a/internal/handler/api/v1/custom_filter_handler_test.go +++ b/internal/handler/api/v1/custom_filter_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -66,13 +67,27 @@ func (s *CustomFilterHandlerTestSuite) TestList_BadRequest_InvalidAccountID() { func (s *CustomFilterHandlerTestSuite) TestList_Success() { r := gin.New() - r.GET("/api/v1/accounts/:account_id/custom_filters", s.handler.List) + r.GET("/api/v1/accounts/:account_id/custom_filters", func(c *gin.Context) { + c.Set("user_id", uint(42)) + s.handler.List(c) + }) + + filter := &model.CustomFilter{AccountID: s.account.ID, CreatedByID: 42, Name: "conversation-filter", FilterType: "conversation", Query: []byte(`{"status":"open"}`)} + s.Require().NoError(s.db.Create(filter).Error) + otherUser := &model.CustomFilter{AccountID: s.account.ID, CreatedByID: 7, Name: "other-user-filter", FilterType: "conversation", Query: []byte(`{"status":"open"}`)} + s.Require().NoError(s.db.Create(otherUser).Error) + contactFilter := &model.CustomFilter{AccountID: s.account.ID, CreatedByID: 42, Name: "contact-filter", FilterType: "contact", Query: []byte(`{"name":"Ada"}`)} + s.Require().NoError(s.db.Create(contactFilter).Error) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/custom_filters", s.account.ID), nil) r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) + var resp []map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Require().Len(resp, 1) + assert.Equal(s.T(), "conversation-filter", resp[0]["name"]) } func (s *CustomFilterHandlerTestSuite) TestCreate_Success() { @@ -83,12 +98,17 @@ func (s *CustomFilterHandlerTestSuite) TestCreate_Success() { }) w := httptest.NewRecorder() - body := `{"custom_filter":{"name":"test-filter","filter_type":"conversation","query":{"status":"open"}}}` + body := `{"name":"test-filter","filter_type":"conversation","query":{"status":"open"}}` req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/custom_filters", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.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.Equal(s.T(), "test-filter", resp["name"]) + assert.Nil(s.T(), resp["account_id"]) + assert.Nil(s.T(), resp["user_id"]) } func (s *CustomFilterHandlerTestSuite) TestGet_Success() { @@ -106,7 +126,7 @@ func (s *CustomFilterHandlerTestSuite) TestGet_Success() { } func (s *CustomFilterHandlerTestSuite) TestUpdate_Success() { - filter := &model.CustomFilter{AccountID: s.account.ID, Name: "update-test-filter", FilterType: "conversation", Query: []byte(`{"status":"open"}`)} + filter := &model.CustomFilter{AccountID: s.account.ID, CreatedByID: 1, Name: "update-test-filter", FilterType: "conversation", Query: []byte(`{"status":"open"}`)} s.Require().NoError(s.db.Create(filter).Error) r := gin.New() @@ -182,4 +202,4 @@ func (s *CustomFilterHandlerTestSuite) TestDelete_BadRequest_InvalidID() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} \ No newline at end of file +} diff --git a/internal/repository/custom_attribute_definition_repo.go b/internal/repository/custom_attribute_definition_repo.go index 83656fcb..94cf5da8 100644 --- a/internal/repository/custom_attribute_definition_repo.go +++ b/internal/repository/custom_attribute_definition_repo.go @@ -44,7 +44,8 @@ func (r *CustomAttributeDefinitionRepo) FindByAccount(ctx context.Context, accou return nil, 0, err } - err := q.Offset(offset).Limit(limit).Order("created_at DESC").Find(&defs).Error + q = applyOffsetLimit(q, offset, limit) + err := q.Order("created_at DESC").Find(&defs).Error return defs, total, err } @@ -58,7 +59,8 @@ func (r *CustomAttributeDefinitionRepo) FindByAccountAndModel(ctx context.Contex return nil, 0, err } - err := q.Offset(offset).Limit(limit).Order("created_at DESC").Find(&defs).Error + q = applyOffsetLimit(q, offset, limit) + err := q.Order("created_at DESC").Find(&defs).Error return defs, total, err } diff --git a/internal/repository/custom_filter_repo.go b/internal/repository/custom_filter_repo.go index 6cff5052..240977e5 100644 --- a/internal/repository/custom_filter_repo.go +++ b/internal/repository/custom_filter_repo.go @@ -44,7 +44,8 @@ func (r *CustomFilterRepo) FindByAccount(ctx context.Context, accountID uint, of return nil, 0, err } - err := q.Offset(offset).Limit(limit).Order("created_at DESC").Find(&filters).Error + q = applyOffsetLimit(q, offset, limit) + err := q.Order("created_at DESC").Find(&filters).Error return filters, total, err } @@ -58,10 +59,36 @@ func (r *CustomFilterRepo) FindByAccountAndType(ctx context.Context, accountID u return nil, 0, err } - err := q.Offset(offset).Limit(limit).Order("created_at DESC").Find(&filters).Error + q = applyOffsetLimit(q, offset, limit) + err := q.Order("created_at DESC").Find(&filters).Error return filters, total, err } +// FindByAccountUserAndType retrieves filters by account, creator, and filter type. +func (r *CustomFilterRepo) FindByAccountUserAndType(ctx context.Context, accountID, userID uint, filterType string, offset, limit int) ([]model.CustomFilter, int64, error) { + var filters []model.CustomFilter + var total int64 + + q := r.db.WithContext(ctx).Where("account_id = ? AND created_by_id = ? AND filter_type = ?", accountID, userID, filterType) + if err := q.Model(&model.CustomFilter{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + q = applyOffsetLimit(q, offset, limit) + err := q.Order("created_at DESC").Find(&filters).Error + return filters, total, err +} + +func applyOffsetLimit(q *gorm.DB, offset, limit int) *gorm.DB { + if offset > 0 { + q = q.Offset(offset) + } + if limit > 0 { + q = q.Limit(limit) + } + return q +} + // Update modifies a filter by primary key. func (r *CustomFilterRepo) Update(ctx context.Context, filter *model.CustomFilter) error { return r.db.WithContext(ctx).Save(filter).Error diff --git a/internal/service/custom_attribute_definition_service.go b/internal/service/custom_attribute_definition_service.go index 1c0ded0c..7caa9c99 100644 --- a/internal/service/custom_attribute_definition_service.go +++ b/internal/service/custom_attribute_definition_service.go @@ -2,9 +2,11 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "regexp" + "strconv" "strings" "gorm.io/datatypes" @@ -40,38 +42,155 @@ func NewCustomAttributeDefinitionService(repo *repository.CustomAttributeDefinit // CreateCustomAttributeDefinitionRequest is the DTO for creating a definition. // Chatwoot compatibility: uses attribute_key, attribute_display_type, attribute_description type CreateCustomAttributeDefinitionRequest struct { - AttributeKey string `json:"attribute_key" validate:"required,min=1"` // Chatwoot: attribute_key (maps to attribute_name) - AttributeDisplayName string `json:"attribute_display_name" validate:"required,min=1"` - AttributeDisplayType string `json:"attribute_display_type" validate:"required,oneof=text number date checkbox list link currency percent"` // 1:1 Chatwoot: added currency, percent - AttributeModel string `json:"attribute_model" validate:"required,oneof=conversation contact company"` // 1:1 Chatwoot: added company_attribute - DefaultValue datatypes.JSON `json:"default_value,omitempty"` - AttributeValues datatypes.JSON `json:"attribute_values,omitempty"` // Predefined valid values for list-type attributes - RegexPattern string `json:"regex_pattern,omitempty"` // Regex validation pattern - RegexCue string `json:"regex_cue,omitempty"` // Regex validation hint - AttributeDescription string `json:"attribute_description,omitempty"` + AttributeKey string `json:"attribute_key" validate:"required,min=1"` // Chatwoot: attribute_key (maps to attribute_name) + AttributeDisplayName string `json:"attribute_display_name" validate:"required,min=1"` + AttributeDisplayType string `json:"attribute_display_type" validate:"required,oneof=text number currency percent link date list checkbox"` + AttributeModel string `json:"attribute_model" validate:"required,oneof=conversation_attribute contact_attribute company_attribute"` + DefaultValue datatypes.JSON `json:"default_value,omitempty"` + AttributeValues datatypes.JSON `json:"attribute_values,omitempty"` // Predefined valid values for list-type attributes + RegexPattern string `json:"regex_pattern,omitempty"` // Regex validation pattern + RegexCue string `json:"regex_cue,omitempty"` // Regex validation hint + AttributeDescription string `json:"attribute_description,omitempty"` } // UpdateCustomAttributeDefinitionRequest is the DTO for updating a definition. // Chatwoot compatibility: uses attribute_key, attribute_display_type, attribute_description type UpdateCustomAttributeDefinitionRequest struct { - AttributeDisplayName string `json:"attribute_display_name,omitempty" validate:"omitempty,min=1"` - AttributeDisplayType string `json:"attribute_display_type,omitempty" validate:"omitempty,oneof=text number date checkbox list link"` - DefaultValue datatypes.JSON `json:"default_value,omitempty"` - AttributeValues datatypes.JSON `json:"attribute_values,omitempty"` - RegexPattern string `json:"regex_pattern,omitempty"` - RegexCue string `json:"regex_cue,omitempty"` - AttributeDescription string `json:"attribute_description,omitempty"` + AttributeDisplayName string `json:"attribute_display_name,omitempty" validate:"omitempty,min=1"` + AttributeDisplayType string `json:"attribute_display_type,omitempty" validate:"omitempty,oneof=text number currency percent link date list checkbox"` + DefaultValue datatypes.JSON `json:"default_value,omitempty"` + AttributeValues datatypes.JSON `json:"attribute_values,omitempty"` + RegexPattern string `json:"regex_pattern,omitempty"` + RegexCue string `json:"regex_cue,omitempty"` + AttributeDescription string `json:"attribute_description,omitempty"` +} + +func (r *CreateCustomAttributeDefinitionRequest) UnmarshalJSON(data []byte) error { + type rawCreate struct { + AttributeKey string `json:"attribute_key"` + AttributeDisplayName string `json:"attribute_display_name"` + AttributeDisplayType json.RawMessage `json:"attribute_display_type"` + AttributeModel json.RawMessage `json:"attribute_model"` + DefaultValue datatypes.JSON `json:"default_value"` + AttributeValues datatypes.JSON `json:"attribute_values"` + RegexPattern string `json:"regex_pattern"` + RegexCue string `json:"regex_cue"` + AttributeDescription string `json:"attribute_description"` + } + var raw rawCreate + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.AttributeKey = raw.AttributeKey + r.AttributeDisplayName = raw.AttributeDisplayName + r.AttributeDisplayType = normalizeAttributeDisplayType(raw.AttributeDisplayType) + r.AttributeModel = normalizeAttributeModel(raw.AttributeModel) + r.DefaultValue = raw.DefaultValue + r.AttributeValues = raw.AttributeValues + r.RegexPattern = raw.RegexPattern + r.RegexCue = raw.RegexCue + r.AttributeDescription = raw.AttributeDescription + return nil +} + +func (r *UpdateCustomAttributeDefinitionRequest) UnmarshalJSON(data []byte) error { + type rawUpdate struct { + AttributeDisplayName string `json:"attribute_display_name"` + AttributeDisplayType json.RawMessage `json:"attribute_display_type"` + DefaultValue datatypes.JSON `json:"default_value"` + AttributeValues datatypes.JSON `json:"attribute_values"` + RegexPattern string `json:"regex_pattern"` + RegexCue string `json:"regex_cue"` + AttributeDescription string `json:"attribute_description"` + } + var raw rawUpdate + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.AttributeDisplayName = raw.AttributeDisplayName + r.AttributeDisplayType = normalizeAttributeDisplayType(raw.AttributeDisplayType) + r.DefaultValue = raw.DefaultValue + r.AttributeValues = raw.AttributeValues + r.RegexPattern = raw.RegexPattern + r.RegexCue = raw.RegexCue + r.AttributeDescription = raw.AttributeDescription + return nil +} + +func normalizeAttributeModel(raw json.RawMessage) string { + value := normalizeJSONEnum(raw) + switch value { + case "0", "conversation", "conversation_attribute": + return "conversation_attribute" + case "1", "contact", "contact_attribute": + return "contact_attribute" + case "2", "company", "company_attribute": + return "company_attribute" + default: + return value + } +} + +func normalizeAttributeModelValue(value string) string { + data, _ := json.Marshal(value) + return normalizeAttributeModel(data) +} + +func normalizeAttributeDisplayType(raw json.RawMessage) string { + value := normalizeJSONEnum(raw) + switch value { + case "0": + return "text" + case "1": + return "number" + case "2": + return "currency" + case "3": + return "percent" + case "4": + return "link" + case "5": + return "date" + case "6": + return "list" + case "7": + return "checkbox" + default: + return value + } +} + +func normalizeAttributeDisplayTypeValue(value string) string { + data, _ := json.Marshal(value) + return normalizeAttributeDisplayType(data) +} + +func normalizeJSONEnum(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return strings.TrimSpace(text) + } + var number int + if err := json.Unmarshal(raw, &number); err == nil { + return strconv.Itoa(number) + } + return strings.TrimSpace(string(raw)) } // Create creates a new custom attribute definition for an account. func (s *CustomAttributeDefinitionService) Create(ctx context.Context, accountID uint, req *CreateCustomAttributeDefinitionRequest) (*model.CustomAttributeDefinition, error) { - if err := pkgvalidator.ValidateStruct(req); err != nil { - return nil, err - } - // 1:1 Chatwoot: normalize_attribute_fields — strip whitespace req.AttributeKey = strings.TrimSpace(req.AttributeKey) req.AttributeDisplayName = strings.TrimSpace(req.AttributeDisplayName) + req.AttributeModel = normalizeAttributeModelValue(req.AttributeModel) + req.AttributeDisplayType = normalizeAttributeDisplayTypeValue(req.AttributeDisplayType) + + if err := pkgvalidator.ValidateStruct(req); err != nil { + return nil, err + } // 1:1 Chatwoot: attribute_key format validation — /\A[\p{L}\p{N}_.-]+\z/ if !attributeKeyFormatRegex.MatchString(req.AttributeKey) { @@ -123,6 +242,7 @@ func (s *CustomAttributeDefinitionService) Get(ctx context.Context, accountID, i // List retrieves definitions for an account, optionally filtered by attribute_model. func (s *CustomAttributeDefinitionService) List(ctx context.Context, accountID uint, attributeModel string, offset, limit int) ([]model.CustomAttributeDefinition, int64, error) { + attributeModel = normalizeAttributeModelValue(attributeModel) if attributeModel != "" { return s.repo.FindByAccountAndModel(ctx, accountID, attributeModel, offset, limit) } @@ -131,6 +251,9 @@ func (s *CustomAttributeDefinitionService) List(ctx context.Context, accountID u // Update modifies a definition, verifying it belongs to the account. func (s *CustomAttributeDefinitionService) Update(ctx context.Context, accountID, id uint, req *UpdateCustomAttributeDefinitionRequest) (*model.CustomAttributeDefinition, error) { + req.AttributeDisplayType = normalizeAttributeDisplayTypeValue(req.AttributeDisplayType) + req.AttributeDisplayName = strings.TrimSpace(req.AttributeDisplayName) + if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } @@ -184,4 +307,4 @@ func (s *CustomAttributeDefinitionService) Delete(ctx context.Context, accountID } return s.repo.Delete(ctx, id) -} \ No newline at end of file +} diff --git a/internal/service/custom_attribute_definition_service_test.go b/internal/service/custom_attribute_definition_service_test.go index 8236f31e..d1f7106d 100644 --- a/internal/service/custom_attribute_definition_service_test.go +++ b/internal/service/custom_attribute_definition_service_test.go @@ -53,11 +53,11 @@ func TestCustomAttributeDefinitionService_Create(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "custom_priority_score", + AttributeKey: "custom_priority_score", AttributeDisplayName: "Custom Priority Score", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", - AttributeDescription: "Conversation priority level", + AttributeDescription: "Conversation priority level", } def, err := svc.Create(context.Background(), accountID, req) @@ -67,7 +67,7 @@ func TestCustomAttributeDefinitionService_Create(t *testing.T) { assert.Equal(t, "custom_priority_score", def.AttributeName) assert.Equal(t, "Custom Priority Score", def.AttributeDisplayName) assert.Equal(t, "text", def.AttributeType) - assert.Equal(t, "conversation", def.AttributeModel) + assert.Equal(t, "conversation_attribute", def.AttributeModel) assert.Equal(t, "Conversation priority level", def.Description) } @@ -75,9 +75,9 @@ func TestCustomAttributeDefinitionService_Create_InvalidType(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "bad_type", + AttributeKey: "bad_type", AttributeDisplayName: "Bad Type", - AttributeDisplayType: "invalid_type", + AttributeDisplayType: "invalid_type", AttributeModel: "conversation", } @@ -89,9 +89,9 @@ func TestCustomAttributeDefinitionService_Create_InvalidModel(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "bad_model", + AttributeKey: "bad_model", AttributeDisplayName: "Bad Model", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "invalid_model", } @@ -103,9 +103,9 @@ func TestCustomAttributeDefinitionService_Create_EmptyName(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "", + AttributeKey: "", AttributeDisplayName: "Empty Name", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } @@ -119,9 +119,9 @@ func TestCustomAttributeDefinitionService_Create_AllValidTypes(t *testing.T) { validTypes := []string{"text", "number", "date", "checkbox", "list", "link"} for _, attrType := range validTypes { req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "attr_" + attrType, + AttributeKey: "attr_" + attrType, AttributeDisplayName: "Attr " + attrType, - AttributeDisplayType: attrType, + AttributeDisplayType: attrType, AttributeModel: "conversation", } def, err := svc.Create(context.Background(), accountID, req) @@ -136,9 +136,9 @@ func TestCustomAttributeDefinitionService_Get(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "status", + AttributeKey: "status", AttributeDisplayName: "Status", - AttributeDisplayType: "list", + AttributeDisplayType: "list", AttributeModel: "contact", } def, err := svc.Create(context.Background(), accountID, req) @@ -154,9 +154,9 @@ func TestCustomAttributeDefinitionService_Get_WrongAccount(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "private_attr", + AttributeKey: "private_attr", AttributeDisplayName: "Private", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } def, err := svc.Create(context.Background(), accountID, req) @@ -181,9 +181,9 @@ func TestCustomAttributeDefinitionService_List_All(t *testing.T) { for _, name := range []string{"attr_a", "attr_b"} { req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: name, + AttributeKey: name, AttributeDisplayName: name, - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } _, err := svc.Create(context.Background(), accountID, req) @@ -192,9 +192,9 @@ func TestCustomAttributeDefinitionService_List_All(t *testing.T) { // Create a contact attribute req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "contact_level", + AttributeKey: "contact_level", AttributeDisplayName: "Contact Level", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "contact", } _, err := svc.Create(context.Background(), accountID, req) @@ -212,9 +212,9 @@ func TestCustomAttributeDefinitionService_List_FilterByModel(t *testing.T) { // Create conversation attributes for _, name := range []string{"conv_a", "conv_b"} { req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: name, + AttributeKey: name, AttributeDisplayName: name, - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } _, err := svc.Create(context.Background(), accountID, req) @@ -223,9 +223,9 @@ func TestCustomAttributeDefinitionService_List_FilterByModel(t *testing.T) { // Create contact attribute req := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "contact_c", + AttributeKey: "contact_c", AttributeDisplayName: "Contact C", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "contact", } _, err := svc.Create(context.Background(), accountID, req) @@ -248,9 +248,9 @@ func TestCustomAttributeDefinitionService_Update(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) createReq := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "custom_priority_score", + AttributeKey: "custom_priority_score", AttributeDisplayName: "Custom Priority Score", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } def, err := svc.Create(context.Background(), accountID, createReq) @@ -258,8 +258,8 @@ func TestCustomAttributeDefinitionService_Update(t *testing.T) { updateReq := &UpdateCustomAttributeDefinitionRequest{ AttributeDisplayName: "Priority Level", - AttributeDisplayType: "number", - AttributeDescription: "Updated description", + AttributeDisplayType: "number", + AttributeDescription: "Updated description", } updated, err := svc.Update(context.Background(), accountID, def.ID, updateReq) @@ -269,16 +269,16 @@ func TestCustomAttributeDefinitionService_Update(t *testing.T) { assert.Equal(t, "Updated description", updated.Description) // Name and model should stay unchanged assert.Equal(t, "custom_priority_score", updated.AttributeName) - assert.Equal(t, "conversation", updated.AttributeModel) + assert.Equal(t, "conversation_attribute", updated.AttributeModel) } func TestCustomAttributeDefinitionService_Update_WrongAccount(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) createReq := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "private_attr", + AttributeKey: "private_attr", AttributeDisplayName: "Private", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } def, err := svc.Create(context.Background(), accountID, createReq) @@ -309,9 +309,9 @@ func TestCustomAttributeDefinitionService_Delete(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) createReq := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "temp_attr", + AttributeKey: "temp_attr", AttributeDisplayName: "Temp", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } def, err := svc.Create(context.Background(), accountID, createReq) @@ -329,9 +329,9 @@ func TestCustomAttributeDefinitionService_Delete_WrongAccount(t *testing.T) { svc, accountID := setupCustomAttrDefService(t) createReq := &CreateCustomAttributeDefinitionRequest{ - AttributeKey: "private_attr", + AttributeKey: "private_attr", AttributeDisplayName: "Private", - AttributeDisplayType: "text", + AttributeDisplayType: "text", AttributeModel: "conversation", } def, err := svc.Create(context.Background(), accountID, createReq) diff --git a/internal/service/custom_attribute_value_service.go b/internal/service/custom_attribute_value_service.go index 9b61ac4b..ff143b9c 100644 --- a/internal/service/custom_attribute_value_service.go +++ b/internal/service/custom_attribute_value_service.go @@ -17,8 +17,8 @@ import ( // Reference: Chatwoot app/controllers/api/v1/accounts/custom_attribute_definitions_controller.rb // The value operations merge/remove individual keys into the CustomAttributes jsonb field. type CustomAttributeValueService struct { - defRepo *repository.CustomAttributeDefinitionRepo - convRepo *repository.ConversationRepo + defRepo *repository.CustomAttributeDefinitionRepo + convRepo *repository.ConversationRepo contactRepo *repository.ContactRepo } @@ -29,8 +29,8 @@ func NewCustomAttributeValueService( contactRepo *repository.ContactRepo, ) *CustomAttributeValueService { return &CustomAttributeValueService{ - defRepo: defRepo, - convRepo: convRepo, + defRepo: defRepo, + convRepo: convRepo, contactRepo: contactRepo, } } @@ -43,10 +43,17 @@ type SetAttributeValueRequest struct { // validateAttributeDefinition checks that the attribute definition exists for the account and model. func (s *CustomAttributeValueService) validateAttributeDefinition(ctx context.Context, accountID uint, attributeName, attributeModel string) (*model.CustomAttributeDefinition, error) { - defs, _, err := s.defRepo.FindByAccountAndModel(ctx, accountID, attributeModel, 0, 1000) + normalizedModel := normalizeAttributeModelValue(attributeModel) + defs, _, err := s.defRepo.FindByAccountAndModel(ctx, accountID, normalizedModel, 0, 1000) if err != nil { return nil, err } + if len(defs) == 0 && normalizedModel != attributeModel { + defs, _, err = s.defRepo.FindByAccountAndModel(ctx, accountID, attributeModel, 0, 1000) + if err != nil { + return nil, err + } + } for _, def := range defs { if def.AttributeName == attributeName { return &def, nil diff --git a/internal/service/custom_filter_service.go b/internal/service/custom_filter_service.go index ec3f544b..7ca6b037 100644 --- a/internal/service/custom_filter_service.go +++ b/internal/service/custom_filter_service.go @@ -72,6 +72,18 @@ func (s *CustomFilterService) Get(ctx context.Context, accountID, id uint) (*mod return filter, nil } +// GetForUser retrieves a filter scoped the way Chatwoot does: account + current user. +func (s *CustomFilterService) GetForUser(ctx context.Context, accountID, userID, id uint) (*model.CustomFilter, error) { + filter, err := s.Get(ctx, accountID, id) + if err != nil { + return nil, err + } + if filter.CreatedByID != userID { + return nil, errors.New("custom filter not found") + } + return filter, nil +} + // List retrieves filters for an account, optionally filtered by type. func (s *CustomFilterService) List(ctx context.Context, accountID uint, filterType string, offset, limit int) ([]model.CustomFilter, int64, error) { if filterType != "" { @@ -80,6 +92,14 @@ func (s *CustomFilterService) List(ctx context.Context, accountID uint, filterTy return s.repo.FindByAccount(ctx, accountID, offset, limit) } +// ListForUser retrieves filters using Chatwoot's account + user + filter_type scope. +func (s *CustomFilterService) ListForUser(ctx context.Context, accountID, userID uint, filterType string, offset, limit int) ([]model.CustomFilter, int64, error) { + if filterType == "" { + filterType = "conversation" + } + return s.repo.FindByAccountUserAndType(ctx, accountID, userID, filterType, offset, limit) +} + // Update modifies a filter, verifying it belongs to the account. func (s *CustomFilterService) Update(ctx context.Context, accountID, id uint, req *UpdateCustomFilterRequest) (*model.CustomFilter, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { @@ -112,6 +132,38 @@ func (s *CustomFilterService) Update(ctx context.Context, accountID, id uint, re return filter, nil } +// UpdateForUser updates a custom filter only when owned by the current user. +func (s *CustomFilterService) UpdateForUser(ctx context.Context, accountID, userID, id uint, req *UpdateCustomFilterRequest) (*model.CustomFilter, error) { + filter, err := s.GetForUser(ctx, accountID, userID, id) + if err != nil { + return nil, err + } + return s.updateFilter(ctx, filter, req) +} + +func (s *CustomFilterService) updateFilter(ctx context.Context, filter *model.CustomFilter, req *UpdateCustomFilterRequest) (*model.CustomFilter, error) { + if err := pkgvalidator.ValidateStruct(req); err != nil { + return nil, err + } + + if req.Name != "" { + filter.Name = req.Name + } + if req.FilterType != "" { + filter.FilterType = req.FilterType + } + if req.Query != nil { + filter.Query = req.Query + } + + if err := s.repo.Update(ctx, filter); err != nil { + applogger.L().Errorf("Update custom filter failed: %v", err) + return nil, err + } + + return filter, nil +} + // Delete soft-deletes a filter, verifying it belongs to the account. func (s *CustomFilterService) Delete(ctx context.Context, accountID, id uint) error { filter, err := s.repo.GetByID(ctx, id) @@ -123,4 +175,13 @@ func (s *CustomFilterService) Delete(ctx context.Context, accountID, id uint) er } return s.repo.Delete(ctx, id) -} \ No newline at end of file +} + +// DeleteForUser deletes a custom filter only when owned by the current user. +func (s *CustomFilterService) DeleteForUser(ctx context.Context, accountID, userID, id uint) error { + filter, err := s.GetForUser(ctx, accountID, userID, id) + if err != nil { + return err + } + return s.repo.Delete(ctx, filter.ID) +}