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

@@ -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."},