fix(backend): enforce gapless 1..N reindexing normalization across research areas CRUD

- Implement normalizeResearchAreasOrder inside transactions for Create, Update, and Delete operations
- Prevent duplicate or corrupted display_order overlaps when inserting/moving items
- Clean up existing database display_order sequence
- Pass all unit tests and E2E test gates cleanly
This commit is contained in:
2026-08-24 23:05:27 +09:00
parent 74b637bcc9
commit c2703c131d
2 changed files with 109 additions and 18 deletions
+59 -4
View File
@@ -126,6 +126,35 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc
return areas, rows.Err()
}
func (r *HomeRepository) normalizeResearchAreasOrder(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `SELECT id FROM research_areas ORDER BY display_order ASC, id ASC`)
if err != nil {
return fmt.Errorf("query areas for normalization: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("scan area id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return err
}
for idx, id := range ids {
newOrder := idx + 1
_, err := tx.ExecContext(ctx, `UPDATE research_areas SET display_order = ? WHERE id = ?`, newOrder, id)
if err != nil {
return fmt.Errorf("update area %d to order %d: %w", id, newOrder, err)
}
}
return nil
}
func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
@@ -161,12 +190,20 @@ func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.Resear
return 0, fmt.Errorf("insert research area: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("get inserted area id: %w", err)
}
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
return 0, fmt.Errorf("normalize display_order: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit tx: %w", err)
}
id, err := res.LastInsertId()
return int(id), err
return int(id), nil
}
func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error {
@@ -202,6 +239,8 @@ func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a model
if err != nil {
return fmt.Errorf("shift research areas display_order on update: %w", err)
}
} else if a.DisplayOrder <= 0 {
a.DisplayOrder = oldOrder
}
res, err := tx.ExecContext(ctx, `
@@ -216,11 +255,22 @@ func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a model
if err == nil && rows == 0 {
return sql.ErrNoRows
}
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
return fmt.Errorf("normalize display_order on update: %w", err)
}
return tx.Commit()
}
func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete research area: %w", err)
}
@@ -228,7 +278,12 @@ func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error {
if err == nil && rows == 0 {
return sql.ErrNoRows
}
return err
if err := r.normalizeResearchAreasOrder(ctx, tx); err != nil {
return fmt.Errorf("normalize display_order on delete: %w", err)
}
return tx.Commit()
}
func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) {