# Agent + AgentBot + AssignmentPolicy Implementation Plan > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. **Goal:** Add complete Agent CRUD (with assignable_agents), AgentBot CRUD (with reset_token + avatar), AssignmentPolicy CRUD (with round-robin and lowest-load engines), and auto_assignment listener that triggers on conversation.created — the seat management + auto-distribution system that GoChat currently lacks entirely. **Architecture:** Follow the existing gochat pattern: model → repo → service → handler → route. Agent and AgentBot are new resources under `/api/v1/accounts/:id/agents` and `/api/v1/accounts/:id/agent_bots`. AssignmentPolicy is under `/api/v1/accounts/:id/assignment_policies`. The autoassignment module already exists with `model.go`, `service.go`, `listener.go`, `round_robin.go`, and `rate_limiter.go` — we extend it with `lowest_load.go` (new engine) and wire it into bootstrap + router. The `InboxMemberRepo` already has `FindByInbox` which provides assignable agents for a given inbox. **Tech Stack:** Go 1.24, Gin, GORM, Redis (go-redis/v9), go-playground/validator, golang-migrate --- ## Task 1: Add AgentBot model **Objective:** Define the AgentBot GORM model in `internal/model/agent_bot.go`. **Files:** - Create: `internal/model/agent_bot.go` **Step 1: Write the model** ```go package model // AgentBot represents a bot agent in the system. // Reference: Chatwoot app/models/agent_bot.rb // Bots can be assigned to conversations like human agents, // but they respond via configured automation rules or LLM hooks. type AgentBot struct { Base AccountID uint `gorm:"index;not null" json:"account_id"` Name string `gorm:"size:255;not null" json:"name"` Description string `gorm:"type:text" json:"description,omitempty"` AvatarURL string `gorm:"size:1024" json:"avatar_url,omitempty"` AccessToken string `gorm:"size:255;uniqueIndex" json:"access_token,omitempty"` OutgoingURL string `gorm:"size:1024" json:"outgoing_url,omitempty"` BotType string `gorm:"size:50;default:custom" json:"bot_type"` // custom, chatgpt, dialogflow Status string `gorm:"size:50;default:active" json:"status"` // active, inactive Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` } func (AgentBot) TableName() string { return "agent_bots" } ``` **Step 2: Verify the model compiles** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/model/` Expected: no errors **Step 3: Commit** ```bash git add internal/model/agent_bot.go git commit -m "feat: add AgentBot model" ``` --- ## Task 2: Add AssignmentPolicy model fields (extend existing) **Objective:** Extend the existing `autoassignment/model.go` with a `PolicyLowestLoad` constant and ensure `InboxAssignmentPolicy` model is complete. **Files:** - Modify: `internal/autoassignment/model.go` **Step 1: Add lowest-load policy type** In `internal/autoassignment/model.go`, add the `PolicyLowestLoad` constant alongside existing `PolicyRoundRobin` and `PolicyLongestWaiting`: ```go // PolicyLowestLoad assigns conversations to the agent with the fewest // currently assigned open conversations. // Reference: Chatwoot "lowest_load" pattern PolicyLowestLoad AssignmentPolicyType = "lowest_load" ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/autoassignment/` Expected: no errors **Step 3: Commit** ```bash git add internal/autoassignment/model.go git commit -m "feat: add lowest_load policy type to autoassignment model" ``` --- ## Task 3: Create AgentBot repository **Objective:** Create `AgentBotRepo` following the existing repo pattern (GORM CRUD + list by account). **Files:** - Create: `internal/repository/agent_bot_repo.go` - Create: `internal/repository/agent_bot_repo_test.go` **Step 1: Write the repository** ```go package repository import ( "context" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" ) // AgentBotRepo implements GORM repository for AgentBot. // Reference: Chatwoot app/models/agent_bot.rb type AgentBotRepo struct { db *gorm.DB } // NewAgentBotRepo creates a new AgentBot repository. func NewAgentBotRepo(db *gorm.DB) *AgentBotRepo { return &AgentBotRepo{db: db} } // FindByID retrieves an agent_bot by primary key. func (r *AgentBotRepo) FindByID(ctx context.Context, id uint) (*model.AgentBot, error) { var bot model.AgentBot err := r.db.WithContext(ctx).First(&bot, id).Error if err != nil { return nil, err } return &bot, nil } // FindByAccount retrieves all agent_bots for an account. func (r *AgentBotRepo) FindByAccount(ctx context.Context, accountID uint) ([]model.AgentBot, error) { var bots []model.AgentBot err := r.db.WithContext(ctx).Where("account_id = ?", accountID). Order("id DESC").Find(&bots).Error return bots, err } // Create inserts a new agent_bot. func (r *AgentBotRepo) Create(ctx context.Context, bot *model.AgentBot) error { return r.db.WithContext(ctx).Create(bot).Error } // Update modifies an existing agent_bot. func (r *AgentBotRepo) Update(ctx context.Context, bot *model.AgentBot) error { return r.db.WithContext(ctx).Save(bot).Error } // Delete removes an agent_bot (soft delete via GORM DeletedAt). func (r *AgentBotRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.AgentBot{}, id).Error } // FindByAccessToken retrieves an agent_bot by its access token. func (r *AgentBotRepo) FindByAccessToken(ctx context.Context, token string) (*model.AgentBot, error) { var bot model.AgentBot err := r.db.WithContext(ctx).Where("access_token = ?", token).First(&bot).Error if err != nil { return nil, err } return &bot, nil } // FindByAccountAndID retrieves an agent_bot scoped to an account. func (r *AgentBotRepo) FindByAccountAndID(ctx context.Context, accountID, id uint) (*model.AgentBot, error) { var bot model.AgentBot err := r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&bot).Error if err != nil { return nil, err } return &bot, nil } ``` **Step 2: Write the test (stub)** ```go package repository import ( "testing" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" ) func setupAgentBotTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) assert.NoError(t, err) err = db.AutoMigrate(&model.AgentBot{}) assert.NoError(t, err) return db } func TestAgentBotRepo_Create(t *testing.T) { db := setupAgentBotTestDB(t) repo := NewAgentBotRepo(db) bot := &model.AgentBot{ AccountID: 1, Name: "TestBot", BotType: "custom", } err := repo.Create(t.Context(), bot) assert.NoError(t, err) assert.NotZero(t, bot.ID) } func TestAgentBotRepo_FindByAccount(t *testing.T) { db := setupAgentBotTestDB(t) repo := NewAgentBotRepo(db) bot := &model.AgentBot{AccountID: 1, Name: "Bot1"} _ = repo.Create(t.Context(), bot) bots, err := repo.FindByAccount(t.Context(), 1) assert.NoError(t, err) assert.Len(t, bots, 1) } ``` **Step 3: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/ && go vet ./internal/repository/` Expected: no errors **Step 4: Run tests** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go test ./internal/repository/ -run AgentBot -v` Expected: PASS (2 tests) **Step 5: Commit** ```bash git add internal/repository/agent_bot_repo.go internal/repository/agent_bot_repo_test.go git commit -m "feat: add AgentBot repository" ``` --- ## Task 4: Create AssignmentPolicy repository **Objective:** Create `AssignmentPolicyRepo` and `InboxAssignmentPolicyRepo` for CRUD operations on policies. **Files:** - Create: `internal/repository/assignment_policy_repo.go` - Create: `internal/repository/assignment_policy_repo_test.go` **Step 1: Write the repository** ```go package repository import ( "context" "gorm.io/gorm" "github.com/gochat/gochat/internal/autoassignment" "github.com/gochat/gochat/internal/model" ) // AssignmentPolicyRepo implements GORM repository for AssignmentPolicy. type AssignmentPolicyRepo struct { db *gorm.DB } // NewAssignmentPolicyRepo creates a new AssignmentPolicy repository. func NewAssignmentPolicyRepo(db *gorm.DB) *AssignmentPolicyRepo { return &AssignmentPolicyRepo{db: db} } // FindByID retrieves an assignment policy by primary key. func (r *AssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*autoassignment.AssignmentPolicy, error) { var policy autoassignment.AssignmentPolicy err := r.db.WithContext(ctx).First(&policy, id).Error if err != nil { return nil, err } return &policy, nil } // FindByAccount retrieves the assignment policy for an account (there should be one per account). func (r *AssignmentPolicyRepo) FindByAccount(ctx context.Context, accountID uint) (*autoassignment.AssignmentPolicy, error) { var policy autoassignment.AssignmentPolicy err := r.db.WithContext(ctx).Where("account_id = ?", accountID).First(&policy).Error if err != nil { return nil, err } return &policy, nil } // Create inserts a new assignment policy. func (r *AssignmentPolicyRepo) Create(ctx context.Context, policy *autoassignment.AssignmentPolicy) error { return r.db.WithContext(ctx).Create(policy).Error } // Update modifies an existing assignment policy. func (r *AssignmentPolicyRepo) Update(ctx context.Context, policy *autoassignment.AssignmentPolicy) error { return r.db.WithContext(ctx).Save(policy).Error } // Delete removes an assignment policy. func (r *AssignmentPolicyRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&autoassignment.AssignmentPolicy{}, id).Error } // InboxAssignmentPolicyRepo implements GORM repository for InboxAssignmentPolicy. type InboxAssignmentPolicyRepo struct { db *gorm.DB } // NewInboxAssignmentPolicyRepo creates a new InboxAssignmentPolicy repository. func NewInboxAssignmentPolicyRepo(db *gorm.DB) *InboxAssignmentPolicyRepo { return &InboxAssignmentPolicyRepo{db: db} } // FindByInbox retrieves the assignment policy override for a specific inbox. func (r *InboxAssignmentPolicyRepo) FindByInbox(ctx context.Context, inboxID uint) (*autoassignment.InboxAssignmentPolicy, error) { var policy autoassignment.InboxAssignmentPolicy err := r.db.WithContext(ctx).Where("inbox_id = ?", inboxID).First(&policy).Error if err != nil { return nil, err } return &policy, nil } // FindByAccount retrieves all inbox-level policy overrides for an account's inboxes. func (r *InboxAssignmentPolicyRepo) FindByAccount(ctx context.Context, accountID uint) ([]autoassignment.InboxAssignmentPolicy, error) { var policies []autoassignment.InboxAssignmentPolicy err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Find(&policies).Error return policies, err } // Create inserts a new inbox assignment policy. func (r *InboxAssignmentPolicyRepo) Create(ctx context.Context, policy *autoassignment.InboxAssignmentPolicy) error { return r.db.WithContext(ctx).Create(policy).Error } // Update modifies an existing inbox assignment policy. func (r *InboxAssignmentPolicyRepo) Update(ctx context.Context, policy *autoassignment.InboxAssignmentPolicy) error { return r.db.WithContext(ctx).Save(policy).Error } // Delete removes an inbox assignment policy. func (r *InboxAssignmentPolicyRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&autoassignment.InboxAssignmentPolicy{}, id).Error } ``` **Step 2: Write the test** ```go package repository import ( "testing" "github.com/stretchr/testify/assert" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/gochat/gochat/internal/autoassignment" "github.com/gochat/gochat/internal/model" ) func setupPolicyTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) assert.NoError(t, err) err = db.AutoMigrate( &autoassignment.AssignmentPolicy{}, &autoassignment.InboxAssignmentPolicy{}, &model.Account{}, &model.Inbox{}, ) assert.NoError(t, err) return db } func TestAssignmentPolicyRepo_Create(t *testing.T) { db := setupPolicyTestDB(t) repo := NewAssignmentPolicyRepo(db) policy := &autoassignment.AssignmentPolicy{ AccountID: 1, Policy: autoassignment.PolicyRoundRobin, FairDistributionLimit: 5, FairDistributionWindow: 300, } err := repo.Create(t.Context(), policy) assert.NoError(t, err) assert.NotZero(t, policy.ID) } func TestAssignmentPolicyRepo_FindByAccount(t *testing.T) { db := setupPolicyTestDB(t) repo := NewAssignmentPolicyRepo(db) policy := &autoassignment.AssignmentPolicy{ AccountID: 1, Policy: autoassignment.PolicyRoundRobin, } _ = repo.Create(t.Context(), policy) found, err := repo.FindByAccount(t.Context(), 1) assert.NoError(t, err) assert.Equal(t, autoassignment.PolicyRoundRobin, found.Policy) } ``` **Step 3: Verify compilation and run tests** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/ && go test ./internal/repository/ -run Policy -v` Expected: PASS (2 tests) **Step 4: Commit** ```bash git add internal/repository/assignment_policy_repo.go internal/repository/assignment_policy_repo_test.go git commit -m "feat: add AssignmentPolicy repository" ``` --- ## Task 5: Create Agent service **Objective:** Create `AgentService` that wraps AccountUser + InboxMember for agent CRUD, bulk create, and assignable_agents listing. **Files:** - Create: `internal/service/agent_service.go` **Step 1: Write the service** ```go package service import ( "context" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // AgentService implements business logic for Agent (account user) operations. // Reference: Chatwoot app/controllers/api/v1/agents_controller.rb // // In Chatwoot, "Agent" is an AccountUser with role=agent or administrator. // GoChat follows the same pattern: agents are AccountUsers viewed through // the lens of inbox membership and availability. type AgentService struct { inboxMemberRepo *repository.InboxMemberRepo } // NewAgentService creates a new Agent service. func NewAgentService(inboxMemberRepo *repository.InboxMemberRepo) *AgentService { return &AgentService{inboxMemberRepo: inboxMemberRepo} } // AssignableAgentsRequest is the DTO for fetching assignable agents. type AssignableAgentsRequest struct { InboxID uint `json:"inbox_id" validate:"required"` } // GetAssignableAgents returns the list of agents who can be assigned // to conversations in the given inbox. // Reference: Chatwoot AssignableAgentsService // Returns inbox members who are online (availability = "online"). func (s *AgentService) GetAssignableAgents(ctx context.Context, accountID, inboxID uint) ([]model.InboxMember, error) { members, err := s.inboxMemberRepo.FindByInbox(ctx, inboxID) if err != nil { applogger.L().Errorf("failed to get inbox members for inbox %d: %v", inboxID, err) return nil, err } // Filter to only online/available agents var assignable []model.InboxMember for _, m := range members { if m.AvailabilityStatus == "online" { assignable = append(assignable, m) } } return assignable, nil } // UpdateAvailability updates an agent's availability status. // Reference: Chatwoot AgentUpdateAvailabilityService func (s *AgentService) UpdateAvailability(ctx context.Context, inboxID, userID uint, status string) error { member, err := s.inboxMemberRepo.FindByInboxAndUser(ctx, inboxID, userID) if err != nil { return err } member.AvailabilityStatus = status return s.inboxMemberRepo.Update(ctx, member) } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/service/` Expected: no errors **Step 3: Commit** ```bash git add internal/service/agent_service.go git commit -m "feat: add Agent service with assignable_agents" ``` --- ## Task 6: Create AgentBot service **Objective:** Create `AgentBotService` with CRUD + reset_token + avatar URL handling. **Files:** - Create: `internal/service/agent_bot_service.go` **Step 1: Write the service** ```go package service import ( "context" "crypto/rand" "encoding/hex" "errors" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // AgentBotService implements business logic for AgentBot operations. // Reference: Chatwoot app/controllers/api/v1/agent_bots_controller.rb type AgentBotService struct { repo *repository.AgentBotRepo } // NewAgentBotService creates a new AgentBot service. func NewAgentBotService(repo *repository.AgentBotRepo) *AgentBotService { return &AgentBotService{repo: repo} } // CreateAgentBotRequest is the DTO for creating an agent bot. type CreateAgentBotRequest struct { Name string `json:"name" validate:"required,min=2"` Description string `json:"description,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` OutgoingURL string `json:"outgoing_url,omitempty"` BotType string `json:"bot_type,omitempty" validate:"omitempty,oneof=custom chatgpt dialogflow"` } // UpdateAgentBotRequest is the DTO for updating an agent bot. type UpdateAgentBotRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` Description string `json:"description,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` OutgoingURL string `json:"outgoing_url,omitempty"` BotType string `json:"bot_type,omitempty" validate:"omitempty,oneof=custom chatgpt dialogflow"` Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive"` } // ListByAccount retrieves all agent bots for an account. func (s *AgentBotService) ListByAccount(ctx context.Context, accountID uint) ([]model.AgentBot, error) { return s.repo.FindByAccount(ctx, accountID) } // GetByID retrieves a single agent bot. func (s *AgentBotService) GetByID(ctx context.Context, id uint) (*model.AgentBot, error) { return s.repo.FindByID(ctx, id) } // GetByAccountAndID retrieves an agent bot scoped to an account. func (s *AgentBotService) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.AgentBot, error) { return s.repo.FindByAccountAndID(ctx, accountID, id) } // Create creates a new agent bot. func (s *AgentBotService) Create(ctx context.Context, accountID uint, req CreateAgentBotRequest) (*model.AgentBot, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } token, err := generateBotToken() if err != nil { return nil, err } bot := &model.AgentBot{ AccountID: accountID, Name: req.Name, Description: req.Description, AvatarURL: req.AvatarURL, AccessToken: token, OutgoingURL: req.OutgoingURL, BotType: req.BotType, Status: "active", } if req.BotType == "" { bot.BotType = "custom" } if err := s.repo.Create(ctx, bot); err != nil { applogger.L().Errorf("Failed to create agent bot: %v", err) return nil, err } return bot, nil } // Update modifies an existing agent bot. func (s *AgentBotService) Update(ctx context.Context, accountID, id uint, req UpdateAgentBotRequest) (*model.AgentBot, error) { bot, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return nil, err } if req.Name != "" { bot.Name = req.Name } if req.Description != "" { bot.Description = req.Description } if req.AvatarURL != "" { bot.AvatarURL = req.AvatarURL } if req.OutgoingURL != "" { bot.OutgoingURL = req.OutgoingURL } if req.BotType != "" { bot.BotType = req.BotType } if req.Status != "" { bot.Status = req.Status } if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if err := s.repo.Update(ctx, bot); err != nil { return nil, err } return bot, nil } // Delete removes an agent bot. func (s *AgentBotService) Delete(ctx context.Context, accountID, id uint) error { bot, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return err } if bot == nil { return errors.New("agent bot not found") } return s.repo.Delete(ctx, id) } // ResetToken generates a new access token for the agent bot. // Reference: Chatwoot AgentBotsController#reset_token func (s *AgentBotService) ResetToken(ctx context.Context, accountID, id uint) (*model.AgentBot, error) { bot, err := s.repo.FindByAccountAndID(ctx, accountID, id) if err != nil { return nil, err } token, err := generateBotToken() if err != nil { return nil, err } bot.AccessToken = token if err := s.repo.Update(ctx, bot); err != nil { return nil, err } return bot, nil } // generateBotToken creates a random access token for agent bots. func generateBotToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/service/` Expected: no errors **Step 3: Commit** ```bash git add internal/service/agent_bot_service.go git commit -m "feat: add AgentBot service with reset_token" ``` --- ## Task 7: Create AssignmentPolicy service **Objective:** Create `AssignmentPolicyService` for CRUD on account-level and inbox-level policies. **Files:** - Create: `internal/service/assignment_policy_service.go` **Step 1: Write the service** ```go package service import ( "context" "errors" "github.com/gochat/gochat/internal/autoassignment" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // AssignmentPolicyService implements business logic for AssignmentPolicy operations. // Reference: Chatwoot AssignmentPolicy CRUD type AssignmentPolicyService struct { policyRepo *repository.AssignmentPolicyRepo inboxPolicyRepo *repository.InboxAssignmentPolicyRepo } // NewAssignmentPolicyService creates a new AssignmentPolicy service. func NewAssignmentPolicyService(policyRepo *repository.AssignmentPolicyRepo, inboxPolicyRepo *repository.InboxAssignmentPolicyRepo) *AssignmentPolicyService { return &AssignmentPolicyService{policyRepo: policyRepo, inboxPolicyRepo: inboxPolicyRepo} } // CreateAssignmentPolicyRequest is the DTO for creating an assignment policy. type CreateAssignmentPolicyRequest struct { Policy autoassignment.AssignmentPolicyType `json:"policy" validate:"required,oneof=round_robin longest_waiting lowest_load"` FairDistributionLimit int `json:"fair_distribution_limit,omitempty" validate:"omitempty,min=1"` FairDistributionWindow int `json:"fair_distribution_window,omitempty" validate:"omitempty,min=60"` } // UpdateAssignmentPolicyRequest is the DTO for updating an assignment policy. type UpdateAssignmentPolicyRequest struct { Policy autoassignment.AssignmentPolicyType `json:"policy,omitempty" validate:"omitempty,oneof=round_robin longest_waiting lowest_load"` FairDistributionLimit int `json:"fair_distribution_limit,omitempty" validate:"omitempty,min=1"` FairDistributionWindow int `json:"fair_distribution_window,omitempty" validate:"omitempty,min=60"` } // CreateInboxPolicyRequest is the DTO for creating an inbox-level policy override. type CreateInboxPolicyRequest struct { InboxID uint `json:"inbox_id" validate:"required"` Policy autoassignment.AssignmentPolicyType `json:"policy" validate:"required,oneof=round_robin longest_waiting lowest_load"` FairDistributionLimit int `json:"fair_distribution_limit,omitempty" validate:"omitempty,min=1"` FairDistributionWindow int `json:"fair_distribution_window,omitempty" validate:"omitempty,min=60"` } // GetByAccount retrieves the assignment policy for an account. func (s *AssignmentPolicyService) GetByAccount(ctx context.Context, accountID uint) (*autoassignment.AssignmentPolicy, error) { return s.policyRepo.FindByAccount(ctx, accountID) } // Create creates a new account-level assignment policy. func (s *AssignmentPolicyService) Create(ctx context.Context, accountID uint, req CreateAssignmentPolicyRequest) (*autoassignment.AssignmentPolicy, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } policy := &autoassignment.AssignmentPolicy{ AccountID: accountID, Policy: req.Policy, FairDistributionLimit: req.FairDistributionLimit, FairDistributionWindow: req.FairDistributionWindow, } // Apply defaults if not set if policy.FairDistributionLimit == 0 { policy.FairDistributionLimit = 5 } if policy.FairDistributionWindow == 0 { policy.FairDistributionWindow = 300 } if err := s.policyRepo.Create(ctx, policy); err != nil { applogger.L().Errorf("Failed to create assignment policy: %v", err) return nil, err } return policy, nil } // Update modifies an existing account-level assignment policy. func (s *AssignmentPolicyService) Update(ctx context.Context, id uint, req UpdateAssignmentPolicyRequest) (*autoassignment.AssignmentPolicy, error) { policy, err := s.policyRepo.FindByID(ctx, id) if err != nil { return nil, err } if req.Policy != "" { policy.Policy = req.Policy } if req.FairDistributionLimit > 0 { policy.FairDistributionLimit = req.FairDistributionLimit } if req.FairDistributionWindow > 0 { policy.FairDistributionWindow = req.FairDistributionWindow } if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if err := s.policyRepo.Update(ctx, policy); err != nil { return nil, err } return policy, nil } // Delete removes an account-level assignment policy. func (s *AssignmentPolicyService) Delete(ctx context.Context, id uint) error { return s.policyRepo.Delete(ctx, id) } // GetInboxPolicy retrieves the inbox-level assignment policy override. func (s *AssignmentPolicyService) GetInboxPolicy(ctx context.Context, inboxID uint) (*autoassignment.InboxAssignmentPolicy, error) { return s.inboxPolicyRepo.FindByInbox(ctx, inboxID) } // ListInboxPolicies retrieves all inbox-level policy overrides for an account. func (s *AssignmentPolicyService) ListInboxPolicies(ctx context.Context, accountID uint) ([]autoassignment.InboxAssignmentPolicy, error) { return s.inboxPolicyRepo.FindByAccount(ctx, accountID) } // CreateInboxPolicy creates a new inbox-level assignment policy override. func (s *AssignmentPolicyService) CreateInboxPolicy(ctx context.Context, accountID uint, req CreateInboxPolicyRequest) (*autoassignment.InboxAssignmentPolicy, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } policy := &autoassignment.InboxAssignmentPolicy{ AccountID: accountID, InboxID: req.InboxID, Policy: req.Policy, FairDistributionLimit: req.FairDistributionLimit, FairDistributionWindow: req.FairDistributionWindow, } if policy.FairDistributionLimit == 0 { policy.FairDistributionLimit = 5 } if policy.FairDistributionWindow == 0 { policy.FairDistributionWindow = 300 } if err := s.inboxPolicyRepo.Create(ctx, policy); err != nil { applogger.L().Errorf("Failed to create inbox assignment policy: %v", err) return nil, err } return policy, nil } // UpdateInboxPolicy modifies an existing inbox-level policy override. func (s *AssignmentPolicyService) UpdateInboxPolicy(ctx context.Context, id uint, req UpdateAssignmentPolicyRequest) (*autoassignment.InboxAssignmentPolicy, error) { // InboxAssignmentPolicy uses the same update DTO shape policy, err := s.inboxPolicyRepo.FindByID(ctx, id) if err != nil { return nil, err } if policy == nil { return nil, errors.New("inbox assignment policy not found") } if req.Policy != "" { policy.Policy = req.Policy } if req.FairDistributionLimit > 0 { policy.FairDistributionLimit = req.FairDistributionLimit } if req.FairDistributionWindow > 0 { policy.FairDistributionWindow = req.FairDistributionWindow } if err := s.inboxPolicyRepo.Update(ctx, policy); err != nil { return nil, err } return policy, nil } // DeleteInboxPolicy removes an inbox-level policy override. func (s *AssignmentPolicyService) DeleteInboxPolicy(ctx context.Context, id uint) error { return s.inboxPolicyRepo.Delete(ctx, id) } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/service/` Expected: no errors **Step 3: Commit** ```bash git add internal/service/assignment_policy_service.go git commit -m "feat: add AssignmentPolicy service with CRUD + inbox overrides" ``` --- ## Task 8: Add lowest_load selector to autoassignment **Objective:** Create `LowestLoadSelector` that picks the agent with the fewest currently assigned open conversations. **Files:** - Create: `internal/autoassignment/lowest_load.go` **Step 1: Write the selector** ```go package autoassignment // LowestLoadSelector implements the lowest-load assignment strategy. // // Reference: Chatwoot "lowest_load" pattern // - Selects the agent with the fewest currently assigned open conversations. // - Falls back to round-robin if all agents have equal load. // - Works alongside the RateLimiter to prevent over-assignment. // // Implementation: // - Queries the database for each candidate agent's open conversation count // - Picks the agent with the lowest count // - If tied, picks the first agent alphabetically by ID for determinism import ( "context" "fmt" "gorm.io/gorm" applogger "github.com/gochat/gochat/pkg/logger" ) // LowestLoadSelector selects the agent with the fewest open conversations. type LowestLoadSelector struct { db *gorm.DB } // NewLowestLoadSelector creates a new LowestLoadSelector. func NewLowestLoadSelector(db *gorm.DB) *LowestLoadSelector { return &LowestLoadSelector{db: db} } // Select picks the agent with the lowest current load (fewest assigned open conversations). // agentIDs is the list of eligible agent IDs. // Returns the selected agent ID, or 0 if no agent is available. func (ll *LowestLoadSelector) Select(ctx context.Context, inboxID uint, agentIDs []uint) (uint, error) { if len(agentIDs) == 0 { return 0, fmt.Errorf("no eligible agents for inbox %d", inboxID) } // Query open conversation count per agent type AgentLoad struct { AssigneeID uint Count int } var loads []AgentLoad err := ll.db.WithContext(ctx). Model(&ConversationQueryModel{}). Select("assignee_id, COUNT(*) as count"). Where("inbox_id = ? AND status = ? AND assignee_id IN ?", inboxID, "open", agentIDs). Group("assignee_id"). Find(&loads).Error if err != nil { return 0, fmt.Errorf("failed to query agent loads: %w", err) } // Build a map of agent → load loadMap := make(map[uint]int) for _, l := range loads { loadMap[l.AssigneeID] = l.Count } // Find the agent with the lowest load var selectedID uint minLoad := -1 for _, id := range agentIDs { load := loadMap[id] // 0 if agent has no open conversations if minLoad == -1 || load < minLoad { minLoad = load selectedID = id } } applogger.L().Debugf("lowest_load selected agent %d (load=%d) for inbox %d", selectedID, minLoad, inboxID) return selectedID, nil } // ConversationQueryModel is a minimal model for querying conversation counts. // Uses the existing conversations table. type ConversationQueryModel struct { AssigneeID uint InboxID uint Status string } func (ConversationQueryModel) TableName() string { return "conversations" } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/autoassignment/` Expected: no errors **Step 3: Commit** ```bash git add internal/autoassignment/lowest_load.go git commit -m "feat: add lowest-load selector to autoassignment" ``` --- ## Task 9: Wire lowest-load into AssignmentService **Objective:** Extend the existing `AssignmentService` to support lowest-load policy alongside round-robin. **Files:** - Modify: `internal/autoassignment/service.go` **Step 1: Add LowestLoadSelector to AssignmentService** In `service.go`, add `lowestLoad *LowestLoadSelector` field to `AssignmentService` struct, wire it in `NewAssignmentService`, and update `selectAgent` to use it when policy is `lowest_load`: ```go // In AssignmentService struct, add: lowestLoad *LowestLoadSelector // In NewAssignmentService, add: ll := NewLowestLoadSelector(db) return &AssignmentService{ db: db, redis: rdb, roundRobin: rr, rateLimiter: rl, lowestLoad: ll, } ``` In the `selectAgent` method, add a branch for lowest-load: ```go case PolicyLowestLoad: agentID, err = s.lowestLoad.Select(ctx, inboxID, filteredIDs) ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/autoassignment/` Expected: no errors **Step 3: Commit** ```bash git add internal/autoassignment/service.go git commit -m "feat: wire lowest-load selector into AssignmentService" ``` --- ## Task 10: Create Agent handler **Objective:** Create `AgentHandler` with List assignable_agents endpoint and UpdateAvailability. **Files:** - Create: `internal/handler/api/v1/agent_handler.go` **Step 1: Write the handler** ```go package v1 import ( "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" ) // AgentHandler handles agent-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/agents_controller.rb type AgentHandler struct { svc *service.AgentService } // NewAgentHandler creates a new AgentHandler. func NewAgentHandler(svc *service.AgentService) *AgentHandler { return &AgentHandler{svc: svc} } // GetAssignableAgents returns the list of agents who can be assigned // to conversations in the given inbox. // GET /api/v1/accounts/:id/agents/assignable?inbox_id=123 func (h *AgentHandler) GetAssignableAgents(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } inboxIDStr := c.Query("inbox_id") if inboxIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "inbox_id query parameter is required"}) return } inboxID, err := strconv.Atoi(inboxIDStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox_id"}) return } agents, err := h.svc.GetAssignableAgents(c.Request.Context(), uint(accountID), uint(inboxID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get assignable agents"}) return } c.JSON(http.StatusOK, gin.H{"agents": agents, "meta": gin.H{"count": len(agents)}}) } // UpdateAvailability updates an agent's availability status. // PATCH /api/v1/accounts/:id/agents/:agent_id/availability func (h *AgentHandler) UpdateAvailability(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } inboxIDStr := c.Query("inbox_id") inboxID, _ := strconv.Atoi(inboxIDStr) agentID, err := strconv.Atoi(c.Param("agent_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent id"}) return } var req struct { Availability string `json:"availability" binding:"required,oneof=online offline busy"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "availability must be one of: online, offline, busy"}) return } if err := h.svc.UpdateAvailability(c.Request.Context(), uint(inboxID), uint(agentID), req.Availability); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update availability"}) return } c.JSON(http.StatusOK, gin.H{"id": agentID, "availability": req.Availability}) } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/handler/api/v1/` Expected: no errors **Step 3: Commit** ```bash git add internal/handler/api/v1/agent_handler.go git commit -m "feat: add Agent handler with assignable_agents" ``` --- ## Task 11: Create AgentBot handler **Objective:** Create `AgentBotHandler` with full CRUD + reset_token + avatar. **Files:** - Create: `internal/handler/api/v1/agent_bot_handler.go` **Step 1: Write the handler** ```go package v1 import ( "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" ) // AgentBotHandler handles agent bot-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/agent_bots_controller.rb type AgentBotHandler struct { svc *service.AgentBotService } // NewAgentBotHandler creates a new AgentBotHandler. func NewAgentBotHandler(svc *service.AgentBotService) *AgentBotHandler { return &AgentBotHandler{svc: svc} } // List returns all agent bots for an account. // GET /api/v1/accounts/:id/agent_bots func (h *AgentBotHandler) List(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } bots, err := h.svc.ListByAccount(c.Request.Context(), uint(accountID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agent bots"}) return } c.JSON(http.StatusOK, gin.H{"agent_bots": bots, "meta": gin.H{"count": len(bots)}}) } // Get returns a single agent bot. // GET /api/v1/accounts/:id/agent_bots/:bot_id func (h *AgentBotHandler) Get(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } botID, err := strconv.Atoi(c.Param("bot_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) return } bot, err := h.svc.GetByAccountAndID(c.Request.Context(), uint(accountID), uint(botID)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "agent bot not found"}) return } c.JSON(http.StatusOK, bot) } // Create creates a new agent bot. // POST /api/v1/accounts/:id/agent_bots func (h *AgentBotHandler) Create(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } var req service.CreateAgentBotRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } bot, err := h.svc.Create(c.Request.Context(), uint(accountID), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create agent bot"}) return } c.JSON(http.StatusCreated, bot) } // Update modifies an existing agent bot. // PUT /api/v1/accounts/:id/agent_bots/:bot_id func (h *AgentBotHandler) Update(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } botID, err := strconv.Atoi(c.Param("bot_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) return } var req service.UpdateAgentBotRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } bot, err := h.svc.Update(c.Request.Context(), uint(accountID), uint(botID), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update agent bot"}) return } c.JSON(http.StatusOK, bot) } // Delete removes an agent bot. // DELETE /api/v1/accounts/:id/agent_bots/:bot_id func (h *AgentBotHandler) Delete(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } botID, err := strconv.Atoi(c.Param("bot_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) return } if err := h.svc.Delete(c.Request.Context(), uint(accountID), uint(botID)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete agent bot"}) return } c.JSON(http.StatusOK, gin.H{"id": botID, "deleted": true}) } // ResetToken generates a new access token for the agent bot. // POST /api/v1/accounts/:id/agent_bots/:bot_id/reset_token func (h *AgentBotHandler) ResetToken(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } botID, err := strconv.Atoi(c.Param("bot_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"}) return } bot, err := h.svc.ResetToken(c.Request.Context(), uint(accountID), uint(botID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset token"}) return } c.JSON(http.StatusOK, gin.H{"id": bot.ID, "access_token": bot.AccessToken}) } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/handler/api/v1/` Expected: no errors **Step 3: Commit** ```bash git add internal/handler/api/v1/agent_bot_handler.go git commit -m "feat: add AgentBot handler with CRUD + reset_token" ``` --- ## Task 12: Create AssignmentPolicy handler **Objective:** Create `AssignmentPolicyHandler` with CRUD for account-level and inbox-level policies. **Files:** - Create: `internal/handler/api/v1/assignment_policy_handler.go` **Step 1: Write the handler** ```go package v1 import ( "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/service" ) // AssignmentPolicyHandler handles assignment policy-related API endpoints. // Reference: Chatwoot assignment policies CRUD type AssignmentPolicyHandler struct { svc *service.AssignmentPolicyService } // NewAssignmentPolicyHandler creates a new AssignmentPolicyHandler. func NewAssignmentPolicyHandler(svc *service.AssignmentPolicyService) *AssignmentPolicyHandler { return &AssignmentPolicyHandler{svc: svc} } // Get returns the account-level assignment policy. // GET /api/v1/accounts/:id/assignment_policy func (h *AssignmentPolicyHandler) Get(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } policy, err := h.svc.GetByAccount(c.Request.Context(), uint(accountID)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "assignment policy not found"}) return } c.JSON(http.StatusOK, policy) } // Create creates a new account-level assignment policy. // POST /api/v1/accounts/:id/assignment_policy func (h *AssignmentPolicyHandler) Create(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } var req service.CreateAssignmentPolicyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } policy, err := h.svc.Create(c.Request.Context(), uint(accountID), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create assignment policy"}) return } c.JSON(http.StatusCreated, policy) } // Update modifies an existing account-level assignment policy. // PUT /api/v1/accounts/:id/assignment_policy/:policy_id func (h *AssignmentPolicyHandler) Update(c *gin.Context) { policyID, err := strconv.Atoi(c.Param("policy_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy id"}) return } var req service.UpdateAssignmentPolicyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } policy, err := h.svc.Update(c.Request.Context(), uint(policyID), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update assignment policy"}) return } c.JSON(http.StatusOK, policy) } // Delete removes an account-level assignment policy. // DELETE /api/v1/accounts/:id/assignment_policy/:policy_id func (h *AssignmentPolicyHandler) Delete(c *gin.Context) { policyID, err := strconv.Atoi(c.Param("policy_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy id"}) return } if err := h.svc.Delete(c.Request.Context(), uint(policyID)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete assignment policy"}) return } c.JSON(http.StatusOK, gin.H{"id": policyID, "deleted": true}) } // ListInboxPolicies returns all inbox-level assignment policy overrides. // GET /api/v1/accounts/:id/assignment_policy/inbox_policies func (h *AssignmentPolicyHandler) ListInboxPolicies(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } policies, err := h.svc.ListInboxPolicies(c.Request.Context(), uint(accountID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list inbox policies"}) return } c.JSON(http.StatusOK, gin.H{"inbox_policies": policies, "meta": gin.H{"count": len(policies)}}) } // CreateInboxPolicy creates a new inbox-level assignment policy override. // POST /api/v1/accounts/:id/assignment_policy/inbox_policies func (h *AssignmentPolicyHandler) CreateInboxPolicy(c *gin.Context) { accountID, err := strconv.Atoi(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) return } var req service.CreateInboxPolicyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } policy, err := h.svc.CreateInboxPolicy(c.Request.Context(), uint(accountID), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create inbox policy"}) return } c.JSON(http.StatusCreated, policy) } // DeleteInboxPolicy removes an inbox-level assignment policy override. // DELETE /api/v1/accounts/:id/assignment_policy/inbox_policies/:policy_id func (h *AssignmentPolicyHandler) DeleteInboxPolicy(c *gin.Context) { policyID, err := strconv.Atoi(c.Param("policy_id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy id"}) return } if err := h.svc.DeleteInboxPolicy(c.Request.Context(), uint(policyID)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete inbox policy"}) return } c.JSON(http.StatusOK, gin.H{"id": policyID, "deleted": true}) } ``` **Step 2: Verify compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./internal/handler/api/v1/` Expected: no errors **Step 3: Commit** ```bash git add internal/handler/api/v1/assignment_policy_handler.go git commit -m "feat: add AssignmentPolicy handler with CRUD + inbox overrides" ``` --- ## Task 13: Wire repos + services + handlers into bootstrap **Objective:** Add the new repos, services, and handlers to the Bootstrap function in `internal/app/bootstrap.go` and the Handlers struct in `internal/router/router.go`. **Files:** - Modify: `internal/app/bootstrap.go` - Modify: `internal/router/router.go` **Step 1: Add new handler fields to Handlers struct** In `internal/router/router.go`, add to the `Handlers` struct: ```go Agent *v1.AgentHandler AgentBot *v1.AgentBotHandler AssignmentPolicy *v1.AssignmentPolicyHandler ``` **Step 2: Wire repos in bootstrap** In `internal/app/bootstrap.go`, after the existing repo wiring (around line 143), add: ```go // Agent/Bot repos agentBotRepo := repository.NewAgentBotRepo(db) assignmentPolicyRepo := repository.NewAssignmentPolicyRepo(db) inboxAssignmentPolicyRepo := repository.NewInboxAssignmentPolicyRepo(db) inboxMemberRepo := repository.NewInboxMemberRepo(db) ``` Note: `inboxMemberRepo` may already exist in bootstrap — check and reuse it if so. Search the bootstrap file for `InboxMemberRepo` before adding a duplicate. **Step 3: Wire services in bootstrap** After existing service wiring (around line 180), add: ```go // Agent/Bot/Assignment services agentService := service.NewAgentService(inboxMemberRepo) agentBotService := service.NewAgentBotService(agentBotRepo) assignmentPolicyService := service.NewAssignmentPolicyService(assignmentPolicyRepo, inboxAssignmentPolicyRepo) ``` **Step 4: Wire handlers in bootstrap** In the handlers struct initialization (around line 182-209), add: ```go Agent: v1.NewAgentHandler(agentService), AgentBot: v1.NewAgentBotHandler(agentBotService), AssignmentPolicy: v1.NewAssignmentPolicyHandler(assignmentPolicyService), ``` **Step 5: Verify full project compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./cmd/gochat/` Expected: no errors **Step 6: Commit** ```bash git add internal/app/bootstrap.go internal/router/router.go git commit -m "feat: wire Agent+AgentBot+AssignmentPolicy into bootstrap" ``` --- ## Task 14: Register routes in router **Objective:** Add the new API routes under `/api/v1/accounts/:id/` for agents, agent_bots, and assignment_policy. **Files:** - Modify: `internal/router/router.go` **Step 1: Add routes to registerV1Routes** In the `accountScoped` group (after existing inbox/conversation routes), add: ```go // Agent routes (ref: Chatwoot assignable_agents + agent availability) agents := accountScoped.Group("/agents") { agents.GET("/assignable", h.Agent.GetAssignableAgents) agents.PATCH("/:agent_id/availability", h.Agent.UpdateAvailability) } // AgentBot routes (ref: Chatwoot agent_bots_controller) agentBots := accountScoped.Group("/agent_bots") { agentBots.GET("/", h.AgentBot.List) agentBots.POST("/", h.AgentBot.Create) agentBots.GET("/:bot_id", h.AgentBot.Get) agentBots.PUT("/:bot_id", h.AgentBot.Update) agentBots.DELETE("/:bot_id", h.AgentBot.Delete) agentBots.POST("/:bot_id/reset_token", h.AgentBot.ResetToken) } // AssignmentPolicy routes (ref: Chatwoot assignment policies) assignmentPolicy := accountScoped.Group("/assignment_policy") { assignmentPolicy.GET("/", h.AssignmentPolicy.Get) assignmentPolicy.POST("/", h.AssignmentPolicy.Create) assignmentPolicy.PUT("/:policy_id", h.AssignmentPolicy.Update) assignmentPolicy.DELETE("/:policy_id", h.AssignmentPolicy.Delete) assignmentPolicy.GET("/inbox_policies", h.AssignmentPolicy.ListInboxPolicies) assignmentPolicy.POST("/inbox_policies", h.AssignmentPolicy.CreateInboxPolicy) assignmentPolicy.DELETE("/inbox_policies/:policy_id", h.AssignmentPolicy.DeleteInboxPolicy) } ``` **Step 2: Verify full project compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./cmd/gochat/` Expected: no errors **Step 3: Commit** ```bash git add internal/router/router.go git commit -m "feat: register Agent+AgentBot+AssignmentPolicy routes" ``` --- ## Task 15: Wire AutoAssignmentListener into bootstrap **Objective:** Register the AutoAssignmentListener in the bootstrap dispatcher so it actually processes conversation.created events. **Files:** - Modify: `internal/app/bootstrap.go` **Step 1: Wire the listener** In bootstrap, after the channel dispatcher is created, register the auto-assignment listener: ```go // Register auto-assignment listener autoAssignListener := autoassignment.NewAutoAssignmentListener(db, rdb) channelDispatcher.Register(autoAssignListener) ``` Note: The bootstrap may already have a dispatcher — check how `channel.Dispatcher` or `channel.NewDispatcher()` is used. The channel.Dispatcher needs to be accessible from bootstrap. If it's not already wired there, we need to create it and pass it through. **Step 2: Verify full project compilation** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./cmd/gochat/` Expected: no errors **Step 3: Commit** ```bash git add internal/app/bootstrap.go git commit -m "feat: wire AutoAssignmentListener into bootstrap dispatcher" ``` --- ## Task 16: Add database migration for new tables **Objective:** Create SQL migration files for agent_bots, assignment_policies, and inbox_assignment_policies tables. **Files:** - Create: `migrations/YYYYMMDDHHMMSS_create_agent_bots.up.sql` - Create: `migrations/YYYYMMDDHHMMSS_create_agent_bots.down.sql` - Create: `migrations/YYYYMMDDHHMMSS_create_assignment_policies.up.sql` - Create: `migrations/YYYYMMDDHHMMSS_create_assignment_policies.down.sql` - Create: `migrations/YYYYMMDDHHMMSS_create_inbox_assignment_policies.up.sql` - Create: `migrations/YYYYMMDDHHMMSS_create_inbox_assignment_policies.down.sql` **Step 1: Check existing migrations directory** Run: `ls /home/yanghao05/Workspace/gochat/migrations/ | head -10` Determine the naming convention and latest migration number. **Step 2: Write the up migrations** `create_agent_bots.up.sql`: ```sql CREATE TABLE IF NOT EXISTS agent_bots ( id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL, name VARCHAR(255) NOT NULL, description TEXT DEFAULT '', avatar_url VARCHAR(1024) DEFAULT '', access_token VARCHAR(255) UNIQUE, outgoing_url VARCHAR(1024) DEFAULT '', bot_type VARCHAR(50) DEFAULT 'custom', status VARCHAR(50) DEFAULT 'active', created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE ); CREATE INDEX IF NOT EXISTS idx_agent_bots_account_id ON agent_bots(account_id); CREATE INDEX IF NOT EXISTS idx_agent_bots_deleted_at ON agent_bots(deleted_at); ``` `create_assignment_policies.up.sql`: ```sql CREATE TABLE IF NOT EXISTS assignment_policies ( id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL, policy VARCHAR(50) DEFAULT 'round_robin', fair_distribution_limit INT DEFAULT 5, fair_distribution_window INT DEFAULT 300, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE ); CREATE INDEX IF NOT EXISTS idx_assignment_policies_account_id ON assignment_policies(account_id); CREATE INDEX IF NOT EXISTS idx_assignment_policies_deleted_at ON assignment_policies(deleted_at); ``` `create_inbox_assignment_policies.up.sql`: ```sql CREATE TABLE IF NOT EXISTS inbox_assignment_policies ( id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL, inbox_id BIGINT NOT NULL, policy VARCHAR(50) DEFAULT 'round_robin', fair_distribution_limit INT DEFAULT 5, fair_distribution_window INT DEFAULT 300, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE ); CREATE INDEX IF NOT EXISTS idx_inbox_assignment_policies_account_id ON inbox_assignment_policies(account_id); CREATE INDEX IF NOT EXISTS idx_inbox_assignment_policies_inbox_id ON inbox_assignment_policies(inbox_id); CREATE INDEX IF NOT EXISTS idx_inbox_assignment_policies_deleted_at ON inbox_assignment_policies(deleted_at); ``` **Step 3: Write the down migrations** Each down migration is simply `DROP TABLE IF EXISTS ;` **Step 4: Commit** ```bash git add migrations/ git commit -m "feat: add database migrations for agent_bots + assignment policies" ``` --- ## Task 17: Final integration test — build + verify **Objective:** Run full project build to verify all new code integrates correctly. **Files:** None (verification only) **Step 1: Run full build** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./cmd/gochat/ && go vet ./internal/...` Expected: no errors **Step 2: Run existing tests** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go test ./internal/autoassignment/... ./internal/repository/... -v -count=1` Expected: all tests pass (including new AgentBot and AssignmentPolicy repo tests) **Step 3: Verify route registration** Run: `export GOROOT=/usr/lib/go-1.24 && cd /home/yanghao05/Workspace/gochat && go build ./cmd/gochat/ && echo "BUILD SUCCESS"` Expected: BUILD SUCCESS --- ## Summary of files created/modified **New files (12):** 1. `internal/model/agent_bot.go` 2. `internal/repository/agent_bot_repo.go` 3. `internal/repository/agent_bot_repo_test.go` 4. `internal/repository/assignment_policy_repo.go` 5. `internal/repository/assignment_policy_repo_test.go` 6. `internal/service/agent_service.go` 7. `internal/service/agent_bot_service.go` 8. `internal/service/assignment_policy_service.go` 9. `internal/autoassignment/lowest_load.go` 10. `internal/handler/api/v1/agent_handler.go` 11. `internal/handler/api/v1/agent_bot_handler.go` 12. `internal/handler/api/v1/assignment_policy_handler.go` Plus 6 migration files **Modified files (3):** 1. `internal/autoassignment/model.go` — add `PolicyLowestLoad` constant 2. `internal/autoassignment/service.go` — add `lowestLoad` field + branch 3. `internal/app/bootstrap.go` — wire repos/services/handlers/listener 4. `internal/router/router.go` — add handler fields + routes