feat: editor write path
This commit is contained in:
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", ¤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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user