feat(help-center): expose public portal search

This commit is contained in:
2026-06-06 07:59:16 +08:00
parent 5275e6e113
commit d8aa26bcbc
8 changed files with 212 additions and 8 deletions
+1
View File
@@ -353,6 +353,7 @@ var criticalRoutes = []route{
{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/: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"},
{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", Controller: "public/api/v1/portals/categories#index", Source: "routes.rb:595"},
+9 -6
View File
@@ -37,14 +37,14 @@ Hermes plan landing map:
## Current Baseline
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): expose public portal show`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): expose public portal show`.
- 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`.
- 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.6i public help-center portal show drift is now implemented for Chatwoot public portal JSON and default-locale routing. GoChat registers `/hc/:slug` and `/hc/:slug/:locale`, redirects the slug-only route to the configured default locale, resolves only non-archived portals by slug, and returns Chatwoot public HC portal fields with categories, logo, and published article/category/default-locale meta. P6.6g public article lists, P6.6h public categories, 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.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.
- `go test ./...` passes.
- Route dump succeeds with `TOTAL: 878` after adding public help-center portal show/default-locale routes.
- Route dump succeeds with `TOTAL: 879` after adding the public help-center portal search route.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 337 Chatwoot routes: 330 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 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.
- `/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 337-route critical set; help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/article/category routes from `routes.rb:590/592/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, public widget popular-article lists, public help-center category list/show payloads, and public portal show/default-locale 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 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. |
| 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 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. |
| `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. |
@@ -2113,6 +2114,7 @@ Widget/public subtracking:
| 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. |
| 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. |
## Phase 7: Verification Harness
@@ -2322,3 +2324,4 @@ Verification milestone gates:
- 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.
- 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.
+2 -1
View File
@@ -423,6 +423,7 @@ GET /hc/:slug/:locale/categories.json
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 /health
GET /platform/api/v1/accounts
GET /platform/api/v1/accounts/:account_id
@@ -876,4 +877,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: 878
TOTAL: 879
+2 -1
View File
@@ -7,7 +7,7 @@ Generated from:
This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`.
Summary: 330 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 337 tracked critical routes.
Summary: 331 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 338 tracked critical routes.
## Missing Critical Routes
@@ -216,6 +216,7 @@ These routes exist with equivalent method and path shape but different parameter
| GET | `/hc/:slug/:locale/categories/:category_slug` | `/hc/:slug/:locale/categories/:category_slug` | `public/api/v1/portals/categories#show` | `routes.rb:596` | 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 | `/hc/:slug/:locale/search` | `/hc/:slug/:locale/search` | `public/api/v1/portals/search#index` | `routes.rb:593` | 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 |
+131
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strconv"
"strings"
@@ -75,6 +76,46 @@ func (h *ArticleHandler) PublicList(c *gin.Context) {
})
}
// PublicSearch returns public help-center search results for the portal search page.
// GET /hc/:slug/:locale/search
func (h *ArticleHandler) PublicSearch(c *gin.Context) {
portal, ok := h.resolvePublicPortal(c)
if !ok {
return
}
page, perPage, offset, ok := publicSearchPagination(c)
if !ok {
return
}
query := strings.TrimSpace(c.Query("query"))
if query == "" {
c.JSON(http.StatusOK, gin.H{"payload": []gin.H{}, "meta": gin.H{"articles_count": 0, "current_page": page}})
return
}
params := repository.ArticleSearchParams{
PortalID: portal.ID,
Query: query,
Locale: c.Param("locale"),
Status: string(model.ArticleStatusPublished),
SortBy: c.Query("sort"),
Offset: offset,
Limit: perPage,
}
articles, count, err := h.svc.Search(c.Request.Context(), params)
if err != nil {
applogger.L().Errorf("Public article search: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
return
}
c.JSON(http.StatusOK, gin.H{
"payload": publicSearchArticlePayloads(articles, portal.Slug, query),
"meta": gin.H{"articles_count": count, "current_page": page},
})
}
// NewArticleHandler creates a new ArticleHandler.
func NewArticleHandler(svc *service.ArticleService, portalSvc ...*service.PortalService) *ArticleHandler {
h := &ArticleHandler{svc: svc}
@@ -556,6 +597,23 @@ func publicArticlePagination(c *gin.Context) (int, int, int, bool) {
return page, perPage, offset, true
}
func publicSearchPagination(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 := 10
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,
@@ -723,6 +781,79 @@ func publicArticlePayload(article *model.Article, portalSlug string) gin.H {
return payload
}
func publicSearchArticlePayloads(articles []model.Article, portalSlug, query string) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
payload = append(payload, publicSearchArticlePayload(&articles[i], portalSlug, query))
}
return payload
}
func publicSearchArticlePayload(article *model.Article, portalSlug, query string) gin.H {
if article == nil {
return gin.H{}
}
return gin.H{
"id": article.ID,
"category_id": article.CategoryID,
"title": article.Title,
"content": publicSearchArticleSnippet(article.Content, query),
"link": "/hc/" + portalSlug + "/articles/" + article.Slug,
}
}
var publicSearchHTMLTagPattern = regexp.MustCompile(`<[^>]*>`)
func publicSearchArticleSnippet(content, query string) string {
plain := publicSearchPlainText(content)
if plain == "" {
return ""
}
lowerPlain := strings.ToLower(plain)
lowerQuery := strings.ToLower(strings.TrimSpace(query))
if lowerQuery != "" {
if idx := strings.Index(lowerPlain, lowerQuery); idx >= 0 {
start := idx - 110
if start < 0 {
start = 0
}
end := idx + len(query) + 110
if end > len(plain) {
end = len(plain)
}
snippet := strings.TrimSpace(plain[start:end])
if start > 0 {
snippet = "..." + snippet
}
if end < len(plain) {
snippet += "..."
}
return snippet
}
}
return publicSearchTruncate(plain, 220)
}
func publicSearchPlainText(content string) string {
text := publicSearchHTMLTagPattern.ReplaceAllString(content, " ")
replacements := []string{"#", "*", "_", "`", ">", "[", "]", "(", ")", "!"}
for _, old := range replacements {
text = strings.ReplaceAll(text, old, " ")
}
return strings.Join(strings.Fields(text), " ")
}
func publicSearchTruncate(text string, limit int) string {
if limit <= 0 || len(text) <= limit {
return text
}
cut := text[:limit]
if idx := strings.LastIndex(cut, " "); idx > 0 {
cut = cut[:idx]
}
return strings.TrimSpace(cut) + "..."
}
func publicAssociatedArticlePayloads(articles []model.Article) []gin.H {
payload := make([]gin.H, 0, len(articles))
for i := range articles {
@@ -180,6 +180,71 @@ func (s *ArticleHandlerTestSuite) TestSearch_BadRequest_InvalidPortalID() {
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
func (s *ArticleHandlerTestSuite) TestPublicSearch_ReturnsPublishedLocaleSearchArticlePayload() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Search Portal", Slug: "search-portal"}
s.Require().NoError(s.db.Create(portal).Error)
category := &model.Category{AccountID: s.account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"}
s.Require().NoError(s.db.Create(category).Error)
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing setup", Slug: "billing-setup", Content: "# Billing\nUse the billing portal to update invoices and cards.", Status: "published", Locale: "en"}).Error)
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing draft", Slug: "billing-draft", Content: "billing hidden", Status: "draft", Locale: "en"}).Error)
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Facturation", Slug: "facturation", Content: "billing french", Status: "published", Locale: "fr"}).Error)
r := gin.New()
r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/search-portal/en/search?query=%20billing%20", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].([]any)
s.Require().Len(payload, 1)
item := payload[0].(map[string]any)
assert.Equal(s.T(), "Billing setup", item["title"])
assert.Equal(s.T(), "/hc/search-portal/articles/billing-setup", item["link"])
assert.EqualValues(s.T(), category.ID, item["category_id"])
assert.Contains(s.T(), item["content"], "billing portal")
assert.NotContains(s.T(), item, "status")
meta := resp["meta"].(map[string]any)
assert.EqualValues(s.T(), 1, meta["articles_count"])
assert.EqualValues(s.T(), 1, meta["current_page"])
}
func (s *ArticleHandlerTestSuite) TestPublicSearch_EmptyQueryReturnsEmptyPayload() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Empty Search Portal", Slug: "empty-search-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: "Published Searchable", Slug: "published-searchable", Content: "searchable", Status: "published", Locale: "en"}).Error)
r := gin.New()
r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/empty-search-portal/en/search?query=%20", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]any
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.Empty(s.T(), resp["payload"].([]any))
assert.EqualValues(s.T(), 0, resp["meta"].(map[string]any)["articles_count"])
}
func (s *ArticleHandlerTestSuite) TestPublicSearch_NotFoundForArchivedPortal() {
portal := &model.Portal{AccountID: s.account.ID, Name: "Archived Search Portal", Slug: "archived-search-portal", Archived: true}
s.Require().NoError(s.db.Create(portal).Error)
r := gin.New()
r.GET("/hc/:slug/:locale/search", s.handler.PublicSearch)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/hc/archived-search-portal/en/search?query=billing", nil)
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNotFound, w.Code)
}
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)
+1
View File
@@ -285,6 +285,7 @@ func RegisterRoutes(
{
helpCenter.GET("/:slug", handlers.Portal.PublicRedirectDefaultLocale)
helpCenter.GET("/:slug/:locale", handlers.Portal.PublicGet)
helpCenter.GET("/:slug/:locale/search", handlers.Article.PublicSearch)
helpCenter.GET("/:slug/:locale/articles", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/articles.json", handlers.Article.PublicList)
helpCenter.GET("/:slug/:locale/categories", handlers.Category.PublicList)
+1
View File
@@ -38,6 +38,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
"PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id",
"GET /hc/:slug",
"GET /hc/:slug/:locale",
"GET /hc/:slug/:locale/search",
"GET /hc/:slug/:locale/articles.json",
"GET /hc/:slug/:locale/categories.json",
"GET /hc/:slug/:locale/categories/:category_slug",