Files
nexarch/dms/internal/folder/folder.go
T
patrickandClaude Sonnet 5 f7179efb8d DOC-11: soft-delete & papierkorb-logik
trashed_at (Dokumente+Ordner) als reversibler Papierkorb-Status, getrennt von
deleted_at (DOC-16: endgueltige Vernichtung). Kaskadierendes Loeschen/
Wiederherstellen von Ordnerteilbaeumen ueber Materialized Path, PurgeExpired
als Aufraeum-Job (entfernt Dateiobjekte + Datenbankzeile nach Frist).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:40:16 +02:00

282 lines
8.9 KiB
Go

// Package folder implementiert DOC-09: hierarchische Ablage mit
// Verschieben, Umbenennen und effizienten Teilbaum-Abfragen über einen
// Materialized Path (siehe Migration 0006). Der Pfad besteht aus
// Ordner-IDs, nie aus Namen — Umbenennen ändert daher nie den Pfad eines
// Ordners oder seiner Kinder (Akzeptanzkriterium 3).
package folder
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound wird geliefert, wenn ein angefragter Ordner nicht existiert.
var ErrNotFound = errors.New("folder: ordner nicht gefunden")
// ErrCycle wird von Move geliefert, wenn das Ziel der Ordner selbst oder
// einer seiner eigenen Nachfahren ist (Akzeptanzkriterium 1, Pflichtprüfung
// 1: Zyklus beim Verschieben wird verhindert).
var ErrCycle = errors.New("folder: verschieben in den eigenen teilbaum wuerde einen zyklus erzeugen")
// Folder ist EIN Ordner inklusive seines Materialized Path.
type Folder struct {
ID string
ParentFolderID *string
Name string
Path string
}
func rootPath(id string) string {
return "/" + id + "/"
}
func childPath(parentPath, id string) string {
return parentPath + id + "/"
}
// Create legt einen neuen Ordner an — beliebig tief verschachtelbar
// (Akzeptanzkriterium 1), da der Pfad aus dem (ggf. selbst tief
// verschachtelten) Elternpfad abgeleitet wird, nicht aus einer festen
// Tiefe.
func Create(ctx context.Context, pool *pgxpool.Pool, name string, parentFolderID *string, createdBy string) (Folder, error) {
var parentPath string
if parentFolderID != nil {
var err error
parentPath, err = pathOf(ctx, pool, *parentFolderID)
if err != nil {
return Folder{}, err
}
}
var f Folder
f.Name = name
f.ParentFolderID = parentFolderID
if err := pool.QueryRow(ctx, `
INSERT INTO folders (parent_folder_id, name, created_by, path)
VALUES ($1, $2, $3, '')
RETURNING id
`, parentFolderID, name, createdBy).Scan(&f.ID); err != nil {
return Folder{}, fmt.Errorf("folder: ordner anlegen: %w", err)
}
if parentPath == "" {
f.Path = rootPath(f.ID)
} else {
f.Path = childPath(parentPath, f.ID)
}
if _, err := pool.Exec(ctx, `UPDATE folders SET path = $2 WHERE id = $1`, f.ID, f.Path); err != nil {
return Folder{}, fmt.Errorf("folder: pfad setzen: %w", err)
}
return f, nil
}
func pathOf(ctx context.Context, pool *pgxpool.Pool, folderID string) (string, error) {
var path string
err := pool.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1`, folderID).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("folder: pfad lesen: %w", err)
}
return path, nil
}
// Get liefert einen einzelnen Ordner.
func Get(ctx context.Context, pool *pgxpool.Pool, folderID string) (Folder, error) {
var f Folder
f.ID = folderID
err := pool.QueryRow(ctx, `SELECT parent_folder_id, name, path FROM folders WHERE id = $1`, folderID).
Scan(&f.ParentFolderID, &f.Name, &f.Path)
if errors.Is(err, pgx.ErrNoRows) {
return Folder{}, ErrNotFound
}
if err != nil {
return Folder{}, fmt.Errorf("folder: ordner lesen: %w", err)
}
return f, nil
}
// Rename ändert NUR den Namen — der Materialized Path (auf IDs basierend)
// bleibt unverändert, kein Verweis (eigener oder von Nachfahren) bricht
// dadurch (Akzeptanzkriterium/Pflichtprüfung 3).
func Rename(ctx context.Context, pool *pgxpool.Pool, folderID, newName string) error {
tag, err := pool.Exec(ctx, `UPDATE folders SET name = $2, updated_at = now() WHERE id = $1`, folderID, newName)
if err != nil {
return fmt.Errorf("folder: umbenennen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Move verschiebt einen Ordner (mitsamt seinem gesamten Teilbaum) unter
// einen neuen Elternordner (oder an die Wurzel, wenn newParentFolderID
// nil ist) — Akzeptanzkriterium 2: aktualisiert den Pfad JEDES betroffenen
// Nachfahren in einem Rutsch per Präfix-Ersetzung, kein rekursiver
// Einzelaufruf je Kind nötig.
func Move(ctx context.Context, pool *pgxpool.Pool, folderID string, newParentFolderID *string) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("folder: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var oldPath string
if err := tx.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1 FOR UPDATE`, folderID).Scan(&oldPath); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("folder: pfad lesen: %w", err)
}
var newParentPath string
if newParentFolderID != nil {
if *newParentFolderID == folderID {
return ErrCycle
}
if err := tx.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1`, *newParentFolderID).Scan(&newParentPath); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("folder: neuen elternpfad lesen: %w", err)
}
// Pflichtprüfung 1: das Ziel ist der Ordner selbst oder einer
// seiner eigenen Nachfahren, wenn dessen Pfad mit dem eigenen
// Pfad beginnt (der eigene Pfad ist als Präfix im Nachfahren
// enthalten).
if strings.HasPrefix(newParentPath, oldPath) {
return ErrCycle
}
}
newOwnPath := childPath(newParentPath, folderID)
if newParentFolderID == nil {
newOwnPath = rootPath(folderID)
}
if _, err := tx.Exec(ctx, `
UPDATE folders SET path = $2 || substr(path, length($1) + 1)
WHERE path LIKE $1 || '%'
`, oldPath, newOwnPath); err != nil {
return fmt.Errorf("folder: teilbaum-pfade aktualisieren: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE folders SET parent_folder_id = $2, updated_at = now() WHERE id = $1
`, folderID, newParentFolderID); err != nil {
return fmt.Errorf("folder: elternordner aktualisieren: %w", err)
}
return tx.Commit(ctx)
}
// Subtree liefert ALLE Ordner im Teilbaum EINSCHLIESSLICH folderID selbst
// — ein einziger Index-Scan über den Pfad-Präfix (Akzeptanzkriterium 3),
// keine rekursive Abfrage über parent_folder_id.
func Subtree(ctx context.Context, pool *pgxpool.Pool, folderID string) ([]Folder, error) {
path, err := pathOf(ctx, pool, folderID)
if err != nil {
return nil, err
}
rows, err := pool.Query(ctx, `
SELECT id, parent_folder_id, name, path FROM folders
WHERE path LIKE $1 || '%'
ORDER BY path
`, path)
if err != nil {
return nil, fmt.Errorf("folder: teilbaum abfragen: %w", err)
}
defer rows.Close()
var out []Folder
for rows.Next() {
var f Folder
if err := rows.Scan(&f.ID, &f.ParentFolderID, &f.Name, &f.Path); err != nil {
return nil, fmt.Errorf("folder: teilbaum-zeile lesen: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// ListChildren liefert die DIREKTEN Kinder eines Ordners (parentFolderID
// nil bedeutet: Wurzelordner) — Grundlage für DOC-10s lazy ladenden
// Navigationsbaum (nicht der gesamte Teilbaum auf einmal). Papierkorb-
// Ordner (DOC-11) verschwinden aus dieser normalen Ansicht.
func ListChildren(ctx context.Context, pool *pgxpool.Pool, parentFolderID *string) ([]Folder, error) {
var rows pgx.Rows
var err error
if parentFolderID == nil {
rows, err = pool.Query(ctx, `
SELECT id, parent_folder_id, name, path FROM folders WHERE parent_folder_id IS NULL AND trashed_at IS NULL ORDER BY name
`)
} else {
rows, err = pool.Query(ctx, `
SELECT id, parent_folder_id, name, path FROM folders WHERE parent_folder_id = $1 AND trashed_at IS NULL ORDER BY name
`, *parentFolderID)
}
if err != nil {
return nil, fmt.Errorf("folder: kinder abfragen: %w", err)
}
defer rows.Close()
var out []Folder
for rows.Next() {
var f Folder
if err := rows.Scan(&f.ID, &f.ParentFolderID, &f.Name, &f.Path); err != nil {
return nil, fmt.Errorf("folder: kind-zeile lesen: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// Breadcrumb liefert den Pfad von der Wurzel bis EINSCHLIESSLICH folderID
// als geordnete Liste — Grundlage für DOC-10s Brotkrumenpfad. Nutzt den
// Materialized Path (IDs in Reihenfolge extrahiert), EINE Abfrage statt
// eines Aufrufs je Ebene.
func Breadcrumb(ctx context.Context, pool *pgxpool.Pool, folderID string) ([]Folder, error) {
path, err := pathOf(ctx, pool, folderID)
if err != nil {
return nil, err
}
ids := strings.Split(strings.Trim(path, "/"), "/")
rows, err := pool.Query(ctx, `
SELECT id, parent_folder_id, name, path FROM folders WHERE id = ANY($1)
`, ids)
if err != nil {
return nil, fmt.Errorf("folder: brotkrumen abfragen: %w", err)
}
defer rows.Close()
byID := map[string]Folder{}
for rows.Next() {
var f Folder
if err := rows.Scan(&f.ID, &f.ParentFolderID, &f.Name, &f.Path); err != nil {
return nil, fmt.Errorf("folder: brotkrumen-zeile lesen: %w", err)
}
byID[f.ID] = f
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Folder, 0, len(ids))
for _, id := range ids {
f, ok := byID[id]
if !ok {
return nil, fmt.Errorf("folder: brotkrumen-eintrag %q nicht gefunden", id)
}
out = append(out, f)
}
return out, nil
}