254 lines
9.2 KiB
Go
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
|
|
}
|