From 8edfcda94798601d71afdc92182e8634692092c3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 08:14:34 +0800 Subject: [PATCH] feat(help-center): expose public article show --- cmd/route_parity/main.go | 5 +- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 15 +-- docs/parity/gochat_routes.txt | 3 +- docs/parity/route_parity.md | 7 +- internal/handler/api/v1/article_handler.go | 94 +++++++++++++++++++ .../handler/api/v1/article_handler_test.go | 69 ++++++++++++++ internal/repository/article_repo.go | 8 ++ internal/router/router.go | 1 + internal/router/router_test.go | 1 + internal/service/article_service.go | 8 ++ 10 files changed, 201 insertions(+), 10 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index d205cc5a..346dd821 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -361,6 +361,9 @@ var criticalRoutes = []route{ {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"}, + {Method: "GET", Path: "/hc/:slug/articles/:article_slug.png", Controller: "public/api/v1/portals/articles#tracking_pixel", Source: "routes.rb:598"}, + {Method: "GET", Path: "/hc/:slug/articles/:article_slug.md", Controller: "public/api/v1/portals/articles#show_markdown", Source: "routes.rb:599"}, + {Method: "GET", Path: "/hc/:slug/articles/:article_slug", Controller: "public/api/v1/portals/articles#show", Source: "routes.rb:601"}, {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"}, @@ -500,7 +503,7 @@ func writeParity(path string, gochatPath string, chatwootPath string, gochatRout b.WriteString("These routes exist at the same path but with a compatible HTTP method. Rails resource `update` routes expose both `PATCH` and `PUT`; GoChat currently exposes only one method for some update endpoints. Keep these tracked until both methods are registered or the frontend is proven to call only the implemented method.\n\n") writeRouteTable(&b, methodCompatible, gochatRoutes, "method-compatible") b.WriteString("\n## Parameter-Compatible Routes\n\n") - b.WriteString("These routes exist with equivalent method and path shape but different parameter names. They need handler/serializer parity review, and exact external path compatibility must be preserved.\n\n") + b.WriteString("These routes exist with equivalent method and path shape but different parameter names or a Gin-compatible suffix dispatcher. They need handler/serializer parity review, and exact external path compatibility must be preserved.\n\n") writeRouteTable(&b, compatible, gochatRoutes, "parameter-compatible") b.WriteString("\n## Exact Critical Routes\n\n") writeRouteTable(&b, exact, gochatRoutes, "exact") diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 70201e4c..c9c77791 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -37,14 +37,14 @@ Hermes plan landing map: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): expose public portal search`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public portal search`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): expose public article show`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public article show`. - 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.6j public help-center portal search drift is now implemented for Chatwoot `Public::Api::V1::Portals::SearchController#index`, the public search-article Jbuilder partial, and `routes.rb:593`. GoChat registers `/hc/:slug/:locale/search`, resolves only non-archived portals by slug, trims `query`, returns empty results for blank searches, restricts results to published articles in the requested locale, paginates at 10 results, and serializes search article payloads with `id`, `category_id`, `title`, plain-text snippet content, and `/hc/:slug/articles/:article_slug` links. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, 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.6k public help-center article show/markdown/tracking drift is now implemented for Chatwoot `Public::Api::V1::Portals::ArticlesController#show/#show_markdown/#tracking_pixel` and `routes.rb:598-601`. GoChat registers the public article path, dispatches `.md` and `.png` suffixes through the same Gin-compatible route, resolves only non-archived portals by slug, returns public article payloads by article slug, serves published article markdown as `text/markdown`, and increments published article views when serving the 1x1 tracking pixel. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, 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: 879` after adding the public help-center portal search route. +- Route dump succeeds with `TOTAL: 880` after adding the public help-center article show route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 338 Chatwoot routes: 331 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 341 Chatwoot routes: 332 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/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 338-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/article/category/search routes from `routes.rb:590/592-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, public help-center category list/show payloads, public portal show/default-locale payloads, and public portal search 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. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 341-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/article/category/search/article-detail routes from `routes.rb:590/592-601`, 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, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, and public article show/markdown/tracking routes 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 article show` | Advances P3.8/P6.6 public help-center parity by matching `Public::Api::V1::Portals::ArticlesController#show/#show_markdown/#tracking_pixel`, the public article Jbuilder payload, and `routes.rb:598-601`. GoChat now registers the public article route, dispatches `.md` and `.png` suffixes through the same Gin-compatible article slug route, resolves only non-archived portals by slug, returns public article payloads by slug, serves raw markdown for published articles, rejects markdown for drafts, and increments published article views when serving the 1x1 tracking pixel. | `go test ./internal/handler/api/v1 -run ArticleHandler -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: 880`; tracked route parity is `332 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 341`. | P3.8/P6.6k moves to Review for current public article show/markdown/tracking evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(help-center): expose public portal search` | Advances P3.8/P6.6 public help-center parity by matching `Public::Api::V1::Portals::SearchController#index`, the public search-article Jbuilder partial, and `routes.rb:593`. GoChat now registers `/hc/:slug/:locale/search`, resolves only non-archived portals by slug, trims blank searches to empty results, restricts public search to published articles in the requested locale, uses the Chatwoot 10-result page size, and returns search article payloads with plain-text snippets plus `/hc/:slug/articles/:article_slug` links. | `go test ./internal/handler/api/v1 -run ArticleHandler -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: 879`; tracked route parity is `331 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 338`. | P3.8/P6.6j moves to Review for current public portal search evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(help-center): expose public portal show` | Advances P3.8/P6.6 public help-center parity by matching `Public::Api::V1::PortalsController#show`, the public HC portal Jbuilder payload, and `routes.rb:590/592`. GoChat now registers `/hc/:slug` and `/hc/:slug/:locale`, redirects slug-only requests to the portal default locale, resolves only non-archived portals by slug, preloads public categories/articles, and returns Chatwoot public portal fields with public category payloads, optional logo, published article count, category count, and default locale meta. | `go test ./internal/handler/api/v1 -run PortalHandler -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: 878`; tracked route parity is `330 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 337`. | P3.8/P6.6i moves to Review for current public portal show evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `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. | @@ -2115,6 +2116,7 @@ Widget/public subtracking: | 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. | | P6.6i | Public help-center portal show | `Public::Api::V1::PortalsController#show`, public HC portal Jbuilder, and default-locale redirect | Review by `feat(help-center): expose public portal show`; `/hc/:slug` redirects to default locale and `/hc/:slug/:locale` returns public portal fields, categories, logo, and meta counts for non-archived portals. | | P6.6j | Public help-center portal search | `Public::Api::V1::Portals::SearchController#index`, `_search_article.json.jbuilder`, and `routes.rb:593` | Review by `feat(help-center): expose public portal search`; `/hc/:slug/:locale/search` returns empty payloads for blank queries and published locale-scoped search article payloads with snippets and public article links for non-archived portals. | +| P6.6k | Public help-center article show/markdown/tracking | `Public::Api::V1::Portals::ArticlesController#show/#show_markdown/#tracking_pixel`, public article Jbuilder, and `routes.rb:598-601` | Review by `feat(help-center): expose public article show`; `/hc/:slug/articles/:article_slug` returns public article payloads, `.md` serves published markdown, and `.png` serves a 1x1 tracking pixel while incrementing published article views. | ## Phase 7: Verification Harness @@ -2325,3 +2327,4 @@ Verification milestone gates: - 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. - 2026-06-06: P3.8/P6.6i public portal checkpoint prepared as `feat(help-center): expose public portal show`; audited Chatwoot public portal controller/Jbuilder and routes `590/592`. GoChat now exposes `/hc/:slug` and `/hc/:slug/:locale`, redirects slug-only requests to the configured default locale, returns public HC portal fields with categories, optional logo, published article counts, category counts, and default locale meta, and rejects archived portals. Focused portal/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.6j public portal search checkpoint prepared as `feat(help-center): expose public portal search`; audited Chatwoot public portal search controller, `_search_article.json.jbuilder`, and route `593`. GoChat now exposes `/hc/:slug/:locale/search`, trims query input, returns empty payloads for blank queries, filters results to published locale-scoped non-archived portal articles, paginates at 10 results, and returns public search article payloads with plain-text snippets and `/hc/:slug/articles/:article_slug` links. Focused article/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.6k public article checkpoint prepared as `feat(help-center): expose public article show`; audited Chatwoot public articles show/markdown/tracking controller actions, public article Jbuilder, and routes `598-601`. GoChat now exposes `/hc/:slug/articles/:article_slug`, handles `.md` and `.png` suffixes through a Gin-compatible dispatcher, returns public article payloads by slug, serves raw markdown for published articles, rejects draft markdown, and increments published article views when serving the tracking pixel. Focused article/router/route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 15cf4710..c687d49c 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -424,6 +424,7 @@ 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 /hc/:slug/:locale/search +GET /hc/:slug/articles/:article_slug GET /health GET /platform/api/v1/accounts GET /platform/api/v1/accounts/:account_id @@ -877,4 +878,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: 879 +TOTAL: 880 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 8c78836d..1512c601 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 331 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 338 tracked critical routes. +Summary: 332 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 341 tracked critical routes. ## Missing Critical Routes @@ -25,13 +25,15 @@ These routes exist at the same path but with a compatible HTTP method. Rails res ## Parameter-Compatible Routes -These routes exist with equivalent method and path shape but different parameter names. They need handler/serializer parity review, and exact external path compatibility must be preserved. +These routes exist with equivalent method and path shape but different parameter names or a Gin-compatible suffix dispatcher. They need handler/serializer parity review, and exact external path compatibility must be preserved. | Method | Chatwoot Path | GoChat Match | Controller | Source | Status | | --- | --- | --- | --- | --- | --- | | DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#destroy` | `routes.rb:126` | parameter-compatible | | DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users/:user_id` | `api/v1/accounts/agent_capacity_policies/users#destroy` | `routes.rb:125` | parameter-compatible | | GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#index` | `routes.rb:125` | parameter-compatible | +| GET | `/hc/:slug/articles/:article_slug.md` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show_markdown` | `routes.rb:599` | parameter-compatible | +| GET | `/hc/:slug/articles/:article_slug.png` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#tracking_pixel` | `routes.rb:598` | parameter-compatible | | PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | | POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits` | `api/v1/accounts/agent_capacity_policies/inbox_limits#create` | `routes.rb:126` | parameter-compatible | | POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#create` | `routes.rb:125` | parameter-compatible | @@ -217,6 +219,7 @@ These routes exist with equivalent method and path shape but different parameter | 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 | `/hc/:slug/:locale/search` | `/hc/:slug/:locale/search` | `public/api/v1/portals/search#index` | `routes.rb:593` | exact | +| GET | `/hc/:slug/articles/:article_slug` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show` | `routes.rb:601` | 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 | diff --git a/internal/handler/api/v1/article_handler.go b/internal/handler/api/v1/article_handler.go index a614bb28..1e11fac1 100644 --- a/internal/handler/api/v1/article_handler.go +++ b/internal/handler/api/v1/article_handler.go @@ -116,6 +116,60 @@ func (h *ArticleHandler) PublicSearch(c *gin.Context) { }) } +// PublicShow returns a public help-center article by slug. +// GET /hc/:slug/articles/:article_slug +func (h *ArticleHandler) PublicShow(c *gin.Context) { + portal, article, ok := h.resolvePublicArticle(c) + if !ok { + return + } + article.Portal = *portal + c.JSON(http.StatusOK, publicArticlePayload(article, portal.Slug)) +} + +// PublicArticle dispatches Chatwoot article, markdown, and tracking-pixel suffix paths. +func (h *ArticleHandler) PublicArticle(c *gin.Context) { + slug := publicArticleSlugRaw(c) + switch { + case strings.HasSuffix(slug, ".md"): + h.PublicMarkdown(c) + case strings.HasSuffix(slug, ".png"): + h.PublicTrackingPixel(c) + default: + h.PublicShow(c) + } +} + +// PublicMarkdown returns the raw markdown for a published public article. +// GET /hc/:slug/articles/:article_slug.md +func (h *ArticleHandler) PublicMarkdown(c *gin.Context) { + _, article, ok := h.resolvePublicArticle(c) + if !ok { + return + } + if article.Status != string(model.ArticleStatusPublished) { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found") + return + } + c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(article.Content)) +} + +// PublicTrackingPixel increments views for published articles and serves a 1x1 PNG. +// GET /hc/:slug/articles/:article_slug.png +func (h *ArticleHandler) PublicTrackingPixel(c *gin.Context) { + _, article, ok := h.resolvePublicArticle(c) + if !ok { + return + } + if article.Status == string(model.ArticleStatusPublished) { + if err := h.svc.IncrementViews(c.Request.Context(), article.ID); err != nil { + applogger.L().Warnf("Increment public article pixel views %d: %v", article.ID, err) + } + } + c.Header("Cache-Control", "private, max-age=86400") + c.Data(http.StatusOK, "image/png", publicArticleTrackingPixelPNG) +} + // NewArticleHandler creates a new ArticleHandler. func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler { h := &ArticleHandler{svc: svc} @@ -563,6 +617,46 @@ func (h *ArticleHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, boo return portal, true } +func (h *ArticleHandler) resolvePublicArticle(c *gin.Context) (*model.Portal, *model.Article, bool) { + portal, ok := h.resolvePublicPortal(c) + if !ok { + return nil, nil, false + } + slug := publicArticleSlugParam(c) + article, err := h.svc.GetByPortalAndSlug(c.Request.Context(), portal.ID, slug) + if err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "article not found") + return nil, nil, false + } + return portal, article, true +} + +func publicArticleSlugParam(c *gin.Context) string { + value := publicArticleSlugRaw(c) + return strings.TrimSuffix(strings.TrimSuffix(value, ".md"), ".png") +} + +func publicArticleSlugRaw(c *gin.Context) string { + for _, key := range []string{"article_slug", "article_slug.md", "article_slug.png"} { + if value := c.Param(key); value != "" { + return value + } + } + return "" +} + +var publicArticleTrackingPixelPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, + 0x42, 0x60, 0x82, +} + func publicArticlePagination(c *gin.Context) (int, int, int, bool) { page := 1 if raw := c.Query("page"); raw != "" { diff --git a/internal/handler/api/v1/article_handler_test.go b/internal/handler/api/v1/article_handler_test.go index 5f8f331e..0d0b612e 100644 --- a/internal/handler/api/v1/article_handler_test.go +++ b/internal/handler/api/v1/article_handler_test.go @@ -245,6 +245,75 @@ func (s *ArticleHandlerTestSuite) TestPublicSearch_NotFoundForArchivedPortal() { assert.Equal(s.T(), http.StatusNotFound, w.Code) } +func (s *ArticleHandlerTestSuite) TestPublicShow_ReturnsArticleBySlug() { + portal := &model.Portal{AccountID: s.account.ID, Name: "Show Portal", Slug: "show-portal"} + s.Require().NoError(s.db.Create(portal).Error) + category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Guides", Slug: "guides", Locale: "en"} + s.Require().NoError(s.db.Create(category).Error) + article := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Install", Slug: "install", Content: "Install content", Status: "published", Locale: "en"} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/hc/show-portal/articles/install", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "install", payload["slug"]) + assert.Equal(s.T(), "Install content", payload["content"]) + assert.Equal(s.T(), "hc/show-portal/articles/install", payload["link"]) + assert.Equal(s.T(), "guides", payload["category"].(map[string]any)["slug"]) +} + +func (s *ArticleHandlerTestSuite) TestPublicMarkdown_ReturnsOnlyPublishedMarkdown() { + portal := &model.Portal{AccountID: s.account.ID, Name: "Markdown Portal", Slug: "markdown-portal"} + s.Require().NoError(s.db.Create(portal).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Markdown", Slug: "markdown", Content: "# Raw markdown", Status: "published", Locale: "en"}).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "markdown-draft", Content: "draft", Status: "draft", Locale: "en"}).Error) + + r := gin.New() + r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/hc/markdown-portal/articles/markdown.md", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), "text/markdown; charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(s.T(), "# Raw markdown", w.Body.String()) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/hc/markdown-portal/articles/markdown-draft.md", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusNotFound, w.Code) +} + +func (s *ArticleHandlerTestSuite) TestPublicTrackingPixel_IncrementsPublishedArticleViews() { + portal := &model.Portal{AccountID: s.account.ID, Name: "Pixel Portal", Slug: "pixel-portal"} + s.Require().NoError(s.db.Create(portal).Error) + article := &model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Pixel", Slug: "pixel", Content: "pixel", Status: "published", Locale: "en", Views: 3} + s.Require().NoError(s.db.Create(article).Error) + + r := gin.New() + r.GET("/hc/:slug/articles/:article_slug", s.handler.PublicArticle) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/hc/pixel-portal/articles/pixel.png", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), "image/png", w.Header().Get("Content-Type")) + assert.NotEmpty(s.T(), w.Body.Bytes()) + var updated model.Article + s.Require().NoError(s.db.First(&updated, article.ID).Error) + assert.Equal(s.T(), 4, updated.Views) +} + func (s *ArticleHandlerTestSuite) TestStatusCounts_BadRequest_InvalidPortalID() { r := gin.New() r.GET("/api/v1/accounts/:account_id/portals/:portal_id/articles/status_counts", s.handler.StatusCounts) diff --git a/internal/repository/article_repo.go b/internal/repository/article_repo.go index 6250842a..406b55a8 100644 --- a/internal/repository/article_repo.go +++ b/internal/repository/article_repo.go @@ -117,6 +117,14 @@ func (r *ArticleRepo) FindBySlug(ctx context.Context, slug string) (*model.Artic return &article, nil } +func (r *ArticleRepo) FindByPortalAndSlug(ctx context.Context, portalID uint, slug string) (*model.Article, error) { + var article model.Article + if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND slug = ?", portalID, slug).First(&article).Error; err != nil { + return nil, err + } + return &article, nil +} + // ArticleSearchParams holds all filtering parameters for article search. type ArticleSearchParams struct { PortalID uint diff --git a/internal/router/router.go b/internal/router/router.go index b1458c93..1ef6deeb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -284,6 +284,7 @@ func RegisterRoutes( helpCenter.Use(middleware.CORS(corsCfg)) { helpCenter.GET("/:slug", handlers.Portal.PublicRedirectDefaultLocale) + helpCenter.GET("/:slug/articles/:article_slug", handlers.Article.PublicArticle) helpCenter.GET("/:slug/:locale", handlers.Portal.PublicGet) helpCenter.GET("/:slug/:locale/search", handlers.Article.PublicSearch) helpCenter.GET("/:slug/:locale/articles", handlers.Article.PublicList) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 5f02e2a8..8c5d7107 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -42,6 +42,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /hc/:slug/:locale/articles.json", "GET /hc/:slug/:locale/categories.json", "GET /hc/:slug/:locale/categories/:category_slug", + "GET /hc/:slug/articles/:article_slug", "GET /api/v2/accounts/:account_id/reports/summary", "GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", "GET /webhooks/twitter", diff --git a/internal/service/article_service.go b/internal/service/article_service.go index a3f343d5..1f5e2aef 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -315,6 +315,14 @@ func (s *ArticleService) ListByStatus(ctx context.Context, portalID uint, status return articles, count, nil } +func (s *ArticleService) GetByPortalAndSlug(ctx context.Context, portalID uint, slug string) (*model.Article, error) { + article, err := s.repo.FindByPortalAndSlug(ctx, portalID, slug) + if err != nil { + return nil, fmt.Errorf("get article by slug: %w", err) + } + return article, nil +} + // Search returns filtered + searched articles with pagination. // Implements Article.search(params) from M9 spec. func (s *ArticleService) Search(ctx context.Context, params repository.ArticleSearchParams) ([]model.Article, int64, error) {