Files
gochat/docs/plans/2026-05-24-agent-agentbot-assignment-policy.md
T
2026-06-04 15:44:48 +08:00

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):

  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