diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 346dd821..82dfdda8 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -352,6 +352,7 @@ var criticalRoutes = []route{ {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", Controller: "public/api/v1/portals#show", Source: "routes.rb:590"}, + {Method: "GET", Path: "/hc/:slug/sitemap.xml", Controller: "public/api/v1/portals#sitemap", Source: "routes.rb:591"}, {Method: "GET", Path: "/hc/:slug/:locale", Controller: "public/api/v1/portals#show", Source: "routes.rb:592"}, {Method: "GET", Path: "/hc/:slug/:locale/search", Controller: "public/api/v1/portals/search#index", Source: "routes.rb:593"}, {Method: "GET", Path: "/hc/:slug/:locale/articles", Controller: "public/api/v1/portals/articles#index", Source: "routes.rb:594"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index c9c77791..10a3d26d 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 article show`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public article show`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): expose public sitemap`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public sitemap`. - 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.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. +- Worktree status at this implementation checkpoint: P3.8/P6.6l public help-center sitemap drift is now implemented for Chatwoot `Public::Api::V1::PortalsController#sitemap`, `sitemap.xml.erb`, and `routes.rb:591`. GoChat registers `/hc/:slug/sitemap.xml`, resolves only non-archived portals by slug, emits sitemap XML for published portal articles only, uses the portal custom domain when configured, prefixes `https://` when the base URL has no protocol, and emits public article links plus ISO date `lastmod` entries. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, 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: 880` after adding the public help-center article show route. +- Route dump succeeds with `TOTAL: 881` after adding the public help-center sitemap route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- 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. +- Tracked frontend-critical route audit covers 342 Chatwoot routes: 333 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 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. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 342-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-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, public article show/markdown/tracking routes, and public sitemap XML 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 sitemap` | Advances P3.8/P6.6 public help-center parity by matching `Public::Api::V1::PortalsController#sitemap`, `sitemap.xml.erb`, and `routes.rb:591`. GoChat now registers `/hc/:slug/sitemap.xml`, resolves only non-archived portals by slug, emits sitemap XML for published portal articles only, uses custom domain URLs with Chatwoot-style `https://` prefixing when no protocol is present, and writes public article links plus ISO date `lastmod` values. | `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: 881`; tracked route parity is `333 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 342`. | P3.8/P6.6l moves to Review for current public sitemap evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `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. | @@ -2117,6 +2118,7 @@ Widget/public subtracking: | 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. | +| P6.6l | Public help-center sitemap | `Public::Api::V1::PortalsController#sitemap`, `sitemap.xml.erb`, and `routes.rb:591` | Review by `feat(help-center): expose public sitemap`; `/hc/:slug/sitemap.xml` returns XML for published article URLs with custom-domain base URLs and `lastmod` dates. | ## Phase 7: Verification Harness @@ -2328,3 +2330,4 @@ Verification milestone gates: - 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. +- 2026-06-06: P3.8/P6.6l public sitemap checkpoint prepared as `feat(help-center): expose public sitemap`; audited Chatwoot public portal sitemap action, `sitemap.xml.erb`, and route `591`. GoChat now exposes `/hc/:slug/sitemap.xml`, rejects archived portals, returns sitemap XML for published articles only, uses portal custom domains with `https://` prefixing when no protocol is present, and emits public article links plus ISO `lastmod` dates. 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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index c687d49c..bad4b376 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -425,6 +425,7 @@ 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 /hc/:slug/sitemap.xml GET /health GET /platform/api/v1/accounts GET /platform/api/v1/accounts/:account_id @@ -878,4 +879,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: 880 +TOTAL: 881 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 1512c601..6fd60c5d 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: 332 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 341 tracked critical routes. +Summary: 333 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 342 tracked critical routes. ## Missing Critical Routes @@ -220,6 +220,7 @@ These routes exist with equivalent method and path shape but different parameter | 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 | `/hc/:slug/sitemap.xml` | `/hc/:slug/sitemap.xml` | `public/api/v1/portals#sitemap` | `routes.rb:591` | 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/portal_handler.go b/internal/handler/api/v1/portal_handler.go index 8edb02e2..39e995d9 100644 --- a/internal/handler/api/v1/portal_handler.go +++ b/internal/handler/api/v1/portal_handler.go @@ -2,7 +2,9 @@ package v1 import ( "encoding/json" + "html" "net/http" + "os" "strconv" "strings" @@ -43,6 +45,36 @@ func (h *PortalHandler) PublicGet(c *gin.Context) { c.JSON(http.StatusOK, publicPortalPayload(portal)) } +// PublicSitemap returns the public help-center XML sitemap. +// GET /hc/:slug/sitemap.xml +func (h *PortalHandler) PublicSitemap(c *gin.Context) { + portal, ok := h.resolvePublicPortal(c) + if !ok { + return + } + baseURL := publicHelpCenterBaseURL(c, portal) + + var b strings.Builder + b.WriteString("\n") + b.WriteString("\n") + for i := range portal.Articles { + article := &portal.Articles[i] + if article.Status != string(model.ArticleStatusPublished) { + continue + } + b.WriteString(" \n") + b.WriteString(" ") + b.WriteString(html.EscapeString(baseURL + "/hc/" + portal.Slug + "/articles/" + article.Slug)) + b.WriteString("\n") + b.WriteString(" ") + b.WriteString(article.UpdatedAt.Format("2006-01-02")) + b.WriteString("\n") + b.WriteString(" \n") + } + b.WriteString("\n") + c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String())) +} + // Create creates a new portal. // POST /api/v1/accounts/:account_id/portals func (h *PortalHandler) Create(c *gin.Context) { @@ -270,6 +302,27 @@ func (h *PortalHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool return portal, true } +func publicHelpCenterBaseURL(c *gin.Context, portal *model.Portal) string { + baseURL := "" + if portal != nil { + baseURL = strings.TrimSpace(portal.CustomDomain) + } + if baseURL == "" { + baseURL = strings.TrimSpace(os.Getenv("FRONTEND_URL")) + } + if baseURL == "" && c != nil && c.Request != nil { + baseURL = c.Request.Host + } + baseURL = strings.TrimRight(baseURL, "/") + if baseURL == "" { + baseURL = "localhost:3000" + } + if !strings.Contains(baseURL, "://") { + baseURL = "https://" + baseURL + } + return baseURL +} + func portalDefaultLocale(portal *model.Portal) string { if portal == nil { return "en" diff --git a/internal/handler/api/v1/portal_handler_test.go b/internal/handler/api/v1/portal_handler_test.go index 7a11bbce..5f7b6295 100644 --- a/internal/handler/api/v1/portal_handler_test.go +++ b/internal/handler/api/v1/portal_handler_test.go @@ -191,6 +191,29 @@ func (s *PortalHandlerTestSuite) TestPublicGet_NotFoundForArchivedPortal() { assert.Equal(s.T(), http.StatusNotFound, w.Code) } +func (s *PortalHandlerTestSuite) TestPublicSitemap_ReturnsPublishedArticleURLs() { + portal := &model.Portal{AccountID: s.account.ID, Name: "Sitemap", Slug: "sitemap", CustomDomain: "help.example.com"} + s.Require().NoError(s.db.Create(portal).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Published", Slug: "published", Status: "published", Locale: "en"}).Error) + s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "draft", Status: "draft", Locale: "en"}).Error) + + r := gin.New() + r.GET("/hc/:slug/sitemap.xml", s.handler.PublicSitemap) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/hc/sitemap/sitemap.xml", nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), "application/xml; charset=utf-8", w.Header().Get("Content-Type")) + body := w.Body.String() + assert.Contains(s.T(), body, ``) + assert.Contains(s.T(), body, ``) + assert.Contains(s.T(), body, `https://help.example.com/hc/sitemap/articles/published`) + assert.Contains(s.T(), body, ``) + assert.NotContains(s.T(), body, "draft") +} + func (s *PortalHandlerTestSuite) TestUpdate_Success() { portal := &model.Portal{AccountID: s.account.ID, Name: "update-portal", Slug: "update-slug"} s.Require().NoError(s.db.Create(portal).Error) diff --git a/internal/router/router.go b/internal/router/router.go index 1ef6deeb..ef8b7efc 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/sitemap.xml", handlers.Portal.PublicSitemap) helpCenter.GET("/:slug/articles/:article_slug", handlers.Article.PublicArticle) helpCenter.GET("/:slug/:locale", handlers.Portal.PublicGet) helpCenter.GET("/:slug/:locale/search", handlers.Article.PublicSearch) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 8c5d7107..b6c28ef0 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -37,6 +37,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /api/v1/widget/conversations/toggle_status", "PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id", "GET /hc/:slug", + "GET /hc/:slug/sitemap.xml", "GET /hc/:slug/:locale", "GET /hc/:slug/:locale/search", "GET /hc/:slug/:locale/articles.json",