package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/builtintype" "RenewCMS/internal/domain/localization" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/transaction" "context" "errors" "slices" ) const maxSiteLockRounds = 5 type MoveStoryUseCase struct { transactor transaction.Transactor articles article.Repository recorder audit.Recorder } type MoveStoryCommand struct { StoryID uint32 SiteKey string Caller ContentCaller SkipArticleID uint32 } type StoryMover interface { MoveStoryThen(ctx context.Context, command MoveStoryCommand, then func(transaction.Repositories) error) (MoveStoryResult, error) } type MoveStoryResult struct { StoryID uint32 Site site.Site ArticleIDs []uint32 } type storyMove struct { result MoveStoryResult moved []article.Article origin site.Site readable []article.Article } var _ StoryMover = &MoveStoryUseCase{} func NewMoveStoryUseCase(transactor transaction.Transactor, articles article.Repository, recorder audit.Recorder) *MoveStoryUseCase { return &MoveStoryUseCase{transactor: transactor, articles: articles, recorder: recorder} } func (u *MoveStoryUseCase) MoveStory(ctx context.Context, command MoveStoryCommand) (MoveStoryResult, error) { return u.MoveStoryThen(ctx, command, nil) } func (u *MoveStoryUseCase) MoveStoryThen(ctx context.Context, command MoveStoryCommand, then func(transaction.Repositories) error) (MoveStoryResult, error) { if err := command.Caller.requireHolding(role.KeyContentUpdate, builtintype.KeyArticle); err != nil { return MoveStoryResult{}, err } var move storyMove var followUpErr error err := u.transactor.Run(ctx, func(tx transaction.Repositories) error { var err error if move, err = u.moveInTransaction(tx, command); err != nil { return err } if then != nil { followUpErr = then(tx) } return followUpErr }) if followUpErr != nil { return MoveStoryResult{}, err } if errors.Is(err, article.ErrSlugTaken) { err = &article.SlugConflictError{} } if err != nil { u.recordRefusal(ctx, command, err) return MoveStoryResult{}, err } u.recordMoved(ctx, move) return move.result, nil } func (u *MoveStoryUseCase) moveInTransaction(tx transaction.Repositories, command MoveStoryCommand) (storyMove, error) { versions, readable, err := authorizedVersions(tx, command) if err != nil { return storyMove{}, err } destination, err := tx.Sites.GetByKey(command.SiteKey) if err != nil { return storyMove{}, err } if versions, readable, err = lockStorySites(tx, command, destination, versions, readable); err != nil { return storyMove{}, err } origin, err := tx.Sites.Get(readable[0].SiteID) if err != nil { return storyMove{}, err } move := storyMove{origin: origin, readable: readable, result: MoveStoryResult{StoryID: command.StoryID, Site: destination, ArticleIDs: idsOf(readable)}} if origin.ID == destination.ID { return move, nil } if err := checkDestination(tx, destination, versions, readable); err != nil { return storyMove{}, err } ordered := withoutArticle(sortByCreation(versions), command.SkipArticleID) if err := tx.Articles.MoveToSite(idsOf(ordered), destination.ID); err != nil { return storyMove{}, err } move.moved = ordered return move, nil } func withoutArticle(versions []article.Article, skipID uint32) []article.Article { return slices.DeleteFunc(slices.Clone(versions), func(version article.Article) bool { return version.ID == skipID }) } func lockStorySites(tx transaction.Repositories, command MoveStoryCommand, destination site.Site, versions []article.Article, readable []article.Article) ([]article.Article, []article.Article, error) { locked := []uint32{} for attempt := 0; attempt < maxSiteLockRounds; attempt++ { needed := append(siteIDsOf(versions), destination.ID) if allLocked(locked, needed) { return versions, readable, nil } if err := lockSites(tx, needed); err != nil { return nil, nil, err } locked = needed var err error if versions, readable, err = authorizedVersions(tx, command); err != nil { return nil, nil, err } } return nil, nil, transaction.ErrLockContention } func allLocked(locked []uint32, needed []uint32) bool { for _, siteID := range needed { if !slices.Contains(locked, siteID) { return false } } return true } func authorizedVersions(tx transaction.Repositories, command MoveStoryCommand) ([]article.Article, []article.Article, error) { versions, err := tx.Articles.ListByStory(command.StoryID) if err != nil { return nil, nil, err } readable := sortByCreation(command.Caller.readableVersions(versions)) if len(readable) == 0 { return nil, nil, article.ErrArticleNotFound } return versions, readable, requireUpdateOnAll(command.Caller, versions) } func siteIDsOf(versions []article.Article) []uint32 { siteIDs := []uint32{} for _, version := range versions { siteIDs = append(siteIDs, version.SiteID) } return siteIDs } func lockSites(tx transaction.Repositories, siteIDs []uint32) error { ordered := slices.Clone(siteIDs) slices.Sort(ordered) for _, siteID := range slices.Compact(ordered) { if err := tx.Sites.LockSite(siteID); err != nil { return err } } return nil } func requireUpdateOnAll(caller ContentCaller, versions []article.Article) error { for _, version := range versions { if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, version.CreatedBy); err != nil { return err } } return nil } func checkDestination(tx transaction.Repositories, destination site.Site, versions []article.Article, readable []article.Article) error { rows, err := tx.Localization.Rows(destination.ID) if err != nil { return err } if missing := localization.MissingTags(rows, languagesOf(versions)); len(missing) > 0 { return &article.LanguageNotEnabledError{Missing: visibleTags(missing, readable)} } conflicting := []uint32{} taken := false for _, version := range sortByCreation(versions) { clash, err := tx.Articles.SlugTaken(destination.ID, version.Lang, version.Slug, 0) if err != nil { return err } taken = taken || clash if clash && slices.ContainsFunc(readable, func(candidate article.Article) bool { return candidate.ID == version.ID }) { conflicting = append(conflicting, version.ID) } } if taken { return &article.SlugConflictError{ArticleIDs: conflicting} } return nil } func visibleTags(missing []string, readable []article.Article) []string { visible := []string{} for _, tag := range missing { if slices.Contains(languagesOf(readable), tag) { visible = append(visible, tag) } } return visible } func idsOf(versions []article.Article) []uint32 { ids := make([]uint32, 0, len(versions)) for _, version := range versions { ids = append(ids, version.ID) } return ids } func (u *MoveStoryUseCase) recordMoved(ctx context.Context, move storyMove) { for _, version := range move.moved { event := articleEvent(audit.ActionArticleEdit, version) event.Changes = []audit.Change{audit.TextChange(audit.FieldSite, move.origin.Name, move.result.Site.Name)} u.recorder.Record(ctx, event) } } func (u *MoveStoryUseCase) recordRefusal(ctx context.Context, command MoveStoryCommand, cause error) { if isNotFound(cause) || errors.Is(cause, role.ErrMissingPermission) { return } versions, err := u.articles.ListByStory(command.StoryID) readable := sortByCreation(command.Caller.readableVersions(versions)) if err != nil || len(readable) == 0 { return } recordFailure(ctx, u.recorder, articleEvent(audit.ActionArticleEdit, readable[0]), cause) }