munin/internal/store/papierkorb.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

209 lines
7.5 KiB
Go

package store
// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_papierkorb.sql).
//
// Der Lebenslauf eines gelöschten Eintrags:
//
// MoveEntryToTrash → deleted_at gesetzt, für alle Lesewege unsichtbar
// RestoreFromTrash → deleted_at wieder NULL, alles wie vorher
// PutEntry → derselbe Bezeichner wird neu geschrieben: zurückgeholt
// und überschrieben, der gelöschte Stand im Verlauf
// PurgeFromTrash → ein Mensch leert den Papierkorb: endgültig weg
// PurgeTrash → die tägliche Aufgabe räumt nach Ablauf der Frist ab
//
// Endgültig löschen können nur die beiden Purge-Funktionen, und beide fassen
// ausschließlich Einträge an, die schon im Papierkorb liegen. Ein normaler
// Eintrag kann also nie in einem Schritt verschwinden.
import (
"context"
"database/sql"
"fmt"
"time"
)
// TrashRetention bestimmt, wie lange ein gelöschter Eintrag im Papierkorb
// liegt, bevor die tägliche Aufgabe ihn endgültig entfernt.
//
// 30 Tage: lang genug, dass ein Fehler auch nach Urlaub oder Sprintende noch
// auffällt, kurz genug, dass versehentlich gespeicherte Geheimnisse nicht
// ewig liegen bleiben (die lassen sich außerdem sofort von Hand endgültig
// löschen). Eine Konstante hier statt in main.go, damit die Oberfläche
// dieselbe Zahl anzeigt, nach der auch aufgeräumt wird.
const TrashRetention = 30 * 24 * time.Hour
// TrashRetentionDays ist dieselbe Frist in Tagen, für Texte in Oberfläche
// und MCP-Beschreibung.
const TrashRetentionDays = int(TrashRetention / (24 * time.Hour))
// MoveEntryToTrash legt einen Eintrag in den Papierkorb.
//
// Inhalt, Schlagworte, Verweise und Verlauf bleiben unverändert stehen, auch
// die Fassungsnummer: Löschen ist keine neue Fassung, sondern eine Markierung
// daneben. geloeschtVon ist der Name des Zugangs, der gelöscht hat — die
// Papierkorb-Seite zeigt ihn, damit man sieht, ob ein Agent aufgeräumt hat.
//
// Liegt der Eintrag schon im Papierkorb (oder gibt es ihn nicht), kommt
// ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr.
func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloeschtVon string) error {
res, err := s.schreiben.ExecContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
NowString(), geloeschtVon, projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
}
return requireOneRow(res)
}
// ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst.
//
// geloeschtVon schränkt auf einen Zugang ein (leer = alle). So lässt sich
// alles, was ein bestimmter Agent gelöscht hat, auf einen Blick sehen und in
// einem Zug zurückholen.
func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ([]TrashedEntry, error) {
abfrage := `SELECT id, slug, title, version, deleted_at, deleted_by
FROM entries
WHERE project_id = ? AND deleted_at IS NOT NULL`
werte := []any{projectID}
if geloeschtVon != "" {
abfrage += ` AND deleted_by = ?`
werte = append(werte, geloeschtVon)
}
abfrage += ` ORDER BY deleted_at DESC`
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []TrashedEntry
for rows.Next() {
var t TrashedEntry
var geloeschtAm string
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &geloeschtAm, &t.DeletedBy); err != nil {
return nil, err
}
t.DeletedAt = parseTime(geloeschtAm)
liste = append(liste, t)
}
return liste, rows.Err()
}
// RestoreFromTrash holt Einträge aus dem Papierkorb zurück.
//
// Bezeichner, die nicht (mehr) im Papierkorb dieses Projekts liegen, werden
// stillschweigend übersprungen — genauso wie bei BulkUpdateTags: Eine
// untergeschobene Angabe aus einem fremden Projekt verändert nichts.
//
// Zurückgegeben werden die wiederhergestellten Einträge samt Schlagworten,
// damit der Aufrufer sie wieder in den Suchindex aufnehmen kann.
func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) {
var zurueck []Entry
err := s.tx(ctx, func(tx *sql.Tx) error {
for _, slug := range slugs {
res, err := tx.ExecContext(ctx,
`UPDATE entries SET deleted_at = NULL, deleted_by = ''
WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`,
projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht wiederhergestellt werden: %w", slug, err)
}
if n, _ := res.RowsAffected(); n == 0 {
continue
}
row := tx.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ?`, projectID, slug)
e, err := scanEntry(row.Scan)
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err)
}
zurueck = append(zurueck, e)
}
return nil
})
if err != nil {
return nil, err
}
// Die Schlagworte erst nach der Transaktion nachladen: attachTags liest
// über den Lese-Pool, und der sieht die Änderung erst nach dem Commit.
return zurueck, s.attachTagsToSlice(ctx, zurueck)
}
// PurgeFromTrash löscht Einträge aus dem Papierkorb endgültig, samt Verlauf,
// Schlagworten und Verweisen (ON DELETE CASCADE). Das lässt sich nicht
// rückgängig machen.
//
// Die Bedingung "deleted_at IS NOT NULL" ist der Schutz dieser Funktion: Ein
// Eintrag, der nicht im Papierkorb liegt, wird nie angefasst — auch nicht,
// wenn jemand seinen Bezeichner ins Formular schmuggelt.
func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) {
geloescht := 0
err := s.tx(ctx, func(tx *sql.Tx) error {
for _, slug := range slugs {
res, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`,
projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht endgültig gelöscht werden: %w", slug, err)
}
n, _ := res.RowsAffected()
geloescht += int(n)
}
return nil
})
if err != nil {
return 0, err
}
return geloescht, nil
}
// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis aelterAls im
// Papierkorb liegen — über alle Projekte dieser Workspace-Datei. Gedacht für
// die tägliche Aufgabe in main.go, die auch das Protokoll aufräumt.
//
// Gibt die Bezeichner der entfernten Einträge je Projekt zurück, damit der
// Aufrufer jeden Fall im Protokoll vermerken kann.
func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) {
entfernt := map[string][]string{}
err := s.tx(ctx, func(tx *sql.Tx) error {
// Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg.
rows, err := tx.QueryContext(ctx,
`SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls)
if err != nil {
return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
for rows.Next() {
var projektID, slug string
if err := rows.Scan(&projektID, &slug); err != nil {
rows.Close()
return err
}
entfernt[projektID] = append(entfernt[projektID], slug)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls,
); err != nil {
return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return entfernt, nil
}