Files
gochat/internal/service/conversation_finder_strategy.go
T
2026-06-04 15:44:48 +08:00

505 lines
17 KiB
Go

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
}