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:
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) {
|
||||
|
||||
Reference in new issue
Block a user