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{}