refac: article reads, story delete, no leaks, stored SEO status, rate limit, cleanup (%site%, article repo split, old lang repos, one default per site rule to domain)

This commit is contained in:
Florian Sylvain committed 2026-11-03 16:01:52 +01:00
1 parent 9d284e65a9
commit 7c15206170
85 files changed
+1788 -593

No files matched your search

@@ -58,6 +58,8 @@ func ArticleToDomain(m models.Article) article.Article {
EditRevision: m.EditRevision,
SourceArticleID: m.SourceArticleID,
SourceRevision: m.SourceRevision,
SEO: article.SEOSummary{Status: m.SEOStatus, Suggestions: m.SEOSuggestions},
SEOVersion: m.SEOVersion,
}
}
@@ -1,16 +0,0 @@
package mappers
import (
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/models"
)
func SiteLanguageToDomain(m models.SiteLanguage) language.SiteLanguage {
return language.SiteLanguage{
SiteID: m.SiteID,
Tag: m.Lang,
IsDefault: m.IsDefault,
Enabled: m.Enabled,
Confirmed: m.Confirmed,
}
}
@@ -35,4 +35,7 @@ type Article struct {
SourceArticleID *uint32
SourceRevision uint32 `gorm:"not null;default:0"`
RetiredID uint32 `gorm:"column:retired_id;not null;default:0"`
SEOStatus string `gorm:"column:seo_status;size:16;not null;default:'';index"`
SEOSuggestions int `gorm:"column:seo_suggestions;not null;default:0"`
SEOVersion uint32 `gorm:"column:seo_version;not null;default:0"`
}
@@ -61,8 +61,11 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
case domain.StatusDraft:
query = query.Where("is_online = ?", false)
}
if filters.Lang != nil {
query = query.Where("lang = ?", *filters.Lang)
if filters.Lang != "" {
query = query.Where("lang = ?", filters.Lang)
}
if filters.SEOStatus != "" {
query = query.Where("seo_status = ?", filters.SEOStatus)
}
if filters.Langs != nil {
query = restrictToLangs(query, *filters.Langs)
@@ -119,7 +122,8 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
var entityArticles []entity.Article
ordered := a.scoped(filters).Order(orderClause(filters.Sort))
if !filters.Unpaged {
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
offset, limit := filters.Window()
ordered = ordered.Limit(limit).Offset(offset)
}
err := ordered.Find(&entityArticles).Error
if err != nil {
@@ -146,9 +150,9 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
}
pageIds := matchingIds
if !filters.Unpaged {
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds = matchingIds[start:end]
offset, limit := filters.Window()
start := min(offset, len(matchingIds))
pageIds = matchingIds[start : start+min(limit, len(matchingIds)-start)]
}
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
if len(pageIds) == 0 {
@@ -220,6 +224,13 @@ func (a *ArticleRepository) CountVersions(filters domain.Filters) (domain.Counts
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
}
func (a *ArticleRepository) CountSuggestions(filters domain.Filters) (int, error) {
visible := domain.Filters{IsOnline: filters.IsOnline, VisibleTo: filters.VisibleTo, SEOStatus: domain.SEOStatusSuggestions}
var total int64
err := a.scoped(visible).Count(&total).Error
return int(total), err
}
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles {
@@ -463,7 +474,7 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
}
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
return a.updateColumns(id, map[string]any{"body": body})
return a.updateColumns(id, withStaleSEO(map[string]any{"body": body}))
}
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
@@ -603,6 +614,8 @@ func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision u
"edit_revision": written.EditRevision,
"content_revision": written.ContentRevision,
"updated_at": written.UpdatedAt,
seoStatusColumn: "",
seoVersionColumn: gorm.Expr(seoVersionColumn + " + 1"),
})
if result.Error != nil {
return result.Error
@@ -668,20 +681,42 @@ func (a *ArticleRepository) Delete(id uint32) error {
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
}
if err := tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
if err := retireVersion(tx, id); err != nil {
return err
}
return closeEmptyStory(tx, found.StoryID)
})
}
func (a *ArticleRepository) DeleteStory(storyID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
var ids []uint32
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
return err
}
if len(ids) == 0 {
return domain.ErrArticleNotFound
}
for _, id := range ids {
if err := retireVersion(tx, id); err != nil {
return err
}
}
return deleteOrNotFound(tx.Delete(&entity.Story{}, storyID), domain.ErrArticleNotFound)
})
}
func retireVersion(tx *gorm.DB, id uint32) error {
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
}
func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
if storyID == 0 {
return nil
@@ -709,7 +744,7 @@ func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
if err := tx.First(&moving, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")}
moved := withStaleSEO(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
}
@@ -763,7 +798,7 @@ func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32)
if len(*cleared) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumns(withStaleSEO(map[string]any{column: nil})).Error
}
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
@@ -779,7 +814,7 @@ func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint3
if len(articleIDs) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumns(withStaleSEO(map[string]any{column: imageID})).Error
}
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
@@ -791,10 +826,10 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
return err
}
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil {
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumns(withStaleSEO(map[string]any{"cover_image_id": toImageID})).Error; err != nil {
return err
}
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumns(withStaleSEO(map[string]any{"social_image_id": toImageID})).Error
})
if err != nil {
return nil, err
@@ -805,7 +840,7 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumn("lang", lang)
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumns(withStaleSEO(map[string]any{"lang": lang}))
return int(result.RowsAffected), result.Error
}
@@ -0,0 +1,47 @@
package persistence
import (
domain "RenewCMS/internal/domain/article"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
const (
seoStatusColumn = "seo_status"
seoSuggestionsColumn = "seo_suggestions"
seoVersionColumn = "seo_version"
)
func withStaleSEO(columns map[string]any) map[string]any {
columns[seoStatusColumn] = ""
columns[seoVersionColumn] = gorm.Expr(seoVersionColumn + " + 1")
return columns
}
func (a *ArticleRepository) StaleSEO(afterID uint32, limit int) ([]domain.Article, error) {
var found []entity.Article
err := a.db.Model(&entity.Article{}).Where(seoStatusColumn+" = ? AND id > ?", "", afterID).Order("id ASC").Limit(limit).Find(&found).Error
if err != nil {
return nil, err
}
return a.withAuthors(a.db, toDomainArticles(found))
}
func (a *ArticleRepository) StoreSEO(records []domain.SEORecord) error {
return a.db.Transaction(func(tx *gorm.DB) error {
for _, record := range records {
stored := map[string]any{seoStatusColumn: record.Summary.Status, seoSuggestionsColumn: record.Summary.Suggestions}
if err := tx.Model(&entity.Article{}).Where("id = ? AND "+seoVersionColumn+" = ?", record.ArticleID, record.Version).UpdateColumns(stored).Error; err != nil {
return err
}
}
return nil
})
}
func (a *ArticleRepository) MarkSiteSEOStale(siteID uint32) error {
return a.db.Model(&entity.Article{}).Where("site_id = ?", siteID).UpdateColumns(withStaleSEO(map[string]any{})).Error
}
var _ domain.SEOIndex = &ArticleRepository{}
@@ -2,6 +2,7 @@ package persistence
import (
domain "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/language"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
)
@@ -127,16 +128,26 @@ func qualifyingVersions(filters domain.StoryFilters) storySQL {
case domain.StatusDraft:
condition.add("q.is_online = ?", false)
}
if filters.SEOStatus != "" {
condition.add("q.seo_status = ?", filters.SEOStatus)
}
if filters.AuthorID != nil {
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
}
if filters.Missing {
condition.merge(lacksReadableVersionIn("?", filters, ""))
condition.merge(holdsReadableLanguage(filters))
condition.merge(missingCondition(filters))
}
return condition
}
func holdsReadableLanguage(filters domain.StoryFilters) storySQL {
readable := readableCondition("l", filters)
condition := storySQL{}
condition.add("EXISTS (SELECT 1 FROM articles l WHERE l.story_id = q.story_id AND "+readable.where()+" AND l.lang <> ?)", append(readable.args, language.Unassigned)...)
return condition
}
func missingCondition(filters domain.StoryFilters) storySQL {
condition := storySQL{}
if filters.Lang != "" {
@@ -310,6 +321,31 @@ func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.St
return counts, err
}
func (a *ArticleRepository) CountSuggestedStories(readable domain.StoryFilters) (int, error) {
condition := readableCondition("q", readable)
condition.add("q.seo_status = ?", domain.SEOStatusSuggestions)
var stories, standalone int64
if err := a.db.Raw("SELECT COUNT(DISTINCT q.story_id) FROM articles q WHERE "+condition.where()+" AND q.story_id <> ?", append(condition.args, 0)...).Scan(&stories).Error; err != nil {
return 0, err
}
err := a.db.Raw("SELECT COUNT(*) FROM articles q WHERE "+condition.where()+" AND q.story_id = ?", append(condition.args, 0)...).Scan(&standalone).Error
return int(stories + standalone), err
}
func (a *ArticleRepository) ListVersions(ids []uint32) ([]domain.Article, error) {
var found []entity.Article
for _, chunk := range chunkIDs(ids) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
}
return a.withAuthors(a.db, toDomainArticles(found))
}
var _ domain.StoryReader = &ArticleRepository{}
func (a *ArticleRepository) ListByStories(storyIDs []uint32, filters domain.StoryFilters) ([]domain.Article, error) {
var found []entity.Article
for _, chunk := range chunkIDs(storyIDs) {
@@ -282,6 +282,10 @@ func hasCredits(tx *gorm.DB, id uint32) (bool, error) {
}
func removeAuthor(tx *gorm.DB, id uint32) error {
credited := tx.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", id)
if err := tx.Model(&entity.Article{}).Where("id IN (?)", credited).UpdateColumns(withStaleSEO(map[string]any{})).Error; err != nil {
return err
}
if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
@@ -105,9 +105,6 @@ func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error
}
}
}
if err := l.clearOtherDefaults(siteID, rows); err != nil {
return err
}
for _, row := range rows {
if err := l.save(siteID, row, known); err != nil {
return err
@@ -116,15 +113,6 @@ func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error
return nil
}
func (l *LocalizationRepository) clearOtherDefaults(siteID uint32, rows []domain.Row) error {
for _, row := range rows {
if row.IsDefault {
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND is_default = ? AND lang <> ?", siteID, true, row.Tag).UpdateColumn("is_default", false).Error
}
}
return nil
}
func (l *LocalizationRepository) save(siteID uint32, row domain.Row, known map[string]domain.Row) error {
before, exists := known[row.Tag]
if !exists {
@@ -1,42 +0,0 @@
package persistence
import (
domain "RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
type SiteLanguageRepository struct {
db *gorm.DB
}
func NewSiteLanguageRepository(db *gorm.DB) *SiteLanguageRepository {
return &SiteLanguageRepository{db}
}
func (s *SiteLanguageRepository) ListBySite(siteID uint32) ([]domain.SiteLanguage, error) {
var found []entity.SiteLanguage
if err := s.db.Where("site_id = ?", siteID).Order("is_default DESC, lang ASC").Find(&found).Error; err != nil {
return nil, err
}
languages := make([]domain.SiteLanguage, 0, len(found))
for _, row := range found {
languages = append(languages, mappers.SiteLanguageToDomain(row))
}
return languages, nil
}
func (s *SiteLanguageRepository) LockSiteLanguages(siteID uint32) ([]domain.SiteLanguage, error) {
if _, err := lockSite(s.db, siteID); err != nil {
return nil, err
}
return s.ListBySite(siteID)
}
func (s *SiteLanguageRepository) MarkConfirmed(siteID uint32, tag string) error {
return s.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", siteID, tag).UpdateColumn("confirmed", true).Error
}
var _ domain.Repository = &SiteLanguageRepository{}
@@ -258,7 +258,7 @@ func moveArticles(tx *gorm.DB, from uint32, to uint32) error {
if _, err := lockSite(tx, to); err != nil {
return err
}
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumn("site_id", to).Error
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumns(withStaleSEO(map[string]any{"site_id": to})).Error
}
func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) {
@@ -21,14 +21,15 @@ func NewTransactor(db *gorm.DB) *Transactor {
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
err := t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
articles := NewArticleRepository(tx)
return work(domain.Repositories{
Articles: NewArticleRepository(tx),
Articles: articles,
Sites: NewSiteRepository(tx),
Authors: NewAuthorRepository(tx),
Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx),
Languages: NewSiteLanguageRepository(tx),
Localization: NewLocalizationRepository(tx),
ArticleSEO: articles,
})
})
if database.IsLockContention(t.db, err) {