Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
124 lines
4.5 KiB
Go
124 lines
4.5 KiB
Go
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
|
|
}
|