munin/internal/store/trash.go
2026-10-19 10:13:56 +02:00

254 lines
9.2 KiB
Go

package store
// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_trash.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"
"errors"
"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. deletedBy 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, deletedBy string) error {
res, err := s.write.ExecContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE project_id = ? AND slug = ? AND `+notInTrash,
NowString(), deletedBy, projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
}
return requireOneRow(res)
}
// MoveEntriesToTrash legt mehrere Einträge in einem Zug in den Papierkorb —
// die Sammelauswahl in der Eintragsliste.
//
// Ausgewählt wird über die interne ID, nicht den Bezeichner, genau wie bei
// BulkUpdateTags. Und genau wie dort werden IDs, die nicht zu projectID
// gehören, stillschweigend übersprungen: Sonst könnte eine untergeschobene ID
// über dieses Formular Einträge eines fremden Projekts löschen. Ebenso
// übersprungen werden Einträge, die schon im Papierkorb liegen (etwa weil das
// Formular doppelt abgeschickt wurde).
//
// Alles läuft in einer Transaktion: Entweder liegen danach alle gültigen
// Einträge im Papierkorb oder keiner. Zurück kommen die Bezeichner der
// tatsächlich gelöschten Einträge — für Suchindex und Protokoll.
func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, deletedBy string) ([]string, error) {
var deleted []string
now := NowString()
err := s.tx(ctx, func(tx *transaction) error {
for _, id := range entryIDs {
// RETURNING liefert die Spalte der geänderten Zeile gleich mit —
// so braucht es keine zweite Abfrage, um den Bezeichner zu holen.
var slug string
err := tx.QueryRowContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE id = ? AND project_id = ? AND `+notInTrash+`
RETURNING slug`,
now, deletedBy, id, projectID,
).Scan(&slug)
if errors.Is(err, sql.ErrNoRows) {
continue // fremdes Projekt, schon im Papierkorb oder gibt es nicht
}
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
}
deleted = append(deleted, slug)
}
return nil
})
if err != nil {
return nil, err
}
return deleted, nil
}
// ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst.
//
// deletedBy 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, deletedBy string) ([]TrashedEntry, error) {
query := `SELECT id, slug, title, version, deleted_at, deleted_by
FROM entries
WHERE project_id = ? AND deleted_at IS NOT NULL`
values := []any{projectID}
if deletedBy != "" {
query += ` AND deleted_by = ?`
values = append(values, deletedBy)
}
query += ` ORDER BY deleted_at DESC`
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var list []TrashedEntry
for rows.Next() {
var t TrashedEntry
var deletedAt string
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &deletedAt, &t.DeletedBy); err != nil {
return nil, err
}
t.DeletedAt = parseTime(deletedAt)
list = append(list, t)
}
return list, 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 back []Entry
err := s.tx(ctx, func(tx *transaction) 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)
}
back = append(back, 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 back, s.attachTagsToSlice(ctx, back)
}
// 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) {
deleted := 0
err := s.tx(ctx, func(tx *transaction) 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()
deleted += int(n)
}
return nil
})
if err != nil {
return 0, err
}
return deleted, nil
}
// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis olderThan 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, olderThan string) (map[string][]string, error) {
removed := map[string][]string{}
err := s.tx(ctx, func(tx *transaction) 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 < ?`, olderThan)
if err != nil {
return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
for rows.Next() {
var projectID, slug string
if err := rows.Scan(&projectID, &slug); err != nil {
rows.Close()
return err
}
removed[projectID] = append(removed[projectID], 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 < ?`, olderThan,
); err != nil {
return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return removed, nil
}