feat(help-center): expose public article lists
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user