feat(repository): refactor query/mutation ports with capability-safe
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
This commit is contained in:
+43
-52
@@ -136,7 +136,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
return nil, model.NewInternalError("promote staged file", err)
|
||||
}
|
||||
|
||||
file := &model.File{
|
||||
file, err := s.fileRepo.CreateUploadedFile(ctx, repository.UploadedFileParams{
|
||||
ID: fileID,
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
@@ -145,16 +145,19 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
MimeType: mimeType,
|
||||
StoragePath: storagePath,
|
||||
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, file); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
// Compensate: remove the promoted file.
|
||||
_ = s.storage.Delete(ctx, storagePath)
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
return nil, model.NewInternalError("create file record", err)
|
||||
}
|
||||
|
||||
@@ -256,44 +259,42 @@ func (s *FileService) List(ctx context.Context, userID string, parentID *string,
|
||||
|
||||
// Update renames or moves a file or directory.
|
||||
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate new name if provided.
|
||||
var newNamePtr *string
|
||||
if newName != "" {
|
||||
if err := validateFileName(newName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.Name = newName
|
||||
newNamePtr = &newName
|
||||
}
|
||||
|
||||
// If parent is changing, verify the new parent.
|
||||
if newParentID != nil {
|
||||
if *newParentID == fileID {
|
||||
return nil, model.NewBadRequestError("cannot move a directory into itself")
|
||||
}
|
||||
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file.ParentID = newParentID
|
||||
}
|
||||
|
||||
// Check for name conflicts in the target directory.
|
||||
if newName != "" || newParentID != nil {
|
||||
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
|
||||
if err == nil && conflict.ID != fileID {
|
||||
return nil, model.NewConflictError("a file with this name already exists in the target directory")
|
||||
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Update(ctx, file); err != nil {
|
||||
file, err := s.fileRepo.UpdateOwnedMetadata(ctx, repository.FileMetadataUpdate{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
NewName: newNamePtr,
|
||||
NewParentID: newParentID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in the target directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrInvalidMove) {
|
||||
return nil, model.NewBadRequestError("cannot move a directory into itself")
|
||||
}
|
||||
return nil, model.NewInternalError("update file", err)
|
||||
}
|
||||
|
||||
@@ -302,26 +303,13 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
|
||||
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
|
||||
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Directories must be empty before deletion.
|
||||
if file.IsDir {
|
||||
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
|
||||
if err != nil {
|
||||
return model.NewInternalError("check directory contents", err)
|
||||
}
|
||||
if total > 0 {
|
||||
return model.NewConflictError("directory is not empty")
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
|
||||
if err := s.fileRepo.SoftDeleteOwned(ctx, userID, fileID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("file not found", model.ErrNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrDirectoryNotEmpty) {
|
||||
return model.NewConflictError("directory is not empty")
|
||||
}
|
||||
return model.NewInternalError("delete file record", err)
|
||||
}
|
||||
|
||||
@@ -347,19 +335,22 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
||||
return nil, model.NewInternalError("check name conflict", err)
|
||||
}
|
||||
|
||||
dir := &model.File{
|
||||
dir, err := s.fileRepo.CreateDirectory(ctx, repository.DirectoryParams{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Name: dirName,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(ctx, dir); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("a file with this name already exists in this directory")
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotFound) {
|
||||
return nil, model.NewNotFoundError("parent directory not found", model.ErrParentNotFound)
|
||||
}
|
||||
if errors.Is(err, model.ErrParentNotDir) {
|
||||
return nil, model.NewBadRequestError("parent is not a directory")
|
||||
}
|
||||
return nil, model.NewInternalError("create directory record", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user