366 lines
10 KiB
Go
366 lines
10 KiB
Go
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) FindByAvatar(imageID uint32) ([]domain.Author, error) {
|
|
var found []entity.Author
|
|
if err := a.db.Where("avatar_id = ?", imageID).Order("id ASC").Find(&found).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return a.toDomain(a.db, found)
|
|
}
|
|
|
|
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{}
|