feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
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)
}