From c64b4c2f927bb5d7e99eadcab77d487b262913d9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 07:23:55 +0800 Subject: [PATCH] feat(help-center): expose public category lists --- cmd/route_parity/main.go | 3 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 17 ++-- docs/parity/gochat_routes.txt | 5 +- docs/parity/route_parity.md | 5 +- internal/handler/api/v1/category_handler.go | 80 +++++++++++++++++++ .../handler/api/v1/category_handler_test.go | 61 ++++++++++++++ internal/repository/category_repo.go | 5 +- internal/router/router.go | 3 + internal/router/router_test.go | 2 + internal/service/category_service.go | 8 ++ 10 files changed, 179 insertions(+), 10 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index c7fb1874..608c194f 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -353,6 +353,9 @@ var criticalRoutes = []route{ {Method: "PUT", Path: "/public/api/v1/csat_survey/:id", Controller: "public/api/v1/csat_survey#update", Source: "routes.rb:585"}, {Method: "GET", Path: "/hc/:slug/:locale/articles", Controller: "public/api/v1/portals/articles#index", Source: "routes.rb:594"}, {Method: "GET", Path: "/hc/:slug/:locale/articles.json", Controller: "public/api/v1/portals/articles#index", Source: "routes.rb:594"}, + {Method: "GET", Path: "/hc/:slug/:locale/categories", Controller: "public/api/v1/portals/categories#index", Source: "routes.rb:595"}, + {Method: "GET", Path: "/hc/:slug/:locale/categories.json", Controller: "public/api/v1/portals/categories#index", Source: "routes.rb:595"}, + {Method: "GET", Path: "/hc/:slug/:locale/categories/:category_slug", Controller: "public/api/v1/portals/categories#show", Source: "routes.rb:596"}, {Method: "GET", Path: "/hc/:slug/:locale/categories/:category_slug/articles", Controller: "public/api/v1/portals/articles#index", Source: "routes.rb:597"}, {Method: "GET", Path: "/hc/:slug/:locale/categories/:category_slug/articles.json", Controller: "public/api/v1/portals/articles#index", Source: "routes.rb:597"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 1c5cc753..a1ced94e 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -37,14 +37,14 @@ Hermes plan landing map: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): expose public article lists`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public article lists`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): expose public category lists`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public category lists`. - 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.8/P6.6g public help-center article list drift is now implemented for the reused Chatwoot widget popular-articles path. GoChat now registers public `/hc/:slug/:locale/articles(.json)` and category article-list aliases, resolves non-archived portals by slug, returns Chatwoot public `{ payload, meta: { articles_count } }` article serializers, forces published-locale visibility, honors widget `per_page=6`, and maps `sort=views` to view-count ordering. P3.5 company avatar form/pagination drift, 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. +- Worktree status at this implementation checkpoint: P3.8/P6.6h public help-center category list/show drift is now implemented for the Chatwoot public help-center JSON path. GoChat registers `/hc/:slug/:locale/categories(.json)` and `/hc/:slug/:locale/categories/:category_slug`, resolves non-archived portals by slug, returns Chatwoot public category arrays or raw category objects without account/dashboard fields, orders lists by position, filters show by slug plus locale, and counts only published category articles in `meta.articles_count`. P6.6g public article lists, P3.5 company avatar form/pagination drift, 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: 873` after adding public help-center article-list aliases for the reused widget popular-articles path. +- Route dump succeeds with `TOTAL: 876` after adding public help-center category-list/show aliases. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 332 Chatwoot routes: 325 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent. +- Tracked frontend-critical route audit covers 335 Chatwoot routes: 328 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -126,7 +126,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 332-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, public help-center article-list routes from `routes.rb:594/597`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, help-center portal/category/article payloads, and public widget popular-article lists now match the inspected Chatwoot frontend contract. | Run the next evidence-backed route/controller/serializer drift audit. | 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 332-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, public help-center article/category routes from `routes.rb:594-597`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, help-center portal/category/article payloads, public widget popular-article lists, and public help-center category list/show payloads now match the inspected Chatwoot contract. | Run the next evidence-backed route/controller/serializer drift audit. | 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; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 4 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -195,6 +195,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(help-center): expose public category lists` | Advances P3.8/P6.6 public help-center parity by matching `Public::Api::V1::Portals::CategoriesController#index/#show`, public category Jbuilder payloads, and `routes.rb:595-596`. GoChat now registers `/hc/:slug/:locale/categories` plus `.json` and `/hc/:slug/:locale/categories/:category_slug`, resolves only non-archived portals by slug, returns Chatwoot public category arrays or raw category objects without dashboard-only fields, orders lists by `position`, scopes show lookup by slug plus locale, and counts only published articles in `meta.articles_count`. | `go test ./internal/handler/api/v1 -run CategoryHandler -count=1`; `go test ./internal/service -run Category -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./cmd/route_parity -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: 876`; tracked route parity is `328 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 335`. | P3.8/P6.6h moves to Review for current public help-center category evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(help-center): expose public article lists` | Advances P3.8/P6.6 widget/public parity by matching the reused widget `getMostReadArticles` API, `Public::Api::V1::Portals::ArticlesController#index`, public article Jbuilder payloads, and `routes.rb:594/597`. GoChat now registers `/hc/:slug/:locale/articles` plus `.json` and category article-list aliases, resolves only non-archived portals by slug, returns public `{ payload, meta: { articles_count } }` article arrays, serializes public portal/category/author/link fields, restricts public lists to published articles in the requested locale, honors widget `per_page=6`, and maps `sort=views` to view-count ordering. | `go test ./internal/handler/api/v1 -run ArticleHandler -count=1`; `go test ./internal/service -run Article -count=1`; `go test ./internal/router -run Router -count=1`; `go test ./cmd/route_parity -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: 873`; tracked route parity is `325 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 332`. | P3.8/P6.6g moves to Review for current widget/public evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `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. | @@ -1650,7 +1651,7 @@ Frontend-critical API groups to audit first: | 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, public help-center article lists. | Review for current widget/public evidence; public help-center popular-article lists now match the widget `getMostReadArticles` path. Reopen only from fresh widget/public smoke or reference evidence. | +| P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT, public help-center article/category lists. | Review for current widget/public evidence; public help-center popular-article lists and category list/show JSON now match the inspected public routes. Reopen only from fresh widget/public smoke or reference evidence. | | P3.9 | Search payloads | Global search and entity search documents backed by Meilisearch. | Review | | P3.10 | Account webhooks/integration settings | Account webhook CRUD, integration app/hook payloads, event subscriptions, secrets, and delivery backing rows. | Review; reused dashboard Webhooks settings flow now gets Chatwoot `{ payload }` list/mutation serializers, PATCH update, nested `{ webhook }` bodies, generated secrets, scoped mutations, and empty `200 OK` delete. P3.11 integration apps/hooks now also match the reused dashboard integrations list/create/delete path with raw app/hook serializers, `app_id/reference_id`, settings schemas, account-scoped hook mutations, and empty delete. | @@ -2109,6 +2110,7 @@ Widget/public subtracking: | P6.6e | Widget direct uploads and attachments | Chatwoot active storage/direct upload and attachment payloads | Done | | P6.6f | Public CSAT deep behavior | Chatwoot CSAT survey controller/listener and message locking rules | Done | | P6.6g | Public help-center article lists | Widget `getMostReadArticles`, `Public::Api::V1::Portals::ArticlesController#index`, and public article Jbuilder views | Review by `feat(help-center): expose public article lists`; `/hc/:slug/:locale/articles(.json)` returns published locale-scoped public article payloads, `sort=views`, archived-portal 404, and route parity coverage. | +| P6.6h | Public help-center categories | `Public::Api::V1::Portals::CategoriesController#index/#show` and public category Jbuilder views | Review by `feat(help-center): expose public category lists`; `/hc/:slug/:locale/categories(.json)` and `/categories/:category_slug` return public category payloads, slug+locale show lookup, published article counts, and route parity coverage. | ## Phase 7: Verification Harness @@ -2316,3 +2318,4 @@ Verification milestone gates: - 2026-06-05: P5.12 durable message status checkpoint prepared as `feat(conversations): queue message status updates`; widget and public inbox last-seen updates now enqueue `conversation:update_message_status` jobs on the `deferred` queue, and worker replay marks eligible non-incoming `sent/delivered` messages as `read` or `delivered` up to the viewer timestamp while ignoring invalid statuses and missing conversations. Focused service/worker/app tests passed. Remaining Phase 5 follow-up is Captain/Copilot jobs and analytics aggregation. - 2026-06-05: P5.12 durable account bulk action checkpoint prepared as `feat(conversations): queue bulk actions`; account conversation bulk actions now accept Chatwoot frontend payloads without `action_name`, enqueue `conversation:bulk_action` on the `medium` queue, and worker replay applies display-ID-scoped status/team/assignee/snooze/label updates without crossing account boundaries. Focused service/API/worker/app tests passed. Remaining Phase 5 follow-up is Captain/Copilot jobs and analytics aggregation. - 2026-06-06: P3.8/P6.6g public help-center checkpoint prepared as `feat(help-center): expose public article lists`; audited widget `getMostReadArticles`, Chatwoot public article controller/Jbuilder, and routes `594/597`. GoChat now exposes `/hc/:slug/:locale/articles(.json)` plus category article-list aliases, returns public article payloads with portal/category/author/link fields, filters to published locale-scoped non-archived portal articles, honors widget `per_page=6`, and sorts `sort=views` by view count. Focused article/service/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. +- 2026-06-06: P3.8/P6.6h public category checkpoint prepared as `feat(help-center): expose public category lists`; audited Chatwoot public categories controller/Jbuilder and routes `595-596`. GoChat now exposes `/hc/:slug/:locale/categories(.json)` and `/hc/:slug/:locale/categories/:category_slug`, returns public category arrays/raw objects without dashboard-only fields, resolves non-archived portals by slug, filters show by category slug plus locale, orders lists by position, and counts only published category articles. Focused category/service/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 0736f0ed..a6ea25bf 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -416,6 +416,9 @@ GET /auth/validate_token GET /cable GET /hc/:slug/:locale/articles GET /hc/:slug/:locale/articles.json +GET /hc/:slug/:locale/categories +GET /hc/:slug/:locale/categories.json +GET /hc/:slug/:locale/categories/:category_slug GET /hc/:slug/:locale/categories/:category_slug/articles GET /hc/:slug/:locale/categories/:category_slug/articles.json GET /health @@ -871,4 +874,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 873 +TOTAL: 876 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 8ecd19e5..6b784492 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 325 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 332 tracked critical routes. +Summary: 328 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 335 tracked critical routes. ## Missing Critical Routes @@ -209,6 +209,9 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v2/accounts/:account_id/summary_reports/team` | `/api/v2/accounts/:account_id/summary_reports/team` | `api/v2/accounts/summary_reports#team` | `routes.rb:482` | exact | | GET | `/hc/:slug/:locale/articles` | `/hc/:slug/:locale/articles` | `public/api/v1/portals/articles#index` | `routes.rb:594` | exact | | GET | `/hc/:slug/:locale/articles.json` | `/hc/:slug/:locale/articles.json` | `public/api/v1/portals/articles#index` | `routes.rb:594` | exact | +| GET | `/hc/:slug/:locale/categories` | `/hc/:slug/:locale/categories` | `public/api/v1/portals/categories#index` | `routes.rb:595` | exact | +| GET | `/hc/:slug/:locale/categories.json` | `/hc/:slug/:locale/categories.json` | `public/api/v1/portals/categories#index` | `routes.rb:595` | exact | +| GET | `/hc/:slug/:locale/categories/:category_slug` | `/hc/:slug/:locale/categories/:category_slug` | `public/api/v1/portals/categories#show` | `routes.rb:596` | exact | | GET | `/hc/:slug/:locale/categories/:category_slug/articles` | `/hc/:slug/:locale/categories/:category_slug/articles` | `public/api/v1/portals/articles#index` | `routes.rb:597` | exact | | GET | `/hc/:slug/:locale/categories/:category_slug/articles.json` | `/hc/:slug/:locale/categories/:category_slug/articles.json` | `public/api/v1/portals/articles#index` | `routes.rb:597` | exact | | GET | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#show` | `routes.rb:585` | exact | diff --git a/internal/handler/api/v1/category_handler.go b/internal/handler/api/v1/category_handler.go index 0da29e39..999b03dc 100644 --- a/internal/handler/api/v1/category_handler.go +++ b/internal/handler/api/v1/category_handler.go @@ -3,6 +3,7 @@ package v1 import ( "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" @@ -25,6 +26,41 @@ func NewCategoryHandler(svc *service.CategoryService, portalSvc ...*service.Port return h } +// PublicList returns public help-center categories for a portal. +// GET /hc/:slug/:locale/categories.json +func (h *CategoryHandler) PublicList(c *gin.Context) { + portal, ok := h.resolvePublicPortal(c) + if !ok { + return + } + categories, _, err := h.svc.ListByPortalID(c.Request.Context(), portal.ID, "", 0, 0) + if err != nil { + applogger.L().Errorf("Public list categories: %v", err) + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list categories") + return + } + payload := make([]gin.H, 0, len(categories)) + for i := range categories { + payload = append(payload, publicCategoryPayload(&categories[i])) + } + c.JSON(http.StatusOK, gin.H{"payload": payload}) +} + +// PublicGet returns a public help-center category by slug and locale. +// GET /hc/:slug/:locale/categories/:category_slug.json +func (h *CategoryHandler) PublicGet(c *gin.Context) { + portal, ok := h.resolvePublicPortal(c) + if !ok { + return + } + category, err := h.svc.GetByPortalSlugAndLocale(c.Request.Context(), portal.ID, publicCategorySlugParam(c), c.Param("locale")) + if err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "category not found") + return + } + c.JSON(http.StatusOK, publicCategoryPayload(category)) +} + // Create creates a new category. // POST /api/v1/accounts/:account_id/portals/:portal_id/categories func (h *CategoryHandler) Create(c *gin.Context) { @@ -216,6 +252,19 @@ func (h *CategoryHandler) resolvePortal(c *gin.Context, accountID uint) (*model. return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true } +func (h *CategoryHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool) { + if h.portalSvc == nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found") + return nil, false + } + portal, err := h.portalSvc.ResolvePublicBySlug(c.Request.Context(), c.Param("slug")) + if err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "portal not found") + return nil, false + } + return portal, true +} + func categoryPayload(category *model.Category, currentLocale string) gin.H { if category == nil { return gin.H{} @@ -239,6 +288,20 @@ func categoryPayload(category *model.Category, currentLocale string) gin.H { return payload } +func publicCategoryPayload(category *model.Category) gin.H { + if category == nil { + return gin.H{} + } + return gin.H{ + "name": category.Name, + "slug": category.Slug, + "locale": category.Locale, + "description": category.Description, + "position": category.Position, + "meta": gin.H{"articles_count": categoryPublishedArticleCount(category.Articles)}, + } +} + func relatedCategoryPayloads(related []model.RelatedCategory) []gin.H { payload := make([]gin.H, 0, len(related)) for _, item := range related { @@ -271,6 +334,23 @@ func categoryArticleCount(articles []model.Article, locale string) int { return count } +func categoryPublishedArticleCount(articles []model.Article) int { + count := 0 + for _, article := range articles { + if article.Status == string(model.ArticleStatusPublished) { + count++ + } + } + return count +} + +func publicCategorySlugParam(c *gin.Context) string { + if slug := c.Param("category_slug"); slug != "" { + return strings.TrimSuffix(slug, ".json") + } + return strings.TrimSuffix(c.Param("category_slug.json"), ".json") +} + func bindCategoryReorder(c *gin.Context) (map[uint]int, error) { var req struct { PositionsHash map[string]int `json:"positions_hash"` diff --git a/internal/handler/api/v1/category_handler_test.go b/internal/handler/api/v1/category_handler_test.go index 49f41182..d5aff46f 100644 --- a/internal/handler/api/v1/category_handler_test.go +++ b/internal/handler/api/v1/category_handler_test.go @@ -91,9 +91,15 @@ func (s *CategoryHandlerTestSuite) SetupSuite() { portalGroup.POST("/reorder", s.handler.Reorder) } } + publicGroup := s.router.Group("/hc") + { + publicGroup.GET("/:slug/:locale/categories.json", s.handler.PublicList) + publicGroup.GET("/:slug/:locale/categories/:category_slug.json", s.handler.PublicGet) + } } func (s *CategoryHandlerTestSuite) SetupTest() { + s.db.Exec("DELETE FROM articles") s.db.Exec("DELETE FROM categories") s.db.Exec("DELETE FROM related_categories") } @@ -322,6 +328,61 @@ func (s *CategoryHandlerTestSuite) TestList_Empty() { s.Equal(http.StatusOK, w.Code) } +func (s *CategoryHandlerTestSuite) TestPublicList_ReturnsChatwootCategoryPayloads() { + cat1 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "Second", Slug: "second", Locale: "en", Description: "second", Position: 2} + cat2 := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "First", Slug: "first", Locale: "fr", Description: "first", Position: 1} + s.Require().NoError(s.db.Create(cat1).Error) + s.Require().NoError(s.db.Create(cat2).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &cat2.ID, Title: "Published", Slug: "published-cat", Status: "published", Locale: "fr"}).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &cat2.ID, Title: "Draft", Slug: "draft-cat", Status: "draft", Locale: "fr"}).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/hc/test-portal/en/categories.json", nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].([]interface{}) + s.Len(payload, 2) + first := payload[0].(map[string]interface{}) + s.Equal("First", first["name"]) + s.Equal("first", first["slug"]) + s.Equal("fr", first["locale"]) + s.NotContains(first, "id") + s.EqualValues(1, first["meta"].(map[string]interface{})["articles_count"]) +} + +func (s *CategoryHandlerTestSuite) TestPublicGet_FiltersBySlugAndLocale() { + catEN := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "English", Slug: "shared", Locale: "en", Position: 1} + catFR := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "French", Slug: "shared", Locale: "fr", Position: 2} + s.Require().NoError(s.db.Create(catEN).Error) + s.Require().NoError(s.db.Create(catFR).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.accountID, PortalID: s.portalID, CategoryID: &catFR.ID, Title: "French Article", Slug: "french-article", Status: "published", Locale: "fr"}).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/hc/test-portal/fr/categories/shared.json", nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal("French", resp["name"]) + s.Equal("fr", resp["locale"]) + s.EqualValues(1, resp["meta"].(map[string]interface{})["articles_count"]) +} + +func (s *CategoryHandlerTestSuite) TestPublicGet_NotFoundForWrongLocale() { + cat := &model.Category{AccountID: s.accountID, PortalID: s.portalID, Name: "English", Slug: "english-only", Locale: "en"} + s.Require().NoError(s.db.Create(cat).Error) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/hc/test-portal/fr/categories/english-only.json", nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusNotFound, w.Code) +} + // --- Reorder Tests --- func (s *CategoryHandlerTestSuite) TestReorder_Success() { cat1 := &model.Category{PortalID: s.portalID, Name: "Cat1", Slug: "cat1"} diff --git a/internal/repository/category_repo.go b/internal/repository/category_repo.go index 1bab915e..529091e7 100644 --- a/internal/repository/category_repo.go +++ b/internal/repository/category_repo.go @@ -114,7 +114,10 @@ func (r *CategoryRepo) UpdatePositionsForPortal(ctx context.Context, portalID ui // FindBySlugAndPortalIDAndLocale returns a category by slug, portal, and locale. func (r *CategoryRepo) FindBySlugAndPortalIDAndLocale(ctx context.Context, slug string, portalID uint, locale string) (*model.Category, error) { var category model.Category - if err := r.db.WithContext(ctx).Where("slug = ? AND portal_id = ? AND locale = ?", slug, portalID, locale).First(&category).Error; err != nil { + if err := r.db.WithContext(ctx). + Preload("Articles"). + Where("slug = ? AND portal_id = ? AND locale = ?", slug, portalID, locale). + First(&category).Error; err != nil { return nil, err } return &category, nil diff --git a/internal/router/router.go b/internal/router/router.go index 3d57b97e..a97e01db 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -285,6 +285,9 @@ func RegisterRoutes( { helpCenter.GET("/:slug/:locale/articles", handlers.Article.PublicList) helpCenter.GET("/:slug/:locale/articles.json", handlers.Article.PublicList) + helpCenter.GET("/:slug/:locale/categories", handlers.Category.PublicList) + helpCenter.GET("/:slug/:locale/categories.json", handlers.Category.PublicList) + helpCenter.GET("/:slug/:locale/categories/:category_slug", handlers.Category.PublicGet) helpCenter.GET("/:slug/:locale/categories/:category_slug/articles", handlers.Article.PublicList) helpCenter.GET("/:slug/:locale/categories/:category_slug/articles.json", handlers.Article.PublicList) } diff --git a/internal/router/router_test.go b/internal/router/router_test.go index e5d110a3..62ec14f2 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -37,6 +37,8 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /api/v1/widget/conversations/toggle_status", "PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id", "GET /hc/:slug/:locale/articles.json", + "GET /hc/:slug/:locale/categories.json", + "GET /hc/:slug/:locale/categories/:category_slug", "GET /api/v2/accounts/:account_id/reports/summary", "GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", "GET /webhooks/twitter", diff --git a/internal/service/category_service.go b/internal/service/category_service.go index 7e6ac065..0df2994a 100644 --- a/internal/service/category_service.go +++ b/internal/service/category_service.go @@ -95,6 +95,14 @@ func (s *CategoryService) GetByPortalAndID(ctx context.Context, portalID, id uin return category, nil } +func (s *CategoryService) GetByPortalSlugAndLocale(ctx context.Context, portalID uint, slug string, locale string) (*model.Category, error) { + category, err := s.repo.FindBySlugAndPortalIDAndLocale(ctx, slug, portalID, locale) + if err != nil { + return nil, fmt.Errorf("get category: %w", err) + } + return category, nil +} + func (s *CategoryService) Update(ctx context.Context, id uint, req *UpdateCategoryRequest) (*model.Category, error) { category, err := s.repo.GetByID(ctx, id) if err != nil {