From c831d2c7c60eb0ae9960827efd5b19b5185524a0 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Tue, 16 Jun 2026 15:48:09 +0300 Subject: [PATCH] feat: add visible trash restore for files --- cmd/server/main.go | 1 + internal/handler/node.go | 26 +++++++- internal/model/model.go | 5 ++ internal/repository/node.go | 114 ++++++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 35ef0c9..cfbe3f4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -78,6 +78,7 @@ func main() { r.Get("/", nodeH.Get) r.Patch("/", nodeH.Update) r.Post("/move", nodeH.Move) + r.Post("/restore", nodeH.Restore) r.Delete("/", nodeH.Delete) r.Get("/download", nodeH.Download) r.Get("/access", nodeH.ListAccess) diff --git a/internal/handler/node.go b/internal/handler/node.go index ddccea8..1ddfcb1 100644 --- a/internal/handler/node.go +++ b/internal/handler/node.go @@ -240,6 +240,21 @@ func (h *NodeHandler) Move(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, node) } +func (h *NodeHandler) Restore(w http.ResponseWriter, r *http.Request) { + userID := commonmw.GetUserID(r.Context()) + node, err := h.repo.Restore(r.Context(), chi.URLParam(r, "id"), userID, subordinates(r)) + if errors.Is(err, repository.ErrNotFound) { + writeError(w, http.StatusNotFound, "file not found") + return + } + if err != nil { + writeInternalError(w, r, err, "failed to restore file") + return + } + h.repo.Audit(r.Context(), userID, "files.node_restore", "files_node", node.ID, "{}") + writeJSON(w, http.StatusOK, node) +} + func (h *NodeHandler) Delete(w http.ResponseWriter, r *http.Request) { userID := commonmw.GetUserID(r.Context()) id := chi.URLParam(r, "id") @@ -338,7 +353,16 @@ func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) { if !ok { return } - writeJSON(w, http.StatusOK, node) + response := model.PublicNodeResponse{Node: node} + if node.NodeType == model.NodeTypeFolder { + children, err := h.repo.ListChildrenForPublic(r.Context(), node.ID) + if err != nil { + writeInternalError(w, r, err, "failed to list folder") + return + } + response.Children = children + } + writeJSON(w, http.StatusOK, response) } func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) { diff --git a/internal/model/model.go b/internal/model/model.go index 5158bc7..59f1b0f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -71,6 +71,11 @@ type MoveNodeRequest struct { ParentID *string `json:"parent_id"` } +type PublicNodeResponse struct { + Node *Node `json:"node"` + Children []Node `json:"children,omitempty"` +} + type ReplaceAccessRequest struct { Access []Access `json:"access"` } diff --git a/internal/repository/node.go b/internal/repository/node.go index fb759ab..e988d24 100644 --- a/internal/repository/node.go +++ b/internal/repository/node.go @@ -42,6 +42,44 @@ func scanNode(scan func(dest ...any) error) (*model.Node, error) { func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string) ([]model.Node, error) { args := []any{userID, subordinateIDs} where := []string{"n.deleted_at IS NULL"} + if scope == "trash" { + query := ` + SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id, + n.created_by, n.updated_by, n.storage_key, n.original_filename, n.mime_type, + n.extension, n.size_bytes, n.office_format, n.external_url, n.version, + 'edit' AS effective_access, + n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at + FROM files_nodes n + LEFT JOIN files_nodes p ON p.id = n.parent_id + WHERE n.deleted_at IS NOT NULL + AND (n.parent_id IS NULL OR p.deleted_at IS NULL) + AND ( + n.owner_user_id = $1 + OR n.owner_user_id::text = ANY($2::text[]) + OR EXISTS ( + SELECT 1 FROM files_access a + WHERE a.node_id = n.id + AND a.user_id = $1 + AND a.access_level = 'edit' + ) + ) + ORDER BY n.trashed_at DESC NULLS LAST, lower(n.title)` + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]model.Node, 0) + for rows.Next() { + n, err := scanNode(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *n) + } + return out, rows.Err() + } if parentID == nil || *parentID == "" { where = append(where, "n.parent_id IS NULL") } else { @@ -89,6 +127,33 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs return out, rows.Err() } +func (r *NodeRepository) ListChildrenForPublic(ctx context.Context, parentID string) ([]model.Node, error) { + rows, err := r.pool.Query(ctx, ` + SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id, + n.created_by, n.updated_by, n.storage_key, n.original_filename, n.mime_type, + n.extension, n.size_bytes, n.office_format, n.external_url, n.version, + 'view' AS effective_access, + n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at + FROM files_nodes n + WHERE n.deleted_at IS NULL + AND n.parent_id = $1 + ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC + `, parentID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]model.Node, 0) + for rows.Next() { + n, err := scanNode(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *n) + } + return out, rows.Err() +} + func (r *NodeRepository) GetForUser(ctx context.Context, id, userID string, subordinateIDs []string) (*model.Node, error) { return scanNode(r.pool.QueryRow(ctx, ` SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id, @@ -242,6 +307,55 @@ func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string, pur return nil } +func (r *NodeRepository) Restore(ctx context.Context, id, actorID string, subordinateIDs []string) (*model.Node, error) { + return scanNode(r.pool.QueryRow(ctx, ` + WITH RECURSIVE subtree AS ( + SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NOT NULL + UNION ALL + SELECT c.id FROM files_nodes c JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NOT NULL + ), + restored AS ( + UPDATE files_nodes n + SET deleted_at = NULL, + trashed_at = NULL, + purge_after = NULL, + parent_id = CASE + WHEN n.id = $1 AND EXISTS ( + SELECT 1 FROM files_nodes p WHERE p.id = n.parent_id AND p.deleted_at IS NOT NULL + ) THEN NULL + ELSE n.parent_id + END, + updated_by = $2, + updated_at = now(), + version = version + 1 + WHERE n.id IN (SELECT id FROM subtree) + AND EXISTS ( + SELECT 1 FROM files_nodes root + WHERE root.id = $1 + AND root.deleted_at IS NOT NULL + AND ( + root.owner_user_id = $2 + OR root.owner_user_id::text = ANY($3::text[]) + OR EXISTS ( + SELECT 1 FROM files_access a + WHERE a.node_id = root.id + AND a.user_id = $2 + AND a.access_level = 'edit' + ) + ) + ) + ) + SELECT n.id, n.parent_id, n.node_type, n.title, n.owner_user_id, n.owner_department_id, + n.created_by, n.updated_by, n.storage_key, n.original_filename, n.mime_type, + n.extension, n.size_bytes, n.office_format, n.external_url, n.version, + 'edit' AS effective_access, + n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at + FROM files_nodes n + WHERE n.id = $1 + AND n.deleted_at IS NULL + `, id, actorID, subordinateIDs).Scan) +} + func (r *NodeRepository) ListPurgeableStorageKeys(ctx context.Context) ([]string, error) { rows, err := r.pool.Query(ctx, ` SELECT storage_key