57 KiB
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
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
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:
// 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
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
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)
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
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
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
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
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
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
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
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
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
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
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
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
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:
// 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:
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
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
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
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
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
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
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
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:
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:
// 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:
// 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:
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
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:
// 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
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:
// 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
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:
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:
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:
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 <table_name>;
Step 4: Commit
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):
internal/model/agent_bot.gointernal/repository/agent_bot_repo.gointernal/repository/agent_bot_repo_test.gointernal/repository/assignment_policy_repo.gointernal/repository/assignment_policy_repo_test.gointernal/service/agent_service.gointernal/service/agent_bot_service.gointernal/service/assignment_policy_service.gointernal/autoassignment/lowest_load.gointernal/handler/api/v1/agent_handler.gointernal/handler/api/v1/agent_bot_handler.gointernal/handler/api/v1/assignment_policy_handler.goPlus 6 migration files
Modified files (3):
internal/autoassignment/model.go— addPolicyLowestLoadconstantinternal/autoassignment/service.go— addlowestLoadfield + branchinternal/app/bootstrap.go— wire repos/services/handlers/listenerinternal/router/router.go— add handler fields + routes