package service import ( "fmt" "strings" "gorm.io/gorm" ) // --- Strategy Pattern for Conversation Finder --- // Reference: Chatwoot app/finders/conversation_finder.rb // Each strategy applies one filter dimension to a GORM query. // Strategies are composable: they can be chained in any order. // FilterStrategy is the interface for all conversation filter strategies. // Each strategy mutates the query by applying its specific filter condition. type FilterStrategy interface { // Apply adds filter conditions to the GORM query and returns the modified query. Apply(query *gorm.DB) *gorm.DB // Name returns the strategy identifier for debugging/logging. Name() string } // BaseStrategy provides common fields shared by all filter strategies. type BaseStrategy struct { Params FilterParams // CurrentUserID is the authenticated user's ID (used for "me" assignee_type). CurrentUserID uint // AccountID is the account scope for the query. AccountID uint // IsAdmin indicates whether the current user is an administrator in the account. IsAdmin bool } // NewBaseStrategy creates a BaseStrategy with the given parameters. func NewBaseStrategy(params FilterParams, currentUserID uint, accountID uint, isAdmin bool) BaseStrategy { return BaseStrategy{ Params: params, CurrentUserID: currentUserID, AccountID: accountID, IsAdmin: isAdmin, } } // --- Strategy Implementations for 7 Chatwoot Filter Dimensions --- // 1. Status Filter Strategy // 2. AssigneeType Filter Strategy // 3. SortBy/Order Filter Strategy // 4. Labels Filter Strategy (AND/OR combination) // 5. InboxIDs Filter Strategy (multi-inbox) // 6. Tags Filter Strategy (custom tags) // 7. ConversationType Filter Strategy // ============================================================ // 1. StatusFilterStrategy — status (open/resolved/pending/all/snoozed) // Reference: Chatwoot ConversationFinder#filter_by_status // - If status == "all" → no status filter applied // - If status is empty → default to "open" // - Otherwise → exact match on status field // ============================================================ // StatusFilterStrategy filters conversations by their status field. type StatusFilterStrategy struct { BaseStrategy } func (s *StatusFilterStrategy) Name() string { return "status" } func (s *StatusFilterStrategy) Apply(query *gorm.DB) *gorm.DB { status := s.Params.Status // Chatwoot: filter_by_status — "all" means no filter if status == "all" { return query } // Chatwoot: DEFAULT_STATUS = 'open' when no status given if status == "" { status = "open" } return query.Where("status = ?", status) } // ============================================================ // 2. AssigneeTypeFilterStrategy — assignee_type (me/unassigned/assigned/all) // Reference: Chatwoot ConversationFinder#filter_by_assignee_type // - "me" → assignee_id = current_user_id // - "unassigned" → assignee_id IS NULL // - "assigned" → assignee_id IS NOT NULL // - "all" or empty → no filter // - If AssigneeID is explicitly set, it overrides assignee_type // ============================================================ // AssigneeTypeFilterStrategy filters conversations by assignee relationship. type AssigneeTypeFilterStrategy struct { BaseStrategy } func (s *AssigneeTypeFilterStrategy) Name() string { return "assignee_type" } func (s *AssigneeTypeFilterStrategy) Apply(query *gorm.DB) *gorm.DB { // If AssigneeID is explicitly set, it takes precedence over assignee_type if s.Params.AssigneeID != nil { if *s.Params.AssigneeID == 0 { // AssigneeID = 0 means explicitly unassigned return query.Where("assignee_id IS NULL") } return query.Where("assignee_id = ?", *s.Params.AssigneeID) } switch s.Params.AssigneeType { case "me": // Chatwoot: assigned_to(current_user) → assignee_id = current_user.id return query.Where("assignee_id = ?", s.CurrentUserID) case "unassigned": // Chatwoot: unassigned scope → assignee_id IS NULL return query.Where("assignee_id IS NULL") case "assigned": // Chatwoot: assigned scope → assignee_id IS NOT NULL return query.Where("assignee_id IS NOT NULL") case "all": // No filter return query default: // Empty assignee_type → no additional filter return query } } // ============================================================ // 3. SortByFilterStrategy — sort_by + order // Reference: Chatwoot ConversationFinder::SORT_OPTIONS // - last_activity_at_asc/desc // - created_at_asc/desc // - priority_asc/desc // - waiting_since_asc/desc // - "latest" → last_activity_at DESC (legacy alias) // - Default: last_activity_at DESC // ============================================================ // SortOption maps a sort_by value to its SQL column and order direction. type SortOption struct { Column string Order string // "ASC" or "DESC" } // SortOptionsRegistry maps all valid sort_by keys to their column+order pairs. // Reference: Chatwoot ConversationFinder::SORT_OPTIONS (with_indifferent_access) var SortOptionsRegistry = map[string]SortOption{ "last_activity_at_asc": {Column: "last_activity_at", Order: "ASC"}, "last_activity_at_desc": {Column: "last_activity_at", Order: "DESC"}, "created_at_asc": {Column: "created_at", Order: "ASC"}, "created_at_desc": {Column: "created_at", Order: "DESC"}, "priority_asc": {Column: "priority", Order: "ASC"}, "priority_desc": {Column: "priority", Order: "DESC"}, "waiting_since_asc": {Column: "waiting_since", Order: "ASC"}, "waiting_since_desc": {Column: "waiting_since", Order: "DESC"}, // Legacy aliases (Chatwoot: to be removed in v3.5.0) "latest": {Column: "last_activity_at", Order: "DESC"}, "sort_on_created_at": {Column: "created_at", Order: "ASC"}, "sort_on_priority": {Column: "priority", Order: "DESC"}, "sort_on_waiting_since": {Column: "waiting_since", Order: "ASC"}, } // DefaultSortOption is the fallback when sort_by is not specified. var DefaultSortOption = SortOption{Column: "last_activity_at", Order: "DESC"} // SortByFilterStrategy applies sort order to the query. type SortByFilterStrategy struct { BaseStrategy } func (s *SortByFilterStrategy) Name() string { return "sort_by" } func (s *SortByFilterStrategy) Apply(query *gorm.DB) *gorm.DB { sortBy := s.Params.SortBy opt, ok := SortOptionsRegistry[sortBy] if !ok { opt = DefaultSortOption } orderClause := fmt.Sprintf("%s %s", opt.Column, opt.Order) return query.Order(orderClause) } // ============================================================ // 4. LabelsFilterStrategy — labels (AND/OR combination) // Reference: Chatwoot ConversationFinder#filter_by_labels // - Chatwoot uses ActsAsTaggableOn: tagged_with(labels, any: true) // - "any: true" means OR semantics (match any of the given labels) // - In GoChat, labels are stored as a comma-separated text field // - We support both AND (match all) and OR (match any) modes // ============================================================ // LabelsMode determines how multi-label filtering works. type LabelsMode string const ( // LabelsModeAny means OR — match any of the given labels (Chatwoot default). LabelsModeAny LabelsMode = "any" // LabelsModeAll means AND — match all of the given labels. LabelsModeAll LabelsMode = "all" ) // LabelsFilterStrategy filters conversations by label tags. type LabelsFilterStrategy struct { BaseStrategy // Mode controls AND vs OR semantics for multi-label matching. // Default: LabelsModeAny (matches Chatwoot's tagged_with with any: true). Mode LabelsMode } func (s *LabelsFilterStrategy) Name() string { return "labels" } func (s *LabelsFilterStrategy) Apply(query *gorm.DB) *gorm.DB { if s.Params.Labels == "" { return query } labels := splitAndTrimLabels(s.Params.Labels) if len(labels) == 0 { return query } mode := s.Mode if mode == "" { mode = LabelsModeAny // Chatwoot default: any: true } switch mode { case LabelsModeAny: // OR semantics: match any label — LIKE '%label1%' OR LIKE '%label2%' // Chatwoot: tagged_with(labels, any: true) conditions := make([]string, len(labels)) args := make([]interface{}, len(labels)) for i, label := range labels { conditions[i] = "labels LIKE ?" args[i] = "%" + label + "%" } combined := strings.Join(conditions, " OR ") return query.Where(combined, args...) case LabelsModeAll: // AND semantics: match all labels — LIKE '%label1%' AND LIKE '%label2%' for _, label := range labels { query = query.Where("labels LIKE ?", "%"+label+"%") } return query default: return query } } // ============================================================ // 5. InboxIDsFilterStrategy — inbox_ids (multi-inbox filtering) // Reference: Chatwoot ConversationFinder#set_inboxes + find_conversation_by_inbox // - When inbox_id is provided → filter by that specific inbox (scoped to user's assigned inboxes) // - When no inbox_id → scope to all inboxes the user is assigned to // - In GoChat, we support multiple inbox IDs via InboxIDs field // ============================================================ // InboxIDsFilterStrategy filters conversations by inbox membership. type InboxIDsFilterStrategy struct { BaseStrategy // AllowedInboxIDs is the list of inbox IDs the current user is permitted to access. // If empty, no inbox filtering is applied (admin can see all). AllowedInboxIDs []uint } func (s *InboxIDsFilterStrategy) Name() string { return "inbox_ids" } func (s *InboxIDsFilterStrategy) Apply(query *gorm.DB) *gorm.DB { // If InboxIDs are explicitly provided in params, use them if len(s.Params.InboxIDs) > 0 { return query.Where("inbox_id IN ?", s.Params.InboxIDs) } // If a single InboxID is provided (legacy param), use it if s.Params.InboxID != nil { return query.Where("inbox_id = ?", *s.Params.InboxID) } // If the user is not an admin, restrict to their assigned inboxes if !s.IsAdmin && len(s.AllowedInboxIDs) > 0 { return query.Where("inbox_id IN ?", s.AllowedInboxIDs) } // Admin with no inbox filter → show all return query } // ============================================================ // 6. TagsFilterStrategy — custom tags filtering // Reference: Chatwoot ConversationFinder — tags are handled via labels field // - In Chatwoot, "tags" and "labels" are the same concept (ActsAsTaggableOn) // - GoChat stores both labels (system-level) and Tags (user-level) on conversation // - This strategy filters by the Tags field specifically // ============================================================ // TagsFilterStrategy filters conversations by user-defined tags. type TagsFilterStrategy struct { BaseStrategy } func (s *TagsFilterStrategy) Name() string { return "tags" } func (s *TagsFilterStrategy) Apply(query *gorm.DB) *gorm.DB { if s.Params.Tags == "" { return query } tags := splitAndTrimLabels(s.Params.Tags) if len(tags) == 0 { return query } // OR semantics by default — match any of the provided tags conditions := make([]string, len(tags)) args := make([]interface{}, len(tags)) for i, tag := range tags { conditions[i] = "tags LIKE ?" args[i] = "%" + tag + "%" } combined := strings.Join(conditions, " OR ") return query.Where(combined, args...) } // ============================================================ // 7. ConversationTypeFilterStrategy — conversation_type (mention/participating/unattended) // Reference: Chatwoot ConversationFinder#filter_by_conversation_type // ============================================================ // ConversationTypeFilterStrategy filters by conversation participation type. type ConversationTypeFilterStrategy struct { BaseStrategy } func (s *ConversationTypeFilterStrategy) Name() string { return "conversation_type" } func (s *ConversationTypeFilterStrategy) Apply(query *gorm.DB) *gorm.DB { switch s.Params.ConversationType { case "mention": // Chatwoot: mention → subquery on mentions table return query.Where("id IN (SELECT conversation_id FROM mentions WHERE user_id = ?)", s.CurrentUserID) case "participating": // Chatwoot: participating → subquery on conversation_participants return query.Where("id IN (SELECT conversation_id FROM conversation_participants WHERE user_id = ?)", s.CurrentUserID) case "unattended": // Chatwoot: unattended → first_reply_at IS NULL AND assignee_id IS NULL // In GoChat, we use first_reply_created_at (int64 timestamp) instead of datetime return query.Where("first_reply_created_at IS NULL AND assignee_id IS NULL") default: return query } } // ============================================================ // Bonus: UpdatedWithinFilterStrategy // Reference: Chatwoot ConversationFinder#conversations — updated_within param // ============================================================ // UpdatedWithinFilterStrategy filters conversations updated within N seconds. type UpdatedWithinFilterStrategy struct { BaseStrategy } func (s *UpdatedWithinFilterStrategy) Name() string { return "updated_within" } func (s *UpdatedWithinFilterStrategy) Apply(query *gorm.DB) *gorm.DB { if s.Params.UpdatedWithin == nil { return query } seconds := *s.Params.UpdatedWithin return query.Where("updated_at > NOW() - INTERVAL '? seconds'", fmt.Sprintf("%d", seconds)) } // ============================================================ // Bonus: TeamFilterStrategy // Reference: Chatwoot ConversationFinder#filter_by_team // ============================================================ // TeamFilterStrategy filters conversations by team assignment. type TeamFilterStrategy struct { BaseStrategy } func (s *TeamFilterStrategy) Name() string { return "team" } func (s *TeamFilterStrategy) Apply(query *gorm.DB) *gorm.DB { if s.Params.TeamID == nil { return query } return query.Where("team_id = ?", *s.Params.TeamID) } // ============================================================ // Bonus: PriorityFilterStrategy // ============================================================ // PriorityFilterStrategy filters conversations by priority level. type PriorityFilterStrategy struct { BaseStrategy } func (s *PriorityFilterStrategy) Name() string { return "priority" } func (s *PriorityFilterStrategy) Apply(query *gorm.DB) *gorm.DB { if s.Params.Priority == "" { return query } return query.Where("priority = ?", s.Params.Priority) } // ============================================================ // Strategy Chain — composable filter chain // ============================================================ // StrategyChain holds an ordered list of strategies to apply sequentially. type StrategyChain struct { strategies []FilterStrategy } // NewStrategyChain creates an empty strategy chain. func NewStrategyChain() *StrategyChain { return &StrategyChain{strategies: []FilterStrategy{}} } // Add appends a strategy to the chain. func (c *StrategyChain) Add(strategy FilterStrategy) *StrategyChain { c.strategies = append(c.strategies, strategy) return c } // ApplyAll applies all strategies in sequence to the base query. // Returns the fully filtered query. func (c *StrategyChain) ApplyAll(query *gorm.DB) *gorm.DB { for _, strategy := range c.strategies { query = strategy.Apply(query) } return query } // Names returns the names of all strategies in the chain for debugging. func (c *StrategyChain) Names() []string { names := make([]string, len(c.strategies)) for i, s := range c.strategies { names[i] = s.Name() } return names } // ============================================================ // Helper functions // ============================================================ // splitAndTrimLabels splits a comma-separated string into trimmed, non-empty labels. func splitAndTrimLabels(raw string) []string { if raw == "" { return nil } parts := strings.Split(raw, ",") var result []string for _, p := range parts { trimmed := strings.TrimSpace(p) if trimmed != "" { result = append(result, trimmed) } } return result } // buildDefaultStrategyChain creates the standard Chatwoot-compatible filter chain. // Order matches Chatwoot's ConversationFinder#set_up flow: // 1. status // 2. assignee_type // 3. priority // 4. inbox_ids // 5. team // 6. labels // 7. tags // 8. conversation_type // 9. sort_by // Note: updated_within is applied after pagination in Chatwoot, // but we include it in the chain for simplicity. func buildDefaultStrategyChain(base BaseStrategy) *StrategyChain { chain := NewStrategyChain() chain.Add(&StatusFilterStrategy{BaseStrategy: base}) chain.Add(&AssigneeTypeFilterStrategy{BaseStrategy: base}) chain.Add(&PriorityFilterStrategy{BaseStrategy: base}) chain.Add(&InboxIDsFilterStrategy{ BaseStrategy: base, AllowedInboxIDs: base.Params.AllowedInboxIDs, }) chain.Add(&TeamFilterStrategy{BaseStrategy: base}) chain.Add(&LabelsFilterStrategy{BaseStrategy: base}) chain.Add(&TagsFilterStrategy{BaseStrategy: base}) chain.Add(&ConversationTypeFilterStrategy{BaseStrategy: base}) chain.Add(&UpdatedWithinFilterStrategy{BaseStrategy: base}) chain.Add(&SortByFilterStrategy{BaseStrategy: base}) return chain }