HH-597: repair assignment policy migration parity (#156)

* fix(HH-597): repair assignment policy schema migration

* fix(HH-597): harden assignment policy migration

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-24 13:09:13 +08:00
committed by GitHub
co-authored by rogee
parent a7e96afef4
commit 4eeb0f8957
7 changed files with 236 additions and 104 deletions
@@ -0,0 +1,79 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/repository"
)
func TestInboxAssignmentPolicyProductionMigration(t *testing.T) {
db, dbURL := openUploadMigrationPostgres(t)
migrations := productionMigrationsPath(t)
require.NoError(t, MigrateSteps(dbURL, migrations, 85))
assert.False(t, db.Migrator().HasTable("inbox_assignment_policies"))
var accountID, inboxID, policyID uint
require.NoError(t, db.Raw("INSERT INTO accounts(name) VALUES (?) RETURNING id", "assignment migration account").Scan(&accountID).Error)
require.NoError(t, db.Raw(`INSERT INTO inboxes(account_id, name, channel_type, channel_id)
VALUES (?, 'assignment migration inbox', 'web_widget', 1) RETURNING id`, accountID).Scan(&inboxID).Error)
require.NoError(t, db.Raw(`INSERT INTO assignment_policies(account_id, inbox_id, policy_type)
VALUES (?, ?, 'round_robin') RETURNING id`, accountID, inboxID).Scan(&policyID).Error)
require.NoError(t, MigrateSteps(dbURL, migrations, 1))
assert.True(t, db.Migrator().HasTable("inbox_assignment_policies"))
assert.True(t, db.Migrator().HasIndex("assignment_policies", "idx_account_policy_name"))
assert.True(t, db.Migrator().HasIndex("inbox_assignment_policies", "idx_inbox_assignment_policies_inbox_id"))
assert.True(t, db.Migrator().HasIndex("inbox_assignment_policies", "idx_inbox_assignment_policies_assignment_policy_id"))
policy, err := repository.NewInboxAssignmentPolicyRepo(db).FindPolicyByInbox(context.Background(), accountID, inboxID)
require.NoError(t, err)
assert.Equal(t, policyID, policy.ID)
assert.Equal(t, "round_robin "+fmt.Sprint(policyID), policy.Name)
var newInboxID, newPolicyID uint
require.NoError(t, db.Raw(`INSERT INTO inboxes(account_id, name, channel_type, channel_id)
VALUES (?, 'post-up inbox', 'web_widget', 2) RETURNING id`, accountID).Scan(&newInboxID).Error)
require.NoError(t, db.Raw(`INSERT INTO assignment_policies(account_id, name, description)
VALUES (?, 'post-up policy', 'must survive rollback') RETURNING id`, accountID).Scan(&newPolicyID).Error)
require.NoError(t, db.Exec("INSERT INTO inbox_assignment_policies(inbox_id, assignment_policy_id) VALUES (?, ?)", newInboxID, newPolicyID).Error)
down, err := os.ReadFile(filepath.Join("..", "..", "migrations", "000086_repair_assignment_policy_schema.down.sql"))
require.NoError(t, err)
err = db.Transaction(func(tx *gorm.DB) error { return tx.Exec(string(down)).Error })
require.ErrorContains(t, err, "migration 000086 is irreversible")
assert.True(t, db.Migrator().HasTable("inbox_assignment_policies"))
assert.True(t, db.Migrator().HasColumn("assignment_policies", "description"))
var description string
require.NoError(t, db.Raw("SELECT description FROM assignment_policies WHERE id = ?", newPolicyID).Scan(&description).Error)
assert.Equal(t, "must survive rollback", description)
var links int64
require.NoError(t, db.Table("inbox_assignment_policies").Where("inbox_id = ? AND assignment_policy_id = ?", newInboxID, newPolicyID).Count(&links).Error)
assert.Equal(t, int64(1), links)
}
func TestInboxAssignmentPolicyMigrationRejectsDuplicateLegacyBindings(t *testing.T) {
db, dbURL := openUploadMigrationPostgres(t)
migrations := productionMigrationsPath(t)
require.NoError(t, MigrateSteps(dbURL, migrations, 85))
var accountID, inboxID uint
require.NoError(t, db.Raw("INSERT INTO accounts(name) VALUES (?) RETURNING id", "duplicate migration account").Scan(&accountID).Error)
require.NoError(t, db.Raw(`INSERT INTO inboxes(account_id, name, channel_type, channel_id)
VALUES (?, 'duplicate migration inbox', 'web_widget', 1) RETURNING id`, accountID).Scan(&inboxID).Error)
for _, policyType := range []string{"round_robin", "balanced"} {
require.NoError(t, db.Exec(`INSERT INTO assignment_policies(account_id, inbox_id, policy_type)
VALUES (?, ?, ?)`, accountID, inboxID, policyType).Error)
}
err := MigrateSteps(dbURL, migrations, 1)
require.ErrorContains(t, err, "multiple legacy assignment policies target one inbox")
}
@@ -72,7 +72,7 @@ func TestUploadPostgresProductionMigrationsFromEmptySchema(t *testing.T) {
require.NoError(t, RunMigrations(dbURL, productionMigrationsPath(t)))
version, dirty, err := CurrentVersion(dbURL, productionMigrationsPath(t))
require.NoError(t, err)
assert.Equal(t, uint(85), version)
assert.Equal(t, uint(86), version)
assert.False(t, dirty)
exerciseUploadProductionSchema(t, db)
}
@@ -94,26 +94,13 @@ func NewInboxAssignmentPolicyRepo(db *gorm.DB) *InboxAssignmentPolicyRepo {
return &InboxAssignmentPolicyRepo{db: db}
}
// Create creates a new inbox assignment policy.
func (r *InboxAssignmentPolicyRepo) Create(ctx context.Context, policy *model.InboxAssignmentPolicy) error {
return r.db.WithContext(ctx).Create(policy).Error
}
// FindByID retrieves an inbox assignment policy by primary key.
func (r *InboxAssignmentPolicyRepo) FindByID(ctx context.Context, id uint) (*model.InboxAssignmentPolicy, error) {
var policy model.InboxAssignmentPolicy
if err := r.db.WithContext(ctx).First(&policy, id).Error; err != nil {
return nil, err
}
return &policy, nil
}
// FindByInbox retrieves the active assignment policy override for an inbox.
func (r *InboxAssignmentPolicyRepo) FindByInbox(ctx context.Context, accountID, inboxID uint) (*model.InboxAssignmentPolicy, error) {
var policy model.InboxAssignmentPolicy
if err := r.db.WithContext(ctx).
Joins("JOIN assignment_policies ON assignment_policies.id = inbox_assignment_policies.assignment_policy_id").
Where("assignment_policies.account_id = ? AND inbox_assignment_policies.inbox_id = ?", accountID, inboxID).
Joins("JOIN inboxes ON inboxes.id = inbox_assignment_policies.inbox_id").
Where("assignment_policies.account_id = ? AND inboxes.account_id = ? AND inbox_assignment_policies.inbox_id = ?", accountID, accountID, inboxID).
First(&policy).Error; err != nil {
return nil, err
}
@@ -125,7 +112,8 @@ func (r *InboxAssignmentPolicyRepo) FindPolicyByInbox(ctx context.Context, accou
var policy model.AssignmentPolicy
if err := r.db.WithContext(ctx).
Joins("JOIN inbox_assignment_policies ON inbox_assignment_policies.assignment_policy_id = assignment_policies.id").
Where("assignment_policies.account_id = ? AND inbox_assignment_policies.inbox_id = ?", accountID, inboxID).
Joins("JOIN inboxes ON inboxes.id = inbox_assignment_policies.inbox_id").
Where("assignment_policies.account_id = ? AND inboxes.account_id = ? AND inbox_assignment_policies.inbox_id = ?", accountID, accountID, inboxID).
First(&policy).Error; err != nil {
return nil, err
}
@@ -138,16 +126,24 @@ func (r *InboxAssignmentPolicyRepo) FindInboxesByPolicy(ctx context.Context, acc
err := r.db.WithContext(ctx).
Joins("JOIN inbox_assignment_policies ON inbox_assignment_policies.inbox_id = inboxes.id").
Joins("JOIN assignment_policies ON assignment_policies.id = inbox_assignment_policies.assignment_policy_id").
Where("assignment_policies.account_id = ? AND assignment_policies.id = ?", accountID, policyID).
Where("inboxes.account_id = ? AND assignment_policies.account_id = ? AND assignment_policies.id = ?", accountID, accountID, policyID).
Order("inboxes.id ASC").
Find(&inboxes).Error
return inboxes, err
}
// ReplaceForInbox attaches one policy to an inbox, replacing an old association.
func (r *InboxAssignmentPolicyRepo) ReplaceForInbox(ctx context.Context, inboxID, policyID uint) (*model.InboxAssignmentPolicy, error) {
func (r *InboxAssignmentPolicyRepo) ReplaceForInbox(ctx context.Context, accountID, inboxID, policyID uint) (*model.InboxAssignmentPolicy, error) {
var policy model.InboxAssignmentPolicy
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var inbox model.Inbox
if err := tx.Select("id").Where("account_id = ? AND id = ?", accountID, inboxID).First(&inbox).Error; err != nil {
return err
}
var assignmentPolicy model.AssignmentPolicy
if err := tx.Select("id").Where("account_id = ? AND id = ?", accountID, policyID).First(&assignmentPolicy).Error; err != nil {
return err
}
if err := tx.Where("inbox_id = ?", inboxID).Delete(&model.InboxAssignmentPolicy{}).Error; err != nil {
return err
}
@@ -161,18 +157,15 @@ func (r *InboxAssignmentPolicyRepo) ReplaceForInbox(ctx context.Context, inboxID
}
// DeleteByInbox removes an inbox assignment policy association.
func (r *InboxAssignmentPolicyRepo) DeleteByInbox(ctx context.Context, inboxID uint) error {
return r.db.WithContext(ctx).
Where("inbox_id = ?", inboxID).
Delete(&model.InboxAssignmentPolicy{}).Error
}
// Update updates an inbox assignment policy.
func (r *InboxAssignmentPolicyRepo) Update(ctx context.Context, policy *model.InboxAssignmentPolicy) error {
return r.db.WithContext(ctx).Save(policy).Error
}
// Delete soft-deletes an inbox assignment policy.
func (r *InboxAssignmentPolicyRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.InboxAssignmentPolicy{}, id).Error
func (r *InboxAssignmentPolicyRepo) DeleteByInbox(ctx context.Context, accountID, inboxID uint) error {
result := r.db.WithContext(ctx).
Where("inbox_id = ? AND EXISTS (SELECT 1 FROM inboxes WHERE inboxes.id = inbox_assignment_policies.inbox_id AND inboxes.account_id = ?)", inboxID, accountID).
Delete(&model.InboxAssignmentPolicy{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
@@ -16,6 +16,7 @@ func setupAssignmentPolicyTestDB(t *testing.T) *gorm.DB {
return setupTestDB(t,
&model.AssignmentPolicy{},
&model.InboxAssignmentPolicy{},
&model.Inbox{},
)
}
@@ -141,13 +142,12 @@ func TestAssignmentPolicyRepo_CountAssignedInboxes(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewAssignmentPolicyRepo(db)
inboxRepo := NewInboxAssignmentPolicyRepo(db)
policy := &model.AssignmentPolicy{AccountID: 1, Name: "P1"}
require.NoError(t, repo.Create(ctx, policy))
require.NoError(t, inboxRepo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: policy.ID}))
require.NoError(t, inboxRepo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 2, AssignmentPolicyID: policy.ID}))
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: policy.ID}).Error)
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: 2, AssignmentPolicyID: policy.ID}).Error)
count, err := repo.CountAssignedInboxes(ctx, policy.ID)
require.NoError(t, err)
@@ -158,39 +158,6 @@ func TestAssignmentPolicyRepo_CountAssignedInboxes(t *testing.T) {
// InboxAssignmentPolicyRepo
// ---------------------------------------------------------------------------
func TestInboxAssignmentPolicyRepo_Create(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
iap := &model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: 1}
err := repo.Create(ctx, iap)
require.NoError(t, err)
assert.NotZero(t, iap.ID)
}
func TestInboxAssignmentPolicyRepo_FindByID(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
iap := &model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: 1}
require.NoError(t, repo.Create(ctx, iap))
found, err := repo.FindByID(ctx, iap.ID)
require.NoError(t, err)
assert.Equal(t, iap.ID, found.ID)
}
func TestInboxAssignmentPolicyRepo_FindByID_NotFound(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
_, err := repo.FindByID(ctx, 99999)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestInboxAssignmentPolicyRepo_FindByInbox(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
@@ -199,9 +166,11 @@ func TestInboxAssignmentPolicyRepo_FindByInbox(t *testing.T) {
policy := &model.AssignmentPolicy{AccountID: 1, Name: "P1"}
require.NoError(t, policyRepo.Create(ctx, policy))
require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 10, AssignmentPolicyID: policy.ID}))
inbox := &model.Inbox{AccountID: 1, Name: "I1", ChannelType: "web_widget"}
require.NoError(t, db.Create(inbox).Error)
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox.ID, AssignmentPolicyID: policy.ID}).Error)
found, err := repo.FindByInbox(ctx, 1, 10)
found, err := repo.FindByInbox(ctx, 1, inbox.ID)
require.NoError(t, err)
assert.Equal(t, policy.ID, found.AssignmentPolicyID)
}
@@ -223,9 +192,11 @@ func TestInboxAssignmentPolicyRepo_FindPolicyByInbox(t *testing.T) {
policy := &model.AssignmentPolicy{AccountID: 1, Name: "P1"}
require.NoError(t, policyRepo.Create(ctx, policy))
require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 10, AssignmentPolicyID: policy.ID}))
inbox := &model.Inbox{AccountID: 1, Name: "I1", ChannelType: "web_widget"}
require.NoError(t, db.Create(inbox).Error)
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox.ID, AssignmentPolicyID: policy.ID}).Error)
found, err := repo.FindPolicyByInbox(ctx, 1, 10)
found, err := repo.FindPolicyByInbox(ctx, 1, inbox.ID)
require.NoError(t, err)
assert.Equal(t, policy.ID, found.ID)
assert.Equal(t, "P1", found.Name)
@@ -253,8 +224,8 @@ func TestInboxAssignmentPolicyRepo_FindInboxesByPolicy(t *testing.T) {
inbox2 := &model.Inbox{AccountID: 1, Name: "I2", ChannelType: "web_widget"}
require.NoError(t, db.Create(inbox1).Error)
require.NoError(t, db.Create(inbox2).Error)
require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: inbox1.ID, AssignmentPolicyID: policy.ID}))
require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: inbox2.ID, AssignmentPolicyID: policy.ID}))
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox1.ID, AssignmentPolicyID: policy.ID}).Error)
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox2.ID, AssignmentPolicyID: policy.ID}).Error)
inboxes, err := repo.FindInboxesByPolicy(ctx, 1, policy.ID)
require.NoError(t, err)
@@ -265,14 +236,21 @@ func TestInboxAssignmentPolicyRepo_ReplaceForInbox(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
inbox := &model.Inbox{AccountID: 1, Name: "I1", ChannelType: "web_widget"}
policy1 := &model.AssignmentPolicy{AccountID: 1, Name: "P1"}
policy2 := &model.AssignmentPolicy{AccountID: 1, Name: "P2"}
require.NoError(t, db.Create(inbox).Error)
for _, policy := range []*model.AssignmentPolicy{policy1, policy2} {
require.NoError(t, db.Create(policy).Error)
}
// Create initial association
require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 10, AssignmentPolicyID: 1}))
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox.ID, AssignmentPolicyID: policy1.ID}).Error)
// Replace
result, err := repo.ReplaceForInbox(ctx, 10, 2)
result, err := repo.ReplaceForInbox(ctx, 1, inbox.ID, policy2.ID)
require.NoError(t, err)
assert.Equal(t, uint(2), result.AssignmentPolicyID)
assert.Equal(t, policy2.ID, result.AssignmentPolicyID)
// Verify old one is gone
all := []model.InboxAssignmentPolicy{}
@@ -284,40 +262,50 @@ func TestInboxAssignmentPolicyRepo_DeleteByInbox(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
policy := &model.AssignmentPolicy{AccountID: 1, Name: "P1"}
inbox := &model.Inbox{AccountID: 1, Name: "I1", ChannelType: "web_widget"}
require.NoError(t, db.Create(policy).Error)
require.NoError(t, db.Create(inbox).Error)
require.NoError(t, repo.Create(ctx, &model.InboxAssignmentPolicy{InboxID: 10, AssignmentPolicyID: 1}))
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inbox.ID, AssignmentPolicyID: policy.ID}).Error)
require.NoError(t, repo.DeleteByInbox(ctx, 10))
require.NoError(t, repo.DeleteByInbox(ctx, 1, inbox.ID))
_, err := repo.FindByInbox(ctx, 1, 10)
_, err := repo.FindByInbox(ctx, 1, inbox.ID)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
}
func TestInboxAssignmentPolicyRepo_Update(t *testing.T) {
func TestInboxAssignmentPolicyRepoRejectsCrossTenantAccess(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
policyA := &model.AssignmentPolicy{AccountID: 1, Name: "A"}
policyB := &model.AssignmentPolicy{AccountID: 2, Name: "B"}
inboxA := &model.Inbox{AccountID: 1, Name: "A", ChannelType: "web_widget"}
inboxB := &model.Inbox{AccountID: 2, Name: "B", ChannelType: "web_widget"}
for _, value := range []any{policyA, policyB, inboxA, inboxB} {
require.NoError(t, db.Create(value).Error)
}
require.NoError(t, db.Create(&model.InboxAssignmentPolicy{InboxID: inboxB.ID, AssignmentPolicyID: policyB.ID}).Error)
iap := &model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: 1}
require.NoError(t, repo.Create(ctx, iap))
_, err := repo.ReplaceForInbox(ctx, 1, inboxB.ID, policyA.ID)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
_, err = repo.ReplaceForInbox(ctx, 1, inboxA.ID, policyB.ID)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
require.ErrorIs(t, repo.DeleteByInbox(ctx, 1, inboxB.ID), gorm.ErrRecordNotFound)
iap.AssignmentPolicyID = 2
require.NoError(t, repo.Update(ctx, iap))
policy, err := repo.FindPolicyByInbox(ctx, 2, inboxB.ID)
require.NoError(t, err)
assert.Equal(t, policyB.ID, policy.ID)
found, _ := repo.FindByID(ctx, iap.ID)
assert.Equal(t, uint(2), found.AssignmentPolicyID)
}
func TestInboxAssignmentPolicyRepo_Delete(t *testing.T) {
db := setupAssignmentPolicyTestDB(t)
ctx := context.Background()
repo := NewInboxAssignmentPolicyRepo(db)
iap := &model.InboxAssignmentPolicy{InboxID: 1, AssignmentPolicyID: 1}
require.NoError(t, repo.Create(ctx, iap))
require.NoError(t, repo.Delete(ctx, iap.ID))
_, err := repo.FindByID(ctx, iap.ID)
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
require.NoError(t, db.Model(&model.InboxAssignmentPolicy{}).
Where("inbox_id = ?", inboxB.ID).
Update("assignment_policy_id", policyA.ID).Error)
_, err = repo.FindByInbox(ctx, 1, inboxB.ID)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
_, err = repo.FindPolicyByInbox(ctx, 1, inboxB.ID)
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
inboxes, err := repo.FindInboxesByPolicy(ctx, 1, policyA.ID)
require.NoError(t, err)
assert.Empty(t, inboxes)
}
@@ -237,7 +237,7 @@ func (s *AssignmentPolicyService) CreateInboxPolicy(ctx context.Context, account
if _, err := s.policyRepo.FindByAccountAndID(ctx, accountID, req.AssignmentPolicyID); err != nil {
return nil, fmt.Errorf("assignment policy not found: %w", err)
}
if _, err := s.inboxPolicyRepo.ReplaceForInbox(ctx, req.InboxID, req.AssignmentPolicyID); err != nil {
if _, err := s.inboxPolicyRepo.ReplaceForInbox(ctx, accountID, req.InboxID, req.AssignmentPolicyID); err != nil {
return nil, fmt.Errorf("create inbox assignment policy: %w", err)
}
return s.GetInboxPolicy(ctx, accountID, req.InboxID)
@@ -257,7 +257,7 @@ func (s *AssignmentPolicyService) DeleteInboxPolicy(ctx context.Context, id, acc
return fmt.Errorf("inbox assignment policy not found: %w", err)
}
if err := s.inboxPolicyRepo.DeleteByInbox(ctx, id); err != nil {
if err := s.inboxPolicyRepo.DeleteByInbox(ctx, accountID, id); err != nil {
return fmt.Errorf("failed to delete inbox assignment policy: %w", err)
}
return nil
@@ -0,0 +1,8 @@
-- v86 backfills legacy bindings and may receive new assignment-policy data.
-- Removing its table, columns, or indexes would destroy that data and could
-- also remove objects which predated this migration.
DO $$
BEGIN
RAISE EXCEPTION 'migration 000086 is irreversible: restore from backup instead of migrating down';
END
$$;
@@ -0,0 +1,64 @@
ALTER TABLE assignment_policies
ADD COLUMN IF NOT EXISTS name VARCHAR(255),
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS assignment_order INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS conversation_priority INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS fair_distribution_limit INTEGER NOT NULL DEFAULT 100,
ADD COLUMN IF NOT EXISTS fair_distribution_window INTEGER NOT NULL DEFAULT 3600;
UPDATE assignment_policies
SET name = COALESCE(NULLIF(policy_type, ''), 'Policy') || ' ' || id
WHERE name IS NULL OR name = '';
ALTER TABLE assignment_policies
ALTER COLUMN name SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_policy_name
ON assignment_policies(account_id, name);
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM assignment_policies
WHERE inbox_id IS NOT NULL
GROUP BY inbox_id
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'migration 000086: multiple legacy assignment policies target one inbox';
END IF;
IF EXISTS (
SELECT 1
FROM assignment_policies
JOIN inboxes ON inboxes.id = assignment_policies.inbox_id
WHERE assignment_policies.inbox_id IS NOT NULL
AND assignment_policies.account_id <> inboxes.account_id
) THEN
RAISE EXCEPTION 'migration 000086: legacy assignment policy and inbox belong to different accounts';
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS inbox_assignment_policies (
id BIGSERIAL PRIMARY KEY,
inbox_id BIGINT NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
assignment_policy_id BIGINT NOT NULL REFERENCES assignment_policies(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO inbox_assignment_policies(inbox_id, assignment_policy_id, created_at, updated_at)
SELECT assignment_policies.inbox_id, assignment_policies.id, assignment_policies.created_at, assignment_policies.updated_at
FROM assignment_policies
WHERE assignment_policies.inbox_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM inbox_assignment_policies
WHERE inbox_assignment_policies.inbox_id = assignment_policies.inbox_id
);
CREATE UNIQUE 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_assignment_policy_id
ON inbox_assignment_policies(assignment_policy_id);