feat: Authors feature backend (and upgraded deprecated mods)
This commit is contained in:
1 parent
a11338094e
commit
b34a173d83
103 files changed
+6454
-240
No files matched your search
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB, publicURL string) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const emptyLinks = "[]"
|
||||
|
||||
func decodeLinks(raw string) []author.SocialLink {
|
||||
links := []author.SocialLink{}
|
||||
if raw == "" {
|
||||
return links
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &links); err != nil || links == nil {
|
||||
return []author.SocialLink{}
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func encodeLinks(links []author.SocialLink) string {
|
||||
if len(links) == 0 {
|
||||
return emptyLinks
|
||||
}
|
||||
encoded, err := json.Marshal(links)
|
||||
if err != nil {
|
||||
return emptyLinks
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func AuthorToDomain(m models.Author, articleCount int) author.Author {
|
||||
return author.Author{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Slug: m.Slug,
|
||||
Bio: m.Bio,
|
||||
AvatarID: m.AvatarID,
|
||||
Website: m.Website,
|
||||
SocialLinks: decodeLinks(m.SocialLinks),
|
||||
UserID: m.UserID,
|
||||
IsLinked: m.UserID != nil,
|
||||
ArticleCount: articleCount,
|
||||
}
|
||||
}
|
||||
|
||||
func AuthorToModel(a author.Author) models.Author {
|
||||
return models.Author{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Slug: a.Slug,
|
||||
Bio: a.Bio,
|
||||
AvatarID: a.AvatarID,
|
||||
Website: a.Website,
|
||||
SocialLinks: encodeLinks(a.SocialLinks),
|
||||
UserID: a.UserID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Author struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
Slug string `gorm:"size:100;not null;uniqueIndex"`
|
||||
Bio string `gorm:"size:280;not null"`
|
||||
AvatarID *uint32 `gorm:"index"`
|
||||
Website *string `gorm:"size:2048"`
|
||||
SocialLinks string `gorm:"type:text;not null"`
|
||||
UserID *uint32 `gorm:"uniqueIndex"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
type ArticleAuthor struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
ArticleID uint32 `gorm:"not null;uniqueIndex:idx_article_authors_pair"`
|
||||
AuthorID uint32 `gorm:"not null;uniqueIndex:idx_article_authors_pair;index"`
|
||||
Position int `gorm:"not null"`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
authordomain "RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
@@ -89,7 +90,11 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
|
||||
if err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
return domain.PageResult{Items: toDomainArticles(entityArticles), Total: int(total)}, nil
|
||||
items, err := a.withAuthors(a.db, toDomainArticles(entityArticles))
|
||||
if err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
return domain.PageResult{Items: items, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.PageResult, error) {
|
||||
@@ -125,7 +130,11 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
|
||||
ordered = append(ordered, entityArticle)
|
||||
}
|
||||
}
|
||||
page.Items = toDomainArticles(ordered)
|
||||
items, err := a.withAuthors(a.db, toDomainArticles(ordered))
|
||||
if err != nil {
|
||||
return domain.PageResult{}, err
|
||||
}
|
||||
page.Items = items
|
||||
return page, nil
|
||||
}
|
||||
|
||||
@@ -150,6 +159,77 @@ func toDomainArticles(entityArticles []entity.Article) []domain.Article {
|
||||
return domainArticles
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) withAuthors(db *gorm.DB, articles []domain.Article) ([]domain.Article, error) {
|
||||
ids := make([]uint32, 0, len(articles))
|
||||
for _, article := range articles {
|
||||
ids = append(ids, article.ID)
|
||||
}
|
||||
bylines, err := loadBylines(db, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range articles {
|
||||
articles[index].Authors = bylines[articles[index].ID]
|
||||
if articles[index].Authors == nil {
|
||||
articles[index].Authors = []authordomain.Byline{}
|
||||
}
|
||||
}
|
||||
return articles, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article) (domain.Article, error) {
|
||||
loaded, err := a.withAuthors(db, []domain.Article{article})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return loaded[0], nil
|
||||
}
|
||||
|
||||
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
|
||||
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
|
||||
if len(articleIDs) == 0 {
|
||||
return bylines, nil
|
||||
}
|
||||
var credits []entity.ArticleAuthor
|
||||
if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(credits) == 0 {
|
||||
return bylines, nil
|
||||
}
|
||||
authorIDs := make([]uint32, 0, len(credits))
|
||||
for _, credit := range credits {
|
||||
authorIDs = append(authorIDs, credit.AuthorID)
|
||||
}
|
||||
var found []entity.Author
|
||||
if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[uint32]entity.Author, len(found))
|
||||
for _, model := range found {
|
||||
byID[model.ID] = model
|
||||
}
|
||||
for _, credit := range credits {
|
||||
if model, ok := byID[credit.AuthorID]; ok {
|
||||
bylines[credit.ArticleID] = append(bylines[credit.ArticleID], mappers.AuthorToDomain(model, 0).Byline())
|
||||
}
|
||||
}
|
||||
return bylines, nil
|
||||
}
|
||||
|
||||
func replaceCredits(tx *gorm.DB, articleID uint32, authorIDs []uint32) error {
|
||||
if err := tx.Where("article_id = ?", articleID).Delete(&entity.ArticleAuthor{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for position, authorID := range authorIDs {
|
||||
credit := entity.ArticleAuthor{ArticleID: articleID, AuthorID: authorID, Position: position}
|
||||
if err := tx.Create(&credit).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func foldForSearch(value string) string {
|
||||
folder := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
folded, _, err := transform.String(folder, value)
|
||||
@@ -166,7 +246,7 @@ func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
|
||||
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
|
||||
return mappers.ArticleToDomain(article), nil
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
@@ -176,7 +256,7 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
|
||||
return mappers.ArticleToDomain(article), nil
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
@@ -192,12 +272,23 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&created).Error
|
||||
if err := tx.Create(&created).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceCredits(tx, created.ID, bylineIDs(article.Authors))
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return mappers.ArticleToDomain(created), nil
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
|
||||
}
|
||||
|
||||
func bylineIDs(bylines []authordomain.Byline) []uint32 {
|
||||
ids := make([]uint32, 0, len(bylines))
|
||||
for _, byline := range bylines {
|
||||
ids = append(ids, byline.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetAll() []domain.Article {
|
||||
@@ -228,9 +319,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
newArticle := mappers.ArticleToDomain(localArticle)
|
||||
|
||||
return newArticle, nil
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
|
||||
@@ -250,12 +339,18 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
if update.SiteID != nil {
|
||||
localArticle.SiteID = *update.SiteID
|
||||
}
|
||||
return tx.Save(&localArticle).Error
|
||||
if err := tx.Save(&localArticle).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if update.AuthorIDs != nil {
|
||||
return replaceCredits(tx, id, *update.AuthorIDs)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
return mappers.ArticleToDomain(localArticle), nil
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
|
||||
@@ -271,12 +366,16 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
return mappers.ArticleToDomain(localArticle), nil
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Delete(id uint32) error {
|
||||
result := a.db.Delete(&entity.Article{}, id)
|
||||
return deleteOrNotFound(result, domain.ErrArticleNotFound)
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
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 (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AuthorRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAuthorRepository(db *gorm.DB) *AuthorRepository {
|
||||
return &AuthorRepository{db}
|
||||
}
|
||||
|
||||
type creditTotal struct {
|
||||
AuthorID uint32
|
||||
Total int
|
||||
}
|
||||
|
||||
func creditCounts(db *gorm.DB, authorIDs []uint32) (map[uint32]int, error) {
|
||||
counts := make(map[uint32]int, len(authorIDs))
|
||||
if len(authorIDs) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
var totals []creditTotal
|
||||
err := db.Table("article_authors").
|
||||
Select("article_authors.author_id AS author_id, COUNT(*) AS total").
|
||||
Joins("JOIN articles ON articles.id = article_authors.article_id").
|
||||
Where("articles.deleted_at IS NULL").
|
||||
Where("article_authors.author_id IN ?", authorIDs).
|
||||
Group("article_authors.author_id").
|
||||
Scan(&totals).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, total := range totals {
|
||||
counts[total.AuthorID] = total.Total
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) toDomain(db *gorm.DB, models []entity.Author) ([]domain.Author, error) {
|
||||
ids := make([]uint32, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
counts, err := creditCounts(db, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authors := make([]domain.Author, 0, len(models))
|
||||
for _, model := range models {
|
||||
authors = append(authors, mappers.AuthorToDomain(model, counts[model.ID]))
|
||||
}
|
||||
return authors, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) loadOne(db *gorm.DB, model entity.Author) (domain.Author, error) {
|
||||
authors, err := a.toDomain(db, []entity.Author{model})
|
||||
if err != nil {
|
||||
return domain.Author{}, err
|
||||
}
|
||||
return authors[0], nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) List(query domain.ListQuery) (domain.Page, error) {
|
||||
query.Page = max(query.Page, 1)
|
||||
query.Size = max(query.Size, 0)
|
||||
needle := strings.TrimSpace(query.Q)
|
||||
if needle != "" {
|
||||
return a.findMatching(query, foldForSearch(needle))
|
||||
}
|
||||
var total int64
|
||||
if err := a.db.Model(&entity.Author{}).Count(&total).Error; err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
var found []entity.Author
|
||||
err := a.db.Order("id DESC").Limit(query.Size).Offset(pageStart(query.Page, query.Size, int(total))).Find(&found).Error
|
||||
if err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
items, err := a.toDomain(a.db, found)
|
||||
if err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
return domain.Page{Items: items, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func pageStart(page int, size int, total int) int {
|
||||
if size <= 0 || page-1 > total/size {
|
||||
return total
|
||||
}
|
||||
return min((page-1)*size, total)
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) findMatching(query domain.ListQuery, needle string) (domain.Page, error) {
|
||||
var candidates []entity.Author
|
||||
if err := a.db.Select("id", "name", "slug").Order("id DESC").Find(&candidates).Error; err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
matching := make([]uint32, 0)
|
||||
for _, candidate := range candidates {
|
||||
if strings.Contains(foldForSearch(candidate.Name), needle) || strings.Contains(foldForSearch(candidate.Slug), needle) {
|
||||
matching = append(matching, candidate.ID)
|
||||
}
|
||||
}
|
||||
start := pageStart(query.Page, query.Size, len(matching))
|
||||
end := min(start+query.Size, len(matching))
|
||||
page := domain.Page{Items: []domain.Author{}, Total: len(matching)}
|
||||
if start == end {
|
||||
return page, nil
|
||||
}
|
||||
var found []entity.Author
|
||||
if err := a.db.Where("id IN ?", matching[start:end]).Order("id DESC").Find(&found).Error; err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
items, err := a.toDomain(a.db, found)
|
||||
if err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
page.Items = items
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) Get(id uint32) (domain.Author, error) {
|
||||
var model entity.Author
|
||||
if err := a.db.First(&model, id).Error; err != nil {
|
||||
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
|
||||
}
|
||||
return a.loadOne(a.db, model)
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) GetBySlug(slug string) (domain.Author, error) {
|
||||
var model entity.Author
|
||||
if err := a.db.Where("slug = ?", slug).First(&model).Error; err != nil {
|
||||
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
|
||||
}
|
||||
return a.loadOne(a.db, model)
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) GetByUserID(userID uint32) (domain.Author, error) {
|
||||
var model entity.Author
|
||||
if err := a.db.Where("user_id = ?", userID).First(&model).Error; err != nil {
|
||||
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
|
||||
}
|
||||
return a.loadOne(a.db, model)
|
||||
}
|
||||
|
||||
func slugInUse(tx *gorm.DB, slug string, exceptID uint32) (bool, error) {
|
||||
var total int64
|
||||
err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", slug, exceptID).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func userInUse(tx *gorm.DB, userID *uint32, exceptID uint32) (bool, error) {
|
||||
if userID == nil {
|
||||
return false, nil
|
||||
}
|
||||
var total int64
|
||||
err := tx.Model(&entity.Author{}).Where("user_id = ? AND id <> ?", *userID, exceptID).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func ensureUnique(tx *gorm.DB, author domain.Author) error {
|
||||
taken, err := slugInUse(tx, author.Slug, author.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return domain.ErrSlugTaken
|
||||
}
|
||||
linked, err := userInUse(tx, author.UserID, author.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if linked {
|
||||
return domain.ErrUserAlreadyLinked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) explainWriteFailure(author domain.Author, cause error) error {
|
||||
if err := ensureUnique(a.db, author); err != nil {
|
||||
return err
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) Create(author domain.Author) (domain.Author, error) {
|
||||
model := mappers.AuthorToModel(author)
|
||||
model.ID = 0
|
||||
author.ID = 0
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ensureUnique(tx, author); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Author{}, a.explainWriteFailure(author, err)
|
||||
}
|
||||
return mappers.AuthorToDomain(model, 0), nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) Update(author domain.Author) (domain.Author, error) {
|
||||
changes := map[string]any{
|
||||
"name": author.Name,
|
||||
"slug": author.Slug,
|
||||
"bio": author.Bio,
|
||||
"avatar_id": author.AvatarID,
|
||||
"website": author.Website,
|
||||
"social_links": mappers.AuthorToModel(author).SocialLinks,
|
||||
"user_id": author.UserID,
|
||||
}
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockAuthor(tx, author.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureUnique(tx, author); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&entity.Author{}).Where("id = ?", author.ID).Updates(changes).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Author{}, a.explainWriteFailure(author, err)
|
||||
}
|
||||
return a.Get(author.ID)
|
||||
}
|
||||
|
||||
func lockAuthor(tx *gorm.DB, id uint32) (entity.Author, error) {
|
||||
var locked entity.Author
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
|
||||
return entity.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) DeleteMovingCredits(id uint32, reassignTo *uint32) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockAuthor(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if reassignTo == nil {
|
||||
credited, err := hasCredits(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if credited {
|
||||
return domain.ErrAuthorHasArticles
|
||||
}
|
||||
return removeAuthor(tx, id)
|
||||
}
|
||||
if _, err := lockAuthor(tx, *reassignTo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := moveCredits(tx, id, *reassignTo); err != nil {
|
||||
return err
|
||||
}
|
||||
return removeAuthor(tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) DeleteUncrediting(id uint32) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockAuthor(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return removeAuthor(tx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func hasCredits(tx *gorm.DB, id uint32) (bool, error) {
|
||||
var total int64
|
||||
err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", id).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func removeAuthor(tx *gorm.DB, id uint32) error {
|
||||
if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return deleteOrNotFound(tx.Delete(&entity.Author{}, id), domain.ErrAuthorNotFound)
|
||||
}
|
||||
|
||||
func moveCredits(tx *gorm.DB, from uint32, to uint32) error {
|
||||
var alreadyCredited []uint32
|
||||
if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", to).Pluck("article_id", &alreadyCredited).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(alreadyCredited) > 0 {
|
||||
if err := tx.Where("author_id = ? AND article_id IN ?", from, alreadyCredited).Delete(&entity.ArticleAuthor{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", from).UpdateColumn("author_id", to).Error
|
||||
}
|
||||
|
||||
type avatarTotal struct {
|
||||
AvatarID uint32
|
||||
Total int
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) {
|
||||
var totals []avatarTotal
|
||||
err := a.db.Model(&entity.Author{}).Select("avatar_id, COUNT(*) AS total").Where("avatar_id IS NOT NULL").Group("avatar_id").Scan(&totals).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage := make(map[uint32]int, len(totals))
|
||||
for _, total := range totals {
|
||||
usage[total.AvatarID] = total.Total
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) {
|
||||
ids := []uint32{}
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&entity.Author{}).Where("avatar_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Author{}).Where("id IN ?", ids).UpdateColumn("avatar_id", nil).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) AssignAvatar(authorIDs []uint32, imageID uint32) error {
|
||||
if len(authorIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.db.Model(&entity.Author{}).Where("id IN ?", authorIDs).UpdateColumn("avatar_id", imageID).Error
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
|
||||
if len(fromImageIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result := a.db.Model(&entity.Author{}).Where("avatar_id IN ?", fromImageIDs).UpdateColumn("avatar_id", toImageID)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return int(result.RowsAffected), nil
|
||||
}
|
||||
|
||||
var _ domain.Repository = &AuthorRepository{}
|
||||
@@ -3,8 +3,10 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type CreateArticleUseCase struct {
|
||||
@@ -12,16 +14,18 @@ type CreateArticleUseCase struct {
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
type CreateArticleCommand struct {
|
||||
Title string
|
||||
Body string
|
||||
Format article.BodyFormat
|
||||
Title string
|
||||
Body string
|
||||
Format article.BodyFormat
|
||||
CreatedBy uint32
|
||||
}
|
||||
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
|
||||
@@ -32,6 +36,17 @@ func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
|
||||
return defaultSite.ID, nil
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) creatorBylines(userID uint32) ([]author.Byline, error) {
|
||||
found, err := g.authors.GetByUserID(userID)
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
return []author.Byline{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []author.Byline{found.Byline()}, nil
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle CreateArticleCommand) (article.Article, error) {
|
||||
event := audit.NewEvent(audit.ActionArticleCreate, audit.CategoryArticles)
|
||||
if err := article.ValidateTitle(createArticle.Title); err != nil {
|
||||
@@ -45,11 +60,18 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
creditedAuthors, err := g.creatorBylines(createArticle.CreatedBy)
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
created, err := g.articleRepository.Create(article.Article{
|
||||
Title: createArticle.Title,
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
SiteID: siteID,
|
||||
Authors: creditedAuthors,
|
||||
})
|
||||
if err != nil {
|
||||
recordFailure(ctx, g.recorder, event, err)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CreateAuthorUseCase struct {
|
||||
authors author.Repository
|
||||
users user.Repository
|
||||
images image.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateAuthorCommand struct {
|
||||
Name string
|
||||
Slug string
|
||||
Bio string
|
||||
AvatarID *uint32
|
||||
Website *string
|
||||
SocialLinks []author.SocialLink
|
||||
UserID *uint32
|
||||
}
|
||||
|
||||
func NewCreateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *CreateAuthorUseCase {
|
||||
return &CreateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *CreateAuthorUseCase) Create(ctx context.Context, command CreateAuthorCommand) (author.Author, error) {
|
||||
command.Name = strings.TrimSpace(command.Name)
|
||||
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
|
||||
event.Target = audit.Target{Type: audit.TargetAuthor, Name: command.Name}
|
||||
created, err := u.create(command)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return author.Author{}, err
|
||||
}
|
||||
event.Target = authorTarget(created)
|
||||
u.recorder.Record(ctx, event)
|
||||
return withUserEmail(u.users, created), nil
|
||||
}
|
||||
|
||||
func (u *CreateAuthorUseCase) create(command CreateAuthorCommand) (author.Author, error) {
|
||||
website, err := validateAuthorContent(command.Bio, command.Website, command.SocialLinks)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
if err := ensureImageExists(u.images, command.AvatarID); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
if command.UserID != nil {
|
||||
if err := ensureUserCanBeLinked(u.users, u.authors, *command.UserID); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
}
|
||||
slug, err := u.slugFor(command)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
links := command.SocialLinks
|
||||
if links == nil {
|
||||
links = []author.SocialLink{}
|
||||
}
|
||||
return u.authors.Create(author.Author{
|
||||
Name: command.Name,
|
||||
Slug: slug,
|
||||
Bio: command.Bio,
|
||||
AvatarID: command.AvatarID,
|
||||
Website: website,
|
||||
SocialLinks: links,
|
||||
UserID: command.UserID,
|
||||
IsLinked: command.UserID != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *CreateAuthorUseCase) slugFor(command CreateAuthorCommand) (string, error) {
|
||||
slug := command.Slug
|
||||
if slug == "" {
|
||||
slug = author.GenerateSlug(command.Name)
|
||||
}
|
||||
if !author.IsValidSlug(slug) {
|
||||
return "", author.ErrInvalidSlug
|
||||
}
|
||||
taken, err := isSlugTaken(u.authors, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if taken {
|
||||
return "", author.ErrSlugTaken
|
||||
}
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
func validateAuthorContent(bio string, website *string, links []author.SocialLink) (*string, error) {
|
||||
if err := author.ValidateBio(bio); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := author.ValidateLinks(links); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return author.NormalizeWebsite(website)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxOwnSlugAttempts = 50
|
||||
|
||||
type CreateMyAuthorUseCase struct {
|
||||
authors author.Repository
|
||||
users user.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCreateMyAuthorUseCase(authors author.Repository, users user.Repository, recorder audit.Recorder) *CreateMyAuthorUseCase {
|
||||
return &CreateMyAuthorUseCase{authors: authors, users: users, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *CreateMyAuthorUseCase) Create(ctx context.Context, userID uint32) (author.Author, error) {
|
||||
caller, err := u.users.Get(userID)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
name := authorNameOf(caller)
|
||||
event := audit.NewEvent(audit.ActionAuthorCreate, audit.CategoryAuthors)
|
||||
event.Target = audit.Target{Type: audit.TargetAuthor, Name: name}
|
||||
created, err := u.create(caller, name)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return author.Author{}, err
|
||||
}
|
||||
event.Target = authorTarget(created)
|
||||
u.recorder.Record(ctx, event)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func authorNameOf(caller user.User) string {
|
||||
name := strings.TrimSpace(caller.DisplayName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(caller.Username)
|
||||
}
|
||||
runes := []rune(name)
|
||||
return string(runes[:min(len(runes), author.MaxNameRunes)])
|
||||
}
|
||||
|
||||
func (u *CreateMyAuthorUseCase) create(caller user.User, name string) (author.Author, error) {
|
||||
_, err := u.authors.GetByUserID(caller.ID)
|
||||
if err == nil {
|
||||
return author.Author{}, author.ErrUserAlreadyLinked
|
||||
}
|
||||
if !errors.Is(err, author.ErrAuthorNotFound) {
|
||||
return author.Author{}, err
|
||||
}
|
||||
return u.createWithFreeSlug(caller.ID, name)
|
||||
}
|
||||
|
||||
func (u *CreateMyAuthorUseCase) createWithFreeSlug(userID uint32, name string) (author.Author, error) {
|
||||
base := author.GenerateSlug(name)
|
||||
for attempt := 1; attempt <= maxOwnSlugAttempts; attempt++ {
|
||||
slug := author.SlugWithSuffix(base, attempt)
|
||||
taken, err := isSlugTaken(u.authors, slug)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
if taken {
|
||||
continue
|
||||
}
|
||||
created, err := u.authors.Create(author.Author{Name: name, Slug: slug, SocialLinks: []author.SocialLink{}, UserID: &userID, IsLinked: true})
|
||||
if errors.Is(err, author.ErrSlugTaken) {
|
||||
continue
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
return author.Author{}, author.ErrSlugTaken
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"context"
|
||||
)
|
||||
|
||||
type ReassignMode int
|
||||
|
||||
const (
|
||||
ReassignAbsent ReassignMode = iota
|
||||
ReassignNone
|
||||
ReassignToAuthor
|
||||
ReassignMalformed
|
||||
)
|
||||
|
||||
type Reassignment struct {
|
||||
Mode ReassignMode
|
||||
Destination uint32
|
||||
}
|
||||
|
||||
type DeleteAuthorUseCase struct {
|
||||
authors author.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewDeleteAuthorUseCase(authors author.Repository, recorder audit.Recorder) *DeleteAuthorUseCase {
|
||||
return &DeleteAuthorUseCase{authors: authors, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteAuthorUseCase) Delete(ctx context.Context, id uint32, reassignment Reassignment) error {
|
||||
target, err := u.authors.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionAuthorDelete, audit.CategoryAuthors)
|
||||
event.Target = authorTarget(target)
|
||||
if err := u.delete(target, reassignment); err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return err
|
||||
}
|
||||
u.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *DeleteAuthorUseCase) delete(target author.Author, reassignment Reassignment) error {
|
||||
if reassignment.Mode == ReassignNone {
|
||||
return u.authors.DeleteUncrediting(target.ID)
|
||||
}
|
||||
if target.ArticleCount == 0 {
|
||||
return u.authors.DeleteMovingCredits(target.ID, nil)
|
||||
}
|
||||
destination, err := u.destination(target, reassignment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return u.authors.DeleteMovingCredits(target.ID, &destination)
|
||||
}
|
||||
|
||||
func (u *DeleteAuthorUseCase) destination(target author.Author, reassignment Reassignment) (uint32, error) {
|
||||
switch reassignment.Mode {
|
||||
case ReassignAbsent:
|
||||
return 0, author.ErrAuthorHasArticles
|
||||
case ReassignMalformed:
|
||||
return 0, author.ErrInvalidReassign
|
||||
}
|
||||
if reassignment.Destination == 0 || reassignment.Destination == target.ID {
|
||||
return 0, author.ErrInvalidReassign
|
||||
}
|
||||
if _, err := u.authors.Get(reassignment.Destination); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return reassignment.Destination, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
@@ -12,10 +13,11 @@ type DeleteMediaUseCase struct {
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
|
||||
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *DeleteMediaUseCase {
|
||||
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
|
||||
@@ -23,8 +25,14 @@ func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clearedAuthors, err := u.authors.ClearAvatar(img.ID)
|
||||
if err != nil {
|
||||
_ = u.sites.AssignImage(clearedSites, img.ID)
|
||||
return err
|
||||
}
|
||||
if err := u.remover.remove(img); err != nil {
|
||||
_ = u.sites.AssignImage(clearedSites, img.ID)
|
||||
_ = u.authors.AssignAvatar(clearedAuthors, img.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type GetMyAuthorUseCase struct {
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
func NewGetMyAuthorUseCase(authors author.Repository) *GetMyAuthorUseCase {
|
||||
return &GetMyAuthorUseCase{authors: authors}
|
||||
}
|
||||
|
||||
func (u *GetMyAuthorUseCase) Get(userID uint32) (*author.Author, error) {
|
||||
found, err := u.authors.GetByUserID(userID)
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &found, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
@@ -20,6 +21,7 @@ type ListActivityUseCase struct {
|
||||
apiKeys apikey.Repository
|
||||
now func() time.Time
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
type ListActivityCommand struct {
|
||||
@@ -39,8 +41,8 @@ type ActivityPage struct {
|
||||
Size int
|
||||
}
|
||||
|
||||
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository) *ListActivityUseCase {
|
||||
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites}
|
||||
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time, sites site.Repository, authors author.Repository) *ListActivityUseCase {
|
||||
return &ListActivityUseCase{events: events, users: users, articles: articles, images: images, apiKeys: apiKeys, now: now, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
|
||||
@@ -130,6 +132,9 @@ func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
|
||||
case audit.TargetSite:
|
||||
_, err = l.sites.Get(uint32(id))
|
||||
return existsUnless(err, site.ErrSiteNotFound)
|
||||
case audit.TargetAuthor:
|
||||
_, err = l.authors.Get(uint32(id))
|
||||
return existsUnless(err, author.ErrAuthorNotFound)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/user"
|
||||
)
|
||||
|
||||
type ListAuthorsUseCase struct {
|
||||
authors author.Repository
|
||||
users user.Repository
|
||||
}
|
||||
|
||||
type ListAuthorsCommand struct {
|
||||
Q string
|
||||
Page int
|
||||
Size int
|
||||
CallerRole user.Role
|
||||
}
|
||||
|
||||
type AuthorPage struct {
|
||||
Items []author.Author
|
||||
Total int
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
func NewListAuthorsUseCase(authors author.Repository, users user.Repository) *ListAuthorsUseCase {
|
||||
return &ListAuthorsUseCase{authors: authors, users: users}
|
||||
}
|
||||
|
||||
func (u *ListAuthorsUseCase) List(command ListAuthorsCommand) (AuthorPage, error) {
|
||||
found, err := u.authors.List(author.ListQuery{Q: command.Q, Page: command.Page, Size: command.Size})
|
||||
if err != nil {
|
||||
return AuthorPage{}, err
|
||||
}
|
||||
items := make([]author.Author, 0, len(found.Items))
|
||||
for _, item := range found.Items {
|
||||
if isAdminRole(command.CallerRole) {
|
||||
item = withUserEmail(u.users, item)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return AuthorPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
@@ -38,10 +39,11 @@ type ListMediaUseCase struct {
|
||||
userRepository user.Repository
|
||||
variantFiles image.VariantFileRepository
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites}
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository, authors author.Repository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
@@ -108,7 +110,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
siteUsage, err := siteImageUsage(l.sites)
|
||||
siteUsage, err := imageUsageCount(l.sites, l.authors)
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
@@ -143,7 +145,7 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
siteUsage, err := siteImageUsage(l.sites)
|
||||
siteUsage, err := imageUsageCount(l.sites, l.authors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"cmp"
|
||||
@@ -16,6 +17,7 @@ type MergeMediaResult struct {
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
SitesUpdated int
|
||||
AuthorsUpdated int
|
||||
UpdatedArticleIDs []uint32
|
||||
}
|
||||
|
||||
@@ -25,18 +27,27 @@ type MergeMediaUseCase struct {
|
||||
remover mediaRemover
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites}
|
||||
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
|
||||
func copyIdsOf(copies []image.Image) []uint32 {
|
||||
copyIds := make([]uint32, 0, len(copies))
|
||||
for _, duplicate := range copies {
|
||||
copyIds = append(copyIds, duplicate.ID)
|
||||
}
|
||||
return u.sites.RepointImage(copyIds, keptId)
|
||||
return copyIds
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) {
|
||||
return u.sites.RepointImage(copyIdsOf(copies), keptId)
|
||||
}
|
||||
|
||||
func (u *MergeMediaUseCase) repointAuthors(copies []image.Image, keptId uint32) (int, error) {
|
||||
return u.authors.RepointAvatar(copyIdsOf(copies), keptId)
|
||||
}
|
||||
|
||||
type bodyRewrite struct {
|
||||
@@ -95,13 +106,19 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
authorsUpdated, err := u.repointAuthors(copies, kept.ID)
|
||||
if err != nil {
|
||||
u.restoreBodies(rewrites)
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
for _, duplicate := range copies {
|
||||
if err := u.remover.remove(duplicate); err != nil {
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
}
|
||||
|
||||
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
@@ -29,8 +30,8 @@ type PreviewPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
}
|
||||
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites}}
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}}
|
||||
}
|
||||
|
||||
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/domain/site"
|
||||
@@ -23,9 +24,9 @@ type RunPurgeUseCase struct {
|
||||
guard sync.Mutex
|
||||
}
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository) *RunPurgeUseCase {
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository, authors author.Repository) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites},
|
||||
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
@@ -88,6 +89,7 @@ func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
result.GroupsPurged++
|
||||
result.Merged += merged.Merged
|
||||
result.SitesUpdated += merged.SitesUpdated
|
||||
result.AuthorsUpdated += merged.AuthorsUpdated
|
||||
for _, id := range merged.UpdatedArticleIDs {
|
||||
updated[id] = true
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UpdateArticleUseCase struct {
|
||||
@@ -13,16 +16,18 @@ type UpdateArticleUseCase struct {
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
type UpdateArticleCommand struct {
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
Format article.BodyFormat
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
Format article.BodyFormat
|
||||
}
|
||||
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites}
|
||||
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *UpdateArticleUseCase {
|
||||
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
|
||||
@@ -38,7 +43,10 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID})
|
||||
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID, AuthorIDs: command.AuthorIDs})
|
||||
if err != nil {
|
||||
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
|
||||
return article.Article{}, err
|
||||
@@ -58,11 +66,51 @@ func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*s
|
||||
return &destination, nil
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
|
||||
if ids == nil {
|
||||
return nil
|
||||
}
|
||||
for _, id := range *ids {
|
||||
if _, err := g.authors.Get(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorsChange(before article.Article, after article.Article) (audit.Change, bool) {
|
||||
beforeIDs := bylineIDs(before.Authors)
|
||||
afterIDs := bylineIDs(after.Authors)
|
||||
if slices.Equal(beforeIDs, afterIDs) {
|
||||
return audit.Change{}, false
|
||||
}
|
||||
return audit.TextChange(audit.FieldAuthors, bylineNames(before.Authors), bylineNames(after.Authors)), true
|
||||
}
|
||||
|
||||
func bylineIDs(bylines []author.Byline) []uint32 {
|
||||
ids := make([]uint32, 0, len(bylines))
|
||||
for _, byline := range bylines {
|
||||
ids = append(ids, byline.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func bylineNames(bylines []author.Byline) string {
|
||||
names := make([]string, 0, len(bylines))
|
||||
for _, byline := range bylines {
|
||||
names = append(names, byline.Name)
|
||||
}
|
||||
return strings.Join(names, authorChangeSeparator)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) {
|
||||
changes := audit.ArticleChanges(before, after)
|
||||
if destination != nil && before.SiteID != after.SiteID {
|
||||
changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name))
|
||||
}
|
||||
if change, changed := authorsChange(before, after); changed {
|
||||
changes = append(changes, change)
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UpdateAuthorUseCase struct {
|
||||
authors author.Repository
|
||||
users user.Repository
|
||||
images image.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateAuthorUseCase(authors author.Repository, users user.Repository, images image.Repository, recorder audit.Recorder) *UpdateAuthorUseCase {
|
||||
return &UpdateAuthorUseCase{authors: authors, users: users, images: images, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) Update(ctx context.Context, id uint32, caller AuthorCaller, patch author.Patch) (author.Author, error) {
|
||||
before, err := u.authors.Get(id)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionAuthorEdit, audit.CategoryAuthors)
|
||||
event.Target = authorTarget(before)
|
||||
after, err := u.prepare(before, caller, patch)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return author.Author{}, err
|
||||
}
|
||||
|
||||
changes := authorChanges(u.users, u.images, before, after)
|
||||
if len(changes) == 0 {
|
||||
return u.present(before, caller), nil
|
||||
}
|
||||
saved, err := u.authors.Update(after)
|
||||
if err != nil {
|
||||
recordFailure(ctx, u.recorder, event, err)
|
||||
return author.Author{}, err
|
||||
}
|
||||
event.Target = authorTarget(saved)
|
||||
event.Changes = changes
|
||||
u.recorder.Record(ctx, event)
|
||||
return u.present(saved, caller), nil
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) present(a author.Author, caller AuthorCaller) author.Author {
|
||||
if caller.isAdmin() {
|
||||
return withUserEmail(u.users, a)
|
||||
}
|
||||
a.UserEmail = nil
|
||||
return a
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) prepare(before author.Author, caller AuthorCaller, patch author.Patch) (author.Author, error) {
|
||||
if err := authorizeEdit(before, caller, patch); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
after := before
|
||||
if patch.Name != nil {
|
||||
after.Name = strings.TrimSpace(*patch.Name)
|
||||
}
|
||||
if patch.Bio != nil {
|
||||
after.Bio = *patch.Bio
|
||||
}
|
||||
if patch.Website.Set {
|
||||
after.Website = patch.Website.Value
|
||||
}
|
||||
if patch.SocialLinks != nil {
|
||||
after.SocialLinks = *patch.SocialLinks
|
||||
}
|
||||
website, err := validateAuthorContent(after.Bio, after.Website, after.SocialLinks)
|
||||
if err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
after.Website = website
|
||||
if err := u.applySlug(&after, patch); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
if err := u.applyAvatar(&after, patch); err != nil {
|
||||
return author.Author{}, err
|
||||
}
|
||||
return after, u.applyLink(&after, patch)
|
||||
}
|
||||
|
||||
func authorizeEdit(before author.Author, caller AuthorCaller, patch author.Patch) error {
|
||||
if caller.isAdmin() {
|
||||
return nil
|
||||
}
|
||||
if before.UserID == nil || *before.UserID != caller.UserID || patch.UserID.Set {
|
||||
return author.ErrAuthorRights
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) applySlug(after *author.Author, patch author.Patch) error {
|
||||
if patch.Slug == nil || *patch.Slug == after.Slug {
|
||||
return nil
|
||||
}
|
||||
if !author.IsValidSlug(*patch.Slug) {
|
||||
return author.ErrInvalidSlug
|
||||
}
|
||||
taken, err := isSlugTaken(u.authors, *patch.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return author.ErrSlugTaken
|
||||
}
|
||||
after.Slug = *patch.Slug
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) applyAvatar(after *author.Author, patch author.Patch) error {
|
||||
if !patch.AvatarID.Set {
|
||||
return nil
|
||||
}
|
||||
if err := ensureImageExists(u.images, patch.AvatarID.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
after.AvatarID = patch.AvatarID.Value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UpdateAuthorUseCase) applyLink(after *author.Author, patch author.Patch) error {
|
||||
if !patch.UserID.Set {
|
||||
return nil
|
||||
}
|
||||
requested := patch.UserID.Value
|
||||
if requested == nil {
|
||||
after.UserID = nil
|
||||
after.IsLinked = false
|
||||
return nil
|
||||
}
|
||||
if after.UserID != nil {
|
||||
if *after.UserID == *requested {
|
||||
return nil
|
||||
}
|
||||
return author.ErrAuthorAlreadyLinked
|
||||
}
|
||||
if err := ensureUserCanBeLinked(u.users, u.authors, *requested); err != nil {
|
||||
return err
|
||||
}
|
||||
after.UserID = requested
|
||||
after.IsLinked = true
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
@@ -64,6 +65,12 @@ var failureReasons = []failureReason{
|
||||
{site.ErrInvalidLocale, "The locale is not valid."},
|
||||
{site.ErrUseSetDefault, "Use the default site action to change the default."},
|
||||
{site.ErrInvalidReassign, "The destination site is not valid."},
|
||||
{author.ErrSlugTaken, "That slug is already used by another author."},
|
||||
{author.ErrUserAlreadyLinked, "That user already has an author."},
|
||||
{author.ErrAuthorAlreadyLinked, "That author is already linked to a user."},
|
||||
{author.ErrAuthorRights, "Only an owner or admin can change this author."},
|
||||
{author.ErrAuthorHasArticles, "The author is still credited on articles."},
|
||||
{author.ErrInvalidReassign, "The destination author is not valid."},
|
||||
}
|
||||
|
||||
var restartFailureReasons = []failureReason{
|
||||
@@ -82,6 +89,11 @@ var notFoundCauses = []error{
|
||||
invite.ErrInviteNotFound,
|
||||
user.ErrUserNotFound,
|
||||
site.ErrSiteNotFound,
|
||||
author.ErrAuthorNotFound,
|
||||
author.ErrBioTooLong,
|
||||
author.ErrInvalidURL,
|
||||
author.ErrTooManyLinks,
|
||||
author.ErrInvalidSlug,
|
||||
}
|
||||
|
||||
func reasonFor(err error, overrides ...failureReason) string {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const authorChangeSeparator = ", "
|
||||
|
||||
type AuthorCaller struct {
|
||||
UserID uint32
|
||||
Role user.Role
|
||||
}
|
||||
|
||||
func isAdminRole(role user.Role) bool {
|
||||
return role == user.RoleOwner || role == user.RoleAdmin
|
||||
}
|
||||
|
||||
func (c AuthorCaller) isAdmin() bool {
|
||||
return isAdminRole(c.Role)
|
||||
}
|
||||
|
||||
func authorTarget(a author.Author) audit.Target {
|
||||
return audit.NewTarget(audit.TargetAuthor, a.ID, a.Name)
|
||||
}
|
||||
|
||||
func isSlugTaken(authors author.Repository, slug string) (bool, error) {
|
||||
_, err := authors.GetBySlug(slug)
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func ensureUserCanBeLinked(users user.Repository, authors author.Repository, userID uint32) error {
|
||||
if _, err := users.Get(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := authors.GetByUserID(userID)
|
||||
if err == nil {
|
||||
return author.ErrUserAlreadyLinked
|
||||
}
|
||||
if errors.Is(err, author.ErrAuthorNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func withUserEmail(users user.Repository, a author.Author) author.Author {
|
||||
a.UserEmail = nil
|
||||
if a.UserID == nil {
|
||||
return a
|
||||
}
|
||||
linked, err := users.Get(*a.UserID)
|
||||
if err != nil {
|
||||
return a
|
||||
}
|
||||
email := linked.Email
|
||||
a.UserEmail = &email
|
||||
return a
|
||||
}
|
||||
|
||||
func linksLabel(links []author.SocialLink) string {
|
||||
labels := make([]string, 0, len(links))
|
||||
for _, link := range links {
|
||||
labels = append(labels, link.Type+": "+link.URL)
|
||||
}
|
||||
return strings.Join(labels, authorChangeSeparator)
|
||||
}
|
||||
|
||||
func websiteLabel(website *string) string {
|
||||
if website == nil {
|
||||
return ""
|
||||
}
|
||||
return *website
|
||||
}
|
||||
|
||||
func usernameLabel(users user.Repository, id *uint32) string {
|
||||
if id == nil {
|
||||
return ""
|
||||
}
|
||||
found, err := users.Get(*id)
|
||||
if err != nil {
|
||||
return strconv.FormatUint(uint64(*id), 10)
|
||||
}
|
||||
return found.Username
|
||||
}
|
||||
|
||||
func sameID(a *uint32, b *uint32) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func authorChanges(users user.Repository, images image.Repository, before author.Author, after author.Author) []audit.Change {
|
||||
changes := []audit.Change{}
|
||||
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
|
||||
changes = appendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
|
||||
changes = appendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
|
||||
changes = appendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
|
||||
changes = appendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
|
||||
if !sameID(before.AvatarID, after.AvatarID) {
|
||||
changes = append(changes, audit.TextChange(audit.FieldAvatar, imageLabel(images, before.AvatarID), imageLabel(images, after.AvatarID)))
|
||||
}
|
||||
if !sameID(before.UserID, after.UserID) {
|
||||
changes = append(changes, audit.TextChange(audit.FieldUser, usernameLabel(users, before.UserID), usernameLabel(users, after.UserID)))
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"crypto/sha256"
|
||||
@@ -28,6 +29,7 @@ type purgePlanner struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
@@ -40,7 +42,7 @@ func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
siteUsage, err := siteImageUsage(p.sites)
|
||||
siteUsage, err := imageUsageCount(p.sites, p.authors)
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/site"
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
|
||||
func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
|
||||
usage := map[uint32]int{}
|
||||
@@ -15,3 +18,18 @@ func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func imageUsageCount(sites site.Repository, authors author.Repository) (map[uint32]int, error) {
|
||||
usage, err := siteImageUsage(sites)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
avatars, err := authors.AvatarUsage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for imageID, total := range avatars {
|
||||
usage[imageID] += total
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
Reference in new issue
Block a user