821 lines
31 KiB
Go
821 lines
31 KiB
Go
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,
|
|
"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
|
|
}
|