diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 475d126f..c21cb0c3 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -37,10 +37,10 @@ Hermes plan landing map: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(integrations): align app hook payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): align app hook payloads`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(companies): align avatar form payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(companies): align avatar form payloads`. - Latest documentation/tooling checkpoint: `docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P3.11 account integrations apps/hooks API parity is now implemented for the reused dashboard integrations settings flows. Account `/integrations/apps` and `/integrations/hooks` now track Chatwoot `routes.rb:345-348`, return Chatwoot-style raw app/hook payloads, expose frontend no-trailing-slash create/list routes plus hook `PATCH`, accept raw frontend hook bodies and nested `{ hook: ... }`, persist `app_id` and `reference_id`, seed the reference app catalog additions, scope hook show/update/delete through current account, serialize hook `status` as boolean and `hook_type` as account/inbox, include app `hooks`, `settings_form_schema`, and `visible_properties`, and return empty `200 OK` delete. P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: P3.5 company avatar form/pagination drift is now implemented for reused dashboard company create/update flows. Account `/companies` create/update now accepts Chatwoot nested multipart `company[...]` bodies generated when the frontend uploads an avatar, maps `company[avatar]` into the serialized `avatar_url` boundary, preserves nested `company[custom_attributes][...]` form fields, and forces company list/search/contact pagination to the reference 25-row page size instead of honoring local `per_page` overrides. P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 869` after adding account integration app/hook no-trailing-slash and PATCH routes. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -195,6 +195,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(companies): align avatar form payloads` | Advances P3.5 company settings/CRM parity by matching Chatwoot enterprise `CompaniesController#create/#update/#index/#search`, company Jbuilder payloads, and the reused dashboard companies Pinia store/API behavior. GoChat company create/update now accepts nested multipart `company[...]` form bodies used when the frontend sends avatar uploads, maps uploaded `company[avatar]` into the serialized `avatar_url` field, preserves nested multipart `company[custom_attributes][...]` values, and makes company list/search/contact list/search use the reference fixed `RESULTS_PER_PAGE = 25` instead of local `per_page` overrides. | `go test ./internal/handler/api/v1 -run CompanyHandler -count=1`; `go test ./internal/service -run Company -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 869` and tracked route parity remains `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`. | P3.5 remains Doing for any future contact/company drift from live smoke or fresh reference inspection; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(integrations): align app hook payloads` | Advances P3.11 account integrations apps/hooks parity by matching Chatwoot `Integrations::AppsController`, `Integrations::HooksController`, app/hook Jbuilder views, `Integrations::App`, `Integrations::Hook`, `config/integration/apps.yml`, and the reused dashboard integrations API/store/settings components. GoChat now tracks `routes.rb:345-348`, registers frontend no-trailing-slash app/hook routes plus hook `PATCH`, returns Chatwoot `{ payload: [...] }` app index and raw app/hook objects, accepts raw frontend hook bodies plus nested `{ hook: ... }`, persists `app_id` and `reference_id`, scopes hook show/update/delete by account, serializes boolean hook `status`, account/inbox `hook_type`, settings, inbox, app hooks, settings schemas, visible properties, and seeds the additional reference app catalog rows. | `go test ./internal/handler/api/v1 -run IntegrationHook -count=1`; `go test ./internal/service -run IntegrationHook -count=1`; `go test ./cmd/migrate -count=1`; `go test ./cmd/route_parity -count=1`; `go test ./internal/router -run Router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 869`; tracked route parity is `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`. | P3.11 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(webhooks): align account payloads` | Advances P3.10 account webhook API parity by matching Chatwoot `WebhooksController`, account webhook Jbuilder views, `Webhook` model validation, and the reused dashboard `webhooks` API/store/settings components. GoChat now tracks `routes.rb:342`, registers frontend `PATCH /api/v1/accounts/:account_id/webhooks/:webhook_id`, returns Chatwoot `{ payload: { webhooks } }` and `{ payload: { webhook } }` payloads, accepts nested `{ webhook: ... }` bodies, persists `name`, `inbox_id`, `url`, `subscriptions`, and generated `secret`, scopes show/update/delete by account, validates HTTP/HTTPS URLs plus the reference allowed event set, returns empty `200 OK` delete, and keeps delivery filtering backed by persisted subscription rows. | `go test ./internal/handler/api/v1 -run WebhookSubscription -count=1`; `go test ./internal/service -run WebhookSubscription -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router ./cmd/route_parity -run 'WebhookSubscription\|Router\|RouteParity' -count=1`; `go test ./cmd/migrate -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 865`; tracked route parity is `313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320`. | P3.10 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(agent-bots): align account payloads` | Advances P3.9 account agent-bot API parity by matching Chatwoot `AgentBotsController`, account agent-bot Jbuilder views, `_agent_bot.json.jbuilder`, `AgentBotPresenter`, and the reused dashboard `AgentBotsAPI`/`agentBots` store path. GoChat now registers frontend no-trailing-slash `GET/POST /api/v1/accounts/:account_id/agent_bots` plus `PATCH /agent_bots/:id`, tracks `routes.rb:94-97`, returns raw bot arrays/objects with `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accepts JSON and multipart form bodies, preserves explicit empty update values, scopes mutation/reset/avatar actions to current-account bots while allowing index/show of system bots, returns empty `200 OK` delete, and returns full bot payloads for reset/avatar actions. | `go test ./internal/handler/api/v1 -run AgentBot -count=1`; `go test ./internal/service -run AgentBot -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'AgentBot\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./cmd/route_parity -count=1`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 864`; tracked route parity is `308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315`. | P3.9 moves to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -1645,7 +1646,7 @@ Frontend-critical API groups to audit first: | P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; account show/update/create/cache-key/active-at plus agents/teams/team-member, assignable-agent settings payloads, profile account permission arrays, platform account-user raw/idempotent membership, agents `order_by_full_name` 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, and shared attachments. | Doing | +| 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. | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Doing; label CRUD is in Review with Chatwoot `title/description/color/show_on_sidebar` payloads and raw/list response contracts, while custom attributes/custom filters remain evidence-driven follow-ups. | | P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, account campaign CRUD/scheduling, and help-center portal/category/article routes/payloads now have focused Chatwoot payload fixtures. Continue only with fresh reference/smoke drift. | | P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Doing | @@ -1658,7 +1659,7 @@ Serializer parity work plan: | --- | --- | --- | --- | --- | | S1 | Auth and profile | `reference/chatwoot/app/controllers/api/v1/profile*`, frontend auth API usage | fixture tests for current user/profile payloads | Done | | S2 | Conversations and messages | `reference/chatwoot/app/controllers/api/v1/accounts/conversations*`, serializers/entities | fixture tests for index/show/message create/update | Review | -| S3 | Contacts and companies | `reference/chatwoot/app/controllers/api/v1/accounts/contacts*`, `companies*` | fixture tests for list/show/search/merge/relation/shared attachment payloads | Doing; contact shared attachments and conversation shared-file payloads now have focused Chatwoot-style coverage. | +| S3 | Contacts and companies | `reference/chatwoot/app/controllers/api/v1/accounts/contacts*`, enterprise `companies*` | fixture tests for list/show/search/merge/relation/shared attachment/avatar payloads | Doing; contact shared attachments, conversation shared-file payloads, company multipart avatar form bodies, and fixed company pagination now have focused Chatwoot-style coverage. | | S4 | Inboxes and channels | `reference/chatwoot/app/controllers/api/v1/accounts/inboxes*`, channel controllers | fixture tests for inbox CRUD, channel settings, widget config | Done | | S5 | Notifications and settings | `reference/chatwoot/app/controllers/api/v1/accounts/notifications*` | fixture tests for notification list/actions/settings | Review; list envelope, includes filters, unread counts, read_all, unread/snooze/update raw responses, and destroy_all read/all are covered. | | S6 | Reports and CSAT | `reference/chatwoot/app/controllers/api/v1/accounts/reports*`, `csat_survey_responses*` | fixture tests for report filters and CSAT metrics/list | Doing | @@ -2158,6 +2159,7 @@ Verification milestone gates: ## Progress Log +- 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. - 2026-06-06: P3.11 account integrations apps/hooks checkpoint prepared as `feat(integrations): align app hook payloads`; audited Chatwoot `routes.rb:345-348`, `Integrations::AppsController`, `Integrations::HooksController`, app/hook Jbuilder views, `Integrations::App`, `Integrations::Hook`, `config/integration/apps.yml`, and reused dashboard `api/integrations.js`, `store/modules/integrations.js`, and settings integration components. Account integration apps/hooks now return Chatwoot `{ payload: [...] }` app index payloads and raw app/hook objects, accept raw frontend hook bodies plus nested `{ hook: ... }`, register frontend no-trailing-slash app/hook routes and hook `PATCH`, persist `app_id/reference_id`, scope hook show/update/delete by account, serialize boolean `status`, account/inbox `hook_type`, `settings`, `inbox`, app `hooks`, settings schemas, and visible properties, seed additional reference app catalog rows, and return empty `200 OK` delete. Focused integration hook handler/service tests, migration test, route parity test, router focused test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 869` and tracked route parity is `321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328`. - 2026-06-06: P3.10 account webhook API checkpoint prepared as `feat(webhooks): align account payloads`; audited Chatwoot `routes.rb:342`, `WebhooksController`, account webhook Jbuilder views, `Webhook`, reused dashboard `api/webhooks.js`, `store/modules/webhooks.js`, and settings Webhooks components. Account webhook APIs now return Chatwoot `{ payload: { webhooks } }` list payloads and `{ payload: { webhook } }` mutation/show payloads, accept nested `{ webhook: ... }` frontend bodies plus legacy raw bodies, register frontend `PATCH` update, persist `name/url/subscriptions/inbox_id/secret`, scope show/update/delete by account, validate HTTP/HTTPS URLs and allowed subscriptions, return empty `200 OK` delete, and keep outbound delivery event filtering on the same rows. Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 865` and tracked route parity is `313 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 320`. - 2026-06-06: P3.9 account agent-bot API checkpoint prepared as `feat(agent-bots): align account payloads`; audited Chatwoot `routes.rb:94-97`, `AgentBotsController`, account agent-bot Jbuilder views, `_agent_bot.json.jbuilder`, `AgentBotPresenter`, `AgentBot`, and reused dashboard `api/agentBots.js`, `store/modules/agentBots.js`, and settings agent-bot screens. Account agent-bot APIs now expose frontend no-trailing-slash GET/POST and PATCH update routes, return raw Chatwoot bot arrays/objects instead of local success/data/meta envelopes, serialize `thumbnail`, `bot_config`, `system_bot`, account-scoped `access_token`/`secret`, accept JSON or multipart form bodies, preserve explicit empty update fields, scope create/update/delete/reset/avatar mutations to current-account bots, allow show/index of system bots through `accessible_to`, return empty `200 OK` delete, and return full bot payloads after reset-access-token/reset-secret/avatar deletion. Focused AgentBot handler/service/router tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed; route dump is `TOTAL: 864` and tracked route parity is `308 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 315`. diff --git a/internal/handler/api/v1/company_handler.go b/internal/handler/api/v1/company_handler.go index e8a5f80e..753f2d80 100644 --- a/internal/handler/api/v1/company_handler.go +++ b/internal/handler/api/v1/company_handler.go @@ -1,8 +1,10 @@ package v1 import ( + "encoding/json" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" @@ -33,7 +35,7 @@ func (h *CompanyHandler) List(c *gin.Context) { return } - pg := pagination.Parse(c) + pg := parseCompanyPagination(c) sort := c.DefaultQuery("sort", "") companies, total, err := h.svc.List(c.Request.Context(), accountID, pg.Offset, pg.PerPage, sort) @@ -55,7 +57,7 @@ func (h *CompanyHandler) Search(c *gin.Context) { return } - pg := pagination.Parse(c) + pg := parseCompanyPagination(c) query := c.DefaultQuery("q", "") if query == "" { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"}) @@ -108,7 +110,7 @@ func (h *CompanyHandler) Create(c *gin.Context) { } var req service.CreateCompanyRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := bindCompanyRequest(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } @@ -138,7 +140,7 @@ func (h *CompanyHandler) Update(c *gin.Context) { } var req service.UpdateCompanyRequest - if err := c.ShouldBindJSON(&req); err != nil { + if err := bindCompanyRequest(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } @@ -239,7 +241,7 @@ func (h *CompanyHandler) ListContacts(c *gin.Context) { return } - pg := pagination.Parse(c) + pg := parseCompanyPagination(c) contacts, total, svcErr := h.svc.ListContacts(c.Request.Context(), uint(companyID), accountID, pg.Offset, pg.PerPage) if svcErr != nil { @@ -292,7 +294,7 @@ func (h *CompanyHandler) SearchContacts(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"}) return } - pg := pagination.Parse(c) + pg := parseCompanyPagination(c) contacts, total, svcErr := h.svc.SearchContacts(c.Request.Context(), uint(companyID), accountID, query, pg.Offset, pg.PerPage) if svcErr != nil { handleServiceError(c, svcErr) @@ -471,3 +473,82 @@ func parseCompanyContactID(c *gin.Context) (uint, error) { } return req.ContactID, nil } + +type companyRequest interface { + *service.CreateCompanyRequest | *service.UpdateCompanyRequest +} + +func parseCompanyPagination(c *gin.Context) pagination.Params { + pg := pagination.Parse(c) + pg.PerPage = service.CompanyResultsPerPage + pg.Offset = (pg.Page - 1) * pg.PerPage + return pg +} + +func bindCompanyRequest[T companyRequest](c *gin.Context, req T) error { + if !strings.HasPrefix(c.GetHeader("Content-Type"), "multipart/form-data") { + return c.ShouldBindJSON(req) + } + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + return err + } + name := companyFormValue(c, "name") + description := companyFormValue(c, "description") + websiteURL := companyFormValue(c, "website_url") + faviconURL := companyFormValue(c, "favicon_url") + domain := companyFormValue(c, "domain") + if file, err := c.FormFile("company[avatar]"); err == nil && file != nil { + faviconURL = file.Filename + } + customAttributes := companyFormJSON(c, "custom_attributes") + + switch r := any(req).(type) { + case *service.CreateCompanyRequest: + r.Name = name + r.Description = description + r.WebsiteURL = websiteURL + r.FaviconURL = faviconURL + r.Domain = domain + r.CustomAttributes = customAttributes + case *service.UpdateCompanyRequest: + r.Name = name + r.Description = description + r.WebsiteURL = websiteURL + r.FaviconURL = faviconURL + r.Domain = domain + r.CustomAttributes = customAttributes + } + return nil +} + +func companyFormValue(c *gin.Context, key string) string { + if value := c.PostForm("company[" + key + "]"); value != "" { + return value + } + return c.PostForm(key) +} + +func companyFormJSON(c *gin.Context, key string) []byte { + if c.Request.MultipartForm == nil { + return nil + } + if value := companyFormValue(c, key); value != "" { + if json.Valid([]byte(value)) { + return []byte(value) + } + } + prefix := "company[" + key + "][" + attrs := map[string]any{} + for formKey, values := range c.Request.MultipartForm.Value { + if !strings.HasPrefix(formKey, prefix) || !strings.HasSuffix(formKey, "]") || len(values) == 0 { + continue + } + attrKey := strings.TrimSuffix(strings.TrimPrefix(formKey, prefix), "]") + attrs[attrKey] = values[0] + } + if len(attrs) == 0 { + return nil + } + data, _ := json.Marshal(attrs) + return data +} diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index 31e5cd3c..f6876e9d 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -153,6 +154,26 @@ func (s *CompanyHandlerTestSuite) makeRequest(method, path string, body interfac return w } +func (s *CompanyHandlerTestSuite) makeMultipartRequest(method, path string, fields map[string]string, files map[string]string) *httptest.ResponseRecorder { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for key, value := range fields { + s.Require().NoError(writer.WriteField(key, value)) + } + for key, filename := range files { + part, err := writer.CreateFormFile(key, filename) + s.Require().NoError(err) + _, err = part.Write([]byte("avatar-bytes")) + s.Require().NoError(err) + } + s.Require().NoError(writer.Close()) + req, _ := http.NewRequest(method, path, body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + return w +} + // ========== List ========== func (s *CompanyHandlerTestSuite) TestList_Empty() { @@ -184,6 +205,22 @@ func (s *CompanyHandlerTestSuite) TestList_WithCompanies() { assert.Equal(s.T(), float64(2), resp["meta"].(map[string]interface{})["total_count"]) } +func (s *CompanyHandlerTestSuite) TestList_IgnoresPerPage() { + companyRepo := repository.NewCompanyRepo(s.db) + for _, name := range []string{"ListCorp1", "ListCorp2", "ListCorp3"} { + s.Require().NoError(companyRepo.Create(context.Background(), &model.Company{AccountID: s.accountID, Name: name})) + } + + w := s.makeRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/companies/?page=1&per_page=1", s.accountID), nil) + assert.Equal(s.T(), http.StatusOK, w.Code) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + assert.Len(s.T(), payload, 3) + assert.Equal(s.T(), float64(3), resp["meta"].(map[string]interface{})["total_count"]) +} + // ========== Create ========== func (s *CompanyHandlerTestSuite) TestCreate_Success() { @@ -204,6 +241,29 @@ func (s *CompanyHandlerTestSuite) TestCreate_Success() { assert.Equal(s.T(), "NewCorp", companyData["name"]) } +func (s *CompanyHandlerTestSuite) TestCreate_MultipartAvatar() { + w := s.makeMultipartRequest( + "POST", + fmt.Sprintf("/api/v1/accounts/%d/companies/", s.accountID), + map[string]string{ + "company[name]": "AvatarCorp", + "company[domain]": "avatar.example", + "company[custom_attributes][segment]": "enterprise", + }, + map[string]string{"company[avatar]": "avatar.png"}, + ) + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + companyData := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "AvatarCorp", companyData["name"]) + assert.Equal(s.T(), "avatar.example", companyData["domain"]) + assert.Equal(s.T(), "avatar.png", companyData["avatar_url"]) + attrs := companyData["custom_attributes"].(map[string]interface{}) + assert.Equal(s.T(), "enterprise", attrs["segment"]) +} + func (s *CompanyHandlerTestSuite) TestCreate_ValidationError() { body := map[string]interface{}{ "name": "", // required field @@ -276,6 +336,26 @@ func (s *CompanyHandlerTestSuite) TestUpdate_Success() { assert.Equal(s.T(), "UpdateCorpUpdated", companyData["name"]) } +func (s *CompanyHandlerTestSuite) TestUpdate_MultipartAvatar() { + companyRepo := repository.NewCompanyRepo(s.db) + company := &model.Company{AccountID: s.accountID, Name: "AvatarUpdateCorp"} + s.Require().NoError(companyRepo.Create(context.Background(), company)) + + w := s.makeMultipartRequest( + "PATCH", + fmt.Sprintf("/api/v1/accounts/%d/companies/%d", s.accountID, company.ID), + map[string]string{"company[name]": "AvatarUpdateCorp", "company[domain]": "updated-avatar.example"}, + map[string]string{"company[avatar]": "updated-avatar.png"}, + ) + assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) + + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + companyData := resp["payload"].(map[string]interface{}) + assert.Equal(s.T(), "updated-avatar.example", companyData["domain"]) + assert.Equal(s.T(), "updated-avatar.png", companyData["avatar_url"]) +} + func (s *CompanyHandlerTestSuite) TestUpdate_NotFound() { body := map[string]interface{}{ "name": "NonexistentCorp", diff --git a/internal/service/company_service.go b/internal/service/company_service.go index d3d46b6b..433e3e0b 100644 --- a/internal/service/company_service.go +++ b/internal/service/company_service.go @@ -25,6 +25,8 @@ type CompanyService struct { searchReader CompanySearchReader } +const CompanyResultsPerPage = 25 + // NewCompanyService creates a new Company service. func NewCompanyService(companyRepo *repository.CompanyRepo, contactRepo *repository.ContactRepo, conversationRepo *repository.ConversationRepo) *CompanyService { return &CompanyService{