2025-12-03 00:56:44 +00:00
package catalog
import (
"errors"
2025-12-13 19:24:54 +00:00
"slices"
"strings"
2025-12-03 00:56:44 +00:00
"time"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2"
)
const (
DirItemKindDirectory = "directory"
DirItemKindFile = "file"
)
2025-12-13 22:44:37 +00:00
// DirectoryInfo represents directory metadata
// @Description Directory information including path and timestamps
2025-12-03 00:56:44 +00:00
type DirectoryInfo struct {
2025-12-13 22:44:37 +00:00
// Item type, always "directory"
Kind string ` json:"kind" example:"directory" `
// Unique directory identifier
ID string ` json:"id" example:"kRp2XYTq9A55" `
// Full path from root (included when ?include=path)
Path virtualfs . Path ` json:"path,omitempty" `
// Directory name
Name string ` json:"name" example:"My Documents" `
// When the directory was created (ISO 8601)
CreatedAt time . Time ` json:"createdAt" example:"2024-12-13T15:04:05Z" `
// When the directory was last updated (ISO 8601)
UpdatedAt time . Time ` json:"updatedAt" example:"2024-12-13T16:30:00Z" `
// When the directory was trashed, null if not trashed (ISO 8601)
DeletedAt * time . Time ` json:"deletedAt,omitempty" example:"2024-12-14T10:00:00Z" `
2025-12-03 00:56:44 +00:00
}
2025-12-13 22:44:37 +00:00
// createDirectoryRequest represents a new directory creation request
// @Description Request to create a new directory
2025-12-03 00:56:44 +00:00
type createDirectoryRequest struct {
2025-12-13 22:44:37 +00:00
// ID of the parent directory
ParentID string ` json:"parentID" example:"kRp2XYTq9A55" `
// Name for the new directory
Name string ` json:"name" example:"New Folder" `
2025-12-03 00:56:44 +00:00
}
2025-12-13 22:44:37 +00:00
// postDirectoryContentRequest represents a move items request
// @Description Request to move items into this directory
2025-12-13 19:24:54 +00:00
type postDirectoryContentRequest struct {
2025-12-13 22:44:37 +00:00
// Array of file/directory IDs to move
Items [ ] string ` json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55" `
2025-12-13 19:24:54 +00:00
}
2025-12-14 16:43:05 +00:00
// moveItemsToDirectoryResponse represents the response to a request
// to move items into a directory.
// @Description Response from moving items to a directory with status for each item
type moveItemsToDirectoryResponse struct {
// Array of items included in the request (FileInfo or DirectoryInfo objects)
Items [ ] any ` json:"items" `
// Array of IDs of successfully moved items
Moved [ ] string ` json:"moved" example:"mElnUNCm8F22,kRp2XYTq9A55" `
// Array of IDs of items that conflicted with existing items in the target directory
Conflicts [ ] string ` json:"conflicts" example:"xYz123AbC456" `
// Array of errors that occurred during the move operation
Errors [ ] moveItemError ` json:"errors" `
}
// moveItemError represents an error that occurred while moving a specific item
// @Description Error details for a failed item move
type moveItemError struct {
// ID of the item that failed to move
ID string ` json:"id" example:"mElnUNCm8F22" `
// Error message describing what went wrong
Error string ` json:"error" example:"permission denied" `
}
2025-12-03 00:56:44 +00:00
func ( h * HTTPHandler ) currentDirectoryMiddleware ( c * fiber . Ctx ) error {
account := account . CurrentAccount ( c )
if account == nil {
return c . SendStatus ( fiber . StatusUnauthorized )
}
directoryID := c . Params ( "directoryID" )
2025-12-15 00:13:10 +00:00
var node * virtualfs . Node
if directoryID == "root" {
n , err := h . vfs . FindRootDirectory ( c . Context ( ) , h . db , account . ID )
if err != nil {
return httperr . Internal ( err )
2025-12-03 00:56:44 +00:00
}
2025-12-15 00:13:10 +00:00
node = n
} else {
n , err := h . vfs . FindNodeByPublicID ( c . Context ( ) , h . db , account . ID , directoryID )
if err != nil {
if errors . Is ( err , virtualfs . ErrNodeNotFound ) {
return c . SendStatus ( fiber . StatusNotFound )
}
return httperr . Internal ( err )
}
node = n
2025-12-03 00:56:44 +00:00
}
c . Locals ( "directory" , node )
return c . Next ( )
}
func mustCurrentDirectoryNode ( c * fiber . Ctx ) * virtualfs . Node {
return c . Locals ( "directory" ) . ( * virtualfs . Node )
}
2025-12-13 19:24:54 +00:00
func includeParam ( c * fiber . Ctx ) [ ] string {
return strings . Split ( c . Query ( "include" ) , "," )
}
2025-12-13 22:44:37 +00:00
// createDirectory creates a new directory
// @Summary Create directory
// @Description Create a new directory within a parent directory
// @Tags directories
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param request body createDirectoryRequest true "Directory details"
// @Param include query string false "Include additional fields" Enums(path)
// @Success 200 {object} DirectoryInfo "Created directory"
// @Failure 400 {object} map[string]string "Parent not found or not a directory"
// @Failure 401 {string} string "Not authenticated"
// @Failure 409 {object} map[string]string "Directory already exists"
// @Router /accounts/{accountID}/directories [post]
2025-12-03 00:56:44 +00:00
func ( h * HTTPHandler ) createDirectory ( c * fiber . Ctx ) error {
account := account . CurrentAccount ( c )
if account == nil {
return c . SendStatus ( fiber . StatusUnauthorized )
}
req := new ( createDirectoryRequest )
if err := c . BodyParser ( req ) ; err != nil {
return c . SendStatus ( fiber . StatusBadRequest )
}
2025-12-05 00:55:41 +00:00
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
if err != nil {
return httperr . Internal ( err )
}
defer tx . Rollback ( )
parent , err := h . vfs . FindNodeByPublicID ( c . Context ( ) , tx , account . ID , req . ParentID )
2025-12-03 00:56:44 +00:00
if err != nil {
if errors . Is ( err , virtualfs . ErrNodeNotFound ) {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "Parent not found" } )
}
return httperr . Internal ( err )
}
if parent . Kind != virtualfs . NodeKindDirectory {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "Parent is not a directory" } )
}
2025-12-05 00:55:41 +00:00
node , err := h . vfs . CreateDirectory ( c . Context ( ) , tx , account . ID , parent . ID , req . Name )
2025-12-03 00:56:44 +00:00
if err != nil {
2025-12-05 00:38:31 +00:00
if errors . Is ( err , virtualfs . ErrNodeConflict ) {
return c . Status ( fiber . StatusConflict ) . JSON ( fiber . Map { "error" : "Directory already exists" } )
}
2025-12-03 00:56:44 +00:00
return httperr . Internal ( err )
}
2025-12-13 19:24:54 +00:00
i := DirectoryInfo {
2025-12-03 00:56:44 +00:00
Kind : DirItemKindDirectory ,
ID : node . PublicID ,
Name : node . Name ,
CreatedAt : node . CreatedAt ,
UpdatedAt : node . UpdatedAt ,
DeletedAt : node . DeletedAt ,
2025-12-13 19:24:54 +00:00
}
include := includeParam ( c )
if slices . Contains ( include , "path" ) {
p , err := h . vfs . RealPath ( c . Context ( ) , tx , node )
if err != nil {
return httperr . Internal ( err )
}
i . Path = p
}
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
return c . JSON ( i )
2025-12-03 00:56:44 +00:00
}
2025-12-13 22:44:37 +00:00
// fetchDirectory returns directory metadata
// @Summary Get directory info
// @Description Retrieve metadata for a specific directory
// @Tags directories
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Param include query string false "Include additional fields" Enums(path)
// @Success 200 {object} DirectoryInfo "Directory metadata"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [get]
2025-12-03 00:56:44 +00:00
func ( h * HTTPHandler ) fetchDirectory ( c * fiber . Ctx ) error {
node := mustCurrentDirectoryNode ( c )
2025-12-05 00:38:05 +00:00
2025-12-03 00:56:44 +00:00
i := DirectoryInfo {
Kind : DirItemKindDirectory ,
ID : node . PublicID ,
Name : node . Name ,
CreatedAt : node . CreatedAt ,
UpdatedAt : node . UpdatedAt ,
DeletedAt : node . DeletedAt ,
}
2025-12-05 00:38:05 +00:00
2025-12-13 19:24:54 +00:00
include := includeParam ( c )
if slices . Contains ( include , "path" ) {
2025-12-05 00:38:05 +00:00
p , err := h . vfs . RealPath ( c . Context ( ) , h . db , node )
if err != nil {
return httperr . Internal ( err )
}
i . Path = p
}
2025-12-03 00:56:44 +00:00
return c . JSON ( i )
}
2025-12-13 22:44:37 +00:00
// listDirectory returns directory contents
// @Summary List directory contents
// @Description Get all files and subdirectories within a directory
// @Tags directories
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Success 200 {array} interface{} "Array of FileInfo and DirectoryInfo objects"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID}/content [get]
2025-12-03 00:56:44 +00:00
func ( h * HTTPHandler ) listDirectory ( c * fiber . Ctx ) error {
node := mustCurrentDirectoryNode ( c )
children , err := h . vfs . ListChildren ( c . Context ( ) , h . db , node )
if err != nil {
if errors . Is ( err , virtualfs . ErrNodeNotFound ) {
return c . SendStatus ( fiber . StatusNotFound )
}
return httperr . Internal ( err )
}
items := make ( [ ] any , len ( children ) )
for i , child := range children {
switch child . Kind {
case virtualfs . NodeKindDirectory :
items [ i ] = DirectoryInfo {
Kind : DirItemKindDirectory ,
ID : child . PublicID ,
Name : child . Name ,
CreatedAt : child . CreatedAt ,
UpdatedAt : child . UpdatedAt ,
DeletedAt : child . DeletedAt ,
}
case virtualfs . NodeKindFile :
items [ i ] = FileInfo {
Kind : DirItemKindFile ,
ID : child . PublicID ,
Name : child . Name ,
Size : child . Size ,
MimeType : child . MimeType ,
CreatedAt : child . CreatedAt ,
UpdatedAt : child . UpdatedAt ,
DeletedAt : child . DeletedAt ,
}
}
}
return c . JSON ( items )
}
2025-12-13 22:44:37 +00:00
// patchDirectory updates directory properties
// @Summary Update directory
// @Description Update directory properties such as name (rename)
// @Tags directories
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Param request body patchDirectoryRequest true "Directory update"
// @Success 200 {object} DirectoryInfo "Updated directory metadata"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [patch]
2025-12-03 00:56:44 +00:00
func ( h * HTTPHandler ) patchDirectory ( c * fiber . Ctx ) error {
node := mustCurrentDirectoryNode ( c )
patch := new ( patchDirectoryRequest )
if err := c . BodyParser ( patch ) ; err != nil {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "Invalid request" } )
}
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
if err != nil {
return httperr . Internal ( err )
}
defer tx . Rollback ( )
if patch . Name != "" {
err := h . vfs . RenameNode ( c . Context ( ) , tx , node , patch . Name )
if err != nil {
if errors . Is ( err , virtualfs . ErrNodeNotFound ) {
return c . SendStatus ( fiber . StatusNotFound )
}
return httperr . Internal ( err )
}
}
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
return c . JSON ( DirectoryInfo {
Kind : DirItemKindDirectory ,
ID : node . PublicID ,
Name : node . Name ,
CreatedAt : node . CreatedAt ,
UpdatedAt : node . UpdatedAt ,
DeletedAt : node . DeletedAt ,
} )
}
2025-12-13 22:44:37 +00:00
// deleteDirectory removes a directory
// @Summary Delete directory
// @Description Delete a directory permanently or move it to trash. Deleting a directory also affects all its contents.
// @Tags directories
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 204 {string} string "Directory deleted"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [delete]
2025-12-03 00:56:44 +00:00
func ( h * HTTPHandler ) deleteDirectory ( c * fiber . Ctx ) error {
node := mustCurrentDirectoryNode ( c )
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
if err != nil {
return httperr . Internal ( err )
}
defer tx . Rollback ( )
shouldTrash := c . Query ( "trash" ) == "true"
if shouldTrash {
2025-12-15 00:13:10 +00:00
_ , err := h . vfs . SoftDeleteNode ( c . Context ( ) , tx , node )
2025-12-03 00:56:44 +00:00
if err != nil {
return httperr . Internal ( err )
}
2025-12-15 00:13:10 +00:00
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
return c . JSON ( directoryInfoFromNode ( node ) )
2025-12-03 00:56:44 +00:00
} else {
2025-12-15 00:13:10 +00:00
err = h . vfs . PermanentlyDeleteNode ( c . Context ( ) , tx , node )
2025-12-03 00:56:44 +00:00
if err != nil {
return httperr . Internal ( err )
}
2025-12-15 00:13:10 +00:00
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
return c . SendStatus ( fiber . StatusNoContent )
2025-12-03 00:56:44 +00:00
}
2025-12-15 00:13:10 +00:00
}
// deleteDirectories removes multiple directories
// @Summary Bulk delete directories
// @Description Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.
// @Tags directories
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 204 {string} string "Directories deleted"
// @Failure 400 {object} map[string]string "All items must be directories"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/directories [delete]
func ( h * HTTPHandler ) deleteDirectories ( c * fiber . Ctx ) error {
account := account . CurrentAccount ( c )
if account == nil {
return c . SendStatus ( fiber . StatusUnauthorized )
}
idq := c . Query ( "id" , "" )
if idq == "" {
return c . SendStatus ( fiber . StatusNoContent )
}
ids := strings . Split ( idq , "," )
if len ( ids ) == 0 {
return c . SendStatus ( fiber . StatusNoContent )
}
shouldTrash := c . Query ( "trash" ) == "true"
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
2025-12-03 00:56:44 +00:00
if err != nil {
return httperr . Internal ( err )
}
2025-12-15 00:13:10 +00:00
defer tx . Rollback ( )
nodes , err := h . vfs . FindNodesByPublicID ( c . Context ( ) , tx , account . ID , ids )
if err != nil {
return httperr . Internal ( err )
}
if len ( nodes ) == 0 {
return c . SendStatus ( fiber . StatusNoContent )
}
for _ , node := range nodes {
if node . Kind != virtualfs . NodeKindDirectory {
return httperr . NewHTTPError ( fiber . StatusBadRequest , "all items must be directories" , nil )
}
}
if shouldTrash {
deleted , err := h . vfs . SoftDeleteNodes ( c . Context ( ) , tx , nodes )
if err != nil {
return httperr . Internal ( err )
}
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
res := make ( [ ] DirectoryInfo , 0 , len ( deleted ) )
for _ , node := range deleted {
res = append ( res , directoryInfoFromNode ( node ) )
}
return c . JSON ( deleted )
} else {
for _ , node := range nodes {
err = h . vfs . PermanentlyDeleteNode ( c . Context ( ) , tx , node )
if err != nil {
return httperr . Internal ( err )
}
}
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
return c . SendStatus ( fiber . StatusNoContent )
}
2025-12-03 00:56:44 +00:00
}
2025-12-13 19:24:54 +00:00
2025-12-13 22:44:37 +00:00
// moveItemsToDirectory moves files and directories into this directory
// @Summary Move items to directory
2025-12-14 16:43:05 +00:00
// @Description Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors.
2025-12-13 22:44:37 +00:00
// @Tags directories
// @Accept json
2025-12-14 16:43:05 +00:00
// @Produce json
2025-12-13 22:44:37 +00:00
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Target directory ID"
// @Param request body postDirectoryContentRequest true "Items to move"
2025-12-14 16:43:05 +00:00
// @Success 200 {object} moveItemsToDirectoryResponse "Move operation results with moved, conflict, and error states"
2025-12-13 22:44:37 +00:00
// @Failure 400 {object} map[string]string "Invalid request or items not in same directory"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {object} map[string]string "One or more items not found"
// @Router /accounts/{accountID}/directories/{directoryID}/content [post]
2025-12-13 19:24:54 +00:00
func ( h * HTTPHandler ) moveItemsToDirectory ( c * fiber . Ctx ) error {
acc := account . CurrentAccount ( c )
if acc == nil {
return c . SendStatus ( fiber . StatusUnauthorized )
}
targetDir := mustCurrentDirectoryNode ( c )
req := new ( postDirectoryContentRequest )
if err := c . BodyParser ( req ) ; err != nil {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "Invalid request" } )
}
if len ( req . Items ) == 0 {
return c . SendStatus ( fiber . StatusNoContent )
}
tx , err := h . db . BeginTx ( c . Context ( ) , nil )
if err != nil {
return httperr . Internal ( err )
}
defer tx . Rollback ( )
nodes , err := h . vfs . FindNodesByPublicID ( c . Context ( ) , tx , acc . ID , req . Items )
if err != nil {
return httperr . Internal ( err )
}
if len ( nodes ) != len ( req . Items ) {
return c . Status ( fiber . StatusNotFound ) . JSON ( fiber . Map { "error" : "One or more items not found" } )
}
// Move all nodes to the target directory
2025-12-14 16:43:05 +00:00
result , err := h . vfs . MoveNodesInSameDirectory ( c . Context ( ) , tx , nodes , targetDir . ID )
2025-12-13 19:24:54 +00:00
if err != nil {
if errors . Is ( err , virtualfs . ErrUnsupportedOperation ) {
return c . Status ( fiber . StatusBadRequest ) . JSON ( fiber . Map { "error" : "All items must be in the same directory" } )
}
if errors . Is ( err , virtualfs . ErrNodeConflict ) {
return c . Status ( fiber . StatusConflict ) . JSON ( fiber . Map { "error" : "Name conflict in target directory" } )
}
return httperr . Internal ( err )
}
err = tx . Commit ( )
if err != nil {
return httperr . Internal ( err )
}
2025-12-14 16:43:05 +00:00
res := moveItemsToDirectoryResponse { }
for _ , node := range result . Moved {
res . Items = append ( res . Items , toDirectoryItem ( node ) )
res . Moved = append ( res . Moved , node . PublicID )
}
for _ , node := range result . Conflicts {
res . Items = append ( res . Items , toDirectoryItem ( node ) )
res . Conflicts = append ( res . Conflicts , node . PublicID )
}
for _ , err := range result . Errors {
res . Errors = append ( res . Errors , moveItemError {
ID : err . Node . PublicID ,
Error : err . Error . Error ( ) ,
} )
}
return c . JSON ( res )
2025-12-13 19:24:54 +00:00
}