package mysql import ( "context" "errors" "strings" "time" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" "wucher/internal/shared/pkg/uuidv7" ) type FileManagerFolderRepository struct { db *gorm.DB } func NewFileManagerFolderRepository(db *gorm.DB) *FileManagerFolderRepository { return &FileManagerFolderRepository{db: db} } func (r *FileManagerFolderRepository) Create(ctx context.Context, row *filemanager.Folder) error { return r.db.WithContext(ctx).Create(row).Error } func (r *FileManagerFolderRepository) Update(ctx context.Context, row *filemanager.Folder) error { return r.db.WithContext(ctx).Save(row).Error } func (r *FileManagerFolderRepository) MoveSubtree(ctx context.Context, id []byte, targetParentID []byte, targetName string, targetNameNormalized string, updatedBy []byte) (*filemanager.Folder, error) { var moved filemanager.Folder err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var current filemanager.Folder if err := tx.Model(&filemanager.Folder{}). Where("id = ? AND deleted_at IS NULL", id). First(¤t).Error; err != nil { return err } targetDepth := 0 targetParentPath := "" if len(targetParentID) != 0 { if folderRepoBytesEqual16(targetParentID, current.ID) { return filemanager.ErrInvalidMove } var parent filemanager.Folder if err := tx.Model(&filemanager.Folder{}). Where("id = ? AND deleted_at IS NULL", targetParentID). First(&parent).Error; err != nil { return err } isDescendant, err := folderRepoIsDescendantFolder(tx, targetParentID, current.ID) if err != nil { return err } if isDescendant { return filemanager.ErrInvalidMove } targetDepth = parent.Depth + 1 targetParentPath = parent.PathCache } var dupTotal int64 if err := folderParentScope( tx.Model(&filemanager.Folder{}), targetParentID, ).Where("name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL AND id <> ?", targetNameNormalized, id).Count(&dupTotal).Error; err != nil { return err } if dupTotal > 0 { return gorm.ErrDuplicatedKey } oldPath := current.PathCache oldDepth := current.Depth newPath := folderRepoBuildPathCache(targetParentPath, targetName) res := tx.Model(&filemanager.Folder{}). Where("id = ? AND deleted_at IS NULL", id). Updates(map[string]any{ "parent_id": targetParentID, "name": targetName, "name_normalized": targetNameNormalized, "depth": targetDepth, "path_cache": newPath, "updated_by": updatedBy, "updated_at": time.Now().UTC(), }) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } depthDelta := targetDepth - oldDepth if oldPath != newPath || depthDelta != 0 { if err := folderRepoRewriteDescendantTree(tx, current.ID, oldPath, newPath, depthDelta, updatedBy); err != nil { return err } } current.ParentID = targetParentID current.Name = targetName current.NameNormalized = targetNameNormalized current.Depth = targetDepth current.PathCache = newPath current.UpdatedBy = updatedBy moved = current return nil }) if err != nil { return nil, err } return &moved, nil } func (r *FileManagerFolderRepository) Delete(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error { now := time.Now().UTC() nameSlot := deletedNameSlot(id) updates := map[string]any{ "deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy, "name_slot": nameSlot, } if purgeAt != nil { v := purgeAt.UTC() updates["purge_at"] = v } res := r.db.WithContext(ctx). Model(&filemanager.Folder{}). Where("id = ? AND deleted_at IS NULL", id). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFolderRepository) Restore(ctx context.Context, id []byte, restoredBy []byte) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var row filemanager.Folder if err := tx.Model(&filemanager.Folder{}).Where("id = ?", id).First(&row).Error; err != nil { return err } if row.DeletedAt == nil { return gorm.ErrRecordNotFound } var total int64 if err := folderParentScope( tx.Model(&filemanager.Folder{}), row.ParentID, ).Where("name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", row.NameNormalized).Count(&total).Error; err != nil { return err } if total > 0 { return gorm.ErrDuplicatedKey } updates := map[string]any{ "deleted_at": nil, "deleted_by": nil, "purge_at": nil, "updated_by": restoredBy, "name_slot": "live", } res := tx.Model(&filemanager.Folder{}). Where("id = ? AND deleted_at IS NOT NULL", id). Updates(updates) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil }) } func (r *FileManagerFolderRepository) Purge(ctx context.Context, id []byte) error { res := r.db.WithContext(ctx). Where("id = ? AND deleted_at IS NOT NULL", id). Delete(&filemanager.Folder{}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (r *FileManagerFolderRepository) GetByID(ctx context.Context, id []byte) (*filemanager.Folder, error) { var row filemanager.Folder err := r.db.WithContext(ctx). Where("id = ? AND deleted_at IS NULL", id). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFolderRepository) GetByIDAny(ctx context.Context, id []byte) (*filemanager.Folder, error) { var row filemanager.Folder err := r.db.WithContext(ctx). Where("id = ?", id). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFolderRepository) GetByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*filemanager.Folder, error) { var row filemanager.Folder q := folderParentScope(r.db.WithContext(ctx).Model(&filemanager.Folder{}), parentID). Where("name_normalized = ? AND name_slot = 'live' AND deleted_at IS NULL", nameNormalized) err := q.First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return &row, err } func (r *FileManagerFolderRepository) ListByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) { var rows []filemanager.Folder var total int64 base := folderParentScope( r.db.WithContext(ctx). Model(&filemanager.Folder{}). Where("deleted_at IS NULL AND name_slot = 'live'"), parentID, ) query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *FileManagerFolderRepository) SearchByName(ctx context.Context, name string, sort string, limit, offset int) ([]filemanager.Folder, int64, error) { var rows []filemanager.Folder var total int64 base := r.db.WithContext(ctx). Model(&filemanager.Folder{}). Where("deleted_at IS NULL AND name_slot = 'live' AND name LIKE ?", "%"+name+"%") query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *FileManagerFolderRepository) ListTrash(ctx context.Context, sort string, limit, offset int) ([]filemanager.Folder, int64, error) { var rows []filemanager.Folder var total int64 base := r.db.WithContext(ctx). Model(&filemanager.Folder{}). Where("deleted_at IS NOT NULL") query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func (r *FileManagerFolderRepository) ListTrashByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) { var rows []filemanager.Folder var total int64 base := folderParentScope( r.db.WithContext(ctx). Model(&filemanager.Folder{}). Where("deleted_at IS NOT NULL"), parentID, ) query := base if sort != "" { query = query.Order(sort) } if err := base.Count(&total).Error; err != nil { return nil, 0, err } if limit > 0 { query = query.Limit(limit).Offset(offset) } if err := query.Find(&rows).Error; err != nil { return nil, 0, err } return rows, total, nil } func folderParentScope(tx *gorm.DB, parentID []byte) *gorm.DB { if len(parentID) == 0 { return tx.Where("parent_id IS NULL") } return tx.Where("parent_id = ?", parentID) } func deletedNameSlot(id []byte) string { if len(id) == 16 { if v, err := uuidv7.BytesToString(id); err == nil { return v } } if v, err := uuidv7.BytesToString(uuidv7.MustBytes()); err == nil { return v } return "deleted" } func folderRepoIsDescendantFolder(tx *gorm.DB, candidateID []byte, ancestorID []byte) (bool, error) { currentID := append([]byte(nil), candidateID...) for len(currentID) != 0 { if folderRepoBytesEqual16(currentID, ancestorID) { return true, nil } var current filemanager.Folder err := tx.Model(&filemanager.Folder{}). Where("id = ? AND deleted_at IS NULL", currentID). First(¤t).Error if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } if err != nil { return false, err } if len(current.ParentID) == 0 { return false, nil } currentID = append([]byte(nil), current.ParentID...) } return false, nil } func folderRepoRewriteDescendantTree(tx *gorm.DB, rootID []byte, oldPrefix string, newPrefix string, depthDelta int, updatedBy []byte) error { queue := [][]byte{append([]byte(nil), rootID...)} for len(queue) > 0 { parentID := queue[0] queue = queue[1:] var children []filemanager.Folder if err := tx.Model(&filemanager.Folder{}). Where("parent_id = ?", parentID). Find(&children).Error; err != nil { return err } for i := range children { child := children[i] pathCache := child.PathCache if pathCache != "" { pathCache = folderRepoReplacePathPrefix(pathCache, oldPrefix, newPrefix) } depth := child.Depth + depthDelta if depth < 0 { depth = 0 } if err := tx.Model(&filemanager.Folder{}). Where("id = ?", child.ID). Updates(map[string]any{ "path_cache": pathCache, "depth": depth, "updated_by": updatedBy, "updated_at": time.Now().UTC(), }).Error; err != nil { return err } queue = append(queue, append([]byte(nil), child.ID...)) } } return nil } func folderRepoReplacePathPrefix(pathCache string, oldPrefix string, newPrefix string) string { if oldPrefix == "" { return pathCache } if pathCache == oldPrefix { return newPrefix } prefixWithSlash := oldPrefix + "/" if strings.HasPrefix(pathCache, prefixWithSlash) { return newPrefix + strings.TrimPrefix(pathCache, oldPrefix) } return pathCache } func folderRepoBuildPathCache(parentPath, name string) string { parentPath = strings.TrimSpace(parentPath) if parentPath == "" { return "/" + name } parentPath = strings.TrimRight(parentPath, "/") return parentPath + "/" + name } func folderRepoBytesEqual16(a, b []byte) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }