mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 00:31:35 +00:00
feat: Implement MergeWork command and consolidate tasks
This commit introduces the `MergeWork` command, a new feature to merge two `Work` entities, their associations, and their statistics. The entire operation is performed atomically within a database transaction to ensure data integrity. Key changes include: - A new `MergeWork` method in `internal/app/work/commands.go`. - An `Add` method on the `WorkStats` entity for combining statistics. - A new `GetWithAssociationsInTx` method on the `WorkRepository` to fetch entities within a transaction. - A comprehensive integration test using an in-memory SQLite database to validate the merge logic. This commit also consolidates all scattered TODOs and build issues from `TODO.md` and `BUILD_ISSUES.md` into a single, actionable `TASKS.md` file. The legacy documentation files have been removed to create a single source of truth for pending work.
This commit is contained in:
parent
3ad00de327
commit
a1c8088987
@ -98,4 +98,12 @@ func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pag
|
||||
func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||
args := m.Called(ctx, workID, authorID)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) {
|
||||
return m.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (m *mockWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) {
|
||||
return m.GetByID(ctx, id)
|
||||
}
|
||||
@ -68,4 +68,12 @@ func (m *mockWorkRepoForUserTests) ListWithTranslations(ctx context.Context, pag
|
||||
}
|
||||
func (m *mockWorkRepoForUserTests) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkRepoForUserTests) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockWorkRepoForUserTests) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@ -135,3 +135,141 @@ func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error {
|
||||
// TODO: implement this
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeWork merges two works, moving all associations from the source to the target and deleting the source.
|
||||
func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) error {
|
||||
if sourceID == targetID {
|
||||
return fmt.Errorf("%w: source and target work IDs cannot be the same", domain.ErrValidation)
|
||||
}
|
||||
|
||||
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||
if !ok {
|
||||
return domain.ErrUnauthorized
|
||||
}
|
||||
|
||||
// The repo is a work.WorkRepository, which embeds domain.BaseRepository.
|
||||
// We can use the WithTx method from the base repository to run the merge in a transaction.
|
||||
err := c.repo.WithTx(ctx, func(tx *gorm.DB) error {
|
||||
// We need to use the transaction `tx` for all operations inside this function.
|
||||
// For repository methods that are not on the base repository, we need to
|
||||
// create a new repository instance that uses the transaction.
|
||||
// However, since we added `GetWithAssociationsInTx`, we can pass the tx directly.
|
||||
|
||||
// Authorization: Ensure user can edit both works
|
||||
sourceWork, err := c.repo.GetWithAssociationsInTx(ctx, tx, sourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source work: %w", err)
|
||||
}
|
||||
targetWork, err := c.repo.GetWithAssociationsInTx(ctx, tx, targetID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get target work: %w", err)
|
||||
}
|
||||
|
||||
canEditSource, err := c.authzSvc.CanEditWork(ctx, userID, sourceWork)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canEditTarget, err := c.authzSvc.CanEditWork(ctx, userID, targetWork)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canEditSource || !canEditTarget {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
|
||||
// Merge WorkStats
|
||||
if err = mergeWorkStats(tx, sourceID, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-associate polymorphic Translations
|
||||
if err = tx.Model(&domain.Translation{}).
|
||||
Where("translatable_id = ? AND translatable_type = ?", sourceID, "works").
|
||||
Update("translatable_id", targetID).Error; err != nil {
|
||||
return fmt.Errorf("failed to merge translations: %w", err)
|
||||
}
|
||||
|
||||
// Append many-to-many associations
|
||||
if err = tx.Model(targetWork).Association("Authors").Append(sourceWork.Authors); err != nil {
|
||||
return fmt.Errorf("failed to merge authors: %w", err)
|
||||
}
|
||||
if err = tx.Model(targetWork).Association("Tags").Append(sourceWork.Tags); err != nil {
|
||||
return fmt.Errorf("failed to merge tags: %w", err)
|
||||
}
|
||||
if err = tx.Model(targetWork).Association("Categories").Append(sourceWork.Categories); err != nil {
|
||||
return fmt.Errorf("failed to merge categories: %w", err)
|
||||
}
|
||||
if err = tx.Model(targetWork).Association("Copyrights").Append(sourceWork.Copyrights); err != nil {
|
||||
return fmt.Errorf("failed to merge copyrights: %w", err)
|
||||
}
|
||||
if err = tx.Model(targetWork).Association("Monetizations").Append(sourceWork.Monetizations); err != nil {
|
||||
return fmt.Errorf("failed to merge monetizations: %w", err)
|
||||
}
|
||||
|
||||
// Finally, delete the source work.
|
||||
if err = tx.Select("Authors", "Tags", "Categories", "Copyrights", "Monetizations").Delete(sourceWork).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete source work associations: %w", err)
|
||||
}
|
||||
if err = tx.Delete(&work.Work{}, sourceID).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete source work: %w", err)
|
||||
}
|
||||
|
||||
// Re-index the target work in the search client *after* the transaction commits.
|
||||
// We can't do it here, so we'll do it after the WithTx call.
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-index the target work in the search client now that the transaction is committed.
|
||||
targetWork, err := c.repo.GetByID(ctx, targetID)
|
||||
if err == nil && targetWork != nil {
|
||||
if searchErr := c.searchClient.IndexWork(ctx, targetWork, ""); searchErr != nil {
|
||||
// Log the error but don't fail the main operation
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error {
|
||||
var sourceStats work.WorkStats
|
||||
err := tx.Where("work_id = ?", sourceWorkID).First(&sourceStats).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("failed to get source work stats: %w", err)
|
||||
}
|
||||
|
||||
// If source has no stats, there's nothing to do.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetStats work.WorkStats
|
||||
err = tx.Where("work_id = ?", targetWorkID).First(&targetStats).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// If target has no stats, create new ones based on source stats.
|
||||
sourceStats.ID = 0 // Let GORM create a new record
|
||||
sourceStats.WorkID = targetWorkID
|
||||
if err = tx.Create(&sourceStats).Error; err != nil {
|
||||
return fmt.Errorf("failed to create new target stats: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to get target work stats: %w", err)
|
||||
} else {
|
||||
// Both have stats, so add source to target.
|
||||
targetStats.Add(&sourceStats)
|
||||
if err = tx.Save(&targetStats).Error; err != nil {
|
||||
return fmt.Errorf("failed to save merged target stats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the old source stats
|
||||
if err = tx.Delete(&work.WorkStats{}, sourceStats.ID).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete source work stats: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -3,13 +3,17 @@ package work
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"tercul/internal/app/authz"
|
||||
"tercul/internal/data/sql"
|
||||
"tercul/internal/domain"
|
||||
workdomain "tercul/internal/domain/work"
|
||||
platform_auth "tercul/internal/platform/auth"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type WorkCommandsSuite struct {
|
||||
@ -146,4 +150,93 @@ func (s *WorkCommandsSuite) TestDeleteWork_RepoError() {
|
||||
func (s *WorkCommandsSuite) TestAnalyzeWork_Success() {
|
||||
err := s.commands.AnalyzeWork(context.Background(), 1)
|
||||
assert.NoError(s.T(), err)
|
||||
}
|
||||
|
||||
func TestMergeWork_Integration(t *testing.T) {
|
||||
// Setup in-memory SQLite DB
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Run migrations for all relevant tables
|
||||
err = db.AutoMigrate(
|
||||
&workdomain.Work{},
|
||||
&domain.Translation{},
|
||||
&domain.Author{},
|
||||
&domain.Tag{},
|
||||
&domain.Category{},
|
||||
&domain.Copyright{},
|
||||
&domain.Monetization{},
|
||||
&workdomain.WorkStats{},
|
||||
&workdomain.WorkAuthor{},
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create real repositories and services pointing to the test DB
|
||||
workRepo := sql.NewWorkRepository(db)
|
||||
authzSvc := authz.NewService(workRepo, nil) // Using real repo for authz checks
|
||||
searchClient := &mockSearchClient{} // Mock search client is fine
|
||||
commands := NewWorkCommands(workRepo, searchClient, authzSvc)
|
||||
|
||||
// --- Seed Data ---
|
||||
author1 := &domain.Author{Name: "Author One"}
|
||||
db.Create(author1)
|
||||
author2 := &domain.Author{Name: "Author Two"}
|
||||
db.Create(author2)
|
||||
|
||||
tag1 := &domain.Tag{Name: "Tag One"}
|
||||
db.Create(tag1)
|
||||
tag2 := &domain.Tag{Name: "Tag Two"}
|
||||
db.Create(tag2)
|
||||
|
||||
sourceWork := &workdomain.Work{
|
||||
TranslatableModel: domain.TranslatableModel{Language: "en"},
|
||||
Title: "Source Work",
|
||||
Authors: []*domain.Author{author1},
|
||||
Tags: []*domain.Tag{tag1},
|
||||
}
|
||||
db.Create(sourceWork)
|
||||
db.Create(&domain.Translation{Title: "Source Translation", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"})
|
||||
db.Create(&workdomain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5})
|
||||
|
||||
targetWork := &workdomain.Work{
|
||||
TranslatableModel: domain.TranslatableModel{Language: "en"},
|
||||
Title: "Target Work",
|
||||
Authors: []*domain.Author{author2},
|
||||
Tags: []*domain.Tag{tag2},
|
||||
}
|
||||
db.Create(targetWork)
|
||||
db.Create(&domain.Translation{Title: "Target Translation", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"})
|
||||
db.Create(&workdomain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10})
|
||||
|
||||
// --- Execute Merge ---
|
||||
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
|
||||
err = commands.MergeWork(ctx, sourceWork.ID, targetWork.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// --- Assertions ---
|
||||
// 1. Source work should be deleted
|
||||
var deletedWork workdomain.Work
|
||||
err = db.First(&deletedWork, sourceWork.ID).Error
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound))
|
||||
|
||||
// 2. Target work should have merged data
|
||||
var finalTargetWork workdomain.Work
|
||||
db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID)
|
||||
|
||||
assert.Len(t, finalTargetWork.Translations, 2, "Translations should be merged")
|
||||
assert.Len(t, finalTargetWork.Authors, 2, "Authors should be merged")
|
||||
assert.Len(t, finalTargetWork.Tags, 2, "Tags should be merged")
|
||||
|
||||
// 3. Stats should be merged
|
||||
var finalStats workdomain.WorkStats
|
||||
db.Where("work_id = ?", targetWork.ID).First(&finalStats)
|
||||
assert.Equal(t, int64(30), finalStats.Views, "Views should be summed")
|
||||
assert.Equal(t, int64(15), finalStats.Likes, "Likes should be summed")
|
||||
|
||||
// 4. Source stats should be deleted
|
||||
var deletedStats workdomain.WorkStats
|
||||
err = db.First(&deletedStats, "work_id = ?", sourceWork.ID).Error
|
||||
assert.Error(t, err, "Source stats should be deleted")
|
||||
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound))
|
||||
}
|
||||
@ -2,6 +2,8 @@ package sql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tercul/internal/domain"
|
||||
"tercul/internal/domain/work"
|
||||
|
||||
@ -120,6 +122,43 @@ func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*wor
|
||||
return r.FindWithPreload(ctx, []string{"Translations"}, id)
|
||||
}
|
||||
|
||||
// GetWithAssociations gets a work with all of its direct and many-to-many associations.
|
||||
func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) {
|
||||
associations := []string{
|
||||
"Translations",
|
||||
"Authors",
|
||||
"Tags",
|
||||
"Categories",
|
||||
"Copyrights",
|
||||
"Monetizations",
|
||||
}
|
||||
return r.FindWithPreload(ctx, associations, id)
|
||||
}
|
||||
|
||||
// GetWithAssociationsInTx gets a work with all associations within a transaction.
|
||||
func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) {
|
||||
var entity work.Work
|
||||
query := tx.WithContext(ctx)
|
||||
associations := []string{
|
||||
"Translations",
|
||||
"Authors",
|
||||
"Tags",
|
||||
"Categories",
|
||||
"Copyrights",
|
||||
"Monetizations",
|
||||
}
|
||||
for _, preload := range associations {
|
||||
query = query.Preload(preload)
|
||||
}
|
||||
if err := query.First(&entity, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrEntityNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
|
||||
}
|
||||
return &entity, nil
|
||||
}
|
||||
|
||||
// IsAuthor checks if a user is an author of a work.
|
||||
// Note: This assumes a direct relationship between user ID and author ID,
|
||||
// which may need to be revised based on the actual domain model.
|
||||
|
||||
@ -71,6 +71,22 @@ type WorkStats struct {
|
||||
Work *Work `gorm:"foreignKey:WorkID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
}
|
||||
|
||||
// Add combines the values of another WorkStats into this one.
|
||||
func (ws *WorkStats) Add(other *WorkStats) {
|
||||
if other == nil {
|
||||
return
|
||||
}
|
||||
ws.Views += other.Views
|
||||
ws.Likes += other.Likes
|
||||
ws.Comments += other.Comments
|
||||
ws.Bookmarks += other.Bookmarks
|
||||
ws.Shares += other.Shares
|
||||
ws.TranslationCount += other.TranslationCount
|
||||
ws.ReadingTime += other.ReadingTime
|
||||
// Note: Complexity and Sentiment are not additive. We could average them,
|
||||
// but for now, we'll just keep the target's values.
|
||||
}
|
||||
|
||||
type WorkSeries struct {
|
||||
domain.BaseModel
|
||||
WorkID uint `gorm:"index;uniqueIndex:uniq_work_series"`
|
||||
|
||||
@ -2,6 +2,7 @@ package work
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gorm.io/gorm"
|
||||
"tercul/internal/domain"
|
||||
)
|
||||
|
||||
@ -13,6 +14,8 @@ type WorkRepository interface {
|
||||
FindByCategory(ctx context.Context, categoryID uint) ([]Work, error)
|
||||
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
||||
GetWithTranslations(ctx context.Context, id uint) (*Work, error)
|
||||
GetWithAssociations(ctx context.Context, id uint) (*Work, error)
|
||||
GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*Work, error)
|
||||
ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
||||
IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user