package files import ( "context" "errors" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // Gruppenordner: Ordner, die keinem Konto gehören, sondern einer Gruppe. // Sichtbar für jedes Mitglied, angelegt und entfernt ausschließlich vom Admin. // ErrGroupRootProtected: Der Wurzelordner einer Gruppe lässt sich nicht aus der // Dateiansicht heraus löschen oder umbenennen. Er ist kein gewöhnlicher Ordner, // sondern der Zugang der Gruppe – wenn ein beliebiges Mitglied ihn entfernen // könnte, wären die Daten aller anderen mit weg. Verwaltet wird er im // Admin-Bereich; alles *innerhalb* bleibt normal löschbar. var ErrGroupRootProtected = errors.New("group folder can only be removed by an administrator") // ErrGroupFolderExists: Eine Gruppe hat genau einen Ordner, nicht mehrere. // Das hält das Modell verständlich („der Ordner der Gruppe") und erlaubt es, // beim Löschen den gesamten Speicherbereich der Gruppe in einem Zug zu // entfernen, statt Blob für Blob – wodurch keine leeren Verzeichnisse // zurückbleiben. Wer mehr Struktur braucht, legt Unterordner an. var ErrGroupFolderExists = errors.New("this group already has a folder") // GroupFolder ist ein Gruppenordner samt Gruppe – für die Admin-Übersicht. type GroupFolder struct { ID uuid.UUID `json:"id"` Name string `json:"name"` GroupID uuid.UUID `json:"group_id"` GroupName string `json:"group_name"` Members int `json:"members"` UsedBytes int64 `json:"used_bytes"` QuotaByte int64 `json:"quota_bytes"` } // IsGroupRoot sagt, ob ein Ordner der Wurzelordner einer Gruppe ist. func (s *Store) IsGroupRoot(ctx context.Context, folderID uuid.UUID) (bool, error) { var isRoot bool err := s.db.QueryRow(ctx, `SELECT group_id IS NOT NULL AND parent_id IS NULL FROM folders WHERE id = $1`, folderID).Scan(&isRoot) if errors.Is(err, pgx.ErrNoRows) { return false, ErrNotFound } if err != nil { return false, fmt.Errorf("check group root: %w", err) } return isRoot, nil } // GroupName liefert den Namen einer Gruppe – für die Beschriftung des // Gruppenordners in WebDAV. func (s *Store) GroupName(ctx context.Context, groupID uuid.UUID) (string, error) { var name string err := s.db.QueryRow(ctx, `SELECT name FROM groups WHERE id = $1`, groupID).Scan(&name) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("group name: %w", err) } return name, nil } // ListGroupFoldersForUser liefert die Gruppenordner, die dem Nutzer über seine // Mitgliedschaften zustehen. Sie erscheinen in seiner Wurzel neben den eigenen // Ordnern – ohne dass er etwas dafür tun muss. func (s *Store) ListGroupFoldersForUser(ctx context.Context, userID uuid.UUID) ([]*Folder, error) { rows, err := s.db.Query(ctx, `SELECT f.id, f.owner_id, f.group_id, f.parent_id, f.name, f.created_at FROM folders f JOIN group_members m ON m.group_id = f.group_id WHERE m.user_id = $1 AND f.parent_id IS NULL ORDER BY f.name`, userID) if err != nil { return nil, fmt.Errorf("list group folders: %w", err) } defer rows.Close() out := []*Folder{} for rows.Next() { var f Folder if err := rows.Scan(&f.ID, &f.OwnerID, &f.GroupID, &f.ParentID, &f.Name, &f.CreatedAt); err != nil { return nil, fmt.Errorf("scan group folder: %w", err) } out = append(out, &f) } return out, rows.Err() } // ListGroupFolders liefert alle Gruppenordner für den Admin-Bereich. func (s *Store) ListGroupFolders(ctx context.Context) ([]*GroupFolder, error) { rows, err := s.db.Query(ctx, ` SELECT f.id, f.name, g.id, g.name, g.quota_bytes, (SELECT count(*) FROM group_members m WHERE m.group_id = g.id), COALESCE((SELECT SUM(x.size) FROM files x WHERE x.group_id = g.id AND x.is_deleted = false), 0) FROM folders f JOIN groups g ON g.id = f.group_id WHERE f.parent_id IS NULL ORDER BY g.name, f.name`) if err != nil { return nil, fmt.Errorf("list group folders: %w", err) } defer rows.Close() out := []*GroupFolder{} for rows.Next() { var gf GroupFolder if err := rows.Scan(&gf.ID, &gf.Name, &gf.GroupID, &gf.GroupName, &gf.QuotaByte, &gf.Members, &gf.UsedBytes); err != nil { return nil, fmt.Errorf("scan group folder: %w", err) } out = append(out, &gf) } return out, rows.Err() } // CreateGroupFolder legt den Wurzelordner einer Gruppe an. func (s *Store) CreateGroupFolder(ctx context.Context, groupID uuid.UUID, name string) (*Folder, error) { var exists bool if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM folders WHERE group_id = $1 AND parent_id IS NULL)`, groupID).Scan(&exists); err != nil { return nil, fmt.Errorf("check existing group folder: %w", err) } if exists { return nil, ErrGroupFolderExists } return s.CreateFolder(ctx, &Folder{GroupID: &groupID, Name: name}) } // DeleteGroupFolderTree entfernt einen Gruppenordner mit allem darin und gibt // die Gruppen-ID zurück, damit der Aufrufer deren Speicherbereich am Stück // entfernen kann. Nur für den Admin-Bereich gedacht. func (s *Store) DeleteGroupFolderTree(ctx context.Context, folderID uuid.UUID) (uuid.UUID, error) { var groupID *uuid.UUID var parentID *uuid.UUID err := s.db.QueryRow(ctx, `SELECT group_id, parent_id FROM folders WHERE id = $1`, folderID).Scan(&groupID, &parentID) if errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, ErrNotFound } if err != nil { return uuid.Nil, fmt.Errorf("load group folder: %w", err) } if groupID == nil || parentID != nil { return uuid.Nil, ErrNotFound } // Dateien zuerst: Die Kaskade über folders nimmt nur mit, was unter dem // Ordner hängt. Dateien direkt in der Wurzel hätten sonst keinen // Elternteil mehr, der sie entfernt. if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE group_id = $1`, *groupID); err != nil { return uuid.Nil, fmt.Errorf("delete group files: %w", err) } if _, err := s.db.Exec(ctx, `DELETE FROM folders WHERE group_id = $1`, *groupID); err != nil { return uuid.Nil, fmt.Errorf("delete group folders: %w", err) } return *groupID, nil }