254 lines
7.5 KiB
Go
254 lines
7.5 KiB
Go
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)
|
|
}
|