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" "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. 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) } // 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, geloeschtVon string) ([]string, error) { var geloescht []string jetzt := NowString() err := s.tx(ctx, func(tx *sql.Tx) 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 `+nichtImPapierkorb+` RETURNING slug`, jetzt, geloeschtVon, 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) } geloescht = append(geloescht, slug) } return nil }) if err != nil { return nil, err } return geloescht, nil } // 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 }