package canned import ( "context" "strings" "gorm.io/gorm" ) // DBProvider provides database access — implemented by app.App to break the import cycle. type DBProvider interface { DB() *gorm.DB } // CannedResponseService provides CRUD and search for canned responses. // Reference: Chatwoot CannedResponse — CRUD + order_by_search scope // (ranks by short_code match then content match). type CannedResponseService struct { db DBProvider } // NewCannedResponseService creates a new CannedResponseService. func NewCannedResponseService(db DBProvider) *CannedResponseService { return &CannedResponseService{db: db} } // Create creates a new canned response. func (s *CannedResponseService) Create(ctx context.Context, response *CannedResponse) error { return s.db.DB().WithContext(ctx).Create(response).Error } // GetByID retrieves a canned response by ID. func (s *CannedResponseService) GetByID(ctx context.Context, id uint) (*CannedResponse, error) { var resp CannedResponse if err := s.db.DB().WithContext(ctx).First(&resp, id).Error; err != nil { return nil, err } return &resp, nil } // GetByAccountAndID retrieves a canned response scoped through its account. func (s *CannedResponseService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*CannedResponse, error) { var resp CannedResponse if err := s.db.DB().WithContext(ctx). Where("account_id = ? AND id = ?", accountID, id). First(&resp).Error; err != nil { return nil, err } return &resp, nil } // Update updates a canned response. func (s *CannedResponseService) Update(ctx context.Context, id uint, updates map[string]interface{}) error { return s.db.DB().WithContext(ctx).Model(&CannedResponse{}).Where("id = ?", id).Updates(updates).Error } // UpdateByAccountAndID updates a canned response scoped through its account. func (s *CannedResponseService) UpdateByAccountAndID(ctx context.Context, accountID, id uint, updates map[string]interface{}) error { resp, err := s.GetByAccountAndID(ctx, accountID, id) if err != nil { return err } return s.db.DB().WithContext(ctx).Model(resp).Updates(updates).Error } // Delete soft-deletes a canned response. func (s *CannedResponseService) Delete(ctx context.Context, id uint) error { return s.db.DB().WithContext(ctx).Delete(&CannedResponse{}, id).Error } // DeleteByAccountAndID deletes a canned response scoped through its account. func (s *CannedResponseService) DeleteByAccountAndID(ctx context.Context, accountID, id uint) error { resp, err := s.GetByAccountAndID(ctx, accountID, id) if err != nil { return err } return s.db.DB().WithContext(ctx).Unscoped().Delete(resp).Error } // ListByAccount returns all canned responses for an account. func (s *CannedResponseService) ListByAccount(ctx context.Context, accountID uint) ([]CannedResponse, error) { var responses []CannedResponse err := s.db.DB().WithContext(ctx). Where("account_id = ?", accountID). Order("short_code ASC"). Find(&responses).Error return responses, err } // Search performs a ranked search over canned responses. // Reference: Chatwoot order_by_search scope — ranks by short_code match then content match. // Results are ordered so that short_code matches appear first, then content matches. func (s *CannedResponseService) Search(ctx context.Context, accountID uint, query string) ([]CannedResponse, error) { if query == "" { return s.ListByAccount(ctx, accountID) } var responses []CannedResponse // Use a CASE-based ordering: short_code matches rank higher than content matches. // This mirrors Chatwoot's order_by_search scope. // The search is case-insensitive. lowerQuery := strings.ToLower(query) prefixPattern := lowerQuery + "%" likePattern := "%" + lowerQuery + "%" err := s.db.DB().WithContext(ctx). Select("canned_responses.*, CASE WHEN LOWER(short_code) LIKE ? THEN 1.0 WHEN LOWER(short_code) LIKE ? THEN 0.5 WHEN LOWER(content) LIKE ? THEN 0.2 ELSE 0 END AS search_rank", prefixPattern, likePattern, likePattern). Where("account_id = ? AND (LOWER(short_code) LIKE ? OR LOWER(content) LIKE ?)", accountID, likePattern, likePattern). Order("search_rank DESC"). Order("short_code ASC"). Find(&responses).Error return responses, err } // GetByShortCode retrieves a canned response by its short code within an account. func (s *CannedResponseService) GetByShortCode(ctx context.Context, accountID uint, shortCode string) (*CannedResponse, error) { var resp CannedResponse if err := s.db.DB().WithContext(ctx). Where("account_id = ? AND short_code = ?", accountID, shortCode). First(&resp).Error; err != nil { return nil, err } return &resp, nil }