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

1862 lines
57 KiB
Markdown

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