Files
gochat/internal/handler/api/v1/search_handler.go
T

547 lines
22 KiB
Go

package v1
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/search"
"github.com/gochat/gochat/pkg/response"
)
const chatwootSearchPerPage = 15
// SearchHandler handles global search API endpoints.
// Reference: Chatwoot GlobalSearchService — cross-entity search with advanced filtering.
type SearchHandler struct {
svc *search.SearchService
}
// NewSearchHandler creates a new SearchHandler.
func NewSearchHandler(svc *search.SearchService) *SearchHandler {
return &SearchHandler{svc: svc}
}
// GlobalSearch performs a unified search across conversations, messages, and contacts.
// GET /api/v1/accounts/:account_id/search?q=xxx&types=conversation,message&status=open&assignee_id=1
// Reference: Chatwoot GlobalSearchService — searches across conversations, messages, contacts.
// @Summary Global search across all entity types
// @Description Searches across conversations, messages, contacts, and articles with advanced filtering and pagination
// @Tags Search
// @Accept json
// @Produce json
// @Param account_id path uint true "Account ID"
// @Param q query string false "Search query string"
// @Param types query string false "Entity types to search (conversation,message,contact,article)" default(conversation,message,contact,article)
// @Param search_mode query string false "Search mode: ilike (substring) or trigram (fuzzy)" default(ilike)
// @Param status query string false "Conversation status filter (open,resolved,pending,snoozed)"
// @Param priority query string false "Conversation priority filter (none,low,medium,high,urgent)"
// @Param assignee_id query int false "Assignee agent ID filter"
// @Param team_id query int false "Team ID filter"
// @Param inbox_id query int false "Inbox ID filter"
// @Param labels query string false "Label filter (comma-separated)"
// @Param contact_source query string false "Contact source filter (email,phone,website,api)"
// @Param message_type query string false "Message type filter (incoming,outgoing,activity)"
// @Param sender_type query string false "Sender type filter"
// @Param content_type query string false "Content type filter (text,input_email,card)"
// @Param private query bool false "Private message filter"
// @Param date_from query string false "Date range start (ISO 8601)"
// @Param date_to query string false "Date range end (ISO 8601)"
// @Param portal_id query int false "Portal ID filter (for article search)"
// @Param article_status query string false "Article status filter (draft,published,archived)"
// @Param article_locale query string false "Article locale filter"
// @Param sort_by query string false "Sort field (created_at,last_activity_at,updated_at)" default(created_at)
// @Param sort_order query string false "Sort order (asc,desc)" default(desc)
// @Param page query int false "Page number" default(1)
// @Param per_page query int false "Items per page" default(25)
// @Success 200 {object} search.SearchResponse
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Security ApiKeyAuth
// @Router /api/v1/accounts/{account_id}/search [get]
func (h *SearchHandler) GlobalSearch(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
result, svcErr := h.svc.GlobalSearch(c.Request.Context(), accountID, query, &filter)
if svcErr != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "search failed")
return
}
c.JSON(http.StatusOK, gin.H{"payload": serializeSearchPayload(result.Results)})
}
// SearchConversations performs a conversation-only search with advanced filters.
// GET /api/v1/accounts/:account_id/search/conversations?q=xxx&status=open&assignee_id=1
// Reference: Chatwoot conversations#index with filter params.
// @Summary Search conversations
// @Description Searches conversations by query string with advanced filtering (status, assignee, labels, date range)
// @Tags Search
// @Accept json
// @Produce json
// @Param account_id path uint true "Account ID"
// @Param q query string true "Search query string"
// @Param search_mode query string false "Search mode: ilike (substring) or trigram (fuzzy)" default(ilike)
// @Param status query string false "Conversation status filter (open,resolved,pending,snoozed)"
// @Param priority query string false "Conversation priority filter (none,low,medium,high,urgent)"
// @Param assignee_id query int false "Assignee agent ID filter"
// @Param team_id query int false "Team ID filter"
// @Param inbox_id query int false "Inbox ID filter"
// @Param labels query string false "Label filter (comma-separated)"
// @Param date_from query string false "Date range start (ISO 8601)"
// @Param date_to query string false "Date range end (ISO 8601)"
// @Param sort_by query string false "Sort field (created_at,last_activity_at,updated_at)" default(created_at)
// @Param sort_order query string false "Sort order (asc,desc)" default(desc)
// @Param page query int false "Page number" default(1)
// @Param per_page query int false "Items per page" default(25)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Security ApiKeyAuth
// @Router /api/v1/accounts/{account_id}/search/conversations [get]
func (h *SearchHandler) SearchConversations(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
// Force type to conversations only
filter.Types = []search.SearchResultType{search.ResultTypeConversation}
results, total, svcErr := h.svc.SearchConversations(c.Request.Context(), accountID, query, &filter)
if svcErr != nil {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "conversation search failed")
return
}
_ = total
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversations": serializeSearchConversations(results)}})
}
// SearchMessages performs a message-only search with advanced filters.
// GET /api/v1/accounts/:account_id/search/messages?q=xxx&message_type=incoming&private=false
// Reference: Chatwoot messages search — full text search on message content.
// @Summary Search messages
// @Description Searches messages by query string with advanced filtering (message type, sender type, content type, private)
// @Tags Search
// @Accept json
// @Produce json
// @Param account_id path uint true "Account ID"
// @Param q query string true "Search query string"
// @Param search_mode query string false "Search mode: ilike (substring) or trigram (fuzzy)" default(ilike)
// @Param message_type query string false "Message type filter (incoming,outgoing,activity)"
// @Param sender_type query string false "Sender type filter"
// @Param content_type query string false "Content type filter (text,input_email,card)"
// @Param private query bool false "Private message filter"
// @Param inbox_id query int false "Inbox ID filter"
// @Param date_from query string false "Date range start (ISO 8601)"
// @Param date_to query string false "Date range end (ISO 8601)"
// @Param sort_by query string false "Sort field (created_at,updated_at)" default(created_at)
// @Param sort_order query string false "Sort order (asc,desc)" default(desc)
// @Param page query int false "Page number" default(1)
// @Param per_page query int false "Items per page" default(25)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Security ApiKeyAuth
// @Router /api/v1/accounts/{account_id}/search/messages [get]
func (h *SearchHandler) SearchMessages(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
// Force type to messages only
filter.Types = []search.SearchResultType{search.ResultTypeMessage}
results, total, svcErr := h.svc.SearchMessages(c.Request.Context(), accountID, query, &filter)
if svcErr != nil {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "message search failed")
return
}
_ = total
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"messages": serializeSearchMessages(results)}})
}
// SearchContacts performs a contact-only search with advanced filters.
// GET /api/v1/accounts/:account_id/search/contacts?q=xxx&contact_source=email
// Reference: Chatwoot contacts#search — name, email, phone, identifier.
// @Summary Search contacts
// @Description Searches contacts by query string (name, email, phone, identifier) with advanced filtering
// @Tags Search
// @Accept json
// @Produce json
// @Param account_id path uint true "Account ID"
// @Param q query string true "Search query string"
// @Param search_mode query string false "Search mode: ilike (substring) or trigram (fuzzy)" default(ilike)
// @Param contact_source query string false "Contact source filter (email,phone,website,api)"
// @Param sort_by query string false "Sort field (created_at,updated_at,name)" default(created_at)
// @Param sort_order query string false "Sort order (asc,desc)" default(desc)
// @Param page query int false "Page number" default(1)
// @Param per_page query int false "Items per page" default(25)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Security ApiKeyAuth
// @Router /api/v1/accounts/{account_id}/search/contacts [get]
func (h *SearchHandler) SearchContacts(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
// Force type to contacts only
filter.Types = []search.SearchResultType{search.ResultTypeContact}
results, total, svcErr := h.svc.SearchContacts(c.Request.Context(), accountID, query, &filter)
if svcErr != nil {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "contact search failed")
return
}
_ = total
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"contacts": serializeSearchContacts(results)}})
}
// SearchArticles performs a knowledge base article-only search with advanced filters.
// GET /api/v1/accounts/:account_id/search/articles?q=xxx&portal_id=1&article_status=published&locale=en
// Reference: Chatwoot ArticlesController#search — full text search on article title, description, content.
// @Summary Search knowledge base articles
// @Description Searches knowledge base articles by query string (title, description, content) with portal, status, and locale filters
// @Tags Search
// @Accept json
// @Produce json
// @Param account_id path uint true "Account ID"
// @Param q query string true "Search query string"
// @Param search_mode query string false "Search mode: ilike (substring) or trigram (fuzzy)" default(ilike)
// @Param portal_id query int false "Portal ID filter"
// @Param article_status query string false "Article status filter (draft,published,archived)"
// @Param article_locale query string false "Article locale filter (en,es,fr,de,pt,etc)"
// @Param author_id query int false "Author ID filter"
// @Param sort_by query string false "Sort field (created_at,updated_at)" default(created_at)
// @Param sort_order query string false "Sort order (asc,desc)" default(desc)
// @Param page query int false "Page number" default(1)
// @Param per_page query int false "Items per page" default(25)
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Security ApiKeyAuth
// @Router /api/v1/accounts/{account_id}/search/articles [get]
func (h *SearchHandler) SearchArticles(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
query := c.Query("q")
filter := parseChatwootSearchFilter(c)
// Force type to articles only
filter.Types = []search.SearchResultType{search.ResultTypeArticle}
results, total, svcErr := h.svc.SearchArticles(c.Request.Context(), accountID, query, &filter)
if svcErr != nil {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "article search failed")
return
}
_ = total
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"articles": serializeSearchArticles(results)}})
}
func serializeSearchPayload(results []search.SearchResult) gin.H {
return gin.H{
"conversations": serializeSearchConversations(filterSearchResults(results, search.ResultTypeConversation)),
"contacts": serializeSearchContacts(filterSearchResults(results, search.ResultTypeContact)),
"messages": serializeSearchMessages(filterSearchResults(results, search.ResultTypeMessage)),
"articles": serializeSearchArticles(filterSearchResults(results, search.ResultTypeArticle)),
}
}
func parseChatwootSearchFilter(c *gin.Context) search.SearchFilter {
filter := search.ParseSearchFilter(c)
if c.Query("per_page") == "" {
filter.PerPage = chatwootSearchPerPage
}
return filter
}
func filterSearchResults(results []search.SearchResult, resultType search.SearchResultType) []search.SearchResult {
filtered := make([]search.SearchResult, 0)
for _, result := range results {
if result.Type == resultType {
filtered = append(filtered, result)
}
}
return filtered
}
func serializeSearchConversations(results []search.SearchResult) []map[string]any {
payload := make([]map[string]any, 0, len(results))
for _, result := range results {
payload = append(payload, serializeSearchConversation(result))
}
return payload
}
func serializeSearchConversation(result search.SearchResult) map[string]any {
if conv, ok := result.Data.(model.Conversation); ok {
return map[string]any{
"id": conversationDisplayID(&conv),
"account_id": conv.AccountID,
"created_at": conv.CreatedAt.Unix(),
"additional_attributes": jsonObject(conv.AdditionalAttributes),
}
}
if conv, ok := result.Data.(*model.Conversation); ok && conv != nil {
return map[string]any{
"id": conversationDisplayID(conv),
"account_id": conv.AccountID,
"created_at": conv.CreatedAt.Unix(),
"additional_attributes": jsonObject(conv.AdditionalAttributes),
}
}
data := nestedSearchData(result, "conversation")
return map[string]any{
"id": firstMapValue(data, "display_id", "id"),
"account_id": firstMapValue(data, "account_id"),
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
"additional_attributes": firstMapValue(data, "additional_attributes"),
}
}
func serializeSearchContacts(results []search.SearchResult) []map[string]any {
payload := make([]map[string]any, 0, len(results))
for _, result := range results {
payload = append(payload, serializeSearchContact(result))
}
return payload
}
func serializeSearchContact(result search.SearchResult) map[string]any {
if contact, ok := result.Data.(model.Contact); ok {
return serializeSearchContactModel(&contact)
}
if contact, ok := result.Data.(*model.Contact); ok && contact != nil {
return serializeSearchContactModel(contact)
}
data := nestedSearchData(result, "contact")
return map[string]any{
"email": firstMapValue(data, "email"),
"id": firstMapValue(data, "id"),
"name": firstMapValue(data, "name"),
"phone_number": firstMapValue(data, "phone_number"),
"identifier": firstMapValue(data, "identifier"),
"additional_attributes": firstMapValue(data, "additional_attributes"),
"last_activity_at": unixFromMapValue(firstMapValue(data, "last_activity_at")),
}
}
func serializeSearchContactModel(contact *model.Contact) map[string]any {
return map[string]any{
"email": contact.Email,
"id": contact.ID,
"name": contact.Name,
"phone_number": contact.PhoneNumber,
"identifier": contact.Identifier,
"additional_attributes": jsonObject(contact.AdditionalAttributes),
"last_activity_at": int64Value(contact.LastActivityAt),
}
}
func serializeSearchMessages(results []search.SearchResult) []map[string]any {
payload := make([]map[string]any, 0, len(results))
for _, result := range results {
payload = append(payload, serializeSearchMessage(result))
}
return payload
}
func serializeSearchMessage(result search.SearchResult) map[string]any {
if message, ok := result.Data.(model.Message); ok {
return serializeSearchMessageModel(&message)
}
if message, ok := result.Data.(*model.Message); ok && message != nil {
return serializeSearchMessageModel(message)
}
data := nestedSearchData(result, "message")
return map[string]any{
"id": firstMapValue(data, "id"),
"content": firstMapValue(data, "content"),
"account_id": firstMapValue(data, "account_id"),
"inbox_id": firstMapValue(data, "inbox_id"),
"conversation_id": firstMapValue(data, "conversation_id"),
"message_type": normalizeSearchMessageType(firstMapValue(data, "message_type")),
"content_type": firstMapValue(data, "content_type"),
"status": firstMapValue(data, "status"),
"content_attributes": firstMapValue(data, "content_attributes"),
"additional_attributes": firstMapValue(data, "additional_attributes"),
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
"private": firstMapValue(data, "private"),
"source_id": firstMapValue(data, "source_id"),
}
}
func normalizeSearchMessageType(value any) any {
s, ok := value.(string)
if !ok {
return value
}
if strings.TrimSpace(s) == "" {
return value
}
return messageTypeValue(s)
}
func serializeSearchMessageModel(message *model.Message) map[string]any {
return map[string]any{
"id": message.ID,
"content": message.Content,
"account_id": message.AccountID,
"inbox_id": message.InboxID,
"conversation_id": message.ConversationID,
"message_type": messageTypeValue(message.MessageType),
"content_type": nonEmpty(message.ContentType, "text"),
"status": nonEmpty(message.Status, "sent"),
"content_attributes": jsonObject(message.ContentAttributes),
"additional_attributes": jsonObject(message.AdditionalAttributes),
"created_at": message.CreatedAt.Unix(),
"private": message.Private,
"source_id": message.SourceID,
}
}
func serializeSearchArticles(results []search.SearchResult) []map[string]any {
payload := make([]map[string]any, 0, len(results))
for _, result := range results {
payload = append(payload, serializeSearchArticle(result))
}
return payload
}
func serializeSearchArticle(result search.SearchResult) map[string]any {
if article, ok := result.Data.(model.Article); ok {
return serializeSearchArticleModel(&article)
}
if article, ok := result.Data.(*model.Article); ok && article != nil {
return serializeSearchArticleModel(article)
}
data := nestedSearchData(result, "article")
return map[string]any{
"id": firstMapValue(data, "id"),
"title": firstMapValue(data, "title"),
"locale": firstMapValue(data, "locale"),
"content": firstMapValue(data, "content"),
"slug": firstMapValue(data, "slug"),
"portal_slug": firstMapValue(data, "portal_slug"),
"account_id": firstMapValue(data, "account_id"),
"category_name": firstMapValue(data, "category_name"),
"status": firstMapValue(data, "status"),
"updated_at": unixFromMapValue(firstMapValue(data, "updated_at", "updated_at_ts")),
}
}
func serializeSearchArticleModel(article *model.Article) map[string]any {
portalSlug := ""
if article.Portal.Slug != "" {
portalSlug = article.Portal.Slug
}
categoryName := ""
if article.Category != nil && article.Category.Name != "" {
categoryName = article.Category.Name
}
return map[string]any{
"id": article.ID,
"title": article.Title,
"locale": article.Locale,
"content": article.Content,
"slug": article.Slug,
"portal_slug": portalSlug,
"account_id": article.AccountID,
"category_name": categoryName,
"status": article.Status,
"updated_at": article.UpdatedAt.Unix(),
}
}
func nestedSearchData(result search.SearchResult, key string) map[string]any {
root, ok := anyMap(result.Data)
if !ok {
return map[string]any{}
}
if nested, ok := anyMap(root[key]); ok {
return nested
}
if data, ok := anyMap(root["data"]); ok {
if nested, ok := anyMap(data[key]); ok {
return nested
}
return data
}
return root
}
func anyMap(value any) (map[string]any, bool) {
switch typed := value.(type) {
case map[string]any:
return typed, true
case gin.H:
return map[string]any(typed), true
default:
return nil, false
}
}
func firstMapValue(data map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := data[key]; ok {
return value
}
}
return nil
}
func unixFromMapValue(value any) any {
switch typed := value.(type) {
case float64:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case uint:
return int64(typed)
default:
return typed
}
}