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

@@ -2,6 +2,7 @@ package database
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"fmt"
"gorm.io/gorm"
)
@@ -9,7 +10,9 @@ import (
const (
articleStoriesMarker = "article_stories"
articleStoryLanguageIndex = "idx_articles_story_lang"
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang)"
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang, retired_id)"
articleStoryRetiredMarker = "article_story_language_retired"
articleRetiredIDsMarker = "article_retired_ids"
)
func BackfillArticleStories(db *gorm.DB) error {
@@ -35,9 +38,64 @@ func BackfillArticleStories(db *gorm.DB) error {
})
}
func BackfillRetiredArticles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
applied, err := markerApplied(tx, articleRetiredIDsMarker)
if err != nil || applied {
return err
}
if err := tx.Unscoped().Model(&entity.Article{}).Where("deleted_at IS NOT NULL AND retired_id = ?", 0).UpdateColumn("retired_id", gorm.Expr("id")).Error; err != nil {
return err
}
return applyMarker(tx, articleRetiredIDsMarker)
})
}
type duplicateStoryLanguage struct {
StoryID uint32
Lang string
RetiredID uint32
Versions int
}
func checkStoryLanguagesUnique(db *gorm.DB) error {
var duplicates []duplicateStoryLanguage
err := db.Unscoped().Model(&entity.Article{}).
Select("story_id, lang, retired_id, COUNT(*) AS versions").
Group("story_id, lang, retired_id").
Having("COUNT(*) > 1").
Order("story_id ASC, lang ASC").
Limit(1).
Scan(&duplicates).Error
if err != nil || len(duplicates) == 0 {
return err
}
first := duplicates[0]
return fmt.Errorf("cannot create the unique index %s: story %d holds %d versions in language %q; merge or delete the extra versions, then start again", articleStoryLanguageIndex, first.StoryID, first.Versions, first.Lang)
}
func ensureArticleStoryLanguageIndex(db *gorm.DB) error {
if db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex) {
replaced, err := markerApplied(db, articleStoryRetiredMarker)
if err != nil {
return err
}
indexed := db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex)
if replaced && indexed {
return nil
}
return db.Exec(articleStoryLanguageIndexSQL).Error
if indexed {
if err := db.Migrator().DropIndex(&entity.Article{}, articleStoryLanguageIndex); err != nil {
return err
}
}
if err := checkStoryLanguagesUnique(db); err != nil {
return err
}
if err := db.Exec(articleStoryLanguageIndexSQL).Error; err != nil {
return err
}
if replaced {
return nil
}
return applyMarker(db, articleStoryRetiredMarker)
}
@@ -41,6 +41,9 @@ func Migrate(db *gorm.DB, publicURL string) error {
if err := SeedSiteLanguageSuggestions(db); err != nil {
return err
}
if err := BackfillRetiredArticles(db); err != nil {
return err
}
if err := ensureArticleStoryLanguageIndex(db); err != nil {
return err
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"slices"
"strings"
"time"
@@ -25,6 +26,8 @@ const (
redactedPassword = "***"
mysqlLockNowait = 3572
mysqlLockWaitTimeout = 1205
mysqlDeadlock = 1213
mysqlSnapshotChanged = 1020
)
type mysqlDriver struct{}
@@ -88,7 +91,9 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
return db
}
var mysqlContentionCodes = []uint16{mysqlLockNowait, mysqlLockWaitTimeout, mysqlDeadlock, mysqlSnapshotChanged}
func (mysqlDriver) IsLockContention(err error) bool {
var failure *mysqldriver.MySQLError
return errors.As(err, &failure) && (failure.Number == mysqlLockNowait || failure.Number == mysqlLockWaitTimeout)
return errors.As(err, &failure) && slices.Contains(mysqlContentionCodes, failure.Number)
}
@@ -7,6 +7,7 @@ import (
"net"
"net/url"
"reflect"
"slices"
"time"
"github.com/jackc/pgx/v5/pgconn"
@@ -23,6 +24,8 @@ const (
postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query"
postgresLockNotAvailable = "55P03"
postgresDeadlockDetected = "40P01"
postgresSerializationFailure = "40001"
)
var timeType = reflect.TypeOf(time.Time{})
@@ -109,7 +112,9 @@ func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.T
return NewPostgresBackup(db, directory, now)
}
var postgresContentionCodes = []string{postgresLockNotAvailable, postgresDeadlockDetected, postgresSerializationFailure}
func (postgresDriver) IsLockContention(err error) bool {
var failure *pgconn.PgError
return errors.As(err, &failure) && failure.Code == postgresLockNotAvailable
return errors.As(err, &failure) && slices.Contains(postgresContentionCodes, failure.Code)
}
@@ -55,6 +55,9 @@ func ArticleToDomain(m models.Article) article.Article {
Lang: m.Lang,
StoryID: m.StoryID,
ContentRevision: m.ContentRevision,
EditRevision: m.EditRevision,
SourceArticleID: m.SourceArticleID,
SourceRevision: m.SourceRevision,
}
}
@@ -82,5 +85,8 @@ func ArticleToModel(a article.Article) models.Article {
Lang: a.Lang,
StoryID: a.StoryID,
ContentRevision: a.ContentRevision,
EditRevision: a.EditRevision,
SourceArticleID: a.SourceArticleID,
SourceRevision: a.SourceRevision,
}
}
@@ -31,4 +31,8 @@ type Article struct {
Lang string `gorm:"size:35;not null;default:''"`
StoryID uint32 `gorm:"not null;default:0"`
ContentRevision uint32 `gorm:"not null;default:1"`
EditRevision uint32 `gorm:"not null;default:1"`
SourceArticleID *uint32
SourceRevision uint32 `gorm:"not null;default:0"`
RetiredID uint32 `gorm:"column:retired_id;not null;default:0"`
}
@@ -4,18 +4,23 @@ import (
domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"slices"
"sort"
"strings"
"time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ArticleRepository struct {
@@ -217,11 +222,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
return loaded[0], nil
}
var preservedArticleColumns = []string{"lang", "story_id", "content_revision"}
var errArticleSiteChanged = errors.New("the article moved to another site while it was being locked")
const (
idChunkSize = 500
initialContentRevision = 1
idChunkSize = 500
maxUpdateAttempts = 5
maxSiteLockRounds = 5
)
func chunkIDs(ids []uint32) [][]uint32 {
@@ -321,14 +327,16 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := mappers.ArticleToModel(article)
created.ContentRevision = initialContentRevision
created := mappers.ArticleToModel(article.Opened())
err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil {
return err
}
}
if err := ensureStoryLanguageFree(tx, created.StoryID, created.Lang); err != nil {
return err
}
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
return err
}
@@ -346,6 +354,27 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func ensureStoryLanguageFree(tx *gorm.DB, storyID uint32, lang string) error {
if storyID == 0 {
return nil
}
var stories int64
if err := tx.Model(&entity.Story{}).Where("id = ?", storyID).Count(&stories).Error; err != nil {
return err
}
if stories == 0 {
return domain.ErrArticleNotFound
}
var versions int64
if err := tx.Model(&entity.Article{}).Where("story_id = ? AND lang = ?", storyID, lang).Count(&versions).Error; err != nil {
return err
}
if versions > 0 {
return domain.ErrLanguageTaken
}
return nil
}
func openStory(tx *gorm.DB, created *entity.Article) error {
if created.StoryID != 0 {
return nil
@@ -408,44 +437,71 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
}
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return a.updateColumns(id, map[string]any{"body": body})
}
localArticle.Body = body
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
columns["updated_at"] = time.Now()
if err := a.db.Model(&entity.Article{}).Where("id = ?", id).Updates(columns).Error; err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
return a.Get(id)
}
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
var updated domain.Article
var err error
for attempt := 0; attempt < maxUpdateAttempts; attempt++ {
updated, err = a.updateOnce(id, update)
if err == nil || !database.IsLockContention(a.db, err) {
break
}
}
return updated, err
}
func (a *ArticleRepository) updateOnce(id uint32, update domain.Update) (domain.Article, error) {
for round := 0; round < maxSiteLockRounds; round++ {
var probe entity.Article
if err := a.db.Model(&entity.Article{}).Select("id", "site_id").First(&probe, id).Error; err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
updated, err := a.updateAt(id, update, probe.SiteID)
if !errors.Is(err, errArticleSiteChanged) {
return updated, err
}
}
return domain.Article{}, transaction.ErrLockContention
}
func (a *ArticleRepository) updateAt(id uint32, update domain.Update, probedSiteID uint32) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Transaction(func(tx *gorm.DB) error {
if update.SiteID != nil {
if _, err := lockSite(tx, *update.SiteID); err != nil {
return err
}
locked, err := lockArticleBehindSites(tx, id, update, probedSiteID)
if err != nil {
return err
}
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if update.SiteID == nil && update.Slug != nil {
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
return err
}
localArticle = locked
if update.IfRevision != nil && localArticle.EditRevision != *update.IfRevision {
return &domain.RevisionConflictError{Current: localArticle.EditRevision}
}
before := localArticle
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
return err
}
}
if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil {
authorsChanged, err := authorsDiffer(tx, id, update.AuthorIDs)
if err != nil {
return err
}
impact := domain.ImpactOf(mappers.ArticleToDomain(before), mappers.ArticleToDomain(localArticle), authorsChanged)
revised := mappers.ArticleToDomain(localArticle).Revised(impact)
localArticle.EditRevision = revised.EditRevision
localArticle.ContentRevision = revised.ContentRevision
localArticle.UpdatedAt = time.Now()
if err := writeArticleColumns(tx, localArticle, before.EditRevision); err != nil {
return err
}
if update.AuthorIDs != nil {
@@ -453,13 +509,97 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
}
return nil
})
if errors.Is(err, domain.ErrRevisionConflict) || errors.Is(err, errArticleSiteChanged) {
return domain.Article{}, err
}
if err != nil {
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func lockArticleBehindSites(tx *gorm.DB, id uint32, update domain.Update, probedSiteID uint32) (entity.Article, error) {
if update.SiteID != nil || update.Slug != nil {
if err := lockSitesInOrder(tx, probedSiteID, update.SiteID); err != nil {
return entity.Article{}, err
}
}
var locked entity.Article
if err := tx.Model(&entity.Article{}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
return entity.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if locked.SiteID != probedSiteID {
return entity.Article{}, errArticleSiteChanged
}
return locked, nil
}
func lockSitesInOrder(tx *gorm.DB, currentSiteID uint32, targetSiteID *uint32) error {
siteIDs := []uint32{currentSiteID}
if targetSiteID != nil {
siteIDs = append(siteIDs, *targetSiteID)
}
slices.Sort(siteIDs)
for _, siteID := range slices.Compact(siteIDs) {
if siteID == 0 && targetSiteID != nil {
continue
}
if _, err := lockSite(tx, siteID); err != nil {
return err
}
}
return nil
}
func authorsDiffer(tx *gorm.DB, articleID uint32, authorIDs *[]uint32) (bool, error) {
if authorIDs == nil {
return false, nil
}
var current []uint32
if err := tx.Model(&entity.ArticleAuthor{}).Where("article_id = ?", articleID).Order("position ASC, id ASC").Pluck("author_id", &current).Error; err != nil {
return false, err
}
return !slices.Equal(current, *authorIDs), nil
}
func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision uint32) error {
result := tx.Model(&entity.Article{}).Where("id = ? AND edit_revision = ?", written.ID, expectedRevision).Updates(map[string]any{
"title": written.Title,
"body": written.Body,
"site_id": written.SiteID,
"slug": written.Slug,
"excerpt": written.Excerpt,
"seo_title": written.SEOTitle,
"seo_description": written.SEODescription,
"cover_image_id": written.CoverImageID,
"cover_image_alt": written.CoverImageAlt,
"social_title": written.SocialTitle,
"social_description": written.SocialDescription,
"social_image_id": written.SocialImageID,
"canonical_url": written.CanonicalURL,
"noindex": written.Noindex,
"seo_dismissed": written.SEODismissed,
"edit_revision": written.EditRevision,
"content_revision": written.ContentRevision,
"updated_at": written.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return &domain.RevisionConflictError{Current: currentRevision(tx, written.ID)}
}
return nil
}
func currentRevision(tx *gorm.DB, id uint32) uint32 {
var revision uint32
_ = tx.Model(&entity.Article{}).Where("id = ?", id).Pluck("edit_revision", &revision).Error
return revision
}
func applyUpdate(target *entity.Article, update domain.Update) {
setString(&target.Title, update.Title)
setString(&target.Body, update.Body)
setString(&target.Slug, update.Slug)
setString(&target.Excerpt, update.Excerpt)
@@ -493,19 +633,7 @@ func setString(target *string, value *string) {
}
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.IsOnline = isOnline
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
return a.updateColumns(id, map[string]any{"is_online": isOnline})
}
func (a *ArticleRepository) Delete(id uint32) error {
@@ -514,14 +642,60 @@ func (a *ArticleRepository) Delete(id uint32) error {
if err := tx.First(&found, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
tombstone := domain.Tombstone(id)
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil {
if found.SiteID != 0 {
if _, err := lockSite(tx, found.SiteID); err != nil {
return err
}
}
retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil {
return err
}
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err
}
return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error
if err := tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
return closeEmptyStory(tx, found.StoryID)
})
}
func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
if storyID == 0 {
return nil
}
var remaining int64
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Count(&remaining).Error; err != nil || remaining > 0 {
return err
}
return tx.Delete(&entity.Story{}, storyID).Error
}
func (a *ArticleRepository) ListByStory(storyID uint32) ([]domain.Article, error) {
var found []entity.Article
if err := a.db.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Find(&found).Error; err != nil {
return nil, err
}
return toDomainArticles(found), nil
}
func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
for _, id := range ids {
var moving entity.Article
if err := tx.First(&moving, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if err := ensureSlugFree(tx, siteID, moving.Lang, moving.Slug, id); err != nil {
return err
}
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")}
if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil {
return err
}
}
return nil
})
}
@@ -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."},