package v1 import ( "context" "net/http" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/pkg/response" ) const chatwootSearchPerPage = 15 var chatwootGlobalSearchTypes = []search.SearchResultType{ search.ResultTypeConversation, search.ResultTypeContact, search.ResultTypeMessage, search.ResultTypeArticle, } // SearchHandler handles global search API endpoints. // Reference: Chatwoot GlobalSearchService — cross-entity search with advanced filtering. type SearchHandler struct { svc *search.SearchService db *gorm.DB } // NewSearchHandler creates a new SearchHandler. func NewSearchHandler(svc *search.SearchService, dbs ...*gorm.DB) *SearchHandler { h := &SearchHandler{svc: svc} if len(dbs) > 0 { h.db = dbs[0] } return h } // 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 "Ignored for Chatwoot parity; search results are fixed at 15 per page" default(15) // @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, "global") filter.Types = append([]search.SearchResultType(nil), chatwootGlobalSearchTypes...) 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(c.Request.Context(), h.db, 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 "Ignored for Chatwoot parity; search results are fixed at 15 per page" default(15) // @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, "conversation") // 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(c.Request.Context(), h.db, 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 "Ignored for Chatwoot parity; search results are fixed at 15 per page" default(15) // @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, "message") // 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(c.Request.Context(), h.db, 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 "Ignored for Chatwoot parity; search results are fixed at 15 per page" default(15) // @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, "contact") // Force type to contacts only filter.Types = []search.SearchResultType{search.ResultTypeContact} results, total, svcErr := h.svc.SearchResolvedContacts(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 "Ignored for Chatwoot parity; search results are fixed at 15 per page" default(15) // @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, "article") // 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(c.Request.Context(), h.db, results)}}) } func serializeSearchPayload(ctx context.Context, db *gorm.DB, results []search.SearchResult) gin.H { return gin.H{ "conversations": serializeSearchConversations(ctx, db, filterSearchResults(results, search.ResultTypeConversation)), "contacts": serializeSearchContacts(filterSearchResults(results, search.ResultTypeContact)), "messages": serializeSearchMessages(ctx, db, filterSearchResults(results, search.ResultTypeMessage)), "articles": serializeSearchArticles(ctx, db, filterSearchResults(results, search.ResultTypeArticle)), } } func parseChatwootSearchFilter(c *gin.Context, scope string) search.SearchFilter { filter := search.ParseSearchFilter(c) if userID := getUserID(c); userID != 0 { filter.CurrentUserID = &userID } filter.PerPage = chatwootSearchPerPage filter = sanitizeChatwootSearchFilter(filter, scope) return filter } func sanitizeChatwootSearchFilter(filter search.SearchFilter, scope string) search.SearchFilter { filter.Status = nil filter.Priority = nil filter.AssigneeID = nil filter.TeamID = nil filter.Labels = nil filter.ContactSource = "" filter.MessageType = "" filter.ContentType = "" filter.Private = nil filter.PortalID = nil filter.ArticleStatus = "" filter.ArticleLocale = "" if scope != "message" && scope != "global" { filter.SenderType = "" filter.SenderID = nil filter.InboxID = nil } 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(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any { payload := make([]map[string]any, 0, len(results)) for _, result := range results { payload = append(payload, serializeSearchConversation(ctx, db, result)) } return payload } func serializeSearchConversation(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any { if conv, ok := result.Data.(model.Conversation); ok { return serializeSearchConversationModel(ctx, db, &conv) } if conv, ok := result.Data.(*model.Conversation); ok && conv != nil { return serializeSearchConversationModel(ctx, db, conv) } root := searchDataRoot(result) data := nestedSearchDataFromRoot(root, "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"), "message": serializeSearchConversationMessageMap(ctx, db, firstNestedSearchData(root, data, "message")), "contact": serializeSearchConversationContactMap(firstNestedSearchData(root, data, "contact")), "inbox": serializeSearchConversationInboxMap(firstNestedSearchData(root, data, "inbox")), "agent": serializeSearchConversationAgentMap(firstNestedSearchData(root, data, "agent", "assignee")), } } func serializeSearchConversationModel(ctx context.Context, db *gorm.DB, conv *model.Conversation) map[string]any { return map[string]any{ "id": conversationDisplayID(conv), "account_id": conv.AccountID, "created_at": conv.CreatedAt.Unix(), "additional_attributes": jsonObject(conv.AdditionalAttributes), "message": serializeSearchConversationMessageModel(ctx, db, conv), "contact": serializeSearchConversationContactModel(conv), "inbox": serializeSearchConversationInboxModel(conv), "agent": serializeSearchConversationAgentModel(conv), } } func serializeSearchConversationMessageModel(ctx context.Context, db *gorm.DB, conv *model.Conversation) map[string]any { if len(conv.Messages) == 0 { return map[string]any{} } return serializeSearchMessageModel(ctx, db, &conv.Messages[0]) } func serializeSearchConversationContactModel(conv *model.Conversation) map[string]any { if conv.Contact != nil { return serializeSearchContactModel(conv.Contact) } if conv.ContactID != 0 { return map[string]any{"id": conv.ContactID} } return map[string]any{} } func serializeSearchConversationInboxModel(conv *model.Conversation) map[string]any { if conv.Inbox != nil { return map[string]any{ "id": conv.Inbox.ID, "channel_id": conv.Inbox.ChannelID, "name": conv.Inbox.Name, "channel_type": conv.Inbox.ChannelType, } } if conv.InboxID != 0 { return map[string]any{"id": conv.InboxID, "channel_type": conv.ChannelType} } return map[string]any{} } func serializeSearchConversationAgentModel(conv *model.Conversation) map[string]any { if conv.Assignee != nil { return map[string]any{ "id": conv.Assignee.ID, "available_name": nonEmpty(conv.Assignee.DisplayName, conv.Assignee.Name), "email": conv.Assignee.Email, "name": conv.Assignee.Name, "role": nonEmpty(conv.Assignee.Role, "agent"), } } return map[string]any{} } func serializeSearchConversationMessageMap(ctx context.Context, db *gorm.DB, data map[string]any) map[string]any { if len(data) == 0 { return map[string]any{} } return serializeSearchMessage(ctx, db, search.SearchResult{Data: map[string]any{"message": data}}) } func serializeSearchConversationContactMap(data map[string]any) map[string]any { if len(data) == 0 { return map[string]any{} } 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 serializeSearchConversationInboxMap(data map[string]any) map[string]any { if len(data) == 0 { return map[string]any{} } return map[string]any{ "id": firstMapValue(data, "id"), "channel_id": firstMapValue(data, "channel_id"), "name": firstMapValue(data, "name"), "channel_type": firstMapValue(data, "channel_type"), } } func serializeSearchConversationAgentMap(data map[string]any) map[string]any { if len(data) == 0 { return map[string]any{} } return map[string]any{ "id": firstMapValue(data, "id"), "available_name": firstMapValue(data, "available_name", "display_name", "name"), "email": firstMapValue(data, "email"), "name": firstMapValue(data, "name"), "role": firstMapValue(data, "role"), } } 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(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any { payload := make([]map[string]any, 0, len(results)) for _, result := range results { payload = append(payload, serializeSearchMessage(ctx, db, result)) } return payload } func serializeSearchMessage(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any { if message, ok := result.Data.(model.Message); ok { return serializeSearchMessageModel(ctx, db, &message) } if message, ok := result.Data.(*model.Message); ok && message != nil { return serializeSearchMessageModel(ctx, db, message) } data := nestedSearchData(result, "message") if db != nil { if payload, ok := loadSearchMessagePayload(ctx, db, uintFromAny(firstMapValue(data, "id")), result.AccountID); ok { return payload } } return omitNilSearchMessageFields(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"), "created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")), "private": firstMapValue(data, "private"), "source_id": firstMapValue(data, "source_id"), "echo_id": firstMapValue(data, "echo_id"), "sender": firstMapValue(data, "sender"), "attachments": firstMapValue(data, "attachments"), }) } func normalizeSearchMessageType(value any) any { s, ok := value.(string) if !ok { return value } if strings.TrimSpace(s) == "" { return value } return messageTypeValue(s) } func serializeSearchMessageModel(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any { if db != nil { if payload, ok := loadSearchMessagePayload(ctx, db, message.ID, message.AccountID); ok { return payload } } return omitNilSearchMessageFields(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), "created_at": message.CreatedAt.Unix(), "private": message.Private, "source_id": message.SourceID, "echo_id": message.EchoID, }) } func loadSearchMessagePayload(ctx context.Context, db *gorm.DB, messageID uint, accountID uint) (map[string]any, bool) { if db == nil || messageID == 0 { return nil, false } var message model.Message q := db.WithContext(ctx).Where("id = ?", messageID) if accountID != 0 { q = q.Where("account_id = ?", accountID) } if err := q.First(&message).Error; err != nil { return nil, false } var conversation model.Conversation if err := db.WithContext(ctx). Where("id = ? AND account_id = ?", message.ConversationID, message.AccountID). First(&conversation).Error; err != nil { return chatwootMessagePayloadMap(serializeMessage(ctx, db, &message, nil)), true } return chatwootMessagePayloadMap(serializeMessage(ctx, db, &message, &conversation)), true } func chatwootMessagePayloadMap(payload chatwootMessagePayload) map[string]any { data := map[string]any{ "id": payload.ID, "content": payload.Content, "inbox_id": payload.InboxID, "account_id": payload.AccountID, "conversation_id": payload.ConversationID, "message_type": payload.MessageType, "content_type": payload.ContentType, "status": payload.Status, "content_attributes": payload.ContentAttributes, "created_at": payload.CreatedAt, "private": payload.Private, "source_id": payload.SourceID, } if payload.EchoID != "" { data["echo_id"] = payload.EchoID } if len(payload.Sender) > 0 { data["sender"] = payload.Sender } if len(payload.Attachments) > 0 { data["attachments"] = payload.Attachments } return data } func omitNilSearchMessageFields(data map[string]any) map[string]any { for _, key := range []string{"echo_id", "sender", "attachments"} { if data[key] == nil || data[key] == "" { delete(data, key) } } return data } func serializeSearchArticles(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any { payload := make([]map[string]any, 0, len(results)) for _, result := range results { payload = append(payload, serializeSearchArticle(ctx, db, result)) } return payload } func serializeSearchArticle(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any { if article, ok := result.Data.(model.Article); ok { return serializeSearchArticleModel(ctx, db, &article) } if article, ok := result.Data.(*model.Article); ok && article != nil { return serializeSearchArticleModel(ctx, db, article) } data := nestedSearchData(result, "article") if db != nil { if payload, ok := loadSearchArticlePayload(ctx, db, uintFromAny(firstMapValue(data, "id")), result.AccountID); ok { return payload } } 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(ctx context.Context, db *gorm.DB, article *model.Article) map[string]any { if db != nil { if payload, ok := loadSearchArticlePayload(ctx, db, article.ID, article.AccountID); ok { return payload } } 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 loadSearchArticlePayload(ctx context.Context, db *gorm.DB, articleID uint, accountID uint) (map[string]any, bool) { if db == nil || articleID == 0 { return nil, false } var article model.Article q := db.WithContext(ctx).Preload("Portal").Preload("Category").Where("id = ?", articleID) if accountID != 0 { q = q.Where("account_id = ?", accountID) } if err := q.First(&article).Error; err != nil { return nil, false } return serializeSearchArticleModel(ctx, nil, &article), true } func nestedSearchData(result search.SearchResult, key string) map[string]any { return nestedSearchDataFromRoot(searchDataRoot(result), key) } func searchDataRoot(result search.SearchResult) map[string]any { root, ok := anyMap(result.Data) if !ok { return map[string]any{} } if data, ok := anyMap(root["data"]); ok { return data } return root } func nestedSearchDataFromRoot(root map[string]any, key string) 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 firstNestedSearchData(root map[string]any, fallback map[string]any, keys ...string) map[string]any { for _, key := range keys { if nested, ok := anyMap(root[key]); ok { return nested } if nested, ok := anyMap(fallback[key]); ok { return nested } } return map[string]any{} } 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 } } func uintFromAny(value any) uint { switch typed := value.(type) { case uint: return typed case int: if typed > 0 { return uint(typed) } case int64: if typed > 0 { return uint(typed) } case float64: if typed > 0 { return uint(typed) } } return 0 }