feat: add visible trash restore for files
This commit is contained in:
@@ -78,6 +78,7 @@ func main() {
|
|||||||
r.Get("/", nodeH.Get)
|
r.Get("/", nodeH.Get)
|
||||||
r.Patch("/", nodeH.Update)
|
r.Patch("/", nodeH.Update)
|
||||||
r.Post("/move", nodeH.Move)
|
r.Post("/move", nodeH.Move)
|
||||||
|
r.Post("/restore", nodeH.Restore)
|
||||||
r.Delete("/", nodeH.Delete)
|
r.Delete("/", nodeH.Delete)
|
||||||
r.Get("/download", nodeH.Download)
|
r.Get("/download", nodeH.Download)
|
||||||
r.Get("/access", nodeH.ListAccess)
|
r.Get("/access", nodeH.ListAccess)
|
||||||
|
|||||||
@@ -240,6 +240,21 @@ func (h *NodeHandler) Move(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, node)
|
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) {
|
func (h *NodeHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := commonmw.GetUserID(r.Context())
|
userID := commonmw.GetUserID(r.Context())
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
@@ -338,7 +353,16 @@ func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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) {
|
func (h *NodeHandler) PublicDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ type MoveNodeRequest struct {
|
|||||||
ParentID *string `json:"parent_id"`
|
ParentID *string `json:"parent_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicNodeResponse struct {
|
||||||
|
Node *Node `json:"node"`
|
||||||
|
Children []Node `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ReplaceAccessRequest struct {
|
type ReplaceAccessRequest struct {
|
||||||
Access []Access `json:"access"`
|
Access []Access `json:"access"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs []string, scope string, parentID *string) ([]model.Node, error) {
|
||||||
args := []any{userID, subordinateIDs}
|
args := []any{userID, subordinateIDs}
|
||||||
where := []string{"n.deleted_at IS NULL"}
|
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 == "" {
|
if parentID == nil || *parentID == "" {
|
||||||
where = append(where, "n.parent_id IS NULL")
|
where = append(where, "n.parent_id IS NULL")
|
||||||
} else {
|
} else {
|
||||||
@@ -89,6 +127,33 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
|
|||||||
return out, rows.Err()
|
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) {
|
func (r *NodeRepository) GetForUser(ctx context.Context, id, userID string, subordinateIDs []string) (*model.Node, error) {
|
||||||
return scanNode(r.pool.QueryRow(ctx, `
|
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,
|
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
|
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) {
|
func (r *NodeRepository) ListPurgeableStorageKeys(ctx context.Context) ([]string, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT storage_key
|
SELECT storage_key
|
||||||
|
|||||||
Reference in New Issue
Block a user