505 lines
17 KiB
Go
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
|
|
} |