feat(workspace): trash for notes

Deleting a note (or its folder) moves it to the trash instead of erasing
it. Trashed notes leave the tree, search, export and stats and are hidden
from everyone they were shared with; the owner sees them read-only with a
restore banner. New endpoints list, restore, purge and empty the trash;
restoring recreates a deleted folder. Sidebar entry and mobile menu item
with counter, list view with two-tap permanent delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:57:42 +01:00
1 parent 344f5e4ab2
commit f69f74fdc7
15 files changed
+649 -40

No files matched your search

+11 -3
View File
@@ -549,14 +549,22 @@ func (s *Store) DocRole(userID, docID string) string {
if userID == "" {
return ""
}
var ownerID, sharedFolderID, folderOwner string
var ownerID, sharedFolderID, folderOwner, archivedBy string
var archived bool
err := s.db.QueryRow(
`SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, '')
`SELECT d.user_id, d.shared_folder_id, COALESCE(wsf.owner_id, ''), d.is_archived, d.archived_by
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner)
WHERE d.id = ?`, docID).Scan(&ownerID, &sharedFolderID, &folderOwner, &archived, &archivedBy)
if err != nil {
return ""
}
if archived {
// In the trash: read-only for the owner and whoever trashed it, hidden from everyone else
if ownerID == userID || archivedBy == userID {
return DocRoleViewer
}
return ""
}
if ownerID == userID {
return DocRoleOwner
}
+24 -17
View File
@@ -379,6 +379,8 @@ func (s *Store) migrate() error {
// Idempotent column migrations for existing databases
columnMigrations := []string{
"ALTER TABLE documents ADD COLUMN archived_at TEXT NOT NULL DEFAULT '';",
"ALTER TABLE documents ADD COLUMN archived_by TEXT NOT NULL DEFAULT '';",
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
@@ -1979,13 +1981,16 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
d.shared_folder_id, d.sub_folder_path
FROM documents d
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND d.user_id = ?`,
userID, docID, userID,
WHERE d.id = ? AND (d.user_id = ? OR (d.is_archived = 1 AND d.archived_by = ?))`,
userID, docID, userID, userID,
)
doc, err := s.scanDocumentWithBlocks(row)
if err == nil {
doc.Permission = "owner"
doc.IsOwner = true
doc.IsOwner = doc.UserID == userID
if doc.IsArchived {
doc.Permission = "read" // read-only while in the trash
}
resolveDocumentPath(userID, doc, mounts, names, userFolders)
if shares, err := s.GetDocShares(doc.ID); err == nil {
doc.SharedWith = shares
@@ -2022,7 +2027,7 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
OR (wfs2.target_type = 'team' AND wfs2.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
) wfs ON d.shared_folder_id = wfs.folder_id
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
WHERE d.id = ? AND d.is_archived = 0 AND (s.user_id = ? OR dts.doc_id IS NOT NULL OR wsf.owner_id = ? OR wfs.folder_id IS NOT NULL)
LIMIT 1`,
userID, userID, userID, userID, userID, userID, docID, userID, userID,
)
@@ -2060,6 +2065,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
if err != nil {
return nil, err
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
// Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is
// only changed through the operation log (POST /docs/{id}/sync).
@@ -2121,11 +2129,10 @@ func (s *Store) DeleteDocument(userID, docID string) error {
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID)
return s.DeleteDocShare(userID, docID, userID)
}
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, docID)
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, docID)
_, err = s.db.Exec(`DELETE FROM documents WHERE id = ?`, docID)
return err
if doc.IsArchived {
return s.hardDeleteDocument(docID)
}
return s.trashDocument(userID, docID)
}
func (s *Store) reindexAllDocBlockTags() error {
@@ -2596,15 +2603,9 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
}
_ = rows.Close()
// Delete documents from SQLite, blocks, pins, shares, tags, user_folders
// The notes of the folder go to the trash; restoring one creates its folder again
for _, id := range deletedDocIDs {
_, _ = s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM block_tags WHERE document_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE doc_id = ?`, id)
_, _ = s.db.Exec(`DELETE FROM documents WHERE id = ?`, id)
_ = s.trashDocument(userID, id)
}
// Also clear any personal folder placements for shared notes in this folder
@@ -2619,6 +2620,9 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
if err != nil {
return nil, err
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
cleanTarget := filepath.ToSlash(filepath.Clean(strings.TrimSpace(targetFolderPath)))
if cleanTarget == "." {
@@ -3376,6 +3380,9 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
if err != nil {
return nil, err
}
if doc.IsArchived {
return nil, ErrDocInTrash
}
if !doc.IsOwner {
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
}
+9 -7
View File
@@ -429,14 +429,16 @@ func TestFolderManagementAndDeletion(t *testing.T) {
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
}
// Verify docs are gone from DB
_, err = store.GetDocumentByID(user.ID, doc1.ID)
if err == nil {
t.Fatalf("expected doc1 to be deleted")
// The notes are in the trash now, no longer in the tree
trash, _ := store.GetTrash(user.ID)
if len(trash) != 2 {
t.Fatalf("expected doc1 and doc2 in the trash, got %v", trash)
}
_, err = store.GetDocumentByID(user.ID, doc2.ID)
if err == nil {
t.Fatalf("expected doc2 to be deleted")
docs, _ := store.GetDocuments(user.ID)
for _, d := range docs {
if d.ID == doc1.ID || d.ID == doc2.ID {
t.Fatalf("trashed note %s still in the tree", d.ID)
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package storage
import (
"database/sql"
"errors"
"github.com/sandrodach/mindsift/internal/domain"
)
// Deleted notes go to a trash first (documents.is_archived = 1). Trashed notes vanish from the tree,
// search, export, stats and for everyone they were shared with; their owner can still read them,
// restore them or delete them for good. archived_by records who trashed the note (the owner, or the
// owner of the shared folder it lived in), so that person sees it in their trash as well.
// ErrNotInTrash is returned when restoring or purging a note that is not in the user's trash.
var ErrNotInTrash = errors.New("Notiz nicht im Papierkorb")
// ErrDocInTrash refuses changes to a trashed note; it has to be restored first.
var ErrDocInTrash = errors.New("Die Notiz liegt im Papierkorb. Stelle sie zuerst wieder her.")
// TrashedDocument is one entry of the trash list.
type TrashedDocument struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
FolderPath string `json:"folderPath"`
DeletedAt string `json:"deletedAt"`
IsOwner bool `json:"isOwner"`
}
const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)`
func (s *Store) trashDocument(userID, docID string) error {
_, err := s.db.Exec(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`,
domain.NowISO(), userID, docID)
if err == nil {
_, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, docID)
}
return err
}
// GetTrash lists the notes in the user's trash, newest deletion first.
func (s *Store) GetTrash(userID string) ([]TrashedDocument, error) {
rows, err := s.db.Query(`SELECT d.id, d.title, d.icon, d.folder_path, d.shared_folder_id, d.sub_folder_path, d.archived_at, d.user_id,
COALESCE(wsf.name, '')
FROM documents d LEFT JOIN workspace_shared_folders wsf ON wsf.id = d.shared_folder_id
WHERE d.`+trashScope+` ORDER BY d.archived_at DESC`, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TrashedDocument{}
for rows.Next() {
var t TrashedDocument
var sharedFolderID, subPath, ownerID, folderName string
if err := rows.Scan(&t.ID, &t.Title, &t.Icon, &t.FolderPath, &sharedFolderID, &subPath, &t.DeletedAt, &ownerID, &folderName); err != nil {
continue
}
if sharedFolderID != "" {
t.FolderPath = folderName
if subPath != "" {
t.FolderPath += "/" + subPath
}
}
t.IsOwner = ownerID == userID
out = append(out, t)
}
return out, nil
}
func (s *Store) trashedDocOwner(userID, docID string) (string, error) {
var owner string
err := s.db.QueryRow(`SELECT user_id FROM documents WHERE id = ? AND `+trashScope, docID, userID, userID).Scan(&owner)
if err == sql.ErrNoRows {
return "", ErrNotInTrash
}
return owner, err
}
// RestoreDocument takes a note out of the trash. Its folder is created again if it was deleted in the
// meantime; a shared folder that no longer exists puts the note back into the owner's top level.
func (s *Store) RestoreDocument(userID, docID string) error {
owner, err := s.trashedDocOwner(userID, docID)
if err != nil {
return err
}
var folderPath, sharedFolderID string
_ = s.db.QueryRow(`SELECT folder_path, shared_folder_id FROM documents WHERE id = ?`, docID).Scan(&folderPath, &sharedFolderID)
if sharedFolderID != "" {
var exists int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM workspace_shared_folders WHERE id = ?`, sharedFolderID).Scan(&exists)
if exists == 0 {
_, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID)
}
} else if folderPath != "" {
_, _ = s.CreateFolder(owner, folderPath)
}
_, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID)
return err
}
// PurgeDocument deletes a trashed note for good.
func (s *Store) PurgeDocument(userID, docID string) error {
if _, err := s.trashedDocOwner(userID, docID); err != nil {
return err
}
return s.hardDeleteDocument(docID)
}
// EmptyTrash deletes every note in the user's trash for good and returns their IDs.
func (s *Store) EmptyTrash(userID string) ([]string, error) {
items, err := s.GetTrash(userID)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(items))
for _, t := range items {
if err := s.hardDeleteDocument(t.ID); err != nil {
return ids, err
}
ids = append(ids, t.ID)
}
return ids, nil
}
func (s *Store) hardDeleteDocument(docID string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
// Tasks created from blocks of this note keep existing without the link
`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) = ?`,
`DELETE FROM blocks WHERE document_id = ?`,
`DELETE FROM block_tags WHERE document_id = ?`,
`DELETE FROM doc_pins WHERE doc_id = ?`,
`DELETE FROM doc_shares WHERE doc_id = ?`,
`DELETE FROM doc_team_shares WHERE doc_id = ?`,
`DELETE FROM doc_user_folders WHERE doc_id = ?`,
`DELETE FROM documents WHERE id = ?`,
} {
if _, err := tx.Exec(q, docID); err != nil {
return err
}
}
return tx.Commit()
}
+93
View File
@@ -0,0 +1,93 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestTrashHidesRestoresAndPurgesNotes(t *testing.T) {
store, err := NewStore(filepath.Join(t.TempDir(), "trash.db"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
defer store.Close()
openSharing(t, store)
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Zebrastreifen", Level: 0}}
doc, _ := store.CreateDocument(anna.ID, "", "Verkehr", "🚦", "Projekte/Stadt", blocks, nil, false)
_, _ = store.CreateFolder(anna.ID, "Projekte/Stadt")
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
t.Fatalf("share: %v", err)
}
if err := store.DeleteDocument(anna.ID, doc.ID); err != nil {
t.Fatalf("trash: %v", err)
}
visible := func(uid string) bool {
docs, _ := store.GetDocuments(uid)
for _, d := range docs {
if d.ID == doc.ID {
return true
}
}
return false
}
if visible(anna.ID) || visible(ben.ID) {
t.Errorf("trashed note must leave the tree of owner and shared users")
}
if res, _ := store.SearchGlobal(anna.ID, "Zebrastreifen"); len(res.Documents) != 0 {
t.Errorf("trashed note must not be found: %+v", res.Documents)
}
if store.DocRole(anna.ID, doc.ID) != DocRoleViewer || store.DocRole(ben.ID, doc.ID) != "" {
t.Errorf("trash is read-only for the owner and hidden from others")
}
if got, err := store.GetDocumentByID(anna.ID, doc.ID); err != nil || !got.IsArchived || got.Permission != "read" {
t.Errorf("owner reads the trashed note read-only: %+v %v", got, err)
}
if _, err := store.GetDocumentByID(ben.ID, doc.ID); err == nil {
t.Errorf("shared user must not open a trashed note")
}
title := "Neu"
if _, err := store.UpdateDocument(anna.ID, doc.ID, &title, nil, nil, nil); err == nil {
t.Errorf("trashed note must not be renamed")
}
trash, _ := store.GetTrash(anna.ID)
if len(trash) != 1 || trash[0].FolderPath != "Projekte/Stadt" || trash[0].DeletedAt == "" {
t.Fatalf("trash list wrong: %+v", trash)
}
if err := store.RestoreDocument(ben.ID, doc.ID); err == nil {
t.Errorf("only the owner may restore")
}
// The folder was deleted meanwhile; restoring creates it again
_, _ = store.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ?`, anna.ID)
if err := store.RestoreDocument(anna.ID, doc.ID); err != nil {
t.Fatalf("restore: %v", err)
}
if !visible(anna.ID) || !visible(ben.ID) || store.DocRole(ben.ID, doc.ID) != DocRoleEditor {
t.Errorf("restored note is back for everyone, shares intact")
}
if folders, _ := store.GetFolders(anna.ID); len(folders) == 0 {
t.Errorf("folder should be created again on restore")
}
// Deleting a folder moves its notes to the trash; emptying deletes them for good
if _, err := store.DeleteFolder(anna.ID, "Projekte"); err != nil {
t.Fatalf("DeleteFolder: %v", err)
}
if trash, _ := store.GetTrash(anna.ID); len(trash) != 1 {
t.Fatalf("folder deletion should trash the note, got %+v", trash)
}
if ids, err := store.EmptyTrash(anna.ID); err != nil || len(ids) != 1 {
t.Fatalf("EmptyTrash: %v %v", ids, err)
}
var docs, shares int
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs)
_ = store.db.QueryRow(`SELECT COUNT(*) FROM doc_shares WHERE doc_id = ?`, doc.ID).Scan(&shares)
if docs+shares != 0 {
t.Errorf("purge must remove the note and its shares")
}
}