package repository import ( "context" "errors" "sort" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/dhao2001/mygo/internal/model" ) // UploadedFileParams contains persisted metadata for a completed upload. type UploadedFileParams struct { ID string UserID string ParentID *string Name string Size int64 MimeType string StoragePath string Hash string } // DirectoryParams contains the fields accepted when creating a directory. type DirectoryParams struct { ID string UserID string ParentID *string Name string } // FileMetadataUpdate contains the user-editable metadata fields. // Nil fields are left unchanged. type FileMetadataUpdate struct { FileID string UserID string NewName *string NewParentID *string } // FileRepository provides access to file records. type FileRepository interface { CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) FindByID(ctx context.Context, id string) (*model.File, error) FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) SoftDeleteOwned(ctx context.Context, userID, fileID string) error } type fileRepository struct { db *gorm.DB } // NewFileRepository creates a FileRepository backed by GORM. func NewFileRepository(db *gorm.DB) FileRepository { return &fileRepository{db: db} } func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) { file := &model.File{ ID: params.ID, UserID: params.UserID, ParentID: params.ParentID, Name: params.Name, Size: params.Size, MimeType: params.MimeType, StoragePath: params.StoragePath, Hash: params.Hash, Status: model.StatusActive, IsDir: false, } if err := r.createEntry(ctx, file); err != nil { return nil, err } return file, nil } func (r *fileRepository) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) { dir := &model.File{ ID: params.ID, UserID: params.UserID, ParentID: params.ParentID, Name: params.Name, Status: model.StatusActive, IsDir: true, } if err := r.createEntry(ctx, dir); err != nil { return nil, err } return dir, nil } func (r *fileRepository) createEntry(ctx context.Context, file *model.File) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if file.ParentID != nil { locked, err := lockActiveOwnedFiles(tx, file.UserID, []string{*file.ParentID}) if err != nil { return err } parent, ok := locked[*file.ParentID] if !ok { return model.ErrParentNotFound } if !parent.IsDir { return model.ErrParentNotDir } } result := tx.Create(file) if result.Error != nil { if isDuplicateKeyError(result.Error) { return model.ErrDuplicate } return result.Error } return nil }) } func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) { var file model.File result := r.db.WithContext(ctx).First(&file, "id = ? AND status = ?", id, model.StatusActive) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, model.ErrNotFound } if result.Error != nil { return nil, result.Error } return &file, nil } func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error) { var files []model.File var total int64 if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND status = ?", userID, model.StatusActive).Count(&total).Error; err != nil { return nil, 0, err } result := r.db.WithContext(ctx).Where("user_id = ? AND status = ?", userID, model.StatusActive).Offset(offset).Limit(limit).Find(&files) if result.Error != nil { return nil, 0, result.Error } return files, total, nil } func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) { var files []model.File result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Find(&files) if result.Error != nil { return nil, result.Error } return files, nil } func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error) { var files []model.File var total int64 if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Count(&total).Error; err != nil { return nil, 0, err } result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files) if result.Error != nil { return nil, 0, result.Error } return files, total, nil } func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) { var file model.File result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ?", userID, parentID, name, model.StatusActive).First(&file) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, model.ErrNotFound } if result.Error != nil { return nil, result.Error } return &file, nil } func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) { var updated model.File err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ids := []string{params.FileID} if params.NewParentID != nil { if *params.NewParentID == params.FileID { return model.ErrInvalidMove } ids = append(ids, *params.NewParentID) } locked, err := lockActiveOwnedFiles(tx, params.UserID, ids) if err != nil { return err } source, ok := locked[params.FileID] if !ok { return model.ErrNotFound } updates := make(map[string]any, 2) finalName := source.Name finalParentID := source.ParentID if params.NewName != nil { updates["name"] = *params.NewName finalName = *params.NewName } if params.NewParentID != nil { parent, ok := locked[*params.NewParentID] if !ok { return model.ErrParentNotFound } if !parent.IsDir { return model.ErrParentNotDir } updates["parent_id"] = *params.NewParentID finalParentID = params.NewParentID } if len(updates) > 0 { var conflictCount int64 if err := tx.Model(&model.File{}). Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ? AND id <> ?", params.UserID, finalParentID, finalName, model.StatusActive, params.FileID). Count(&conflictCount).Error; err != nil { return err } if conflictCount > 0 { return model.ErrDuplicate } result := tx.Model(&model.File{}). Where("id = ? AND user_id = ? AND status = ?", params.FileID, params.UserID, model.StatusActive). Updates(updates) if result.Error != nil { if isDuplicateKeyError(result.Error) { return model.ErrDuplicate } return result.Error } if result.RowsAffected == 0 { return model.ErrNotFound } } result := tx.First(&updated, "id = ? AND user_id = ? AND status = ?", source.ID, params.UserID, model.StatusActive) if errors.Is(result.Error, gorm.ErrRecordNotFound) { return model.ErrNotFound } return result.Error }) if err != nil { return nil, err } return &updated, nil } func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID}) if err != nil { return err } file, ok := locked[fileID] if !ok { return model.ErrNotFound } if file.IsDir { var count int64 if err := tx.Model(&model.File{}). Where("parent_id = ? AND status = ?", fileID, model.StatusActive). Count(&count).Error; err != nil { return err } if count > 0 { return model.ErrDirectoryNotEmpty } } result := tx.Model(&model.File{}). Where("id = ? AND user_id = ? AND status = ?", fileID, userID, model.StatusActive). Update("status", model.StatusUserDeleted) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return model.ErrNotFound } return nil }) } func lockActiveOwnedFiles(tx *gorm.DB, userID string, ids []string) (map[string]model.File, error) { unique := make(map[string]struct{}, len(ids)) for _, id := range ids { unique[id] = struct{}{} } ordered := make([]string, 0, len(unique)) for id := range unique { ordered = append(ordered, id) } sort.Strings(ordered) var files []model.File result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}). Where("id IN ? AND user_id = ? AND status = ?", ordered, userID, model.StatusActive). Order("id ASC"). Find(&files) if result.Error != nil { return nil, result.Error } locked := make(map[string]model.File, len(files)) for i := range files { locked[files[i].ID] = files[i] } return locked, nil }