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>
209 lines
7.5 KiB
Go
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
|
|
}
|