diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 3df5b0d2..c7fb1874 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -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"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index c21cb0c3..1c5cc753 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(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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index bf28f000..0736f0ed 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -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 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 1ef1e471..8ecd19e5 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: 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 | diff --git a/internal/handler/api/v1/article_handler.go b/internal/handler/api/v1/article_handler.go index a36dde55..2a55a1b8 100644 --- a/internal/handler/api/v1/article_handler.go +++ b/internal/handler/api/v1/article_handler.go @@ -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, diff --git a/internal/handler/api/v1/article_handler_test.go b/internal/handler/api/v1/article_handler_test.go index cad5db54..949207fd 100644 --- a/internal/handler/api/v1/article_handler_test.go +++ b/internal/handler/api/v1/article_handler_test.go @@ -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) diff --git a/internal/repository/article_repo.go b/internal/repository/article_repo.go index a0174ed3..6250842a 100644 --- a/internal/repository/article_repo.go +++ b/internal/repository/article_repo.go @@ -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 != "" { diff --git a/internal/router/router.go b/internal/router/router.go index d28c839e..3d57b97e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 43591a59..e5d110a3 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -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", diff --git a/internal/service/article_service.go b/internal/service/article_service.go index b2364b15..a3f343d5 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -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) diff --git a/internal/service/portal_service.go b/internal/service/portal_service.go index 2c78f55d..ffda71a1 100644 --- a/internal/service/portal_service.go +++ b/internal/service/portal_service.go @@ -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")