feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -5,6 +5,8 @@ import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
@@ -24,6 +26,7 @@ type CreateArticleCommand struct {
|
||||
Title string
|
||||
Body string
|
||||
Format article.BodyFormat
|
||||
Lang string
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
@@ -59,6 +62,11 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
requested, err := requestedLanguage(createArticle.Lang)
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
siteID, err := g.defaultSiteID()
|
||||
if err != nil {
|
||||
@@ -72,7 +80,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
created, err := g.createWithFreeSlug(ctx, article.Article{
|
||||
created, err := g.createWithFreeSlug(ctx, requested, article.Article{
|
||||
Title: createArticle.Title,
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
@@ -86,14 +94,21 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
event.Target = articleTarget(created)
|
||||
event = articleEvent(audit.ActionArticleCreate, created)
|
||||
g.recorder.Record(ctx, event)
|
||||
return renderArticle(g.bodyRenderer, created, createArticle.Format)
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft article.Article) (article.Article, error) {
|
||||
func requestedLanguage(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return language.Unassigned, nil
|
||||
}
|
||||
return language.Normalize(raw)
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
|
||||
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
|
||||
created, err := g.createInTransaction(ctx, draft)
|
||||
created, err := g.createInTransaction(ctx, requested, draft)
|
||||
if !isSlugTakenError(err) {
|
||||
return created, err
|
||||
}
|
||||
@@ -101,13 +116,19 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft art
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft article.Article) (article.Article, error) {
|
||||
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
|
||||
var created article.Article
|
||||
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
lang, err := tx.Languages.LockStartingLanguage(draft.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if requested != language.Unassigned {
|
||||
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
draft.Lang = lang
|
||||
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
@@ -119,6 +140,23 @@ func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft ar
|
||||
return created, err
|
||||
}
|
||||
|
||||
func chooseLanguage(repository localization.Repository, siteID uint32, requested string) (string, error) {
|
||||
rows, err := repository.Rows(siteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if localization.NeedsSetup(rows) {
|
||||
return "", localization.ErrSetupRequired
|
||||
}
|
||||
if !localization.HasRow(rows, requested) {
|
||||
return "", &article.LanguageNotEnabledError{}
|
||||
}
|
||||
if !localization.EnabledTag(rows, requested) {
|
||||
return "", article.ErrLanguageUnavailable
|
||||
}
|
||||
return requested, nil
|
||||
}
|
||||
|
||||
func articleTarget(a article.Article) audit.Target {
|
||||
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type CreateTranslationUseCase struct {
|
||||
transactor transaction.Transactor
|
||||
articles article.Repository
|
||||
localization localization.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateTranslationCommand struct {
|
||||
ArticleID uint32
|
||||
StoryID uint32
|
||||
Lang string
|
||||
Title string
|
||||
Copy *bool
|
||||
SourceArticleID *uint32
|
||||
Format article.BodyFormat
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
type translationPlan struct {
|
||||
base article.Article
|
||||
tag string
|
||||
title string
|
||||
sourceID uint32
|
||||
copy bool
|
||||
}
|
||||
|
||||
func NewCreateTranslationUseCase(transactor transaction.Transactor, articles article.Repository, localizationRepository localization.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder) *CreateTranslationUseCase {
|
||||
return &CreateTranslationUseCase{transactor: transactor, articles: articles, localization: localizationRepository, bodyRenderer: bodyRenderer, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) CreateTranslation(ctx context.Context, command CreateTranslationCommand) (article.Article, error) {
|
||||
if err := command.Caller.requireHolding(role.KeyContentCreate, builtintype.KeyArticle); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
base, versions, err := u.resolveBase(command)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
event := articleEvent(audit.ActionArticleCreate, base)
|
||||
plan, err := u.plan(command, base, versions)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
created, err := u.createWithRetry(ctx, command.Caller, plan)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
u.recordCreated(ctx, created)
|
||||
return renderArticle(u.bodyRenderer, created, command.Format)
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) resolveBase(command CreateTranslationCommand) (article.Article, []article.Article, error) {
|
||||
if command.StoryID == 0 {
|
||||
base, err := u.articles.Get(command.ArticleID)
|
||||
if err != nil {
|
||||
return article.Article{}, nil, err
|
||||
}
|
||||
if !command.Caller.seesArticle(base) {
|
||||
return article.Article{}, nil, article.ErrArticleNotFound
|
||||
}
|
||||
versions, err := u.storyVersions(base)
|
||||
return base, versions, err
|
||||
}
|
||||
versions, err := u.articles.ListByStory(command.StoryID)
|
||||
if err != nil {
|
||||
return article.Article{}, nil, err
|
||||
}
|
||||
readable := sortByCreation(command.Caller.readableVersions(versions))
|
||||
if len(readable) == 0 {
|
||||
return article.Article{}, nil, article.ErrArticleNotFound
|
||||
}
|
||||
return readable[0], versions, nil
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) storyVersions(base article.Article) ([]article.Article, error) {
|
||||
if base.StoryID == 0 {
|
||||
return []article.Article{base}, nil
|
||||
}
|
||||
return u.articles.ListByStory(base.StoryID)
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) plan(command CreateTranslationCommand, base article.Article, versions []article.Article) (translationPlan, error) {
|
||||
if command.Copy == nil || (!*command.Copy && command.SourceArticleID != nil) {
|
||||
return translationPlan{}, article.ErrInvalidTranslation
|
||||
}
|
||||
tag, err := language.Normalize(command.Lang)
|
||||
if err != nil {
|
||||
return translationPlan{}, err
|
||||
}
|
||||
if base.Lang == language.Unassigned {
|
||||
return translationPlan{}, localization.ErrSetupRequired
|
||||
}
|
||||
plan := translationPlan{base: base, tag: tag, title: command.Title, copy: *command.Copy}
|
||||
if plan.copy {
|
||||
plan.sourceID, err = u.sourceID(command, base)
|
||||
if err != nil {
|
||||
return translationPlan{}, err
|
||||
}
|
||||
}
|
||||
return plan, u.checkLanguage(command.Caller, base, tag, versions)
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) sourceID(command CreateTranslationCommand, base article.Article) (uint32, error) {
|
||||
sourceID := base.ID
|
||||
if command.SourceArticleID != nil {
|
||||
sourceID = *command.SourceArticleID
|
||||
}
|
||||
source, err := u.articles.Get(sourceID)
|
||||
if errors.Is(err, article.ErrArticleNotFound) || err == nil && !command.Caller.seesArticle(source) {
|
||||
return 0, article.ErrSourceUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if source.StoryID != base.StoryID {
|
||||
return 0, article.ErrSourceOtherStory
|
||||
}
|
||||
return sourceID, nil
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) checkLanguage(caller ContentCaller, base article.Article, tag string, versions []article.Article) error {
|
||||
rows, err := u.localization.Rows(base.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !localization.HasRow(rows, tag) {
|
||||
return &article.LanguageNotEnabledError{}
|
||||
}
|
||||
if existing, found := existingVersion(versions, tag); found {
|
||||
if caller.seesArticle(existing) {
|
||||
return &article.LanguageExistsError{ArticleID: existing.ID}
|
||||
}
|
||||
return article.ErrLanguageUnavailable
|
||||
}
|
||||
if !localization.EnabledTag(rows, tag) {
|
||||
return article.ErrLanguageUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingVersion(versions []article.Article, tag string) (article.Article, bool) {
|
||||
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.Lang == tag })
|
||||
if index < 0 {
|
||||
return article.Article{}, false
|
||||
}
|
||||
return versions[index], true
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) createWithRetry(ctx context.Context, caller ContentCaller, plan translationPlan) (article.Article, error) {
|
||||
var err error
|
||||
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
|
||||
var created article.Article
|
||||
created, err = u.createInTransaction(ctx, caller, plan)
|
||||
if err == nil {
|
||||
return created, nil
|
||||
}
|
||||
if !isSlugTakenError(err) && !errors.Is(err, transaction.ErrLockContention) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if errors.Is(err, article.ErrLanguageTaken) {
|
||||
return article.Article{}, article.ErrLanguageUnavailable
|
||||
}
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) createInTransaction(ctx context.Context, caller ContentCaller, plan translationPlan) (article.Article, error) {
|
||||
var created article.Article
|
||||
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
if err := tx.Sites.LockSite(plan.base.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
draft, err := u.draft(tx, caller, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err = tx.Articles.Create(draft)
|
||||
return err
|
||||
})
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) draft(tx transaction.Repositories, caller ContentCaller, plan translationPlan) (article.Article, error) {
|
||||
base, err := tx.Articles.Get(plan.base.ID)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if base.SiteID != plan.base.SiteID || base.StoryID != plan.base.StoryID {
|
||||
return article.Article{}, transaction.ErrLockContention
|
||||
}
|
||||
draft := article.Article{
|
||||
Title: plan.title,
|
||||
SiteID: base.SiteID,
|
||||
StoryID: base.StoryID,
|
||||
Lang: plan.tag,
|
||||
CreatedBy: caller.UserID,
|
||||
SEODismissed: []string{},
|
||||
}
|
||||
if !plan.copy {
|
||||
draft.Authors, err = bylinesOf(tx.Authors, caller.UserID)
|
||||
return draft, err
|
||||
}
|
||||
source, err := tx.Articles.Get(plan.sourceID)
|
||||
if errors.Is(err, article.ErrArticleNotFound) || err == nil && (!caller.seesArticle(source) || source.StoryID != base.StoryID) {
|
||||
return article.Article{}, article.ErrSourceUnavailable
|
||||
}
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
return source.CopiedInto(draft), nil
|
||||
}
|
||||
|
||||
func bylinesOf(authors author.Repository, userID uint32) ([]author.Byline, error) {
|
||||
found, err := authors.GetByUserID(userID)
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
return []author.Byline{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []author.Byline{found.Byline()}, nil
|
||||
}
|
||||
|
||||
func (u *CreateTranslationUseCase) recordCreated(ctx context.Context, created article.Article) {
|
||||
event := articleEvent(audit.ActionArticleCreate, created)
|
||||
event.Params[audit.ParamStoryID] = created.StoryID
|
||||
if created.SourceArticleID != nil {
|
||||
event.Params[audit.ParamSourceArticleID] = *created.SourceArticleID
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
@@ -29,8 +29,7 @@ func (g *DeleteArticleUseCase) DeleteArticle(ctx context.Context, id uint32, cal
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionArticleDelete, audit.CategoryArticles)
|
||||
event.Target = articleTarget(target)
|
||||
event := articleEvent(audit.ActionArticleDelete, target)
|
||||
if err := g.articleRepository.Delete(id); err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type DescribeArticleUseCase struct {
|
||||
articles article.Repository
|
||||
localization localization.Repository
|
||||
}
|
||||
|
||||
type ArticleContext struct {
|
||||
Capabilities []string
|
||||
Translations []article.Translation
|
||||
Source *article.Source
|
||||
}
|
||||
|
||||
func NewDescribeArticleUseCase(articles article.Repository, localizationRepository localization.Repository) *DescribeArticleUseCase {
|
||||
return &DescribeArticleUseCase{articles: articles, localization: localizationRepository}
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) Describe(art article.Article, caller ContentCaller) (ArticleContext, error) {
|
||||
described := ArticleContext{Capabilities: caller.capabilitiesFor(art), Translations: []article.Translation{}}
|
||||
if caller.isAnonymous() {
|
||||
return described, nil
|
||||
}
|
||||
translations, err := d.translations(art, caller)
|
||||
if err != nil {
|
||||
return ArticleContext{}, err
|
||||
}
|
||||
described.Translations = translations
|
||||
described.Source, err = d.source(art, caller)
|
||||
if err != nil {
|
||||
return ArticleContext{}, err
|
||||
}
|
||||
return described, nil
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) translations(art article.Article, caller ContentCaller) ([]article.Translation, error) {
|
||||
versions := []article.Article{art}
|
||||
if art.StoryID != 0 {
|
||||
found, err := d.articles.ListByStory(art.StoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions = found
|
||||
}
|
||||
rows, err := d.rows(art.SiteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translations := []article.Translation{}
|
||||
for _, version := range sortByLanguage(caller.readableVersions(versions), rows) {
|
||||
translations = append(translations, article.Translation{
|
||||
ID: version.ID,
|
||||
Lang: version.Lang,
|
||||
Title: version.Title,
|
||||
IsOnline: version.IsOnline,
|
||||
LanguageEnabled: d.languageEnabled(rows, version.Lang),
|
||||
Capabilities: caller.capabilitiesFor(version),
|
||||
})
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) rows(siteID uint32) ([]localization.Row, error) {
|
||||
return d.localization.Rows(siteID)
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) languageEnabled(rows []localization.Row, tag string) bool {
|
||||
if tag == language.Unassigned {
|
||||
return true
|
||||
}
|
||||
return localization.EnabledTag(rows, tag)
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, error) {
|
||||
if art.SourceArticleID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
origin, err := d.articles.Get(*art.SourceArticleID)
|
||||
if errors.Is(err, article.ErrArticleNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !caller.seesArticle(origin) {
|
||||
return nil, nil
|
||||
}
|
||||
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -21,9 +24,13 @@ type UpdateArticleUseCase struct {
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
images image.Repository
|
||||
localization localization.Repository
|
||||
storyMover StoryMover
|
||||
}
|
||||
|
||||
type UpdateArticleCommand struct {
|
||||
Title *string
|
||||
IfRevision *uint32
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
@@ -42,8 +49,8 @@ type UpdateArticleCommand struct {
|
||||
Format article.BodyFormat
|
||||
}
|
||||
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository, images image.Repository) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images}
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository, images image.Repository, localizationRepository localization.Repository, storyMover StoryMover) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images, localization: localizationRepository, storyMover: storyMover}
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, caller ContentCaller, body string, format article.BodyFormat) (article.Article, error) {
|
||||
@@ -61,10 +68,29 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
|
||||
if err := caller.authorize(role.KeyContentUpdate, builtintype.KeyArticle, before.CreatedBy); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if !caller.seesArticle(before) {
|
||||
return article.Article{}, article.ErrArticleNotFound
|
||||
}
|
||||
if command.IfRevision != nil && before.EditRevision != *command.IfRevision {
|
||||
err := &article.RevisionConflictError{Current: before.EditRevision}
|
||||
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
if command.Title != nil {
|
||||
if err := article.ValidateTitle(*command.Title); err != nil {
|
||||
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
}
|
||||
destination, err := g.destinationSite(command)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
movesWholeStory, err := g.checkSingleVersionMove(before, caller, command)
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
@@ -78,7 +104,28 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
|
||||
return article.Article{}, err
|
||||
}
|
||||
update.Slug = slug
|
||||
if movesWholeStory {
|
||||
return g.updateWithStory(ctx, id, caller, before, command, destination, update, slug)
|
||||
}
|
||||
updated, err := g.articleRepository.Update(id, update)
|
||||
return g.finishEdit(ctx, before, command, slug, destination, updated, err)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) updateWithStory(ctx context.Context, id uint32, caller ContentCaller, before article.Article, command UpdateArticleCommand, destination *site.Site, update article.Update, slug *string) (article.Article, error) {
|
||||
var updated article.Article
|
||||
var writeErr error
|
||||
move := MoveStoryCommand{StoryID: before.StoryID, SiteKey: destination.Key, Caller: caller, SkipArticleID: id}
|
||||
_, err := g.storyMover.MoveStoryThen(ctx, move, func(tx transaction.Repositories) error {
|
||||
updated, writeErr = tx.Articles.Update(id, update)
|
||||
return writeErr
|
||||
})
|
||||
if err != nil && writeErr == nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
return g.finishEdit(ctx, before, command, slug, destination, updated, err)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) finishEdit(ctx context.Context, before article.Article, command UpdateArticleCommand, slug *string, destination *site.Site, updated article.Article, err error) (article.Article, error) {
|
||||
if isSlugTakenError(err) {
|
||||
err = g.raceConflict(before, command, slug)
|
||||
}
|
||||
@@ -92,6 +139,8 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, caller Con
|
||||
|
||||
func (c UpdateArticleCommand) toUpdate() article.Update {
|
||||
update := article.Update{
|
||||
Title: c.Title,
|
||||
IfRevision: c.IfRevision,
|
||||
Body: c.Body,
|
||||
SiteID: c.SiteID,
|
||||
AuthorIDs: c.AuthorIDs,
|
||||
@@ -188,6 +237,47 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s
|
||||
return &destination, nil
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) checkSingleVersionMove(before article.Article, caller ContentCaller, command UpdateArticleCommand) (bool, error) {
|
||||
if command.SiteID == nil || *command.SiteID == before.SiteID {
|
||||
return false, nil
|
||||
}
|
||||
versions, err := g.articleRepository.ListByStory(before.StoryID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(caller.readableVersions(versions)) > 1 {
|
||||
return false, article.ErrStoryMoveRequired
|
||||
}
|
||||
if err := requireUpdateOnAll(caller, versions); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(versions) > 1 {
|
||||
return true, nil
|
||||
}
|
||||
rows, err := g.localization.Rows(*command.SiteID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if missing := localization.MissingTags(rows, []string{before.Lang}); len(missing) > 0 {
|
||||
return false, &article.LanguageNotEnabledError{Missing: missing}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) checkPublishable(before article.Article, isOnline bool) error {
|
||||
if !isOnline || before.IsOnline || before.Lang == language.Unassigned {
|
||||
return nil
|
||||
}
|
||||
rows, err := g.localization.Rows(before.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !localization.EnabledTag(rows, before.Lang) {
|
||||
return article.ErrLanguageDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
|
||||
if ids == nil {
|
||||
return nil
|
||||
@@ -230,8 +320,7 @@ func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Ar
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(audit.ActionArticleEdit, audit.CategoryArticles)
|
||||
event.Target = articleTarget(after)
|
||||
event := articleEvent(audit.ActionArticleEdit, after)
|
||||
event.Changes = changes
|
||||
g.recorder.Record(ctx, event)
|
||||
}
|
||||
@@ -266,6 +355,10 @@ func (g *UpdateArticleUseCase) UpdateIsOnline(ctx context.Context, id uint32, ca
|
||||
if err := caller.authorize(role.KeyContentPublish, builtintype.KeyArticle, before.CreatedBy); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
if err := g.checkPublishable(before, isOnline); err != nil {
|
||||
g.recordFailure(ctx, publishAction(isOnline), before, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.UpdateIsOnline(id, isOnline)
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, publishAction(isOnline), before, err)
|
||||
@@ -287,14 +380,11 @@ func (g *UpdateArticleUseCase) recordChange(ctx context.Context, action string,
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(after)
|
||||
event := articleEvent(action, after)
|
||||
event.Changes = changes
|
||||
g.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) recordFailure(ctx context.Context, action string, before article.Article, cause error) {
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(before)
|
||||
recordFailure(ctx, g.recorder, event, cause)
|
||||
recordFailure(ctx, g.recorder, articleEvent(action, before), cause)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"cmp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func (c ContentCaller) isAnonymous() bool {
|
||||
return c.UserID == 0
|
||||
}
|
||||
|
||||
func (c ContentCaller) capabilitiesFor(art article.Article) []string {
|
||||
capabilities := []string{article.CapabilityRead}
|
||||
if c.Grants.Permits(role.KeyContentUpdate, builtintype.KeyArticle, c.UserID, art.CreatedBy) {
|
||||
capabilities = append(capabilities, article.CapabilityEdit)
|
||||
}
|
||||
if c.Grants.Permits(role.KeyContentPublish, builtintype.KeyArticle, c.UserID, art.CreatedBy) {
|
||||
capabilities = append(capabilities, article.CapabilityPublish)
|
||||
}
|
||||
if c.Grants.Permits(role.KeyContentDelete, builtintype.KeyArticle, c.UserID, art.CreatedBy) {
|
||||
capabilities = append(capabilities, article.CapabilityDelete)
|
||||
}
|
||||
if c.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle) {
|
||||
capabilities = append(capabilities, article.CapabilityCreateTranslation)
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
|
||||
func (c ContentCaller) readableVersions(versions []article.Article) []article.Article {
|
||||
readable := make([]article.Article, 0, len(versions))
|
||||
for _, version := range versions {
|
||||
if c.seesArticle(version) {
|
||||
readable = append(readable, version)
|
||||
}
|
||||
}
|
||||
return readable
|
||||
}
|
||||
|
||||
func sortByCreation(versions []article.Article) []article.Article {
|
||||
ordered := slices.Clone(versions)
|
||||
slices.SortFunc(ordered, func(left article.Article, right article.Article) int {
|
||||
if byTime := left.CreatedAt.Compare(right.CreatedAt); byTime != 0 {
|
||||
return byTime
|
||||
}
|
||||
return cmp.Compare(left.ID, right.ID)
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
func sortByLanguage(versions []article.Article, rows []localization.Row) []article.Article {
|
||||
defaultTag := localization.DefaultTag(rows)
|
||||
ordered := slices.Clone(versions)
|
||||
slices.SortFunc(ordered, func(left article.Article, right article.Article) int {
|
||||
if byTag := localization.CompareTags(defaultTag, left.Lang, right.Lang); byTag != 0 {
|
||||
return byTag
|
||||
}
|
||||
return cmp.Compare(left.ID, right.ID)
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
func languagesOf(versions []article.Article) []string {
|
||||
tags := make([]string, 0, len(versions))
|
||||
for _, version := range versions {
|
||||
tags = append(tags, version.Lang)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func articleEvent(action string, art article.Article) audit.Event {
|
||||
event := audit.NewEvent(action, audit.CategoryArticles)
|
||||
event.Target = articleTarget(art)
|
||||
if art.Lang != language.Unassigned {
|
||||
event.Params[audit.ParamLang] = art.Lang
|
||||
}
|
||||
return event
|
||||
}
|
||||
@@ -28,6 +28,15 @@ type failureReason struct {
|
||||
var failureReasons = []failureReason{
|
||||
{article.ErrInvalidTitle, "The title must be between 3 and 200 characters."},
|
||||
{article.ErrSlugTaken, "That slug is already used by another article on this site."},
|
||||
{article.ErrRevisionConflict, "The article was changed by someone else since it was opened."},
|
||||
{article.ErrLanguageUnavailable, "That language cannot be used for this article."},
|
||||
{article.ErrLanguageExists, "A version in that language already exists."},
|
||||
{article.ErrLanguageNotEnabled, "A language of the article is not enabled on the site."},
|
||||
{article.ErrLanguageDisabled, "That language is disabled on the site."},
|
||||
{article.ErrSourceUnavailable, "The chosen source version cannot be used."},
|
||||
{article.ErrSourceOtherStory, "The chosen source version belongs to another article."},
|
||||
{article.ErrStoryMoveRequired, "The article has other versions, so the whole story must be moved."},
|
||||
{article.ErrSlugConflict, "An address of the article is already used on the destination site."},
|
||||
{image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."},
|
||||
{image.ErrImageTooLarge, "The file is too large to upload."},
|
||||
{image.ErrImageUnreadable, "The image could not be read."},
|
||||
|
||||
Reference in new issue
Block a user