feat: add file move and trash retention
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 30s
CI / test (push) Successful in 19s

This commit is contained in:
Grendgi
2026-06-16 14:40:28 +03:00
parent 2723f20ab0
commit 3de4e5dfe7
7 changed files with 176 additions and 13 deletions

View File

@@ -31,7 +31,7 @@ func scanNode(scan func(dest ...any) error) (*model.Node, error) {
&n.ID, &n.ParentID, &n.NodeType, &n.Title, &n.OwnerUserID, &n.OwnerDepartmentID,
&n.CreatedBy, &n.UpdatedBy, &n.StorageKey, &n.OriginalFilename, &n.MimeType,
&n.Extension, &n.SizeBytes, &n.OfficeFormat, &n.ExternalURL, &n.Version,
&n.EffectiveAccess, &n.CreatedAt, &n.UpdatedAt, &n.DeletedAt,
&n.EffectiveAccess, &n.CreatedAt, &n.UpdatedAt, &n.TrashedAt, &n.PurgeAfter, &n.DeletedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -67,7 +67,7 @@ func (r *NodeRepository) List(ctx context.Context, userID string, subordinateIDs
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,
effective_node_access(n.id, $1, $2::text[]),
n.created_at, n.updated_at, n.deleted_at
n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_nodes n
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY CASE WHEN n.node_type = 'folder' THEN 0 ELSE 1 END, lower(n.title), n.created_at DESC`
@@ -95,7 +95,7 @@ func (r *NodeRepository) GetForUser(ctx context.Context, id, userID string, subo
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,
effective_node_access(n.id, $2, $3::text[]),
n.created_at, n.updated_at, n.deleted_at
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
@@ -110,7 +110,7 @@ func (r *NodeRepository) CreateFolder(ctx context.Context, title string, parentI
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
created_by, updated_by, storage_key, original_filename, mime_type,
extension, size_bytes, office_format, external_url, version,
'edit' AS effective_access, created_at, updated_at, deleted_at
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, parentID, title, ownerID).Scan)
}
@@ -123,7 +123,7 @@ func (r *NodeRepository) CreateFile(ctx context.Context, n *model.Node) (*model.
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
created_by, updated_by, storage_key, original_filename, mime_type,
extension, size_bytes, office_format, external_url, version,
'edit' AS effective_access, created_at, updated_at, deleted_at
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, n.ParentID, n.Title, n.OwnerUserID, n.StorageKey, n.OriginalFilename, n.MimeType, n.Extension, n.SizeBytes).Scan)
}
@@ -141,11 +141,44 @@ func (r *NodeRepository) Update(ctx context.Context, id, actorID string, req mod
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
created_by, updated_by, storage_key, original_filename, mime_type,
extension, size_bytes, office_format, external_url, version,
'edit' AS effective_access, created_at, updated_at, deleted_at
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, id, actorID, req.Title, req.ParentID).Scan)
}
func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) error {
func (r *NodeRepository) Move(ctx context.Context, id, actorID string, subordinateIDs []string, parentID *string) (*model.Node, error) {
if parentID != nil && *parentID != "" {
var wouldCycle bool
if err := r.pool.QueryRow(ctx, `
WITH RECURSIVE subtree AS (
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NULL
UNION ALL
SELECT c.id FROM files_nodes c JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NULL
)
SELECT EXISTS (SELECT 1 FROM subtree WHERE id = $2)
`, id, *parentID).Scan(&wouldCycle); err != nil {
return nil, err
}
if wouldCycle {
return nil, model.ErrInvalidMove
}
}
return scanNode(r.pool.QueryRow(ctx, `
UPDATE files_nodes
SET parent_id = $4,
updated_by = $2,
updated_at = now(),
version = version + 1
WHERE id = $1
AND deleted_at IS NULL
AND effective_node_access(id, $2, $3::text[]) = 'edit'
RETURNING id, parent_id, node_type, title, owner_user_id, owner_department_id,
created_by, updated_by, storage_key, original_filename, mime_type,
extension, size_bytes, office_format, external_url, version,
'edit' AS effective_access, created_at, updated_at, trashed_at, purge_after, deleted_at
`, id, actorID, subordinateIDs, parentID).Scan)
}
func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string, purgeAfter time.Time) error {
tag, err := r.pool.Exec(ctx, `
WITH RECURSIVE subtree AS (
SELECT id FROM files_nodes WHERE id = $1 AND deleted_at IS NULL
@@ -153,10 +186,10 @@ func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) err
SELECT c.id FROM files_nodes c JOIN subtree s ON c.parent_id = s.id WHERE c.deleted_at IS NULL
)
UPDATE files_nodes
SET deleted_at = now(), updated_by = $2, updated_at = now()
SET deleted_at = now(), trashed_at = now(), purge_after = $3, updated_by = $2, updated_at = now()
WHERE id IN (SELECT id FROM subtree)
AND effective_node_access($1, $2, '{}'::text[]) = 'edit'
`, id, actorID)
`, id, actorID, purgeAfter)
if err != nil {
return err
}
@@ -166,6 +199,41 @@ func (r *NodeRepository) SoftDelete(ctx context.Context, id, actorID string) err
return nil
}
func (r *NodeRepository) ListPurgeableStorageKeys(ctx context.Context) ([]string, error) {
rows, err := r.pool.Query(ctx, `
SELECT storage_key
FROM files_nodes
WHERE purge_after IS NOT NULL
AND purge_after <= now()
AND storage_key IS NOT NULL
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, err
}
out = append(out, key)
}
return out, rows.Err()
}
func (r *NodeRepository) PurgeExpired(ctx context.Context) (int64, error) {
tag, err := r.pool.Exec(ctx, `
DELETE FROM files_nodes
WHERE purge_after IS NOT NULL
AND purge_after <= now()
`)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
func (r *NodeRepository) ListAccess(ctx context.Context, nodeID string) ([]model.Access, error) {
rows, err := r.pool.Query(ctx, `
SELECT user_id, access_level, granted_by, created_at
@@ -260,7 +328,7 @@ func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*m
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.deleted_at
'view' AS effective_access, n.created_at, n.updated_at, n.trashed_at, n.purge_after, n.deleted_at
FROM files_public_links l
JOIN files_nodes n ON n.id = l.node_id
WHERE l.token_hash = $1