Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md

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.
This commit is contained in:
2026-07-07 14:44:12 +08:00
parent d4ef996f49
commit aeddedf2a3
1348 changed files with 176 additions and 57 deletions
@@ -0,0 +1,88 @@
package canned
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
)
// testDBProvider implements DBProvider for tests.
type testDBProvider struct {
db *gorm.DB
}
func (p *testDBProvider) DB() *gorm.DB {
return p.db
}
// setupCannedTestDB creates an SQLite in-memory database with all required migrations.
func setupCannedTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("failed to open SQLite test database: %v", err)
}
if err := db.AutoMigrate(
&model.Account{},
&model.User{},
&model.AccountUser{},
&CannedResponse{},
); err != nil {
t.Fatalf("failed to auto-migrate models: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
return db
}
// setupCannedTestDBProvider creates a DBProvider-backed test database.
func setupCannedTestDBProvider(t *testing.T) DBProvider {
t.Helper()
db := setupCannedTestDB(t)
return &testDBProvider{db: db}
}
// seedTestAccount creates a minimal account + user for canned response tests.
func seedTestAccount(db *gorm.DB, t *testing.T) (accountID uint, userID uint) {
t.Helper()
account := &model.Account{Name: "Test Account"}
if err := db.Create(account).Error; err != nil {
t.Fatalf("failed to create test account: %v", err)
}
accountID = account.ID
user := &model.User{
AccountID: accountID,
Name: "Test User",
Email: "test@example.com",
Password: "testpassword",
}
if err := db.Create(user).Error; err != nil {
t.Fatalf("failed to create test user: %v", err)
}
userID = user.ID
au := &model.AccountUser{
AccountID: accountID,
UserID: userID,
Role: "administrator",
}
if err := db.Create(au).Error; err != nil {
t.Fatalf("failed to create account_user: %v", err)
}
return accountID, userID
}
+18
View File
@@ -0,0 +1,18 @@
package canned
import (
"github.com/gochat/gochat/internal/model"
)
// CannedResponse represents a pre-written message template for quick replies.
// Reference: Chatwoot CannedResponse model — account_id, content (text),
// short_code (string, unique scope account_id).
// Has order_by_search scope that ranks by short_code match then content match.
type CannedResponse struct {
model.Base
AccountID uint `gorm:"index;not null;uniqueIndex:idx_canned_short_code" json:"account_id"`
Content string `gorm:"type:text;not null" json:"content"`
ShortCode string `gorm:"size:255;not null;uniqueIndex:idx_canned_short_code" json:"short_code"`
}
func (CannedResponse) TableName() string { return "canned_responses" }
+123
View File
@@ -0,0 +1,123 @@
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
}
+418
View File
@@ -0,0 +1,418 @@
package canned
import (
"context"
"testing"
"github.com/gochat/gochat/internal/model"
)
func TestCannedResponseService_Create(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
cr := &CannedResponse{
AccountID: accountID,
ShortCode: "greeting",
Content: "Hello! How can I help you?",
}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
if cr.ID == 0 {
t.Fatal("expected ID to be set after Create")
}
if cr.ShortCode != "greeting" {
t.Fatalf("expected ShortCode 'greeting', got '%s'", cr.ShortCode)
}
if cr.Content != "Hello! How can I help you?" {
t.Fatalf("expected Content 'Hello! How can I help you?', got '%s'", cr.Content)
}
}
func TestCannedResponseService_Create_DuplicateShortCode(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
cr1 := &CannedResponse{
AccountID: accountID,
ShortCode: "greeting",
Content: "Hello!",
}
if err := svc.Create(context.Background(), cr1); err != nil {
t.Fatalf("first Create failed: %v", err)
}
// Same short_code within the same account should fail due to uniqueIndex.
cr2 := &CannedResponse{
AccountID: accountID,
ShortCode: "greeting",
Content: "Hi there!",
}
if err := svc.Create(context.Background(), cr2); err == nil {
t.Fatal("expected error on duplicate short_code, got nil")
}
}
func TestCannedResponseService_GetByID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
cr := &CannedResponse{
AccountID: accountID,
ShortCode: "thanks",
Content: "Thank you for contacting us!",
}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
found, err := svc.GetByID(context.Background(), cr.ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if found.ID != cr.ID {
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
}
if found.ShortCode != "thanks" {
t.Fatalf("expected ShortCode 'thanks', got '%s'", found.ShortCode)
}
if found.Content != "Thank you for contacting us!" {
t.Fatalf("expected Content 'Thank you for contacting us!', got '%s'", found.Content)
}
}
func TestCannedResponseService_GetByID_NotFound(t *testing.T) {
provider := setupCannedTestDBProvider(t)
svc := NewCannedResponseService(provider)
_, err := svc.GetByID(context.Background(), 9999)
if err == nil {
t.Fatal("expected error for non-existent ID, got nil")
}
}
func TestCannedResponseService_GetByAccountAndID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
otherAccount := &model.Account{Name: "Other Scoped Account"}
if err := provider.DB().Create(otherAccount).Error; err != nil {
t.Fatalf("failed to create other account: %v", err)
}
svc := NewCannedResponseService(provider)
cr := &CannedResponse{AccountID: accountID, ShortCode: "scoped", Content: "Scoped content"}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
found, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID)
if err != nil {
t.Fatalf("GetByAccountAndID failed: %v", err)
}
if found.ID != cr.ID {
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
}
if _, err := svc.GetByAccountAndID(context.Background(), otherAccount.ID, cr.ID); err == nil {
t.Fatal("expected cross-account lookup to fail")
}
}
func TestCannedResponseService_Update(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
cr := &CannedResponse{
AccountID: accountID,
ShortCode: "bye",
Content: "Goodbye!",
}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
updates := map[string]interface{}{
"short_code": "farewell",
"content": "Farewell and thank you!",
}
if err := svc.Update(context.Background(), cr.ID, updates); err != nil {
t.Fatalf("Update failed: %v", err)
}
found, err := svc.GetByID(context.Background(), cr.ID)
if err != nil {
t.Fatalf("GetByID after Update failed: %v", err)
}
if found.ShortCode != "farewell" {
t.Fatalf("expected ShortCode 'farewell', got '%s'", found.ShortCode)
}
if found.Content != "Farewell and thank you!" {
t.Fatalf("expected Content 'Farewell and thank you!', got '%s'", found.Content)
}
}
func TestCannedResponseService_UpdateByAccountAndID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
otherAccount := &model.Account{Name: "Other Update Account"}
if err := provider.DB().Create(otherAccount).Error; err != nil {
t.Fatalf("failed to create other account: %v", err)
}
svc := NewCannedResponseService(provider)
cr := &CannedResponse{AccountID: accountID, ShortCode: "account_update", Content: "Original"}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
if err := svc.UpdateByAccountAndID(context.Background(), otherAccount.ID, cr.ID, map[string]interface{}{"content": "Wrong"}); err == nil {
t.Fatal("expected cross-account update to fail")
}
if err := svc.UpdateByAccountAndID(context.Background(), accountID, cr.ID, map[string]interface{}{"content": "Updated"}); err != nil {
t.Fatalf("UpdateByAccountAndID failed: %v", err)
}
found, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID)
if err != nil {
t.Fatalf("GetByAccountAndID after update failed: %v", err)
}
if found.Content != "Updated" {
t.Fatalf("expected updated content, got %q", found.Content)
}
}
func TestCannedResponseService_Delete(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
cr := &CannedResponse{
AccountID: accountID,
ShortCode: "hello",
Content: "Hello!",
}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
if err := svc.Delete(context.Background(), cr.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
// After soft delete, GetByID should return error.
_, err := svc.GetByID(context.Background(), cr.ID)
if err == nil {
t.Fatal("expected error after Delete, got nil (soft-deleted record should not be found)")
}
}
func TestCannedResponseService_DeleteByAccountAndID(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
otherAccount := &model.Account{Name: "Other Delete Account"}
if err := provider.DB().Create(otherAccount).Error; err != nil {
t.Fatalf("failed to create other account: %v", err)
}
svc := NewCannedResponseService(provider)
cr := &CannedResponse{AccountID: accountID, ShortCode: "account_delete", Content: "Delete"}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
if err := svc.DeleteByAccountAndID(context.Background(), otherAccount.ID, cr.ID); err == nil {
t.Fatal("expected cross-account delete to fail")
}
if _, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID); err != nil {
t.Fatalf("record should still exist after failed cross-account delete: %v", err)
}
if err := svc.DeleteByAccountAndID(context.Background(), accountID, cr.ID); err != nil {
t.Fatalf("DeleteByAccountAndID failed: %v", err)
}
if _, err := svc.GetByAccountAndID(context.Background(), accountID, cr.ID); err == nil {
t.Fatal("expected account-scoped lookup to fail after delete")
}
recreated := &CannedResponse{AccountID: accountID, ShortCode: "account_delete", Content: "Recreated"}
if err := svc.Create(context.Background(), recreated); err != nil {
t.Fatalf("expected hard-deleted short_code to be reusable: %v", err)
}
}
func TestCannedResponseService_ListByAccount(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
// Seed multiple canned responses.
items := []CannedResponse{
{AccountID: accountID, ShortCode: "alpha", Content: "Alpha content"},
{AccountID: accountID, ShortCode: "beta", Content: "Beta content"},
{AccountID: accountID, ShortCode: "gamma", Content: "Gamma content"},
}
for i := range items {
if err := svc.Create(context.Background(), &items[i]); err != nil {
t.Fatalf("Create #%d failed: %v", i, err)
}
}
// Create a second account with its own canned response — should not appear.
db := provider.DB()
account2 := &model.Account{Name: "Other Account"}
if err := db.Create(account2).Error; err != nil {
t.Fatalf("failed to create second account: %v", err)
}
crOther := &CannedResponse{
AccountID: account2.ID,
ShortCode: "other",
Content: "Other account content",
}
if err := svc.Create(context.Background(), crOther); err != nil {
t.Fatalf("Create other-account canned response failed: %v", err)
}
results, err := svc.ListByAccount(context.Background(), accountID)
if err != nil {
t.Fatalf("ListByAccount failed: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
// Results should be ordered by short_code ASC.
if results[0].ShortCode != "alpha" {
t.Fatalf("expected first result ShortCode 'alpha', got '%s'", results[0].ShortCode)
}
if results[1].ShortCode != "beta" {
t.Fatalf("expected second result ShortCode 'beta', got '%s'", results[1].ShortCode)
}
if results[2].ShortCode != "gamma" {
t.Fatalf("expected third result ShortCode 'gamma', got '%s'", results[2].ShortCode)
}
}
func TestCannedResponseService_Search(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
items := []CannedResponse{
{AccountID: accountID, ShortCode: "greet_hi", Content: "Hi there! Welcome."},
{AccountID: accountID, ShortCode: "greet_hello", Content: "Hello! How can we help?"},
{AccountID: accountID, ShortCode: "farewell", Content: "Goodbye and have a great day!"},
}
for i := range items {
if err := svc.Create(context.Background(), &items[i]); err != nil {
t.Fatalf("Create #%d failed: %v", i, err)
}
}
// Search for "greet" — should match short_code on first two items.
results, err := svc.Search(context.Background(), accountID, "greet")
if err != nil {
t.Fatalf("Search failed: %v", err)
}
// short_code matches should rank before content-only matches.
// Both "greet_hi" and "greet_hello" match on short_code.
if len(results) < 2 {
t.Fatalf("expected at least 2 results for 'greet', got %d", len(results))
}
// Verify that short_code match results come first.
for i := 0; i < 2; i++ {
if results[i].ShortCode != "greet_hi" && results[i].ShortCode != "greet_hello" {
t.Fatalf("expected short_code match in top results, got '%s'", results[i].ShortCode)
}
}
// Search for "great" — matches content in "farewell".
results2, err := svc.Search(context.Background(), accountID, "great")
if err != nil {
t.Fatalf("Search for 'great' failed: %v", err)
}
if len(results2) != 1 {
t.Fatalf("expected 1 result for 'great', got %d", len(results2))
}
if results2[0].ShortCode != "farewell" {
t.Fatalf("expected ShortCode 'farewell', got '%s'", results2[0].ShortCode)
}
// Empty query should return all (same as ListByAccount).
results3, err := svc.Search(context.Background(), accountID, "")
if err != nil {
t.Fatalf("Search with empty query failed: %v", err)
}
if len(results3) != 3 {
t.Fatalf("expected 3 results for empty query, got %d", len(results3))
}
}
func TestCannedResponseService_Search_RanksLikeChatwoot(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
items := []CannedResponse{
{AccountID: accountID, ShortCode: "body_only", Content: "Please mention help in the body"},
{AccountID: accountID, ShortCode: "say_help", Content: "Generic"},
{AccountID: accountID, ShortCode: "help_start", Content: "Generic"},
}
for i := range items {
if err := svc.Create(context.Background(), &items[i]); err != nil {
t.Fatalf("Create #%d failed: %v", i, err)
}
}
results, err := svc.Search(context.Background(), accountID, "help")
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
if results[0].ShortCode != "help_start" || results[1].ShortCode != "say_help" || results[2].ShortCode != "body_only" {
t.Fatalf("unexpected ranking: %s, %s, %s", results[0].ShortCode, results[1].ShortCode, results[2].ShortCode)
}
}
func TestCannedResponseService_GetByShortCode(t *testing.T) {
provider := setupCannedTestDBProvider(t)
accountID, _ := seedTestAccount(provider.DB(), t)
svc := NewCannedResponseService(provider)
cr := &CannedResponse{
AccountID: accountID,
ShortCode: "welcome",
Content: "Welcome to our service!",
}
if err := svc.Create(context.Background(), cr); err != nil {
t.Fatalf("Create failed: %v", err)
}
found, err := svc.GetByShortCode(context.Background(), accountID, "welcome")
if err != nil {
t.Fatalf("GetByShortCode failed: %v", err)
}
if found.ID != cr.ID {
t.Fatalf("expected ID %d, got %d", cr.ID, found.ID)
}
if found.ShortCode != "welcome" {
t.Fatalf("expected ShortCode 'welcome', got '%s'", found.ShortCode)
}
// Non-existent short_code should return error.
_, err = svc.GetByShortCode(context.Background(), accountID, "nonexistent")
if err == nil {
t.Fatal("expected error for non-existent short_code, got nil")
}
}