package persistence import ( "RenewCMS/internal/domain/author" domain "RenewCMS/internal/domain/user" entity "RenewCMS/internal/infrastructure/persistence/models" "strings" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( emptySocialLinks = "[]" sqliteDialect = "sqlite" ) var contentionMarkers = []string{ "database is locked", "sqlite_busy", "deadlock", "could not serialize", } type AccountEraser struct { db *gorm.DB } var _ domain.Eraser = &AccountEraser{} func NewAccountEraser(db *gorm.DB) *AccountEraser { return &AccountEraser{db} } func (e *AccountEraser) Erase(erasure domain.Erasure) error { err := e.db.Transaction(func(tx *gorm.DB) error { if err := e.recheckHolders(tx, erasure); err != nil { return err } if err := tx.Where("user_id = ?", erasure.UserID).Delete(&entity.APIKey{}).Error; err != nil { return err } ownAuthorID, err := e.releaseAuthor(tx, erasure.UserID) if err != nil { return err } if erasure.AvatarImageID != nil { if err := tx.Delete(&entity.Image{}, *erasure.AvatarImageID).Error; err != nil { return err } } if err := NewAuditEventRepository(tx).AnonymiseUser(erasure.UserID, ownAuthorID, domain.DeletedUserName); err != nil { return err } result := tx.Delete(&entity.User{}, erasure.UserID) return deleteOrNotFound(result, domain.ErrUserNotFound) }) if err != nil && len(erasure.GuardRoleIDs) > 0 && isContention(err) { return domain.ErrAccountNotDeletable } return err } func isContention(err error) bool { message := strings.ToLower(err.Error()) for _, marker := range contentionMarkers { if strings.Contains(message, marker) { return true } } return false } func (e *AccountEraser) recheckHolders(tx *gorm.DB, erasure domain.Erasure) error { if len(erasure.GuardRoleIDs) == 0 { return nil } if err := lockRoles(tx, erasure.GuardRoleIDs); err != nil { return err } var holders []entity.User err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Select("id"). Where("role_id IN ? AND is_verified = ? AND id <> ?", erasure.GuardRoleIDs, true, erasure.UserID). Order("id ASC"). Find(&holders).Error if err != nil { return err } if len(holders) == 0 { return domain.ErrAccountNotDeletable } return nil } func lockRoles(tx *gorm.DB, roleIDs []uint32) error { if tx.Dialector.Name() == sqliteDialect { return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("id", gorm.Expr("id")).Error } var locked []entity.Role return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", roleIDs).Order("id ASC").Find(&locked).Error } func (e *AccountEraser) releaseAuthor(tx *gorm.DB, userID uint32) (*uint32, error) { var linked entity.Author found := tx.Where("user_id = ?", userID).Limit(1).Find(&linked) if found.Error != nil { return nil, found.Error } if found.RowsAffected == 0 { return nil, nil } ownID := linked.ID var credits int64 if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", linked.ID).Count(&credits).Error; err != nil { return nil, err } if credits == 0 { return &ownID, tx.Delete(&entity.Author{}, linked.ID).Error } slug, err := e.freeDeletedSlug(tx, linked.ID) if err != nil { return nil, err } return &ownID, tx.Model(&entity.Author{}).Where("id = ?", linked.ID).Updates(map[string]any{ "name": author.DeletedName, "slug": slug, "bio": "", "avatar_id": nil, "website": nil, "social_links": emptySocialLinks, "user_id": nil, }).Error } func (e *AccountEraser) freeDeletedSlug(tx *gorm.DB, authorID uint32) (string, error) { for attempt := 1; ; attempt++ { candidate := author.DeletedSlugAttempt(authorID, attempt) var taken int64 err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", candidate, authorID).Count(&taken).Error if err != nil || taken == 0 { return candidate, err } } }