feat(help-center): expose public category lists

This commit is contained in:
2026-06-06 07:23:55 +08:00
parent 53434ab397
commit c64b4c2f92
10 changed files with 179 additions and 10 deletions
+3
View File
@@ -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"},
+10 -7
View File
@@ -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.
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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 |
@@ -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"`
@@ -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"}
+4 -1
View File
@@ -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
+3
View File
@@ -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)
}
+2
View File
@@ -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",
+8
View File
@@ -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 {