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>
282 lines
8.9 KiB
Go
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
|
|
}
|