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) {
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/site"
)
@@ -12,6 +13,7 @@ type CheckArticleSEOUseCase struct {
articleRepository article.Repository
sites site.Repository
images image.Repository
localization localization.Repository
}
type CheckArticleSEOCommand struct {
@@ -30,8 +32,8 @@ type CheckArticleSEOCommand struct {
SEODismissed *[]string
}
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *CheckArticleSEOUseCase {
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository, localizationRepository localization.Repository) *CheckArticleSEOUseCase {
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images, localization: localizationRepository}
}
func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command CheckArticleSEOCommand) (article.SEOCheck, error) {
@@ -57,7 +59,11 @@ func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command
if err != nil {
return article.SEOCheck{}, err
}
assessment := assessSEO(candidate, owner)
rows, err := u.localization.Rows(saved.SiteID)
if err != nil {
return article.SEOCheck{}, err
}
assessment := assessSEO(candidate, owner, localization.SiteTextAllowed(localization.ConfirmedDefaultTag(rows), candidate.Lang))
return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil
}
@@ -1,36 +0,0 @@
package useCases
import (
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/transaction"
"context"
)
type ConfirmDefaultLanguageUseCase struct {
transactor transaction.Transactor
}
func NewConfirmDefaultLanguageUseCase(transactor transaction.Transactor) *ConfirmDefaultLanguageUseCase {
return &ConfirmDefaultLanguageUseCase{transactor}
}
func (u *ConfirmDefaultLanguageUseCase) Confirm(ctx context.Context, siteID uint32) error {
return u.transactor.Run(ctx, func(tx transaction.Repositories) error {
languages, err := tx.Languages.LockSiteLanguages(siteID)
if err != nil {
return err
}
defaultLanguage, found := language.DefaultOf(languages)
if !found {
return language.ErrNoDefaultLanguage
}
if defaultLanguage.Confirmed {
return nil
}
if err := tx.Languages.MarkConfirmed(siteID, defaultLanguage.Tag); err != nil {
return err
}
_, err = tx.Articles.AssignUnassignedLanguage(siteID, defaultLanguage.Tag)
return err
})
}
@@ -124,21 +124,20 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, requested
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 {
languages, err := tx.Languages.LockSiteLanguages(draft.SiteID)
err := tx.Sites.LockSite(draft.SiteID)
if errors.Is(err, site.ErrSiteNotFound) {
return article.ErrTargetSiteDeleted
}
if err != nil {
return err
}
lang := language.StartingTag(languages)
if requested != language.Unassigned {
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
if err != nil {
return err
}
rows, err := tx.Localization.Rows(draft.SiteID)
if err != nil {
return err
}
if draft.Lang, err = chooseLanguage(rows, requested); 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 {
return err
@@ -149,10 +148,9 @@ func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requeste
return created, err
}
func chooseLanguage(repository localization.Repository, siteID uint32, requested string) (string, error) {
rows, err := repository.Rows(siteID)
if err != nil {
return "", err
func chooseLanguage(rows []localization.Row, requested string) (string, error) {
if requested == language.Unassigned {
return localization.ConfirmedDefaultTag(rows), nil
}
if localization.NeedsSetup(rows) {
return "", localization.ErrSetupRequired
@@ -0,0 +1,92 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
)
type DeleteStoryUseCase struct {
transactor transaction.Transactor
articles article.Repository
recorder audit.Recorder
}
type DeleteStoryCommand struct {
StoryID uint32
Caller ContentCaller
}
func NewDeleteStoryUseCase(transactor transaction.Transactor, articles article.Repository, recorder audit.Recorder) *DeleteStoryUseCase {
return &DeleteStoryUseCase{transactor: transactor, articles: articles, recorder: recorder}
}
func (u *DeleteStoryUseCase) DeleteStory(ctx context.Context, command DeleteStoryCommand) error {
if err := requireDeleteOnEveryVersion(command.Caller); err != nil {
return err
}
var deleted []article.Article
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
versions, err := lockStoryVersions(tx, command.StoryID, command.Caller)
if err != nil {
return err
}
deleted = sortByCreation(versions)
return tx.Articles.DeleteStory(command.StoryID)
})
if err != nil {
u.recordRefusal(ctx, command, err)
return err
}
for _, version := range deleted {
event := articleEvent(audit.ActionArticleDelete, version)
event.Params[audit.ParamStoryID] = command.StoryID
u.recorder.Record(ctx, event)
}
return nil
}
func requireDeleteOnEveryVersion(caller ContentCaller) error {
if caller.Grants.ScopeFor(role.KeyContentDelete, builtintype.KeyArticle) != role.ScopeAll {
return role.MissingPermissionError{Key: role.KeyContentDelete}
}
return nil
}
func lockStoryVersions(tx transaction.Repositories, storyID uint32, caller ContentCaller) ([]article.Article, error) {
locked := []uint32{}
for attempt := 0; attempt < maxSiteLockRounds; attempt++ {
versions, err := tx.Articles.ListByStory(storyID)
if err != nil {
return nil, err
}
if len(caller.readableVersions(versions)) == 0 {
return nil, article.ErrArticleNotFound
}
needed := siteIDsOf(versions)
if allLocked(locked, needed) {
return versions, nil
}
if err := lockSites(tx, needed); err != nil {
return nil, err
}
locked = needed
}
return nil, transaction.ErrLockContention
}
func (u *DeleteStoryUseCase) recordRefusal(ctx context.Context, command DeleteStoryCommand, 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.ActionArticleDelete, readable[0]), cause)
}
@@ -2,9 +2,12 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"errors"
"slices"
)
type DescribeArticleUseCase struct {
@@ -13,9 +16,13 @@ type DescribeArticleUseCase struct {
}
type ArticleContext struct {
Capabilities []string
Translations []article.Translation
Source *article.Source
Capabilities []string
Translations []article.Translation
Source *article.Source
SourceUnavailable bool
MissingLangs []string
ExcludedFromPublic bool
PartialView bool
}
func NewDescribeArticleUseCase(articles article.Repository, localizationRepository localization.Repository) *DescribeArticleUseCase {
@@ -23,73 +30,75 @@ func NewDescribeArticleUseCase(articles article.Repository, localizationReposito
}
func (d *DescribeArticleUseCase) Describe(art article.Article, caller ContentCaller) (ArticleContext, error) {
described := ArticleContext{Capabilities: caller.capabilitiesFor(art), Translations: []article.Translation{}}
rows, err := d.localization.Rows(art.SiteID)
if err != nil {
return ArticleContext{}, err
}
described := ArticleContext{
Capabilities: caller.capabilitiesFor(art),
Translations: []article.Translation{},
MissingLangs: []string{},
ExcludedFromPublic: !localization.PublicTag(rows, art.Lang),
PartialView: caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) != role.ScopeAll,
}
if caller.isAnonymous() {
return described, nil
}
translations, err := d.translations(art, caller)
readable, err := d.readableVersions(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
described.Translations = translationsOf(caller, sortByLanguage(readable, rows), rows)
described.MissingLangs = missingLanguages(readable, rows, caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle))
described.Source, described.SourceUnavailable, err = d.source(art, caller)
return described, err
}
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) {
func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, bool, error) {
if art.SourceArticleID == nil {
return nil, nil
return nil, false, nil
}
origin, err := d.articles.Get(*art.SourceArticleID)
if errors.Is(err, article.ErrArticleNotFound) {
return nil, nil
if errors.Is(err, article.ErrArticleNotFound) || err == nil && !caller.seesArticle(origin) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, false, nil
}
func (d *DescribeArticleUseCase) readableVersions(art article.Article, caller ContentCaller) ([]article.Article, error) {
if art.StoryID == 0 {
return []article.Article{art}, nil
}
versions, err := d.articles.ListByStory(art.StoryID)
if err != nil {
return nil, err
}
if !caller.seesArticle(origin) {
return nil, nil
return caller.readableVersions(versions), nil
}
func translationsOf(caller ContentCaller, ordered []article.Article, rows []localization.Row) []article.Translation {
translations := make([]article.Translation, 0, len(ordered))
for _, version := range ordered {
translations = append(translations, article.Translation{
ID: version.ID,
Lang: version.Lang,
Title: version.Title,
IsOnline: version.IsOnline,
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(rows, version.Lang),
ExcludedFromPublic: !localization.PublicTag(rows, version.Lang),
SourceArticleID: trackedSourceID(version, ordered),
Capabilities: caller.capabilitiesFor(version),
})
}
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, nil
return translations
}
func trackedSourceID(version article.Article, readable []article.Article) *uint32 {
if version.SourceArticleID == nil || !slices.ContainsFunc(readable, func(candidate article.Article) bool { return candidate.ID == *version.SourceArticleID }) {
return nil
}
return version.SourceArticleID
}
@@ -28,13 +28,14 @@ type ListStoriesCommand struct {
}
type StoryRow struct {
Article article.Article
SEO article.SEOSummary
UpdatedAt time.Time
Authors []author.Byline
Coverage []article.Translation
MissingLangs []string
Capabilities []string
Article article.Article
SEO article.SEOSummary
UpdatedAt time.Time
Authors []author.Byline
Coverage []article.Translation
MissingLangs []string
ExcludedFromPublic bool
Capabilities []string
}
type StoryRowsPage struct {
@@ -46,11 +47,13 @@ type StoryRowsPage struct {
}
func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) {
if err := u.seo.RefreshStale(); err != nil {
return StoryRowsPage{}, err
}
visibility := visibilityFor(command.Caller)
creatable := command.Caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle)
empty := StoryRowsPage{Rows: []StoryRow{}, Page: command.Page, Size: command.Size}
filters := storyFilters(command, visibility, command.Lang == "" && !creatable)
owners := &siteOwners{repository: u.sites}
if command.SiteKey != nil {
siteID, known, err := u.siteIDOf(*command.SiteKey)
if err != nil {
@@ -61,18 +64,17 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
}
filters.SiteID = &siteID
}
summaries, visibleVersions, err := u.summarizeVisibleVersions(visibility, owners)
if err != nil {
return StoryRowsPage{}, err
}
if filters.VersionIDs, err = u.versionRestriction(command, filters, summaries); err != nil {
var err error
if filters.VersionIDs, err = u.titleRestriction(command, filters); err != nil {
return StoryRowsPage{}, err
}
counts, err := u.articleRepository.CountStories(filters)
if err != nil {
return StoryRowsPage{}, err
}
counts.Suggestions = countSuggestedStories(visibleVersions, summaries)
if counts.Suggestions, err = u.articleRepository.CountSuggestedStories(readableOnly(filters)); err != nil {
return StoryRowsPage{}, err
}
empty.Counts = counts
if command.Tab == article.TabMissing && command.Lang == "" && !creatable {
return empty, nil
@@ -85,7 +87,7 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
if len(found.Refs) == 0 {
return empty, nil
}
rows, err := u.storyRows(command, filters, found.Refs, creatable, storyVersionsByID(visibleVersions), summaries)
rows, err := u.storyRows(command, filters, found.Refs, creatable)
if err != nil {
return StoryRowsPage{}, err
}
@@ -93,6 +95,10 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
return empty, nil
}
func readableOnly(filters article.StoryFilters) article.StoryFilters {
return article.StoryFilters{OnlyOnline: filters.OnlyOnline, VisibleTo: filters.VisibleTo}
}
func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters {
return article.StoryFilters{
SkipMissing: skipMissing,
@@ -101,6 +107,7 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
Lang: command.Lang,
Missing: command.Tab == article.TabMissing,
Status: command.Status,
SEOStatus: command.SEO,
AuthorID: command.Author,
Sort: command.Sort,
Page: command.Page,
@@ -108,75 +115,34 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
}
}
func (u *ListArticlesUseCase) summarizeVisibleVersions(visibility articleVisibility, owners *siteOwners) (map[uint32]article.SEOSummary, []article.Article, error) {
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
func (u *ListArticlesUseCase) titleRestriction(command ListStoriesCommand, filters article.StoryFilters) (*[]uint32, error) {
query := strings.TrimSpace(command.Q)
if query == "" {
return nil, nil
}
matching, err := u.articleRepository.FindTitleMatches(query, filters)
if err != nil {
return nil, nil, err
return nil, err
}
summaries, err := u.summarize(found.Items, owners)
return summaries, found.Items, err
return &matching, nil
}
func (u *ListArticlesUseCase) versionRestriction(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) (*[]uint32, error) {
var allowed *[]uint32
if query := strings.TrimSpace(command.Q); query != "" {
matching, err := u.articleRepository.FindTitleMatches(query, filters)
if err != nil {
return nil, err
}
allowed = &matching
}
if command.SEO == "" {
return allowed, nil
}
matchingTitle := map[uint32]bool{}
if allowed != nil {
for _, id := range *allowed {
matchingTitle[id] = true
}
}
withStatus := []uint32{}
for id, summary := range summaries {
if summary.Status == command.SEO && (allowed == nil || matchingTitle[id]) {
withStatus = append(withStatus, id)
}
}
return &withStatus, nil
}
func storyVersionsByID(versions []article.Article) map[uint32]article.Article {
byID := make(map[uint32]article.Article, len(versions))
for _, version := range versions {
byID[version.ID] = version
}
return byID
}
func countSuggestedStories(versions []article.Article, summaries map[uint32]article.SEOSummary) int {
stories := map[uint32]bool{}
standalone := 0
for _, version := range versions {
if summaries[version.ID].Status != article.SEOStatusSuggestions {
continue
}
if version.StoryID == 0 {
standalone++
continue
}
stories[version.StoryID] = true
}
return len(stories) + standalone
}
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool, visible map[uint32]article.Article, summaries map[uint32]article.SEOSummary) ([]StoryRow, error) {
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool) ([]StoryRow, error) {
storyIDs := make([]uint32, 0, len(refs))
representativeIDs := make([]uint32, 0, len(refs))
for _, ref := range refs {
storyIDs = append(storyIDs, ref.StoryID)
representativeIDs = append(representativeIDs, ref.ArticleID)
}
loaded, err := u.articleRepository.ListByStories(storyIDs, filters)
if err != nil {
return nil, err
}
withBodies, err := u.articleRepository.ListVersions(representativeIDs)
if err != nil {
return nil, err
}
visible := storyVersionsByID(withBodies)
byStory := map[uint32][]article.Article{}
for _, version := range command.Caller.readableVersions(loaded) {
byStory[version.StoryID] = append(byStory[version.StoryID], version)
@@ -188,7 +154,7 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters arti
versions := byStory[ref.StoryID]
_, listed := versionByID(versions, ref.ArticleID)
representative, found := visible[ref.ArticleID]
if !listed || !found {
if !listed || !found || !command.Caller.seesArticle(representative) {
continue
}
languages, cached := siteRows[representative.SiteID]
@@ -207,11 +173,19 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters arti
}
for index := range rows {
rows[index].Article = rendered[index]
rows[index].SEO = summaries[rendered[index].ID]
rows[index].SEO = representatives[index].SEO
}
return rows, nil
}
func storyVersionsByID(versions []article.Article) map[uint32]article.Article {
byID := make(map[uint32]article.Article, len(versions))
for _, version := range versions {
byID[version.ID] = version
}
return byID
}
func versionByID(versions []article.Article, id uint32) (article.Article, bool) {
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.ID == id })
if index < 0 {
@@ -222,19 +196,10 @@ func versionByID(versions []article.Article, id uint32) (article.Article, bool)
func buildStoryRow(caller ContentCaller, representative article.Article, versions []article.Article, languages []localization.Row, creatable bool) StoryRow {
ordered := sortByLanguage(versions, languages)
coverage := make([]article.Translation, 0, len(ordered))
updatedAt := time.Time{}
authors := []author.Byline{}
seen := map[uint32]bool{}
for _, version := range ordered {
coverage = append(coverage, article.Translation{
ID: version.ID,
Lang: version.Lang,
Title: version.Title,
IsOnline: version.IsOnline,
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(languages, version.Lang),
Capabilities: caller.capabilitiesFor(version),
})
if version.UpdatedAt.After(updatedAt) {
updatedAt = version.UpdatedAt
}
@@ -246,17 +211,18 @@ func buildStoryRow(caller ContentCaller, representative article.Article, version
}
}
return StoryRow{
UpdatedAt: updatedAt,
Authors: authors,
Coverage: coverage,
MissingLangs: missingLanguages(ordered, languages, creatable),
Capabilities: caller.capabilitiesFor(representative),
UpdatedAt: updatedAt,
Authors: authors,
Coverage: translationsOf(caller, ordered, languages),
MissingLangs: missingLanguages(ordered, languages, creatable),
ExcludedFromPublic: !localization.PublicTag(languages, representative.Lang),
Capabilities: caller.capabilitiesFor(representative),
}
}
func missingLanguages(versions []article.Article, languages []localization.Row, creatable bool) []string {
missing := []string{}
if !creatable || slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang == language.Unassigned }) {
if !creatable || !slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang != language.Unassigned }) {
return missing
}
present := languagesOf(versions)
@@ -10,14 +10,21 @@ import (
)
type ListArticlesUseCase struct {
articleRepository article.Repository
articleRepository article.ListingRepository
bodyRenderer article.BodyRenderer
sites site.Repository
localization localization.Repository
seo *RefreshArticleSEOUseCase
}
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites, localization: localizationRepository}
func NewListArticlesUseCase(articleRepository article.ListingRepository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
return &ListArticlesUseCase{
articleRepository: articleRepository,
bodyRenderer: bodyRenderer,
sites: sites,
localization: localizationRepository,
seo: NewRefreshArticleSEOUseCase(articleRepository, sites, localizationRepository),
}
}
type ListArticlesCommand struct {
@@ -30,7 +37,7 @@ type ListArticlesCommand struct {
Sort article.Sort
SiteKey *string
SEO string
Lang *string
Lang string
Author *uint32
}
@@ -73,7 +80,7 @@ func (v articleVisibility) apply(filters article.Filters) article.Filters {
}
func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
if command.Lang != nil {
if command.Lang != "" {
return u.countVisibleVersions(command, visibility)
}
return u.countVisible(visibility)
@@ -87,18 +94,18 @@ func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (articl
}
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
onlineOnly := visibility.onlineOnly
if err := u.seo.RefreshStale(); err != nil {
return ArticlePage{}, err
}
counts, err := u.countFlat(command, visibility)
if err != nil {
return ArticlePage{}, err
}
summaries, err := u.summarizeVisible(visibility)
if err != nil {
if counts.Suggestions, err = u.articleRepository.CountSuggestions(visibility.apply(article.Filters{})); err != nil {
return ArticlePage{}, err
}
counts.Suggestions = countSuggestions(summaries)
page := emptyArticlePage(command, counts)
if onlineOnly && command.Status == article.StatusDraft {
if visibility.onlineOnly && command.Status == article.StatusDraft {
return page, nil
}
filters := visibility.apply(pageFilters(command))
@@ -112,39 +119,32 @@ func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesComman
}
filters.SiteID = &siteID
}
filters.Unpaged = command.SEO != ""
found, err := u.articleRepository.FindPage(filters)
if err != nil {
return ArticlePage{}, err
}
items, total := found.Items, found.Total
if command.SEO != "" {
items, total = pageBySEOStatus(found.Items, summaries, command)
}
rendered, err := renderArticles(u.bodyRenderer, items, command.Format)
rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
if err != nil {
return ArticlePage{}, err
}
page.Items = rendered
page.Total = total
page.SEO = summaries
page.Total = found.Total
page.SEO = storedSummaries(found.Items)
return page, nil
}
func storedSummaries(items []article.Article) map[uint32]article.SEOSummary {
summaries := make(map[uint32]article.SEOSummary, len(items))
for _, item := range items {
summaries[item.ID] = item.SEO
}
return summaries
}
func emptyArticlePage(command ListArticlesCommand, counts article.Counts) ArticlePage {
return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
}
func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article {
matching := make([]article.Article, 0, len(items))
for _, item := range items {
if summaries[item.ID].Status == status {
matching = append(matching, item)
}
}
return matching
}
func pageSlice[T any](items []T, page int, size int) []T {
size = min(max(size, 0), len(items))
if size == 0 {
@@ -159,69 +159,11 @@ func pageSlice[T any](items []T, page int, size int) []T {
return items[start:min(start+size, len(items))]
}
func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (map[uint32]article.SEOSummary, error) {
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
if err != nil {
return nil, err
}
return u.summarize(found.Items, &siteOwners{repository: u.sites})
}
func (u *ListArticlesUseCase) countVisibleVersions(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
filters := visibility.apply(article.Filters{Lang: command.Lang})
return u.articleRepository.CountVersions(filters)
}
func countSuggestions(summaries map[uint32]article.SEOSummary) int {
suggestions := 0
for _, summary := range summaries {
if summary.Status == article.SEOStatusSuggestions {
suggestions++
}
}
return suggestions
}
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
matching := withSEOStatus(items, summaries, command.SEO)
return pageSlice(matching, command.Page, command.Size), len(matching)
}
type siteOwners struct {
repository site.Repository
byID map[uint32]site.Site
}
func (o *siteOwners) get() (map[uint32]site.Site, error) {
if o.byID != nil {
return o.byID, nil
}
known, err := o.repository.List()
if err != nil {
return nil, err
}
o.byID = make(map[uint32]site.Site, len(known))
for _, owner := range known {
o.byID[owner.ID] = owner
}
return o.byID, nil
}
func (u *ListArticlesUseCase) summarize(items []article.Article, lookup *siteOwners) (map[uint32]article.SEOSummary, error) {
summaries := make(map[uint32]article.SEOSummary, len(items))
if len(items) == 0 {
return summaries, nil
}
owners, err := lookup.get()
if err != nil {
return nil, err
}
for _, item := range items {
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
}
return summaries, nil
}
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
found, err := u.sites.GetByKey(key)
if errors.Is(err, site.ErrSiteNotFound) {
@@ -234,5 +176,5 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
}
func pageFilters(command ListArticlesCommand) article.Filters {
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, SEOStatus: command.SEO, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
}
@@ -0,0 +1,90 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"errors"
)
const seoRefreshBatchSize = 500
type RefreshArticleSEOUseCase struct {
index article.SEOIndex
sites site.Repository
localization localization.Repository
}
func NewRefreshArticleSEOUseCase(index article.SEOIndex, sites site.Repository, localizationRepository localization.Repository) *RefreshArticleSEOUseCase {
return &RefreshArticleSEOUseCase{index: index, sites: sites, localization: localizationRepository}
}
func (u *RefreshArticleSEOUseCase) RefreshStale() error {
afterID := uint32(0)
for {
stale, err := u.index.StaleSEO(afterID, seoRefreshBatchSize)
if err != nil || len(stale) == 0 {
return err
}
context := u.siteContextReadAfter()
records := make([]article.SEORecord, 0, len(stale))
for _, version := range stale {
summary, err := context.assess(version)
if err != nil {
return err
}
records = append(records, article.SEORecord{ArticleID: version.ID, Version: version.SEOVersion, Summary: summary})
}
if err := u.index.StoreSEO(records); err != nil {
return err
}
afterID = stale[len(stale)-1].ID
}
}
func (u *RefreshArticleSEOUseCase) siteContextReadAfter() *seoSiteContext {
return &seoSiteContext{sites: u.sites, localization: u.localization, owners: map[uint32]site.Site{}, defaults: map[uint32]string{}}
}
type seoSiteContext struct {
sites site.Repository
localization localization.Repository
owners map[uint32]site.Site
defaults map[uint32]string
}
func (c *seoSiteContext) assess(version article.Article) (article.SEOSummary, error) {
owner, err := c.owner(version.SiteID)
if err != nil {
return article.SEOSummary{}, err
}
defaultTag, err := c.defaultTag(version.SiteID)
if err != nil {
return article.SEOSummary{}, err
}
return assessSEO(version, owner, localization.SiteTextAllowed(defaultTag, version.Lang)).Summary(), nil
}
func (c *seoSiteContext) owner(siteID uint32) (site.Site, error) {
if owner, cached := c.owners[siteID]; cached {
return owner, nil
}
owner, err := c.sites.Get(siteID)
if err != nil && !errors.Is(err, site.ErrSiteNotFound) {
return site.Site{}, err
}
c.owners[siteID] = owner
return owner, nil
}
func (c *seoSiteContext) defaultTag(siteID uint32) (string, error) {
if tag, cached := c.defaults[siteID]; cached {
return tag, nil
}
rows, err := c.localization.Rows(siteID)
if err != nil {
return "", err
}
c.defaults[siteID] = localization.ConfirmedDefaultTag(rows)
return c.defaults[siteID], nil
}
@@ -20,7 +20,6 @@ const (
jsonLDArticle = "Article"
jsonLDPerson = "Person"
jsonLDWebPage = "WebPage"
sitePlaceholder = "%site%"
)
type ResolveArticleSEOUseCase struct {
@@ -69,7 +68,7 @@ func (u *ResolveArticleSEOUseCase) languageOf(art article.Article, owner site.Si
localeAlternates = []string{}
}
return seoLanguage{
defaultTag: confirmedDefault(rows),
defaultTag: localization.ConfirmedDefaultTag(rows),
locale: locale,
alternates: alternatesOf(owner, siblings),
localeAlternates: localeAlternates,
@@ -129,7 +128,7 @@ func applyTitleTemplate(template string, title string, siteName string) string {
if template == "" {
template = site.DefaultTitleTemplate
}
replacer := strings.NewReplacer(site.TitlePlaceholder, title, sitePlaceholder, siteName)
replacer := strings.NewReplacer(site.TitlePlaceholder, title, site.SitePlaceholder, siteName)
return strings.TrimSpace(replacer.Replace(template))
}
@@ -195,7 +194,7 @@ func buildJSONLD(headline string, description string, picked article.SEOImage, a
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage, spoken seoLanguage) article.ResolvedSEO {
meta, titleSource := metaTitle(art)
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
siteTextAllowed := art.Lang == "" || art.Lang == spoken.defaultTag
siteTextAllowed := localization.SiteTextAllowed(spoken.defaultTag, art.Lang)
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body), siteTextAllowed)
siteTextSkipped := descriptionSource == article.SEOSourceNone && owner.DefaultDescription != ""
canonical, canonicalSource := resolveCanonical(art, owner)
@@ -96,7 +96,7 @@ func (u *ResolvePublicArticleUseCase) ByStory(query PublicStoryQuery) (PublicArt
if unsupported {
return PublicArticle{}, article.ErrLanguageNotAvailable
}
defaultTag := confirmedDefault(rows)
defaultTag := localization.ConfirmedDefaultTag(rows)
if query.Lang == nil {
requested = defaultTag
}
@@ -1,6 +1,7 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
@@ -11,10 +12,11 @@ type UpdateSiteUseCase struct {
siteRepository site.Repository
imageRepository image.Repository
recorder audit.Recorder
articleSEO article.SEOIndex
}
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *UpdateSiteUseCase {
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder}
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder, articleSEO article.SEOIndex) *UpdateSiteUseCase {
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder, articleSEO}
}
func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Patch) (site.Site, error) {
@@ -43,6 +45,9 @@ func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Pa
event.Target = siteTarget(saved)
event.Changes = changes
u.recorder.Record(ctx, event)
if before.DefaultDescription != saved.DefaultDescription {
return saved, u.articleSEO.MarkSiteSEOStale(saved.ID)
}
return saved, nil
}
@@ -65,10 +65,8 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
if err != nil {
return err
}
if !localization.SameRows(before, after) {
if err := tx.Localization.Replace(found.ID, after); err != nil {
return err
}
if err := replaceLanguages(tx, found.ID, before, after); err != nil {
return err
}
view, err = readSiteLanguages(tx.Localization, found)
return err
@@ -87,19 +85,26 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
return view, nil
}
func languageChanges(before []localization.Row, after []localization.Row) []audit.Change {
changes := []audit.Change{}
changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, defaultTag(before), defaultTag(after))
return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after))
func replaceLanguages(tx transaction.Repositories, siteID uint32, before []localization.Row, after []localization.Row) error {
if localization.SameRows(before, after) {
return nil
}
if err := localization.RequireSingleDefault(after); err != nil {
return err
}
if err := tx.Localization.Replace(siteID, after); err != nil {
return err
}
if localization.DefaultTag(before) == localization.DefaultTag(after) {
return nil
}
return tx.ArticleSEO.MarkSiteSEOStale(siteID)
}
func defaultTag(rows []localization.Row) string {
for _, row := range rows {
if row.IsDefault {
return row.Tag
}
}
return ""
func languageChanges(before []localization.Row, after []localization.Row) []audit.Change {
changes := []audit.Change{}
changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, localization.DefaultTag(before), localization.DefaultTag(after))
return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after))
}
func languagesLabel(rows []localization.Row) string {
@@ -7,21 +7,14 @@ import (
"slices"
)
func confirmedDefault(rows []localization.Row) string {
if localization.NeedsSetup(rows) {
return ""
}
return localization.DefaultTag(rows)
}
func eligibleSiblings(rows []localization.Row, versions []article.Article, served article.Article) []article.Article {
defaultTag := confirmedDefault(rows)
defaultTag := localization.ConfirmedDefaultTag(rows)
siblings := []article.Article{}
for _, version := range versions {
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline || version.Lang == "" {
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline {
continue
}
if localization.EnabledTag(rows, version.Lang) {
if localization.PublicTag(rows, version.Lang) {
siblings = append(siblings, version)
}
}
+4 -6
View File
@@ -15,8 +15,6 @@ func metaTitle(art article.Article) (string, string) {
return art.Title, article.SEOSourceTitle
}
const withSiteText = true
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) (string, string) {
if art.SEODescription != "" {
return art.SEODescription, article.SEOSourceSEODescription
@@ -37,7 +35,7 @@ func suggestion(code string, field string, params map[string]int) article.SEOSug
return article.SEOSuggestion{Code: code, Field: field, Params: params}
}
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []article.SEOSuggestion {
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) []article.SEOSuggestion {
suggestions := []article.SEOSuggestion{}
title, _ := metaTitle(art)
titleRunes := utf8.RuneCountInString(title)
@@ -47,7 +45,7 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
if strings.TrimSpace(title) == "" {
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
}
description, _ := resolveDescription(art, owner, facts, withSiteText)
description, _ := resolveDescription(art, owner, facts, siteTextAllowed)
descriptionRunes := utf8.RuneCountInString(description)
if descriptionRunes < article.SEODescriptionMinRunes {
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
@@ -80,10 +78,10 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
return suggestions
}
func assessSEO(art article.Article, owner site.Site) article.SEOAssessment {
func assessSEO(art article.Article, owner site.Site, siteTextAllowed bool) article.SEOAssessment {
facts := analyzeBody(art.Body)
kept := []article.SEOSuggestion{}
for _, found := range rawSuggestions(art, owner, facts) {
for _, found := range rawSuggestions(art, owner, facts, siteTextAllowed) {
if !slices.Contains(art.SEODismissed, found.Code) {
kept = append(kept, found)
}