a18f96912d
interfaces - refactor: split UserRepository into AuthUserRepository and AdminUserRepository capabilities - refactor: split SessionRepository into AuthSessionRepository and repository-owned CLI/admin methods - refactor: replace generic Repository Update/Delete with operation-specific params and ownership predicates - refactor: replace CredentialRepository generic CRUD with passkey-specific methods - refactor: replace FileRepository generic Create/Update/Delete with UploadedFileParams, DirectoryParams, and owned soft-delete - refactor: remove repository fields from WebApp struct; repositories are now composition-time wiring only - feat: add domain error kinds ErrParentNotFound, ErrParentNotDir, ErrDirectoryNotEmpty, ErrInvalidMove - feat: add CredentialTypeAppPasskey constant - feat: add testutil.SetUserAdmin for test fixture setup that bypasses production service ports - test: add architecture test banning GORM Save in repository package - test: add capability interface contract tests ensuring each service receives the minimal interface - test: add blockingStorage helper for concurrent promotion tests - test: add preserved DSN parameter test for sqliteImmediateDSN - docs: update architecture decisions with repository write rules and capability separation - docs: update roadmap to clarify atomic single-use refresh sessions - docs: add -race test target to development docs
329 lines
9.5 KiB
Go
329 lines
9.5 KiB
Go
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
|
|
}
|