feat: editor write path
This commit is contained in:
1 parent
d9158025d4
commit
a0b3c58c52
88 files changed
+5738
-263
No files matched your search
@@ -137,6 +137,9 @@ type Article struct {
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
ContentRevision uint32 `json:"-"`
|
||||
EditRevision uint32 `json:"-"`
|
||||
SourceArticleID *uint32 `json:"-"`
|
||||
SourceRevision uint32 `json:"-"`
|
||||
}
|
||||
|
||||
func (a Article) UsesImage(imageID uint32) bool {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package article
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"errors"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRevisionConflict = errors.New("the article changed since it was opened")
|
||||
ErrLanguageTaken = errors.New("the story already holds a version in that language")
|
||||
ErrLanguageUnavailable = errors.New("the language cannot be used for this story")
|
||||
ErrLanguageExists = errors.New("a readable version in that language already exists")
|
||||
ErrLanguageNotEnabled = errors.New("the language is not enabled on the site")
|
||||
ErrLanguageDisabled = errors.New("the language is disabled on the site")
|
||||
ErrSourceUnavailable = errors.New("the source version cannot be used")
|
||||
ErrSourceOtherStory = errors.New("the source version belongs to another story")
|
||||
ErrInvalidTranslation = errors.New("the translation request is not valid")
|
||||
ErrStoryMoveRequired = errors.New("the story must be moved as a whole")
|
||||
ErrSlugConflict = errors.New("an address already exists on the destination site")
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityRead = "read"
|
||||
CapabilityEdit = "edit"
|
||||
CapabilityPublish = "publish"
|
||||
CapabilityDelete = "delete"
|
||||
CapabilityCreateTranslation = "create_translation"
|
||||
)
|
||||
|
||||
type RevisionConflictError struct {
|
||||
Current uint32
|
||||
}
|
||||
|
||||
func (e *RevisionConflictError) Error() string {
|
||||
return ErrRevisionConflict.Error()
|
||||
}
|
||||
|
||||
func (e *RevisionConflictError) Is(target error) bool {
|
||||
return target == ErrRevisionConflict
|
||||
}
|
||||
|
||||
type LanguageExistsError struct {
|
||||
ArticleID uint32
|
||||
}
|
||||
|
||||
func (e *LanguageExistsError) Error() string {
|
||||
return ErrLanguageExists.Error()
|
||||
}
|
||||
|
||||
func (e *LanguageExistsError) Is(target error) bool {
|
||||
return target == ErrLanguageExists
|
||||
}
|
||||
|
||||
type LanguageNotEnabledError struct {
|
||||
Missing []string
|
||||
}
|
||||
|
||||
func (e *LanguageNotEnabledError) Error() string {
|
||||
return ErrLanguageNotEnabled.Error()
|
||||
}
|
||||
|
||||
func (e *LanguageNotEnabledError) Is(target error) bool {
|
||||
return target == ErrLanguageNotEnabled
|
||||
}
|
||||
|
||||
type SlugConflictError struct {
|
||||
ArticleIDs []uint32
|
||||
}
|
||||
|
||||
func (e *SlugConflictError) Error() string {
|
||||
return ErrSlugConflict.Error()
|
||||
}
|
||||
|
||||
func (e *SlugConflictError) Is(target error) bool {
|
||||
return target == ErrSlugConflict
|
||||
}
|
||||
|
||||
type Translation struct {
|
||||
ID uint32 `json:"id"`
|
||||
Lang string `json:"lang"`
|
||||
Title string `json:"title"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
LanguageEnabled bool `json:"language_enabled"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ArticleID uint32 `json:"article_id"`
|
||||
Revision uint32 `json:"revision"`
|
||||
}
|
||||
|
||||
type Impact struct {
|
||||
Edited bool
|
||||
Content bool
|
||||
}
|
||||
|
||||
func ImpactOf(before Article, after Article, authorsChanged bool) Impact {
|
||||
content := before.Title != after.Title ||
|
||||
before.Body != after.Body ||
|
||||
before.Excerpt != after.Excerpt ||
|
||||
before.Slug != after.Slug ||
|
||||
before.CoverImageAlt != after.CoverImageAlt ||
|
||||
before.SEOTitle != after.SEOTitle ||
|
||||
before.SEODescription != after.SEODescription ||
|
||||
before.SocialTitle != after.SocialTitle ||
|
||||
before.SocialDescription != after.SocialDescription
|
||||
edited := content ||
|
||||
authorsChanged ||
|
||||
before.SiteID != after.SiteID ||
|
||||
before.CanonicalURL != after.CanonicalURL ||
|
||||
before.Noindex != after.Noindex ||
|
||||
!sameImageID(before.CoverImageID, after.CoverImageID) ||
|
||||
!sameImageID(before.SocialImageID, after.SocialImageID) ||
|
||||
!slices.Equal(before.SEODismissed, after.SEODismissed)
|
||||
return Impact{Edited: edited, Content: content}
|
||||
}
|
||||
|
||||
const InitialRevision uint32 = 1
|
||||
|
||||
func (a Article) Opened() Article {
|
||||
a.EditRevision = InitialRevision
|
||||
a.ContentRevision = InitialRevision
|
||||
return a
|
||||
}
|
||||
|
||||
func (a Article) Revised(impact Impact) Article {
|
||||
if impact.Edited {
|
||||
a.EditRevision++
|
||||
}
|
||||
if impact.Content {
|
||||
a.ContentRevision++
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func sameImageID(left *uint32, right *uint32) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func (a Article) CopiedInto(draft Article) Article {
|
||||
draft.Body = a.Body
|
||||
draft.Excerpt = a.Excerpt
|
||||
draft.SEOTitle = a.SEOTitle
|
||||
draft.SEODescription = a.SEODescription
|
||||
draft.SocialTitle = a.SocialTitle
|
||||
draft.SocialDescription = a.SocialDescription
|
||||
draft.CoverImageID = a.CoverImageID
|
||||
draft.CoverImageAlt = a.CoverImageAlt
|
||||
draft.SocialImageID = a.SocialImageID
|
||||
draft.Authors = slices.Clone(a.Authors)
|
||||
if draft.Authors == nil {
|
||||
draft.Authors = []author.Byline{}
|
||||
}
|
||||
draft.SourceArticleID = &a.ID
|
||||
draft.SourceRevision = a.ContentRevision
|
||||
return draft
|
||||
}
|
||||
@@ -47,6 +47,8 @@ type Nullable[T any] struct {
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Title *string
|
||||
IfRevision *uint32
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
@@ -107,4 +109,6 @@ type Repository interface {
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
|
||||
ListByStory(storyID uint32) ([]Article, error)
|
||||
MoveToSite(ids []uint32, siteID uint32) error
|
||||
}
|
||||
@@ -214,6 +214,9 @@ const (
|
||||
FieldLanguages = "languages"
|
||||
FieldDefaultLanguage = "default_language"
|
||||
ParamAssigned = "assigned"
|
||||
ParamLang = "lang"
|
||||
ParamStoryID = "story_id"
|
||||
ParamSourceArticleID = "source_article_id"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -93,3 +93,44 @@ func Plan(current []Row, counts map[string]VersionCount, change Change) ([]Row,
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func EnabledTag(rows []Row, tag string) bool {
|
||||
return slices.ContainsFunc(rows, func(row Row) bool { return row.Tag == tag && row.Enabled })
|
||||
}
|
||||
|
||||
func HasRow(rows []Row, tag string) bool {
|
||||
return slices.ContainsFunc(rows, func(row Row) bool { return row.Tag == tag })
|
||||
}
|
||||
|
||||
func MissingTags(rows []Row, tags []string) []string {
|
||||
missing := []string{}
|
||||
for _, tag := range tags {
|
||||
if tag != language.Unassigned && !EnabledTag(rows, tag) && !slices.Contains(missing, tag) {
|
||||
missing = append(missing, tag)
|
||||
}
|
||||
}
|
||||
slices.Sort(missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
func DefaultTag(rows []Row) string {
|
||||
for _, row := range rows {
|
||||
if row.IsDefault {
|
||||
return row.Tag
|
||||
}
|
||||
}
|
||||
return language.Unassigned
|
||||
}
|
||||
|
||||
func CompareTags(defaultTag string, left string, right string) int {
|
||||
if left == right {
|
||||
return 0
|
||||
}
|
||||
if left == defaultTag {
|
||||
return -1
|
||||
}
|
||||
if right == defaultTag {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(left, right)
|
||||
}
|
||||
@@ -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", ¤t).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."},
|
||||
|
||||
Reference in new issue
Block a user