1862 lines
57 KiB
Markdown
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 |