feat(help-center): expose public article lists

This commit is contained in:
2026-06-06 07:09:52 +08:00
parent 0aa0470ac9
commit 53434ab397
11 changed files with 298 additions and 10 deletions
+4
View File
@@ -351,6 +351,10 @@ var criticalRoutes = []route{
{Method: "PUT", Path: "/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id", Controller: "public/api/v1/inboxes/messages#update", Source: "routes.rb:580"},
{Method: "GET", Path: "/public/api/v1/csat_survey/:id", Controller: "public/api/v1/csat_survey#show", Source: "routes.rb:585"},
{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/: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"},
{Method: "POST", Path: "/api/v2/accounts/", Controller: "api/v2/accounts#create", Source: "routes.rb:478"},
{Method: "GET", Path: "/api/v2/accounts/:account_id/summary_reports/agent", Controller: "api/v2/accounts/summary_reports#agent", Source: "routes.rb:481"},
+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(companies): align avatar form payloads`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(companies): align avatar form payloads`.
- 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`.
- 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.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.
- 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.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 869` after adding account integration app/hook no-trailing-slash and PATCH routes.
- Route dump succeeds with `TOTAL: 873` after adding public help-center article-list aliases for the reused widget popular-articles path.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 328 Chatwoot routes: 321 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 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.
- `/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 328-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, 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, and help-center portal/category/article payloads 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-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. |
| 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 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. |
| `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. |
@@ -1649,7 +1650,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. | Doing |
| 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.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. |
@@ -2107,6 +2108,7 @@ Widget/public subtracking:
| P6.6d | `/public/api/v1/inboxes` contact/conversation/message core flow | `reference/chatwoot/app/controllers/public/api/v1/inboxes/*` and matching jbuilder views | Done |
| 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. |
## Phase 7: Verification Harness
@@ -2313,3 +2315,4 @@ Verification milestone gates:
- 2026-06-05: P5.11 durable Captain document sync checkpoint prepared as `feat(captain): queue document syncs`; Captain document sync requests now mark the document syncing and enqueue `captain:document_sync` jobs on the `low` queue, while worker replay uses the existing fakeable sync backend to persist disabled, failed, or synced document states. Focused service/API/worker/app tests passed. Remaining Phase 5 follow-up is now Copilot/conversation response jobs and analytics aggregation after P5.11a/P5.11b.
- 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.
+5 -1
View File
@@ -414,6 +414,10 @@ GET /api/v2/accounts/:account_id/summary_reports/label
GET /api/v2/accounts/:account_id/summary_reports/team
GET /auth/validate_token
GET /cable
GET /hc/:slug/:locale/articles
GET /hc/:slug/:locale/articles.json
GET /hc/:slug/:locale/categories/:category_slug/articles
GET /hc/:slug/:locale/categories/:category_slug/articles.json
GET /health
GET /platform/api/v1/accounts
GET /platform/api/v1/accounts/:account_id
@@ -867,4 +871,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: 869
TOTAL: 873
+5 -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: 321 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 328 tracked critical routes.
Summary: 325 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 332 tracked critical routes.
## Missing Critical Routes
@@ -207,6 +207,10 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/api/v2/accounts/:account_id/summary_reports/inbox` | `/api/v2/accounts/:account_id/summary_reports/inbox` | `api/v2/accounts/summary_reports#inbox` | `routes.rb:483` | exact |
| GET | `/api/v2/accounts/:account_id/summary_reports/label` | `/api/v2/accounts/:account_id/summary_reports/label` | `api/v2/accounts/summary_reports#label` | `routes.rb:484` | exact |
| 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/: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 |
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `public/api/v1/inboxes/contacts#show` | `routes.rb:572` | exact |
| GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `public/api/v1/inboxes/conversations#index` | `routes.rb:573` | exact |
+187
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -22,6 +23,55 @@ type ArticleHandler struct {
portalSvc *service.PortalService
}
// PublicList returns published help-center articles for the public portal/widget views.
// GET /hc/:slug/:locale/articles.json
func (h *ArticleHandler) PublicList(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
page, perPage, offset, ok := publicArticlePagination(c)
if !ok {
return
}
locale := c.Param("locale")
query := strings.TrimSpace(c.Query("query"))
params := repository.ArticleSearchParams{
PortalID: portal.ID,
Query: query,
CategorySlug: c.Param("category_slug"),
Locale: locale,
Status: string(model.ArticleStatusPublished),
SortBy: c.Query("sort"),
Offset: offset,
Limit: perPage,
}
countParams := repository.ArticleSearchParams{
PortalID: portal.ID,
Locale: locale,
Status: string(model.ArticleStatusPublished),
}
articlesCount, err := h.svc.Count(c.Request.Context(), countParams)
if err != nil {
applogger.L().Errorf("Public article count: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
articles, _, err := h.svc.Search(c.Request.Context(), params)
if err != nil {
applogger.L().Errorf("Public article list: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list articles")
return
}
c.JSON(http.StatusOK, gin.H{
"payload": publicArticlePayloads(articles, portal.Slug),
"meta": gin.H{"articles_count": articlesCount, "current_page": page},
})
}
// NewArticleHandler creates a new ArticleHandler.
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
h := &ArticleHandler{svc: svc}
@@ -456,6 +506,53 @@ func (h *ArticleHandler) resolvePortal(c *gin.Context, accountID uint) (*model.P
return &model.Portal{Base: model.Base{ID: uint(portalID)}, AccountID: accountID}, true
}
func (h *ArticleHandler) 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 publicArticlePagination(c *gin.Context) (int, int, int, bool) {
page := 1
if raw := c.Query("page"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid page")
return 0, 0, 0, false
}
if parsed > 0 {
page = parsed
}
}
perPage := 0
if raw := c.Query("per_page"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid per_page")
return 0, 0, 0, false
}
perPage = parsed
if perPage < 1 {
perPage = 25
}
if perPage > 100 {
perPage = 100
}
}
offset := 0
if perPage > 0 {
offset = (page - 1) * perPage
}
return page, perPage, offset, true
}
func (h *ArticleHandler) articleSearchParams(c *gin.Context, portalID uint, offset, limit int) (repository.ArticleSearchParams, bool) {
params := repository.ArticleSearchParams{
PortalID: portalID,
@@ -581,6 +678,96 @@ func associatedArticlePayloads(articles []model.Article) []gin.H {
return payload
}
func publicArticlePayloads(articles []model.Article, portalSlug string) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
payload = append(payload, publicArticlePayload(&articles[i], portalSlug))
}
return payload
}
func publicArticlePayload(article *model.Article, portalSlug string) gin.H {
if article == nil {
return gin.H{}
}
if article.Portal.Slug != "" {
portalSlug = article.Portal.Slug
}
payload := gin.H{
"id": article.ID,
"category_id": article.CategoryID,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"position": article.Position,
"account_id": article.AccountID,
"last_updated_at": article.UpdatedAt,
"slug": article.Slug,
"views": article.Views,
"associated_articles": publicAssociatedArticlePayloads(article.AssociatedArticles),
"link": "hc/" + portalSlug + "/articles/" + article.Slug,
}
if article.Portal.ID != 0 {
payload["portal"] = publicPortalPayload(&article.Portal)
}
if article.Category != nil && article.Category.ID != 0 {
payload["category"] = gin.H{"id": article.Category.ID, "slug": article.Category.Slug, "locale": article.Category.Locale}
}
if article.Author != nil && article.Author.ID != 0 {
payload["author"] = publicAuthorPayload(article.Author)
}
return payload
}
func publicAssociatedArticlePayloads(articles []model.Article) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
article := &articles[i]
item := gin.H{
"id": article.ID,
"category_id": article.CategoryID,
"title": article.Title,
"content": article.Content,
"description": article.Description,
"status": article.Status,
"account_id": article.AccountID,
"last_updated_at": article.UpdatedAt,
"views": article.Views,
}
if article.Author != nil && article.Author.ID != 0 {
item["author"] = publicAuthorPayload(article.Author)
}
payload = append(payload, item)
}
return payload
}
func publicPortalPayload(portal *model.Portal) gin.H {
if portal == nil {
return gin.H{}
}
return gin.H{
"custom_domain": portal.CustomDomain,
"header_text": portal.HeaderText,
"homepage_link": portal.HomepageLink,
"name": portal.Name,
"page_title": portal.PageTitle,
"slug": portal.Slug,
}
}
func publicAuthorPayload(user *model.User) gin.H {
if user == nil {
return gin.H{}
}
availableName := user.DisplayName
if availableName == "" {
availableName = user.Name
}
return gin.H{"available_name": availableName, "name": user.Name, "thumbnail": user.AvatarURL}
}
func articleListMetaPayload(meta *service.ArticleListMeta, currentPage int) gin.H {
return gin.H{
"all_articles_count": meta.AllArticlesCount,
@@ -277,6 +277,57 @@ func (s *ArticleHandlerTestSuite) TestList_Success() {
assert.Contains(s.T(), meta, "all_articles_count")
}
func (s *ArticleHandlerTestSuite) TestPublicList_WidgetPopularArticles() {
r := gin.New()
r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList)
author := &model.User{AccountID: s.account.ID, Name: "Article Author", DisplayName: "Writer", Email: "public-article-author@example.com", Password: "secret"}
s.Require().NoError(s.db.Create(author).Error)
category := &model.Category{AccountID: s.account.ID, PortalID: s.portal.ID, Name: "Guides", Slug: "guides", Locale: "en"}
s.Require().NoError(s.db.Create(category).Error)
low := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "Low Views", Slug: "public-low-views", Status: "published", Locale: "en", Views: 3, Content: "low"}
high := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, CategoryID: &category.ID, AuthorID: &author.ID, Title: "High Views", Slug: "public-high-views", Status: "published", Locale: "en", Views: 9, Content: "high"}
draft := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "Draft", Slug: "public-draft", Status: "draft", Locale: "en", Views: 99, Content: "draft"}
french := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "French", Slug: "public-french", Status: "published", Locale: "fr", Views: 99, Content: "fr"}
s.Require().NoError(s.db.Create(low).Error)
s.Require().NoError(s.db.Create(high).Error)
s.Require().NoError(s.db.Create(draft).Error)
s.Require().NoError(s.db.Create(french).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/test-portal/en/articles.json?page=1&sort=views&status=1&per_page=2", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].([]interface{})
assert.Len(s.T(), payload, 2)
first := payload[0].(map[string]interface{})
second := payload[1].(map[string]interface{})
assert.Equal(s.T(), "High Views", first["title"])
assert.Equal(s.T(), "Low Views", second["title"])
assert.Equal(s.T(), "hc/test-portal/articles/public-high-views", first["link"])
assert.Equal(s.T(), "guides", first["category"].(map[string]interface{})["slug"])
assert.Equal(s.T(), "Writer", first["author"].(map[string]interface{})["available_name"])
meta := resp["meta"].(map[string]interface{})
assert.EqualValues(s.T(), 2, meta["articles_count"])
}
func (s *ArticleHandlerTestSuite) TestPublicList_ArchivedPortalNotFound() {
r := gin.New()
r.GET("/hc/:slug/:locale/articles.json", s.handler.PublicList)
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived", Slug: "archived-public", Archived: true}
s.Require().NoError(s.db.Create(portal).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/archived-public/en/articles.json", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ArticleHandlerTestSuite) TestCreate_RawFrontendPayloadAndSlugPortal() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles", s.handler.Create)
+1 -1
View File
@@ -146,7 +146,7 @@ func (r *ArticleRepo) Search(ctx context.Context, params ArticleSearchParams) ([
switch params.SortBy {
case "newest":
db = db.Order("articles.updated_at DESC")
case "popular":
case "popular", "views":
db = db.Order("articles.views DESC")
default:
if params.CategorySlug != "" || params.Status != "" {
+11
View File
@@ -278,6 +278,17 @@ func RegisterRoutes(
}
}
// Public help-center routes consumed by the embedded Chatwoot widget.
// Reference: Chatwoot /hc/:slug/:locale/articles(.json)
helpCenter := engine.Group("/hc")
helpCenter.Use(middleware.CORS(corsCfg))
{
helpCenter.GET("/:slug/:locale/articles", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/articles.json", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/categories/:category_slug/articles", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/categories/:category_slug/articles.json", handlers.Article.PublicList)
}
// API v2 routes — authenticated, account-scoped report APIs.
apiV2 := engine.Group("/api/v2")
apiV2.Use(middleware.AuthMiddleware(jwtCfg))
+1
View File
@@ -36,6 +36,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
"GET /api/v1/widget/conversations",
"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 /api/v2/accounts/:account_id/reports/summary",
"GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics",
"GET /webhooks/twitter",
+8
View File
@@ -325,6 +325,14 @@ func (s *ArticleService) Search(ctx context.Context, params repository.ArticleSe
return articles, count, nil
}
func (s *ArticleService) Count(ctx context.Context, params repository.ArticleSearchParams) (int64, error) {
count, err := s.repo.CountSearch(ctx, params)
if err != nil {
return 0, fmt.Errorf("count articles: %w", err)
}
return count, nil
}
// StatusCounts returns article counts by status for a portal.
func (s *ArticleService) StatusCounts(ctx context.Context, portalID uint) (map[string]int64, error) {
counts, err := s.repo.StatusCounts(ctx, portalID)
+15
View File
@@ -9,6 +9,7 @@ import (
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"gorm.io/gorm"
)
// PortalService implements business logic for Portal CRUD.
@@ -120,6 +121,20 @@ func (s *PortalService) GetByID(ctx context.Context, id uint) (*model.Portal, er
return portal, nil
}
func (s *PortalService) ResolvePublicBySlug(ctx context.Context, slug string) (*model.Portal, error) {
if slug == "" {
return nil, fmt.Errorf("portal not found")
}
portal, err := s.repo.FindBySlug(ctx, slug)
if err != nil {
return nil, fmt.Errorf("portal not found: %w", err)
}
if portal.Archived {
return nil, fmt.Errorf("portal not found: %w", gorm.ErrRecordNotFound)
}
return portal, nil
}
func (s *PortalService) ResolveByAccountAndRouteID(ctx context.Context, accountID uint, routeID string) (*model.Portal, error) {
if routeID == "" {
return nil, fmt.Errorf("portal not found")