feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -0,0 +1,253 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user