From 362dd3b4c6c7a357b2f427683123a23c7afbb308 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:18:25 +0100 Subject: [PATCH 1/8] fix(workspace): extract replaces the branch in the editor, with undo The server only creates the new note and returns the link; the editor swaps the branch for it as an ordinary own edit through the sync engine. No more clash with unsent typing (conflict copies), the link shows at once also with polling, and Strg+Z restores the branch. Undoing (toast or Strg+Z) puts the new note into the trash via POST /extract/undo. The sidebar gets the new note right away and opens its folder. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + Doku/api/rest.md | 3 +- Doku/architecture.md | 2 +- .../src/features/workspace/DocumentEditor.tsx | 94 +++++++++++++------ .../src/features/workspace/WorkspaceView.tsx | 17 ++++ frontend/src/lib/api.ts | 11 ++- internal/api/restructure.go | 20 +++- internal/api/restructure_test.go | 37 ++++++-- internal/api/server.go | 1 + internal/storage/restructure.go | 46 ++++----- 10 files changed, 163 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9b5a4..fb810dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Fixed +- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb. + ## [0.7.0] - 2026-10-30 ### Added diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6b5c1ca..678bdb0 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -20,7 +20,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll | | POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben | | GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` | -| POST | `/documents/{id}/extract` | `{blockId, clientId}`: Punkt samt Unterpunkten wird eigene Notiz (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht); der Punkt wird zu `[[Titel]]`. Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | | GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe | diff --git a/Doku/architecture.md b/Doku/architecture.md index 9afd10e..4d11b87 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -97,7 +97,7 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in ## Notizen umbauen -`internal/storage/restructure.go`: **Ast auslagern** liest den Ast, legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht) und ersetzt den Ast dann über das Operationsprotokoll durch `[[Titel]]` (Hashtags bleiben am Link). **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. +`internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. ## Link-Vorschau diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index c44327b..e91efec 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -47,6 +47,9 @@ export interface DocumentEditorProps { onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; /** Opens the dialog to merge this note into another one (own notes only) */ onRequestMerge?: (doc: Document) => void; + /** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */ + onDocCreated?: (doc: Document) => void; + onDocRemoved?: (docId: string) => void; } import { EmojiPickerModal } from './EmojiPickerModal'; @@ -194,6 +197,8 @@ export const DocumentEditor: React.FC = ({ onOpenTask, onSaveStatusChange, onRequestMerge, + onDocCreated, + onDocRemoved, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -381,37 +386,31 @@ export const DocumentEditor: React.FC = ({ // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action const [notice, setNotice] = useState(null); - const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); + const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); const noticeTimerRef = useRef(null); - const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { + const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => { setNotice(message); - setNoticeAction(action || null); + setNoticeActions(actions); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); noticeTimerRef.current = window.setTimeout(() => { setNotice(null); - setNoticeAction(null); - }, action ? 7000 : 3500); + setNoticeActions([]); + }, actions.length ? 8000 : 3500); }, []); - // A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]] - // and this editor receives that change through the normal sync - const handleExtractBlock = useCallback( - async (blockId: string) => { - const engine = engineRef.current; - try { - await engine?.syncNow(); // the branch as typed so far - const created = await api.extractBranch(docId, blockId, engine?.clientId || ''); - await engine?.syncNow(); - showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, { - label: 'Öffnen', - run: () => onOpenDocBlock?.(created.id), - }); - } catch (err: any) { - showNotice(err?.message || 'Umwandeln fehlgeschlagen'); - } - }, - [docId, onOpenDocBlock, showNotice] - ); + // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash + const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null); + const finishUndoExtract = useCallback(() => { + const info = lastExtractRef.current; + if (!info) return; + const block = currentBlocksRef.current.find((b) => b.id === info.blockId); + if (block && block.content === info.link) return; // the undone step was something else + lastExtractRef.current = null; + api + .undoExtract(info.newDocId) + .then(() => onDocRemoved?.(info.newDocId)) + .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden')); + }, [onDocRemoved, showNotice]); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -423,7 +422,9 @@ export const DocumentEditor: React.FC = ({ const handleUndo = useCallback(() => { const res = engineRef.current?.undo(); if (res?.message) showNotice(res.message); - }, [showNotice]); + if (res?.ok) finishUndoExtract(); + }, [showNotice, finishUndoExtract]); + const handleRedo = useCallback(() => { const res = engineRef.current?.redo(); if (res?.message) showNotice(res.message); @@ -449,6 +450,38 @@ export const DocumentEditor: React.FC = ({ [] ); + // A bullet with its children becomes a note of its own. The server only creates the note; the branch + // is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works. + const handleExtractBlock = useCallback( + async (blockId: string) => { + const engine = engineRef.current; + if (!engine) return; + try { + await engine.syncNow(); // the server reads the branch as typed so far + const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId); + // The focused field keeps its own text while typing; leave it so it shows the link + (document.activeElement as HTMLElement | null)?.blur?.(); + const prev = currentBlocksRef.current; + const i = prev.findIndex((b) => b.id === blockId); + if (i >= 0) { + const level = prev[i].level || 0; + let end = i + 1; + while (end < prev.length && (prev[end].level || 0) > level) end++; + applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]); + } + lastExtractRef.current = { newDocId: created.id, blockId, link }; + onDocCreated?.(created); + showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [ + { label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) }, + { label: 'Rückgängig', run: () => handleUndo() }, + ]); + } catch (err: any) { + showNotice(err?.message || 'Umwandeln fehlgeschlagen'); + } + }, + [docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks] + ); + // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -1126,19 +1159,20 @@ export const DocumentEditor: React.FC = ({ className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel" > {notice} - {noticeAction && ( + {noticeActions.map((a) => ( - )} + ))} )} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0c62ede..b84dac5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1622,6 +1622,23 @@ export const WorkspaceView: React.FC = ({ onWikiLinkClick={handleWikiLinkClick} onOpenDocBlock={handleJumpToDocBlock} onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })} + onDocCreated={(created) => { + setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); + if (created.folderPath) { + const parts = created.folderPath.split('/'); + const open: Record = {}; + parts.forEach((_, i) => { + open[parts.slice(0, i + 1).join('/')] = true; + }); + setExpandedFolders((prev) => ({ ...prev, ...open })); + } + onStatsChange(); + }} + onDocRemoved={(id) => { + setDocs((prev) => prev.filter((d) => d.id !== id)); + reloadTrash(); + onStatsChange(); + }} onBackMobile={() => setMobileShowDetail(false)} onOpenTask={onOpenTask} onSaveStatusChange={(status, err) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 384db62..d7a37ee 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -709,14 +709,19 @@ export const api = { return request('/api/v1/documents/trash'); }, - /** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */ - async extractBranch(docId: string, blockId: string, clientId: string): Promise { - return request(`/api/v1/documents/${docId}/extract`, { + /** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */ + async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> { + return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, { method: 'POST', body: JSON.stringify({ blockId, clientId }), }); }, + /** Puts a note created by extractBranch into the trash again. */ + async undoExtract(newDocId: string): Promise { + await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' }); + }, + /** Appends the source note to the target; the source goes to the trash. */ async mergeDocuments(targetId: string, sourceId: string): Promise { return request(`/api/v1/documents/${targetId}/merge`, { diff --git a/internal/api/restructure.go b/internal/api/restructure.go index 9fe857a..06f172f 100644 --- a/internal/api/restructure.go +++ b/internal/api/restructure.go @@ -53,17 +53,31 @@ func (s *Server) handleExtractBranch(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "blockId fehlt") return } - created, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID)) + created, link, err := s.store.ExtractBranch(userID, docID, req.BlockID, s.restructureActor(r, req.ClientID), s.leases.View(docID)) if err != nil { writeError(w, restructureStatus(err), err.Error()) return } - s.notifyDocChanged(docID, s.store.DocHead(docID)) if full, err := s.store.GetDocumentByID(userID, created.ID); err == nil { created = full } s.broadcastDocToAuthorizedUsers(created.ID, "DOC_CREATED") - writeJSON(w, http.StatusCreated, created) + writeJSON(w, http.StatusCreated, map[string]interface{}{"document": created, "link": link}) +} + +// handleUndoExtract: POST /documents/{id}/extract/undo puts the extracted note {id} into the trash. +func (s *Server) handleUndoExtract(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + users, _ := s.store.GetAuthorizedDocUsers(docID) + if err := s.store.UndoExtract(userID, docID); err != nil { + writeError(w, restructureStatus(err), err.Error()) + return + } + for _, uid := range users { + s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID}) + } + writeJSON(w, http.StatusOK, map[string]bool{"undone": true}) } // handleMergeDocuments: POST /documents/{id}/merge {sourceId, clientId} appends the source to this note. diff --git a/internal/api/restructure_test.go b/internal/api/restructure_test.go index b83a375..883000c 100644 --- a/internal/api/restructure_test.go +++ b/internal/api/restructure_test.go @@ -41,16 +41,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) { if rec.Code != http.StatusCreated { t.Fatalf("extract: %d %s", rec.Code, rec.Body.String()) } - var created domain.Document - _ = json.Unmarshal(rec.Body.Bytes(), &created) - if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" { + var out struct { + Document domain.Document + Link string + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + created := out.Document + if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" || out.Link != "[[Konzept: Garten]] #draußen" { t.Fatalf("new note: %q in %q", created.Title, created.FolderPath) } if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" { t.Fatalf("new note blocks: %s", got) } - if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" { - t.Fatalf("source after extract: %s", got) + // The server leaves the source alone; the editor replaces the branch itself + if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "- Konzept: Garten #draußen|>Beete|>>Erde kaufen|>Zaun|Danach" { + t.Fatalf("source must stay unchanged on the server: %s", got) } // Same title again: the link must stay unique _, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false) @@ -61,10 +66,21 @@ func TestExtractBranchIntoOwnNote(t *testing.T) { p2 = d.ID } } - var second domain.Document + var second struct{ Document domain.Document } _ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second) - if second.Title != "Konzept: Garten (2)" { - t.Fatalf("duplicate title: %q", second.Title) + if second.Document.Title != "Konzept: Garten (2)" { + t.Fatalf("duplicate title: %q", second.Document.Title) + } + // Undo puts the new note into the trash; only its creator may + _, strangerToken := newTestUser(t, store, "fremd2@example.com") + if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", strangerToken, "", ""); rec.Code == http.StatusOK { + t.Fatal("stranger must not undo") + } + if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", token, "", ""); rec.Code != http.StatusOK { + t.Fatalf("undo: %d %s", rec.Code, rec.Body.String()) + } + if d, _ := store.GetDocumentByID(uid, created.ID); d == nil || !d.IsArchived { + t.Fatal("extracted note must be in the trash after undo") } } @@ -87,8 +103,9 @@ func TestExtractInSharedNoteKeepsAccess(t *testing.T) { t.Fatalf("reader must not extract: %d", rec.Code) } rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "") - var created domain.Document - _ = json.Unmarshal(rec.Body.Bytes(), &created) + var out struct{ Document domain.Document } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + created := out.Document if rec.Code != http.StatusCreated { t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String()) } diff --git a/internal/api/server.go b/internal/api/server.go index f9fb765..75e46a0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -136,6 +136,7 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) + r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract) r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview)) diff --git a/internal/storage/restructure.go b/internal/storage/restructure.go index deb74a7..ba52a96 100644 --- a/internal/storage/restructure.go +++ b/internal/storage/restructure.go @@ -92,16 +92,17 @@ func foreignLeaseIn(ids []string, a collab.Actor, leases collab.LeaseView) bool return false } -// ExtractBranch moves a bullet's children into a new note named after the bullet, in the same folder, -// shared like the original; the bullet itself becomes a [[link]] to it. Returns the new note. -func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, error) { +// ExtractBranch creates a new note from a bullet's children, named after the bullet, in the same folder +// and shared like the original. The original note is not changed here: the editor replaces the branch +// by the returned link itself, as an ordinary own edit (no clash with unsent typing, undo works). +func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, leases collab.LeaseView) (*domain.Document, string, error) { source, err := s.GetDocumentByID(userID, docID) if err != nil { - return nil, err + return nil, "", err } state, err := s.readCollabDoc(docID) if err != nil { - return nil, err + return nil, "", err } flat := state.Flatten() start := -1 @@ -112,7 +113,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea } } if start < 0 { - return nil, ErrBlockNotFound + return nil, "", ErrBlockNotFound } root := flat[start] var children []domain.DocumentBlock @@ -122,37 +123,36 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea ids = append(ids, flat[i].ID) } if foreignLeaseIn(ids, a, leases) { - return nil, ErrBranchLocked + return nil, "", ErrBranchLocked } base, tags := branchTitle(root.Content) title := s.uniqueTitle(userID, base) created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false) if err != nil { - return nil, err + return nil, "", err } s.copyShares(userID, source, created.ID) - link := "[[" + title + "]]" if tags != "" { link += " " + tags } - _, _, err = s.applyDocChange(docID, a, leases, func(d *collab.Doc) []collab.Op { - b, ok := d.Blocks[blockID] - if !ok || b.Deleted { - return nil - } - ops := []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: blockID, BaseVersion: b.Version, Content: &link}} - for _, c := range d.Children(blockID) { - ops = append(ops, collab.Op{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: c.ID}) - } - return ops - }) + return created, link, nil +} + +// UndoExtract puts a note created by ExtractBranch into the trash (only its creator may). +func (s *Store) UndoExtract(userID, docID string) error { + doc, err := s.GetDocumentByID(userID, docID) if err != nil { - _ = s.hardDeleteDocument(created.ID) - return nil, err + return err } - return created, nil + if !doc.IsOwner { + return ErrMergeNotOwner + } + if doc.IsArchived { + return nil + } + return s.trashDocument(userID, docID) } // copyShares gives the new note the direct and team shares of the source; when someone else owns the From 446f8e46498e963150a1f94e0822ad4f5ef343d1 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:21:29 +0100 Subject: [PATCH 2/8] fix(workspace): tighten link preview card, slash menu matching/scrolling, and Enter on last empty bullet --- CHANGELOG.md | 5 + .../src/features/workspace/OutlinerBlock.tsx | 85 ++++++----- .../__tests__/OutlinerBlock.caret.test.tsx | 137 ++++++++++++++++++ .../src/lib/__tests__/linkPreview.test.ts | 20 +-- frontend/src/lib/linkPreview.ts | 10 +- 5 files changed, 209 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9b5a4..5537a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Fixed +- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext. +- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit. +- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an. + ## [0.7.0] - 2026-10-30 ### Added diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 08f29ef..03734fc 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock'; import { DictationBlock } from './DictationBlock'; import { currentVoiceConfig } from '../../lib/voice'; import { LinkPreviewCard } from './LinkPreviewCard'; -import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; +import { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { mediaContent, parseMediaContent } from '../../lib/media'; import { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; @@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC = ({ docId, block, index, - isLast, hasChildren, isFocused, canEdit, @@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC = ({ const [slashSearch, setSlashSearch] = useState(null); // Inline picker an empty bullet turns into after /image (local only, nothing synced until done) const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); - // Card above bullets with a plain web address (lib/linkPreview.ts) + // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts) const previewsOn = useLinkPreviewsEnabled(); const previewUrl = previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') - ? firstBareUrl(localContent) + ? linkCardUrl(localContent) : null; const [slashIndex, setSlashIndex] = useState(0); + const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]); const [showLinkModal, setShowLinkModal] = useState(false); const [showTodoMenu, setShowTodoMenu] = useState(false); @@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC = ({ const q = slashSearch.toLowerCase(); const cmdQuery = q.startsWith('code ') ? 'code' : q; // Media only in an otherwise empty bullet - const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; + const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; return SLASH_COMMANDS.filter( (c) => (!c.media || emptyBullet) && @@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC = ({ ); }, [slashSearch, localContent, onExtractBlock]); + useEffect(() => { + if (slashSearch === null) return; + slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }, [slashIndex, slashSearch]); + const applySlashCommand = (cmd: SlashCommand) => { setSlashSearch(null); - const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); - const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); + const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i); + const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); if (cmd.type === 'link') { setLocalContent(clean); + localContentRef.current = clean; setShowLinkModal(true); return; } @@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC = ({ } const langOverride = cmd.type === 'code' && codeLangMatch - ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() + ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase() : undefined; setLocalContent(clean); localContentRef.current = clean; @@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC = ({ const handleInsertLink = (linkMarkdown: string) => { setShowLinkModal(false); + if (localContent.trim() === '') { + const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/); + if (webMatch) { + const bareUrl = webMatch[1]; + setLocalContent(bareUrl); + localContentRef.current = bareUrl; + flushDebounce(bareUrl); + return; + } + } const cursor = textareaRef.current?.selectionStart ?? localContent.length; const before = localContent.slice(0, cursor); const after = localContent.slice(cursor); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); setLocalContent(newContent); + localContentRef.current = newContent; flushDebounce(newContent); }; @@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC = ({ } // Check for slash command invocation - const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; + const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; if (slashMatch) { - setSlashSearch(slashMatch[1]); + setSlashSearch(slashMatch[2]); setSlashIndex(0); } else { setSlashSearch(null); @@ -600,30 +617,32 @@ export const OutlinerBlock: React.FC = ({ if (!canEdit) return; // Slash command navigation - if (slashSearch !== null && filteredSlashCommands.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const selected = filteredSlashCommands[slashIndex]; - if (selected) { - applySlashCommand(selected); - } - return; - } + if (slashSearch !== null) { if (e.key === 'Escape') { e.preventDefault(); setSlashSearch(null); return; } + if (filteredSlashCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredSlashCommands[slashIndex]; + if (selected) { + applySlashCommand(selected); + } + return; + } + } } // 1. Tab / Shift+Tab -> Strict Indent / Outdent @@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC = ({ // 2. Enter (without Shift) -> If block is completely empty: // - If level > minLevel, outdent (Shift+Tab behaviour) - // - If already at minLevel and is the last block, do NOT create another empty block below // - Otherwise split paragraph at cursor and create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); @@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC = ({ onIndentBlockRef.current(block.id, -1); return; } - if (isLast) { - // Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block - return; - } } const start = e.currentTarget.selectionStart ?? localContent.length; @@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC = ({ {filteredSlashCommands.map((cmd, i) => ( ); }; diff --git a/frontend/src/lib/__tests__/useVoiceInput.test.tsx b/frontend/src/lib/__tests__/useVoiceInput.test.tsx index 76d9faa..615a000 100644 --- a/frontend/src/lib/__tests__/useVoiceInput.test.tsx +++ b/frontend/src/lib/__tests__/useVoiceInput.test.tsx @@ -130,4 +130,121 @@ describe('useVoiceInput', () => { await waitFor(() => expect(none.result.current.mode).toBe('unsupported')); expect(none.result.current.available).toBe(false); }); + + it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => { + const instances: any[] = []; + class FakeSpeechRecognition { + lang = ''; + interimResults = false; + continuous = false; + onresult: ((e: any) => void) | null = null; + onerror: ((e: any) => void) | null = null; + onend: (() => void) | null = null; + constructor() { + instances.push(this); + } + start() {} + stop() { + this.onend?.(); + } + abort() {} + } + (window as any).SpeechRecognition = FakeSpeechRecognition; + getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } }); + + const onText = vi.fn(); + const { result } = renderHook(() => useVoiceInput({ onText, live: true })); + await waitFor(() => expect(result.current.mode).toBe('browser')); + + vi.useFakeTimers(); + await act(async () => { + await result.current.start(); + }); + expect(instances).toHaveLength(1); + + // First utterance before a pause + act(() => { + instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] }); + }); + expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false); + + // Browser ends recognition on speech pause -> immediate restart (#2) + act(() => { + instances[0].onend?.(); + }); + expect(instances).toHaveLength(2); + expect(onText).not.toHaveBeenCalledWith(expect.anything(), true); + + // Another immediate onend within <300ms waits until 300ms have passed + act(() => { + instances[1].onend?.(); + }); + expect(instances).toHaveLength(2); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(instances).toHaveLength(3); + + // Second utterance appends to base text + act(() => { + instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] }); + }); + expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false); + + // Explicit stop() finishes with final=true + await act(async () => { + await result.current.stop(); + }); + expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true); + expect(result.current.state).toBe('idle'); + }); + + it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => { + getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } }); + + const resolvers: Array<(text: string) => void> = []; + transcribe.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ); + + const onText = vi.fn(); + const { result } = renderHook(() => useVoiceInput({ onText, live: true })); + await waitFor(() => expect(result.current.mode).toBe('server')); + + vi.useFakeTimers(); + await act(async () => { + await result.current.start(); + }); + + // Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet + await act(async () => { + await vi.advanceTimersByTimeAsync(8000); + }); + expect(transcribe).toHaveBeenCalledTimes(1); + expect(stopTrack).not.toHaveBeenCalled(); + + // Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now + await act(async () => { + await vi.advanceTimersByTimeAsync(4000); + await result.current.stop(); + }); + expect(transcribe).toHaveBeenCalledTimes(2); + expect(stopTrack).toHaveBeenCalledTimes(1); + + // Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering + await act(async () => { + resolvers[1]('zweiter Teil'); + }); + expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false); + + // Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true + await act(async () => { + resolvers[0]('Erster Teil'); + }); + expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true); + expect(result.current.state).toBe('idle'); + }); }); diff --git a/frontend/src/lib/useVoiceInput.ts b/frontend/src/lib/useVoiceInput.ts index b139a24..783d683 100644 --- a/frontend/src/lib/useVoiceInput.ts +++ b/frontend/src/lib/useVoiceInput.ts @@ -10,28 +10,54 @@ export interface VoiceInputOptions { onError?: (message: string) => void; /** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */ continuous?: boolean; + /** Live dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */ + live?: boolean; } +const LIVE_CHUNK_MS = 8000; +const LIVE_SILENCE_MS = 800; +const LIVE_TICK_MS = 200; +const LIVE_SPEECH_LEVEL = 0.02; +const MIN_RESTART_INTERVAL_MS = 300; + /** * Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text * is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or * "unsupported" means: do not show a microphone. */ -export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) { +export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) { const [mode, setMode] = useState(null); const [state, setState] = useState('idle'); + const [pendingChunks, setPendingChunks] = useState(0); const maxSecondsRef = useRef(600); const recorderRef = useRef(null); const recognitionRef = useRef(null); const timerRef = useRef(null); + const segmentTimerRef = useRef(null); + const restartTimerRef = useRef(null); const cancelledRef = useRef(false); + const stoppedRef = useRef(false); const transcriptRef = useRef(''); + const baseTranscriptRef = useRef(''); + const lastRestartAtRef = useRef(0); + const chunkIndexRef = useRef(0); + const chunkResultsRef = useRef>(new Map()); + const inFlightChunksRef = useRef(0); const mountedRef = useRef(true); const onTextRef = useRef(onText); onTextRef.current = onText; const onErrorRef = useRef(onError); onErrorRef.current = onError; + const clearAllTimers = () => { + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + timerRef.current = null; + if (segmentTimerRef.current !== null) window.clearInterval(segmentTimerRef.current); + segmentTimerRef.current = null; + if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current); + restartTimerRef.current = null; + }; + useEffect(() => { mountedRef.current = true; void loadVoiceConfig().then((cfg) => { @@ -41,7 +67,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu }); return () => { mountedRef.current = false; - if (timerRef.current !== null) window.clearTimeout(timerRef.current); + clearAllTimers(); recorderRef.current?.cancel(); try { recognitionRef.current?.abort?.(); @@ -54,17 +80,65 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu const setStateSafe = (s: VoiceState) => { if (mountedRef.current) setState(s); }; + const setPendingChunksSafe = (n: number) => { + if (mountedRef.current) setPendingChunks(n); + }; const fail = (message: string) => { setStateSafe('idle'); + setPendingChunksSafe(0); onErrorRef.current?.(message); }; - const clearTimer = () => { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = null; + + const joinedChunkText = () => + [...chunkResultsRef.current.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, t]) => t.trim()) + .filter(Boolean) + .join(' '); + + const processSegmentPromise = (idx: number, blobPromise: Promise) => { + inFlightChunksRef.current += 1; + setPendingChunksSafe(inFlightChunksRef.current); + void blobPromise + .then(async (blob) => { + if (cancelledRef.current) return; + if (!blob || blob.size === 0) { + inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1); + setPendingChunksSafe(inFlightChunksRef.current); + if (stoppedRef.current && inFlightChunksRef.current === 0) { + setStateSafe('idle'); + onTextRef.current(joinedChunkText(), true); + } + return; + } + const text = await api.transcribe(blob); + if (cancelledRef.current) return; + chunkResultsRef.current.set(idx, text || ''); + inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1); + setPendingChunksSafe(inFlightChunksRef.current); + const combined = joinedChunkText(); + transcriptRef.current = combined; + if (stoppedRef.current && inFlightChunksRef.current === 0) { + setStateSafe('idle'); + onTextRef.current(combined, true); + } else { + onTextRef.current(combined, false); + } + }) + .catch((err: any) => { + if (cancelledRef.current) return; + cancelledRef.current = true; + inFlightChunksRef.current = 0; + clearAllTimers(); + recorderRef.current?.cancel(); + recorderRef.current = null; + fail(err?.message || 'Spracherkennung fehlgeschlagen'); + }); }; const stop = useCallback(async () => { - clearTimer(); + stoppedRef.current = true; + clearAllTimers(); if (recognitionRef.current) { try { recognitionRef.current.stop(); @@ -74,8 +148,20 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu return; } const rec = recorderRef.current; - if (!rec) return; + if (!rec) { + if (live && state === 'listening') { + setStateSafe('idle'); + onTextRef.current(transcriptRef.current, true); + } + return; + } recorderRef.current = null; + if (live) { + const idx = chunkIndexRef.current++; + setStateSafe('transcribing'); + processSegmentPromise(idx, rec.stop()); + return; + } const blob = await rec.stop(); if (cancelledRef.current) return; if (!blob) { @@ -93,11 +179,15 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu } // fail only touches refs and state setters // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [live, state]); const startServer = async () => { const rec = new VoiceRecorder(); recorderRef.current = rec; + chunkIndexRef.current = 0; + chunkResultsRef.current = new Map(); + inFlightChunksRef.current = 0; + setPendingChunksSafe(0); setStateSafe('listening'); try { await rec.start(); @@ -111,6 +201,104 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu return; } timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); + if (live) { + let segmentMs = 0; + let silentMs = 0; + let hadSpeech = false; + segmentTimerRef.current = window.setInterval(() => { + if (cancelledRef.current || stoppedRef.current || recorderRef.current !== rec) return; + const lvl = rec.level(); + if (lvl !== null) { + if (lvl >= LIVE_SPEECH_LEVEL) { + hadSpeech = true; + silentMs = 0; + } else if (hadSpeech) { + silentMs += LIVE_TICK_MS; + } + } + segmentMs += LIVE_TICK_MS; + if (segmentMs >= LIVE_CHUNK_MS || (hadSpeech && silentMs >= LIVE_SILENCE_MS)) { + segmentMs = 0; + silentMs = 0; + hadSpeech = false; + const idx = chunkIndexRef.current++; + processSegmentPromise(idx, rec.cutSegment()); + } + }, LIVE_TICK_MS); + } + }; + + const spawnBrowserRecognition = (Recognition: any, isRestart: boolean) => { + const recognition = new Recognition(); + recognition.lang = 'de-DE'; + recognition.interimResults = true; + recognition.continuous = continuous || live; + recognition.onresult = (event: any) => { + let sessionText = ''; + for (let i = 0; i < event.results.length; i++) sessionText += event.results[i][0].transcript; + if (live && baseTranscriptRef.current) { + const base = baseTranscriptRef.current.trimEnd(); + const next = sessionText.trimStart(); + transcriptRef.current = next ? `${base} ${next}` : base; + } else { + transcriptRef.current = sessionText; + } + onTextRef.current(transcriptRef.current, false); + }; + recognition.onerror = (event: any) => { + if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') { + cancelledRef.current = true; + clearAllTimers(); + recognitionRef.current = null; + fail('Mikrofon nicht freigegeben'); + } + }; + recognition.onend = () => { + recognitionRef.current = null; + if (cancelledRef.current) { + clearAllTimers(); + return; + } + if (!live || stoppedRef.current) { + clearAllTimers(); + setStateSafe('idle'); + onTextRef.current(transcriptRef.current, true); + return; + } + baseTranscriptRef.current = transcriptRef.current; + const now = Date.now(); + const elapsed = now - lastRestartAtRef.current; + const doRestart = () => { + if (cancelledRef.current || stoppedRef.current) return; + lastRestartAtRef.current = Date.now(); + spawnBrowserRecognition(Recognition, true); + }; + if (lastRestartAtRef.current === 0 || elapsed >= MIN_RESTART_INTERVAL_MS) { + doRestart(); + } else { + restartTimerRef.current = window.setTimeout(() => { + restartTimerRef.current = null; + doRestart(); + }, MIN_RESTART_INTERVAL_MS - elapsed); + } + }; + recognitionRef.current = recognition; + if (!isRestart) setStateSafe('listening'); + try { + recognition.start(); + if (!isRestart) { + timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); + } + } catch { + recognitionRef.current = null; + if (!isRestart) { + fail('Spracheingabe konnte nicht gestartet werden'); + } else { + clearAllTimers(); + setStateSafe('idle'); + onTextRef.current(transcriptRef.current, true); + } + } }; const startBrowser = () => { @@ -119,55 +307,31 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu fail('Spracheingabe wird von diesem Browser nicht unterstützt'); return; } - const recognition = new Recognition(); - recognition.lang = 'de-DE'; - recognition.interimResults = true; - recognition.continuous = continuous; - recognition.onresult = (event: any) => { - let combined = ''; - for (let i = 0; i < event.results.length; i++) combined += event.results[i][0].transcript; - transcriptRef.current = combined; - onTextRef.current(combined, false); - }; - recognition.onerror = (event: any) => { - if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') { - cancelledRef.current = true; - recognitionRef.current = null; - fail('Mikrofon nicht freigegeben'); - } - }; - recognition.onend = () => { - clearTimer(); - recognitionRef.current = null; - if (cancelledRef.current) return; - setStateSafe('idle'); - onTextRef.current(transcriptRef.current, true); - }; - recognitionRef.current = recognition; - setStateSafe('listening'); - try { - recognition.start(); - timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); - } catch { - recognitionRef.current = null; - fail('Spracheingabe konnte nicht gestartet werden'); - } + baseTranscriptRef.current = ''; + lastRestartAtRef.current = 0; + spawnBrowserRecognition(Recognition, false); }; const start = useCallback(async () => { if (recorderRef.current || recognitionRef.current) return; cancelledRef.current = false; + stoppedRef.current = false; transcriptRef.current = ''; + baseTranscriptRef.current = ''; + lastRestartAtRef.current = 0; const current = mode ?? currentVoiceConfig().mode; if (current === 'server') await startServer(); else if (current === 'browser') startBrowser(); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, continuous]); + }, [mode, continuous, live]); const cancel = useCallback(() => { cancelledRef.current = true; - clearTimer(); + stoppedRef.current = true; + inFlightChunksRef.current = 0; + setPendingChunksSafe(0); + clearAllTimers(); recorderRef.current?.cancel(); recorderRef.current = null; try { @@ -191,6 +355,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu mode, available: mode === 'server' || mode === 'browser', state, + pendingChunks, start, stop, cancel, diff --git a/frontend/src/lib/voice.ts b/frontend/src/lib/voice.ts index 9c0bfbe..8e447f4 100644 --- a/frontend/src/lib/voice.ts +++ b/frontend/src/lib/voice.ts @@ -86,19 +86,7 @@ export class VoiceRecorder { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); - const type = recordingType(); - this.recorder = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined); - this.chunks = []; - this.recorder.ondataavailable = (e) => { - if (e.data.size > 0) this.chunks.push(e.data); - }; - this.recorder.onstop = () => { - const blob = this.chunks.length ? new Blob(this.chunks, { type: this.recorder?.mimeType || type || 'audio/webm' }) : null; - this.release(); - this.done?.(blob); - this.done = null; - }; - this.recorder.start(1000); + this.startSegmentRecorder(); try { const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext; this.audioCtx = new Ctx(); @@ -113,6 +101,25 @@ export class VoiceRecorder { } } + private startSegmentRecorder(): void { + if (!this.stream) return; + const type = recordingType(); + const rec = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined); + const chunks: Blob[] = []; + rec.ondataavailable = (e) => { + if (e.data.size > 0) chunks.push(e.data); + }; + rec.onstop = () => { + const blob = chunks.length ? new Blob(chunks, { type: rec.mimeType || type || 'audio/webm' }) : null; + this.release(); + this.done?.(blob); + this.done = null; + }; + this.recorder = rec; + this.chunks = chunks; + rec.start(1000); + } + /** Input level 0..1 (roughly loudness), or null when unknown. */ level(): number | null { if (!this.analyser || !this.samples) return null; @@ -137,6 +144,30 @@ export class VoiceRecorder { }); } + /** + * Stops the current MediaRecorder segment and immediately starts a new one on the same MediaStream. + * Resolves with the finished segment's audio Blob while recording continues seamlessly. + */ + cutSegment(): Promise { + if (!this.stream || !this.recorder || this.recorder.state === 'inactive') { + return Promise.resolve(null); + } + const oldRecorder = this.recorder; + const oldChunks = this.chunks; + + this.startSegmentRecorder(); + + return new Promise((resolve) => { + oldRecorder.onstop = () => { + const blob = oldChunks.length + ? new Blob(oldChunks, { type: oldRecorder.mimeType || 'audio/webm' }) + : null; + resolve(blob); + }; + oldRecorder.stop(); + }); + } + cancel(): void { this.done = null; if (this.recorder && this.recorder.state !== 'inactive') { From d2afcf49dff5844c2c13356a5d92cab52b2c054b Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:32:52 +0100 Subject: [PATCH 4/8] feat(notifications): add channel routing matrix, daily summary, and task watch button --- CHANGELOG.md | 4 + .../features/settings/UserSettingsView.tsx | 276 ++++++++++++++++-- .../UserSettingsView.alarms.test.tsx | 106 +++++++ .../src/features/triage/TaskDetailModal.tsx | 46 ++- .../triage/__tests__/TaskDetailModal.test.tsx | 19 +- frontend/src/lib/api.ts | 14 + frontend/src/lib/types.ts | 28 ++ 7 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 frontend/src/features/settings/__tests__/UserSettingsView.alarms.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf7027..d6e0fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Kontoeinstellungen → Alarme: Kanal-Matrix (E-Mail / Discord) für „Fällige Aufgaben (sofort)“, „Freigabe erhalten“, „Geteilte Aufgabe erledigt“ und „Beobachtete Aufgaben“, tägliche Zusammenfassung mit Uhrzeit sowie Schalter zum Pausieren aller Benachrichtigungen und für eigene Änderungen an beobachteten Aufgaben. +- Aufgaben-Details: Knopf „Beobachten“ / „Nicht mehr beobachten“ (`POST/DELETE /tasks/{id}/watch`). + ### Changed - `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab. diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 5ade494..c11cae9 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -17,7 +17,7 @@ import { Pencil, Copy, } from 'lucide-react'; -import { AuthStatus, User, UserTheme } from '../../lib/types'; +import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; @@ -47,6 +47,48 @@ interface UserSettingsViewProps { type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; +export function buildNotificationSettings(user: User): NotificationSettings { + const hasLegacy = Array.isArray(user.alarmChannels); + const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true; + const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true; + const s = user.notificationSettings; + return { + paused: Boolean(s?.paused), + includeOwnWatched: Boolean(s?.includeOwnWatched), + events: { + due: { + email: s?.events?.due?.email ?? dueEmailDefault, + discord: s?.events?.due?.discord ?? dueDiscordDefault, + }, + shared: { + email: s?.events?.shared?.email ?? true, + discord: s?.events?.shared?.discord ?? false, + }, + taskDone: { + email: s?.events?.taskDone?.email ?? false, + discord: s?.events?.taskDone?.discord ?? false, + }, + watched: { + email: s?.events?.watched?.email ?? true, + discord: s?.events?.watched?.discord ?? true, + }, + }, + dailySummary: { + enabled: s?.dailySummary?.enabled ?? false, + time: s?.dailySummary?.time || '07:00', + email: s?.dailySummary?.email ?? true, + discord: s?.dailySummary?.discord ?? false, + }, + }; +} + +const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [ + { key: 'due', label: 'Fällige Aufgaben (sofort)' }, + { key: 'shared', label: 'Freigabe erhalten' }, + { key: 'taskDone', label: 'Geteilte Aufgabe erledigt' }, + { key: 'watched', label: 'Beobachtete Aufgaben' }, +]; + export const UserSettingsView: React.FC = ({ currentUser, activeTheme, @@ -211,6 +253,9 @@ export const UserSettingsView: React.FC = ({ const [alarmChannels, setAlarmChannels] = useState( Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] ); + const [notificationSettings, setNotificationSettings] = useState(() => + buildNotificationSettings(currentUser) + ); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); @@ -226,6 +271,7 @@ export const UserSettingsView: React.FC = ({ setEmail(currentUser.email || ''); setAvatarUrl(currentUser.avatarUrl || ''); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); + setNotificationSettings(buildNotificationSettings(currentUser)); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || ''); @@ -238,6 +284,19 @@ export const UserSettingsView: React.FC = ({ ); }; + const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => { + setNotificationSettings((prev) => ({ + ...prev, + events: { + ...prev.events, + [eventKey]: { + ...prev.events[eventKey], + [channel]: value, + }, + }, + })); + }; + // --- Handlers: Avatar --- const handleAvatarFileSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -324,14 +383,23 @@ export const UserSettingsView: React.FC = ({ setAlarmMsg(null); setSavingAlarms(true); try { + const nextAlarmChannels = [ + ...(notificationSettings.events.due.email ? ['email'] : []), + ...(notificationSettings.events.due.discord ? ['discord'] : []), + ...(alarmChannels.includes('webhook') ? ['webhook'] : []), + ]; const updated = await api.updateSettings({ - alarmChannels, + alarmChannels: nextAlarmChannels, alarmEmail: alarmEmail.trim(), discordWebhookUrl: discordWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(), notifyBeforeMinutes: Number(notifyBeforeMinutes), + notificationSettings, + }); + onUserUpdated({ + ...updated, + notificationSettings: updated.notificationSettings ?? notificationSettings, }); - onUserUpdated(updated); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); } catch (err: any) { setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); @@ -1019,6 +1087,176 @@ export const UserSettingsView: React.FC = ({ )}
+ {/* Global Pause & Own Watched Toggles */} +
+
+
+

Alle Benachrichtigungen pausieren

+

+ Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen. +

+
+ setNotificationSettings((prev) => ({ ...prev, paused: v }))} + title="Alle Benachrichtigungen pausieren" + /> +
+
+
+

+ Auch eigene Änderungen an beobachteten Aufgaben melden +

+

+ Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt. +

+
+ setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))} + title="Auch eigene Änderungen an beobachteten Aufgaben melden" + /> +
+
+ + {/* Routing Matrix: Events x (E-Mail / Discord) */} + {(() => { + const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); + return ( + <> +
+
+ Ereignis + + + E-Mail + + + + Discord + +
+ +
+ {NOTIFICATION_EVENT_ROWS.map((row) => { + const routing = notificationSettings.events[row.key]; + return ( +
+ {row.label} +
+ setEventChannel(row.key, 'email', v)} + title={`${row.label} – E-Mail`} + /> +
+
+ setEventChannel(row.key, 'discord', v)} + title={ + hasDiscordWebhook + ? `${row.label} – Discord` + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+ ); + })} +
+ +

+ Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben) +

+ {!hasDiscordWebhook && ( +

+ Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist. +

+ )} +
+ + {/* Daily Summary */} +
+
+
+

Tageszusammenfassung

+

+ Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht +

+
+ + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, enabled: v }, + })) + } + title="Tageszusammenfassung" + /> +
+ +
+ + +
+
+ E-Mail + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, email: v }, + })) + } + title="Tageszusammenfassung – E-Mail" + /> +
+
+ Discord + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, discord: v }, + })) + } + title={ + hasDiscordWebhook + ? 'Tageszusammenfassung – Discord' + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+
+
+ + ); + })()} + {/* Lead Time Selector */}
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC = ({
{/* Channel 1: E-Mail */} -
+
-
{/* Channel 2: Discord Webhook */} -
+
-
+ {(task.convertedType === 'document_block' || task.docId) ? ( + + )} - {/* Who else has this note open */} - - {/* Sharing Section */} - {doc && !inTrash && (() => { - const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); - const sharesCount = doc.sharedWith?.length || 0; - const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId); - const ownerDisplayName = doc.ownerName || 'Eigentümer'; + {doc && !inTrash && ( + + )} - return ( -
- {isShared && ( - isOwner ? ( - - ) : ( - - ) +
+ + + {showActionsMenu && ( +
+ {!inTrash && ( + + )} + + {onRequestMerge && doc?.isOwner && !inTrash && ( + )} - {onRequestMerge && doc.isOwner && !inTrash && ( - + + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( + <> +
+ + )}
- ); - })()} - - {!inTrash && } - - - - {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( - - )} + )} +
diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index b84dac5..963aae5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -46,7 +46,8 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { FormSheet } from '../../components/FormSheet'; import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; -import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; +import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor'; +import { PresenceAvatars } from './PresenceAvatars'; import { TrashView } from './TrashView'; import { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; @@ -386,6 +387,7 @@ export const WorkspaceView: React.FC = ({ const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); + const [docHeaderStatus, setDocHeaderStatus] = useState(null); // Drag and Drop state @@ -1306,55 +1308,129 @@ export const WorkspaceView: React.FC = ({
- {/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */} + {/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
{subView === 'editor' && selectedDocId ? ( - /* Save Status Pill */ -
- {docSaveStatus === 'saved' && ( - <> - - Gespeichert - + <> + {/* Live connection pill */} + {docHeaderStatus?.showLive && ( + + + {docHeaderStatus.channel === 'longpoll' + ? 'Live (Long-Polling)' + : docHeaderStatus.channel === 'interval' + ? 'Abgleich alle 3 s' + : 'Live'} + )} - {docSaveStatus === 'saving' && ( - <> - - Speichert… - + + {/* Who else has this note open */} + {docHeaderStatus && ( + )} - {docSaveStatus === 'unsaved' && ( - <> - - Ungespeichert - - )} - {docSaveStatus === 'error' && ( - <> - - Fehler - - )} -
+ + {/* Share pill */} + {selectedDocObj && + !selectedDocObj.isArchived && + (() => { + const isOwner = + selectedDocObj.isOwner !== false && + (selectedDocObj.userId === currentUser?.id || + !selectedDocObj.userId || + currentUser?.role === 'admin'); + const sharesCount = selectedDocObj.sharedWith?.length || 0; + const isShared = + sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId); + const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer'; + if (!isShared) return null; + return isOwner ? ( + + ) : ( + + ); + })()} + + {/* Save Status Pill */} +
+ {docSaveStatus === 'saved' && ( + <> + + Gespeichert + + )} + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )} +
+ ) : null}
@@ -1645,6 +1721,7 @@ export const WorkspaceView: React.FC = ({ setDocSaveStatus(status); setDocSaveError(err || null); }} + onHeaderStatusChange={setDocHeaderStatus} /> ) : subView === 'tags' ? ( /* ========================================================================= diff --git a/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx new file mode 100644 index 0000000..8466a4c --- /dev/null +++ b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DocumentEditor } from '../DocumentEditor'; +import { api } from '../../../lib/api'; +import { Document } from '../../../lib/types'; +import { EngineState } from '../../../lib/docSync/engine'; + +let currentListener: ((state: EngineState) => void) | null = null; +const mockUndo = vi.fn(() => ({ ok: true })); +const mockRedo = vi.fn(() => ({ ok: true })); + +const baseEngineState: EngineState = { + ready: true, + status: 'saved', + role: 'owner', + title: 'Test Notiz', + blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }], + pendingCount: 0, + shared: true, + sessions: 2, + presence: [{ userId: 'u2', name: 'Bob' }], + lockedByOthers: [], + conflicts: [], + canUndo: false, + canRedo: false, + channel: 'ws', +}; + +vi.mock('../../../lib/docSync/engine', () => { + return { + DocSyncEngine: class { + subscribe(fn: (state: EngineState) => void) { + currentListener = fn; + fn(baseEngineState); + return () => { + currentListener = null; + }; + } + start() { + return Promise.resolve(); + } + stop() {} + getState() { + return baseEngineState; + } + setTaskLinks() {} + focusBlock() {} + applyFlat() {} + setTitle() {} + undo() { + return mockUndo(); + } + redo() { + return mockRedo(); + } + }, + }; +}); + +vi.mock('../../../lib/api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + api: { + ...actual.api, + getDocument: vi.fn(), + getUsersDirectory: vi.fn(() => Promise.resolve([])), + getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })), + }, + }; +}); + +const makeDoc = (overrides: Partial = {}): Document => ({ + id: 'doc_1', + userId: 'u1', + title: 'Test Notiz', + icon: '📝', + folderPath: '', + content: '', + blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }], + tags: [], + isPinned: false, + isArchived: false, + isOwner: true, + createdAt: '2026-10-30T08:00:00Z', + updatedAt: '2026-10-30T08:00:00Z', + ...overrides, +}); + +describe('DocumentEditor header actions & status callback (Teil D)', () => { + beforeEach(() => { + vi.clearAllMocks(); + const doc = makeDoc(); + vi.mocked(api.getDocument).mockResolvedValue(doc); + }); + + it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => { + const doc = makeDoc({ isOwner: true }); + const onHeaderStatusChange = vi.fn(); + const onRequestMerge = vi.fn(); + const onDeleteDoc = vi.fn(); + const onOpenShareModal = vi.fn(); + + render( + + ); + + await waitFor(() => { + expect(onHeaderStatusChange).toHaveBeenCalledWith( + expect.objectContaining({ + showLive: true, + channel: 'ws', + }) + ); + }); + + // Undo / Redo buttons disabled initially + const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement; + const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement; + expect(undoBtn.disabled).toBe(true); + expect(redoBtn.disabled).toBe(true); + + // Enable Undo & Redo via engine state update + currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true }); + await waitFor(() => { + expect(undoBtn.disabled).toBe(false); + expect(redoBtn.disabled).toBe(false); + }); + + fireEvent.click(undoBtn); + expect(mockUndo).toHaveBeenCalledTimes(1); + fireEvent.click(redoBtn); + expect(mockRedo).toHaveBeenCalledTimes(1); + + // Share button is directly visible + const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen'); + fireEvent.click(shareBtn); + expect(onOpenShareModal).toHaveBeenCalledWith(doc); + + // Three-dots menu opens on click, shows secondary actions, and closes on Escape + const moreBtn = screen.getByTitle('Weitere Aktionen'); + fireEvent.click(moreBtn); + + expect(screen.getByText('Notiz anheften')).toBeTruthy(); + expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy(); + expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy(); + expect(screen.getByText('Notiz löschen')).toBeTruthy(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByText('Notiz anheften')).toBeNull(); + }); +}); From a69bcbaf10a7eae053fd738c487a6ce459696782 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:49:13 +0100 Subject: [PATCH 6/8] feat(notify): bundled notifications, task watchers, daily summary - Per-user routing (own mail / own Discord webhook) for task alarms (sent at once), shares, completed shared tasks and watched tasks; settings as JSON on the user (GET /auth/me, PATCH /auth/settings). - Everything but alarms is queued and sent as one message per user after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an HTML version, Discord as one embed. Three failed attempts drop it. - Own changes never notify the actor, except on watched tasks when asked for; "paused" stops everything including alarms. - Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks. - Daily summary at a chosen time: overdue, today, tomorrow, plus the count of the next 7 days; nothing due, nothing sent. - Migration 7; jobs do not run in the demo. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + CHANGELOG.md | 5 + Doku/api/rest.md | 3 +- Doku/architecture.md | 4 + Doku/database.md | 2 + docker-compose.yml | 1 + internal/api/alarms.go | 28 +- internal/api/handlers_auth.go | 12 + internal/api/handlers_documents.go | 11 + internal/api/handlers_folders.go | 1 + internal/api/handlers_lists.go | 3 + internal/api/handlers_tasklists.go | 3 + internal/api/handlers_thoughts.go | 6 + internal/api/mail.go | 38 ++- internal/api/mail_test.go | 14 +- internal/api/notify.go | 417 +++++++++++++++++++++++++++++ internal/api/notify_test.go | 160 +++++++++++ internal/api/server.go | 7 + internal/config/config.go | 4 + internal/domain/models.go | 63 +++++ internal/storage/migrations.go | 1 + internal/storage/notifications.go | 253 +++++++++++++++++ 22 files changed, 1018 insertions(+), 21 deletions(-) create mode 100644 internal/api/notify.go create mode 100644 internal/api/notify_test.go create mode 100644 internal/storage/notifications.go diff --git a/.env.example b/.env.example index 195bde3..aa809fa 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,9 @@ SMTP_FROM= # Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist). # Ein Name direkt in SMTP_FROM ("Mein Dienst ") hat Vorrang. SMTP_FROM_NAME=MindSift +# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele +# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort. +NOTIFY_DIGEST_MINUTES=5 # true = nur verschlüsselt senden. SMTP_TLS=true # true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver). diff --git a/CHANGELOG.md b/CHANGELOG.md index fb810dd..dcc1b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Benachrichtigungen pro Ereignis und Kanal: fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles. +- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`. +- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht. + ### Fixed - In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb. diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 678bdb0..4612e57 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -24,6 +24,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | +| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` | | GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe | | GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau | | POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) | @@ -40,7 +41,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` | | DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | -| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | +| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 4d11b87..af0fdba 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -99,6 +99,10 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in `internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen. +## Benachrichtigungen + +`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) und eigener Discord-Webhook aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht. + ## Link-Vorschau Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, ``, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus. diff --git a/Doku/database.md b/Doku/database.md index b5ecbba..ecbccb7 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -12,6 +12,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j | `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` | | `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` | | `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` | +| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` | +| `task_watchers` | Beobachtete Aufgaben | `task_id`, `user_id`, `created_at` | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` | diff --git a/docker-compose.yml b/docker-compose.yml index 83f3c5c..e00922c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - SMTP_PASS=${SMTP_PASS:-} - SMTP_FROM=${SMTP_FROM:-} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift} + - NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} # Impressum / Datenschutz diff --git a/internal/api/alarms.go b/internal/api/alarms.go index f362e94..33766da 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -13,6 +13,11 @@ import ( // --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) --- func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error { + return s.sendEmailHTML(toEmail, subject, bodyText, "") +} + +// sendEmailHTML sends text plus an optional HTML version (multipart/alternative). +func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error { if s.cfg.DemoMode { return errDemoOutgoing } @@ -20,7 +25,7 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error if err != nil { return err } - if err := sendMail(cfg, toEmail, subject, bodyText); err != nil { + if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil { step := "" if se, ok := err.(*smtpStepError); ok { step = se.Step @@ -123,6 +128,27 @@ func (s *Server) startAlarmTicker() { if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) { continue } + // Channels come from the notification settings (row "due"); a pause holds the alarm back + set := s.store.NotificationSettingsOf(a.UserID) + if set.Paused { + continue + } + channels := []string{} + if set.Events.Due.Email { + channels = append(channels, "email") + } + if set.Events.Due.Discord { + channels = append(channels, "discord") + } + for _, ch := range a.AlarmChannels { + if ch == "webhook" { + channels = append(channels, "webhook") + } + } + if a.AlarmEmail == "" { + a.AlarmEmail = a.UserEmail + } + a.AlarmChannels = channels if len(a.AlarmChannels) == 0 { continue } diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 8def15f..74d2d42 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -341,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { return } stats, _ := s.store.GetDashboardStats(userID) + ns := s.store.NotificationSettingsOf(userID) + user.NotificationSettings = &ns writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, @@ -448,6 +450,8 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"` // Needed to change password or e-mail address (FLAW-30) CurrentPassword string `json:"currentPassword"` + // Which notifications go where (notify.go) + NotificationSettings *domain.NotificationSettings `json:"notificationSettings"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -528,6 +532,14 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request if changesPassword || changesEmail { s.notifyAccountChange(before, changesPassword, changesEmail, u.Email) } + if req.NotificationSettings != nil { + if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil { + writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden") + return + } + } + ns := s.store.NotificationSettingsOf(userID) + u.NotificationSettings = &ns writeJSON(w, http.StatusOK, u) } diff --git a/internal/api/handlers_documents.go b/internal/api/handlers_documents.go index aacfcca..6626459 100644 --- a/internal/api/handlers_documents.go +++ b/internal/api/handlers_documents.go @@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { if req.TeamID != "" { share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission) + if err == nil { + defer s.notifyShared(userID, "team", req.TeamID, "die Notiz", s.docTitle(userID, docID)) + } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID)) s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") writeJSON(w, http.StatusCreated, share) @@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, users) } + +func (s *Server) docTitle(userID, docID string) string { + if d, err := s.store.GetDocumentByID(userID, docID); err == nil && d.Title != "" { + return d.Title + } + return "Unbenannte Notiz" +} diff --git a/internal/api/handlers_folders.go b/internal/api/handlers_folders.go index 39d2cd2..5b09b91 100644 --- a/internal/api/handlers_folders.go +++ b/internal/api/handlers_folders.go @@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, err.Error()) return } + s.notifyShared(userID, share.TargetType, share.TargetID, "den Ordner", s.store.SharedFolderName(folderID)) s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID}) if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil { for _, docID := range docIDs { diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index 169a8a0..5a281a2 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) { return } + if l, err := s.store.GetListByID(currentUserID, listID); err == nil { + s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title) + } if targetType == "team" { members, _ := s.store.GetTeamMembers(share.TargetID) for _, tm := range members { diff --git a/internal/api/handlers_tasklists.go b/internal/api/handlers_tasklists.go index 8fac112..25a359e 100644 --- a/internal/api/handlers_tasklists.go +++ b/internal/api/handlers_tasklists.go @@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques return } s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED") + if tl, err := s.store.GetTaskListByID(userID, listID); err == nil { + s.notifyShared(userID, req.TargetType, req.TargetID, "die Aufgabenliste", tl.Title) + } writeJSON(w, http.StatusCreated, share) } diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index a0ed90a..07bbf1c 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + s.markWatching(userID, thoughts) writeJSON(w, http.StatusOK, thoughts) } @@ -278,6 +279,8 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { s.notifyDocChanged(docID, head) } s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated) + s.notifyTaskChange(userID, before, updated, false) + updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID] writeJSON(w, http.StatusOK, updated) } @@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { writeStoreError(w, http.StatusInternalServerError, err) return } + if th != nil { + s.notifyTaskChange(userID, th, nil, true) + } s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } diff --git a/internal/api/mail.go b/internal/api/mail.go index 763cfd8..1b3ac27 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -117,7 +117,7 @@ func senderAddress(raw, name string) (envelope, header string, err error) { // buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject // (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings. -func buildMessage(from, to, subject, body string, now time.Time) []byte { +func buildMessage(from, to, subject, body, html string, now time.Time) []byte { idBytes := make([]byte, 12) _, _ = rand.Read(idBytes) domain := ehloName(from) @@ -129,12 +129,26 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte { fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z)) fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain) buf.WriteString("MIME-Version: 1.0\r\n") - buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") - buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") - qp := quotedprintable.NewWriter(&buf) - _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n"))) - _ = qp.Close() - buf.WriteString("\r\n") + part := func(contentType, text string) { + fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType) + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qp := quotedprintable.NewWriter(&buf) + _, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n"))) + _ = qp.Close() + buf.WriteString("\r\n") + } + if html == "" { + part("text/plain", body) + return buf.Bytes() + } + // Text for simple clients, HTML for the rest + boundary := "ms-" + hex.EncodeToString(idBytes) + fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary) + fmt.Fprintf(&buf, "--%s\r\n", boundary) + part("text/plain", body) + fmt.Fprintf(&buf, "--%s\r\n", boundary) + part("text/html", html) + fmt.Fprintf(&buf, "--%s--\r\n", boundary) return buf.Bytes() } @@ -199,14 +213,14 @@ func authenticate(client *smtp.Client, cfg smtpSettings) error { // sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once // and tries LOGIN; a 451 on both points to the mail server's SASL backend. -func sendMail(cfg smtpSettings, to, subject, body string) error { - err := sendMailOnce(cfg, to, subject, body) +func sendMail(cfg smtpSettings, to, subject, body, html string) error { + err := sendMailOnce(cfg, to, subject, body, html) var first *authAttemptError if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered { return err } cfg.authMech = "LOGIN" - retryErr := sendMailOnce(cfg, to, subject, body) + retryErr := sendMailOnce(cfg, to, subject, body, html) var second *authAttemptError if retryErr == nil || !errors.As(retryErr, &second) { return retryErr @@ -220,7 +234,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error { // sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself // properly, honour SkipVerify and report the exact step a server refuses. -func sendMailOnce(cfg smtpSettings, to, subject, body string) error { +func sendMailOnce(cfg smtpSettings, to, subject, body, html string) error { fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName) if err != nil { return &smtpStepError{"Absender", err} @@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error { if err != nil { return &smtpStepError{"DATA", err} } - if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil { + if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, html, time.Now())); err != nil { _ = wc.Close() return &smtpStepError{"DATA", err} } diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go index d50d553..226d895 100644 --- a/internal/api/mail_test.go +++ b/internal/api/mail_test.go @@ -175,13 +175,13 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { f := startFakeSMTP(t, "") // The certificate is self-signed: refused unless verification is switched off - err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo") + err := sendMail(f.settings(false), "anna@example.com", "Test", "Hallo", "") var stepErr *smtpStepError if !errors.As(err, &stepErr) || stepErr.Step != "STARTTLS" { t.Fatalf("expected a STARTTLS error with verification on, got %v", err) } - if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut"); err != nil { + if err := sendMail(f.settings(true), "anna@example.com", "⏰ Aufgabe fällig: Müll", "Zeile 1\nZeile 2 mit Ümlaut", ""); err != nil { t.Fatalf("send with SkipVerify failed: %v", err) } f.mu.Lock() @@ -206,7 +206,7 @@ func TestSendMailStartTLSWithSelfSignedCertificate(t *testing.T) { func TestSendMailReportsTheRefusingStep(t *testing.T) { f := startFakeSMTP(t, "451 4.3.2 Internal server error") - err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo") + err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", "") var stepErr *smtpStepError if !errors.As(err, &stepErr) || stepErr.Step != "RCPT TO" || !strings.Contains(err.Error(), "451") { t.Fatalf("expected the 451 to be reported at RCPT TO, got %v", err) @@ -218,7 +218,7 @@ func TestSendMailWithDisplayNameSender(t *testing.T) { f := startFakeSMTP(t, "") cfg := f.settings(true) cfg.From = "MindSift Alarme <alarm@firma.de>" - if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo"); err != nil { + if err := sendMail(cfg, "Anna <anna@example.com>", "Test", "Hallo", ""); err != nil { t.Fatalf("send failed: %v", err) } f.mu.Lock() @@ -244,7 +244,7 @@ func TestAuthFallsBackToLogin(t *testing.T) { f := startFakeSMTP(t, "") f.authMechs = "PLAIN LOGIN" f.authFail["PLAIN"] = "451 4.3.2 Internal server error" - if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { + if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil { t.Fatalf("LOGIN fallback should succeed: %v", err) } f.mu.Lock() @@ -257,7 +257,7 @@ func TestAuthFallsBackToLogin(t *testing.T) { func TestAuthLoginOnlyAndBothFailing(t *testing.T) { f := startFakeSMTP(t, "") f.authMechs = "LOGIN" - if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo"); err != nil { + if err := sendMail(f.settings(true), "anna@example.com", "Test", "Hallo", ""); err != nil { t.Fatalf("LOGIN-only server: %v", err) } @@ -265,7 +265,7 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) { g.authMechs = "PLAIN LOGIN" g.authFail["PLAIN"] = "451 4.3.2 Internal server error" g.authFail["LOGIN"] = "451 4.3.2 Internal server error" - err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo") + err := sendMail(g.settings(true), "anna@example.com", "Test", "Hallo", "") if err == nil || !strings.Contains(err.Error(), "PLAIN") || !strings.Contains(err.Error(), "LOGIN") || !strings.Contains(err.Error(), "SASL") { t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err) } diff --git a/internal/api/notify.go b/internal/api/notify.go new file mode 100644 index 0000000..0960103 --- /dev/null +++ b/internal/api/notify.go @@ -0,0 +1,417 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "log/slog" + "net/http" + "sort" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" +) + +// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one +// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks +// the channels per event kind (own mail address, own Discord webhook from the alarm settings), can +// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the +// one who made them, except on watched tasks when the user asked for it. The demo sends nothing. + +const notifyMaxAttempts = 3 + +// notifyItem is one line of a bundled message. +type notifyItem struct { + kind, title, text string +} + +// notify queues an event for the recipients that want it (settings, pause, own-change rule). +func (s *Server) notify(kind, actorID string, recipients []string, payload map[string]string) { + if s.cfg.DemoMode { + return + } + seen := map[string]bool{} + for _, uid := range recipients { + if uid == "" || seen[uid] { + continue + } + seen[uid] = true + set := s.store.NotificationSettingsOf(uid) + if set.Paused { + continue + } + if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) { + continue + } + if ch := set.Channels(kind); !ch.Email && !ch.Discord { + continue + } + if err := s.store.QueueNotification(uid, kind, payload); err != nil { + slog.Warn("Benachrichtigung konnte nicht vorgemerkt werden", "user_id", uid, "error", err) + } + } +} + +func (s *Server) actorName(userID string) string { + if u, err := s.store.GetUserByID(userID); err == nil && u != nil && u.DisplayName != "" { + return u.DisplayName + } + return "Jemand" +} + +// notifyShared tells the people a note, folder or list was shared with (teams: all members). +func (s *Server) notifyShared(actorID, targetType, targetID, what, title string) { + var recipients []string + if targetType == "team" { + members, _ := s.store.GetTeamMembers(targetID) + for _, m := range members { + recipients = append(recipients, m.UserID) + } + } else { + recipients = []string{targetID} + } + s.notify("shared", actorID, recipients, map[string]string{ + "title": title, + "text": fmt.Sprintf("%s hat %s „%s“ mit dir geteilt", s.actorName(actorID), what, title), + }) +} + +// notifyTaskChange covers watchers (any change) and list members (task completed). +func (s *Server) notifyTaskChange(actorID string, before, after *domain.Thought, deleted bool) { + t := after + if t == nil { + t = before + } + if t == nil { + return + } + title := strings.TrimSpace(strings.SplitN(t.Content, "\n", 2)[0]) + name := s.actorName(actorID) + var change string + switch { + case deleted: + change = "gelöscht" + case before != nil && after != nil && !before.IsCompleted && after.IsCompleted: + change = "erledigt" + case before != nil && after != nil && before.IsCompleted && !after.IsCompleted: + change = "wieder geöffnet" + case before != nil && after != nil && before.DueDate != after.DueDate: + change = "Fälligkeit geändert auf " + domain.HumanDue(after.DueDate) + case before != nil && after != nil && before.Content != after.Content: + change = "Text geändert" + case before != nil && after != nil && before.Priority != after.Priority: + change = "Priorität geändert auf " + after.Priority + case before != nil && after != nil && before.TaskListID != after.TaskListID: + change = "in eine andere Liste verschoben" + default: + change = "geändert" + } + payload := map[string]string{"title": title, "text": fmt.Sprintf("%s hat „%s“ %s", name, title, change)} + watchers := s.store.TaskWatchers(t.ID) + s.notify("watched", actorID, watchers, payload) + + if change == "erledigt" && t.TaskListID != "" { + watching := map[string]bool{} + for _, w := range watchers { + watching[w] = true + } + var members []string + for _, uid := range s.taskAudience(t) { + if !watching[uid] { // watchers already hear about it + members = append(members, uid) + } + } + s.notify("taskDone", actorID, members, payload) + } + if deleted { + s.store.ForgetTaskWatchers(t.ID) + } +} + +// --- Background jobs --- + +func (s *Server) startNotificationJobs() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for now := range ticker.C { + s.flushNotifications(now) + s.sendDailySummaries(now) + } +} + +// flushNotifications sends one bundled message per recipient whose oldest event waited long enough. +func (s *Server) flushNotifications(now time.Time) { + minutes := s.cfg.NotifyDigestMinutes + if minutes < 1 { + minutes = 5 + } + window := time.Duration(minutes) * time.Minute + users, err := s.store.DueNotificationUsers(now.Add(-window)) + if err != nil { + slog.Error("Benachrichtigungen konnten nicht gelesen werden", "error", err) + return + } + for _, uid := range users { + queued, err := s.store.QueuedNotifications(uid) + if err != nil || len(queued) == 0 { + continue + } + set := s.store.NotificationSettingsOf(uid) + var emailItems, discordItems []notifyItem + ids := make([]int64, 0, len(queued)) + maxAttempts := 0 + for _, q := range queued { + ids = append(ids, q.ID) + if q.Attempts > maxAttempts { + maxAttempts = q.Attempts + } + if set.Paused { + continue + } + item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]} + ch := set.Channels(q.Kind) + if ch.Email { + emailItems = append(emailItems, item) + } + if ch.Discord { + discordItems = append(discordItems, item) + } + } + if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil { + if maxAttempts+1 >= notifyMaxAttempts { + slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err) + _ = s.store.DeleteQueuedNotifications(ids) + } else { + _ = s.store.BumpQueuedNotifications(ids) + } + continue + } + _ = s.store.DeleteQueuedNotifications(ids) + } +} + +// deliver sends the items to the user's mail address and/or Discord webhook. +func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error { + u, err := s.store.GetUserByID(userID) + if err != nil || u == nil || !u.IsActive { + return nil // account gone: nothing to deliver + } + var firstErr error + if len(emailItems) > 0 { + to := u.AlarmEmail + if to == "" { + to = u.Email + } + subject := "MindSift: " + heading + if len(emailItems) == 1 { + subject = "MindSift: " + emailItems[0].text + } + if err := s.sendNotifyEmail(to, subject, notifyText(heading, emailItems, s.notifyLink()), notifyHTML(heading, emailItems, s.notifyLink())); err != nil { + firstErr = err + } + } + if len(discordItems) > 0 && u.DiscordWebhookURL != "" { + if err := s.sendNotifyDiscord(u.DiscordWebhookURL, heading, discordItems); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// sendNotifyEmail / sendNotifyDiscord are replaced in tests. +func (s *Server) sendNotifyEmail(to, subject, text, htmlBody string) error { + if s.notifyEmailHook != nil { + return s.notifyEmailHook(to, subject, text, htmlBody) + } + return s.sendEmailHTML(to, subject, text, htmlBody) +} + +func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyItem) error { + if s.notifyDiscordHook != nil { + return s.notifyDiscordHook(webhookURL, heading, items) + } + return s.sendDiscordDigest(webhookURL, heading, items) +} + +func (s *Server) notifyLink() string { + if !s.cfg.BaseURLSet { + return "" + } + return strings.TrimRight(s.cfg.BaseURL, "/") + "/" +} + +// sendDiscordDigest posts one embed with a line per item (at most 10). +func (s *Server) sendDiscordDigest(webhookURL, heading string, items []notifyItem) error { + if s.cfg.DemoMode { + return errDemoOutgoing + } + allowPrivate := s.allowPrivateWebhooks() + if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil { + return err + } + lines := make([]string, 0, 11) + for i, it := range items { + if i == 10 { + lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-10)) + break + } + lines = append(lines, "• "+it.text) + } + body, _ := json.Marshal(map[string]interface{}{ + "embeds": []map[string]interface{}{{ + "title": "MindSift: " + heading, + "description": strings.Join(lines, "\n"), + "color": 2450411, + }}, + }) + resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("HTTP Status %d", resp.StatusCode) + } + return nil +} + +func notifyText(heading string, items []notifyItem, link string) string { + var b strings.Builder + b.WriteString(heading + "\n\n") + for _, it := range items { + b.WriteString("• " + it.text + "\n") + } + if link != "" { + b.WriteString("\nÖffnen: " + link + "\n") + } + b.WriteString("\nDiese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.\n") + return b.String() +} + +// notifyHTML: simple table layout with inline styles, no external resources (works in most clients). +func notifyHTML(heading string, items []notifyItem, link string) string { + var cards strings.Builder + for _, it := range items { + cards.WriteString(`<tr><td style="padding:12px 16px;border:1px solid #e3e6ea;border-radius:8px;background:#ffffff;font-size:14px;color:#1f2328">` + + html.EscapeString(it.text) + `</td></tr><tr><td style="height:8px"></td></tr>`) + } + button := "" + if link != "" { + button = `<tr><td style="padding-top:8px"><a href="` + html.EscapeString(link) + + `" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-size:14px">In MindSift öffnen</a></td></tr>` + } + return `<!doctype html><html><body style="margin:0;padding:24px;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">` + + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto">` + + `<tr><td style="font-size:18px;font-weight:bold;color:#2563eb;padding-bottom:4px">MindSift</td></tr>` + + `<tr><td style="font-size:15px;color:#1f2328;padding-bottom:16px">` + html.EscapeString(heading) + `</td></tr>` + + cards.String() + button + + `<tr><td style="font-size:12px;color:#6b7280;padding-top:20px">Diese Benachrichtigungen stellst du in MindSift unter Kontoeinstellungen → Alarme ein.</td></tr>` + + `</table></body></html>` +} + +// --- Daily summary --- + +// sendDailySummaries sends each user who wants one their summary in the 30 minutes after the chosen +// time, once a day. Nothing due → no message. +func (s *Server) sendDailySummaries(now time.Time) { + day := now.Format("2006-01-02") + for _, uid := range s.store.ActiveUserIDs() { + set := s.store.NotificationSettingsOf(uid) + ds := set.DailySummary + if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) { + continue + } + at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location()) + if err != nil || now.Before(at) || now.Sub(at) >= 30*time.Minute { + continue + } + items := s.summaryItems(uid, now) + if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) { + continue + } + var mail, discord []notifyItem + if ds.Email { + mail = items + } + if ds.Discord { + discord = items + } + if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil { + slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err) + } + } +} + +// summaryItems: overdue, today, tomorrow (with time), plus how many follow in the next 7 days. +func (s *Server) summaryItems(userID string, now time.Time) []notifyItem { + today := now.Format("2006-01-02") + tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02") + tasks, err := s.store.OpenTasksDueBefore(userID, now.AddDate(0, 0, 8).Format("2006-01-02")) + if err != nil { + return nil + } + sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].DueDate < tasks[j].DueDate }) + var items []notifyItem + later := 0 + for _, t := range tasks { + d := t.DueDate + if len(d) > 10 { + d = d[:10] + } + label := t.Title + if t.ListTitle != "" { + label += " (" + t.ListTitle + ")" + } + clock := "" + if len(t.DueDate) >= 16 { + clock = ", " + t.DueDate[11:16] + " Uhr" + } + switch { + case d < today: + items = append(items, notifyItem{kind: "due", title: t.Title, text: "Überfällig seit " + domain.HumanDue(t.DueDate) + ": " + label}) + case d == today: + items = append(items, notifyItem{kind: "due", title: t.Title, text: "Heute" + clock + ": " + label}) + case d == tomorrow: + items = append(items, notifyItem{kind: "due", title: t.Title, text: "Morgen" + clock + ": " + label}) + default: + later++ + } + } + if len(items) > 0 && later > 0 { + items = append(items, notifyItem{kind: "due", text: fmt.Sprintf("Außerdem %d weitere Aufgaben in den nächsten 7 Tagen", later)}) + } + return items +} + +// --- Watching tasks --- + +// handleWatchTask: POST (watch) / DELETE (stop) /tasks/{id}/watch +func (s *Server) handleWatchTask(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + taskID := chi.URLParam(r, "id") + if _, err := s.store.GetThoughtByID(userID, taskID); err != nil { + writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden") + return + } + on := r.Method == http.MethodPost + if err := s.store.WatchTask(taskID, userID, on); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"watching": on}) +} + +// markWatching sets IsWatching on tasks for the requesting user. +func (s *Server) markWatching(userID string, thoughts []domain.Thought) { + watched := s.store.WatchedTaskIDs(userID) + if len(watched) == 0 { + return + } + for i := range thoughts { + thoughts[i].IsWatching = watched[thoughts[i].ID] + } +} diff --git a/internal/api/notify_test.go b/internal/api/notify_test.go new file mode 100644 index 0000000..fce410f --- /dev/null +++ b/internal/api/notify_test.go @@ -0,0 +1,160 @@ +package api + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +type sentNote struct{ channel, to, subject, text, html string } + +func notifyServer(t *testing.T) (*Server, *[]sentNote) { + t.Helper() + srv, _, _ := setupTestServer(t) + srv.cfg.NotifyDigestMinutes = 5 + var sent []sentNote + srv.notifyEmailHook = func(to, subject, text, html string) error { + sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html}) + return nil + } + srv.notifyDiscordHook = func(url, heading string, items []notifyItem) error { + lines := make([]string, 0, len(items)) + for _, it := range items { + lines = append(lines, it.text) + } + sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")}) + return nil + } + return srv, &sent +} + +func TestWatchedTaskChangesAreBundled(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + router := srv.Router() + owner, ownerToken := newTestUser(t, store, "owner@example.com") + watcher, watcherToken := newTestUser(t, store, "watcher@example.com") + list, _ := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "#000000") + task, _ := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", list.ID, "todo", "") + if _, err := store.AddTaskListShare(list.ID, "user", watcher.ID, "editor", owner.ID); err != nil { + t.Fatal(err) + } + if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", watcherToken, "", ""); rec.Code != http.StatusOK { + t.Fatalf("watch: %d %s", rec.Code, rec.Body.String()) + } + _, strangerToken := newTestUser(t, store, "fremd@example.com") + if rec := sessionCall(router, "POST", "/api/v1/tasks/"+task.ID+"/watch", strangerToken, "", ""); rec.Code != http.StatusNotFound { + t.Fatalf("stranger watch: want 404, got %d", rec.Code) + } + + // Two changes by the owner within the window → one bundled mail for the watcher, none for the owner + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"content":"Fenster putzen (innen)"}`, "") + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, ownerToken, `{"isCompleted":true}`, "") + srv.flushNotifications(time.Now()) + if len(*sent) != 0 { + t.Fatalf("nothing may go out before the window ends: %+v", *sent) + } + srv.flushNotifications(time.Now().Add(6 * time.Minute)) + var mails []sentNote + for _, n := range *sent { + if n.channel == "email" { + mails = append(mails, n) + } + } + if len(mails) != 1 || mails[0].to != "watcher@example.com" || !strings.Contains(mails[0].text, "Text geändert") || + !strings.Contains(mails[0].text, "erledigt") || !strings.Contains(mails[0].html, "<html") { + t.Fatalf("expected one bundled HTML mail to the watcher, got %+v", *sent) + } + *sent = nil + srv.flushNotifications(time.Now().Add(20 * time.Minute)) + if len(*sent) != 0 { + t.Fatal("queue must be empty after sending") + } +} + +func TestOwnChangesPauseAndChannels(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + router := srv.Router() + me, myToken := newTestUser(t, store, "me@example.com") + task, _ := store.CreateThoughtWithDetails(me.ID, "Steuer", "normal", "manual", "", "", "todo", "") + _ = store.WatchTask(task.ID, me.ID, true) + + // Own change on a watched task: nothing … + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"high"}`, "") + srv.flushNotifications(time.Now().Add(10 * time.Minute)) + if len(*sent) != 0 { + t.Fatalf("own change must not notify: %+v", *sent) + } + // … unless asked for, then only over the chosen channel + set := store.NotificationSettingsOf(me.ID) + set.IncludeOwnWatched = true + set.Events.Watched = domain.ChannelChoice{Discord: true} + _ = store.SetNotificationSettings(me.ID, set) + hook := "https://discord.com/api/webhooks/1/x" + _, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, &hook, nil, nil, nil) + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"low"}`, "") + srv.flushNotifications(time.Now().Add(10 * time.Minute)) + if len(*sent) != 1 || (*sent)[0].channel != "discord" { + t.Fatalf("expected one Discord message, got %+v", *sent) + } + + // Paused: nothing at all + *sent = nil + set.Paused = true + _ = store.SetNotificationSettings(me.ID, set) + sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"urgent"}`, "") + srv.flushNotifications(time.Now().Add(10 * time.Minute)) + if len(*sent) != 0 { + t.Fatalf("paused must send nothing: %+v", *sent) + } + + // Settings round trip over the profile API + rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true}`) { + t.Fatalf("settings: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestDailySummary(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + me, _ := newTestUser(t, store, "summary@example.com") + now := time.Date(2026, 10, 30, 7, 0, 30, 0, time.Local) + set := store.NotificationSettingsOf(me.ID) + set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true} + _ = store.SetNotificationSettings(me.ID, set) + + srv.sendDailySummaries(now) + if len(*sent) != 0 { + t.Fatal("nothing due → no summary") + } + for _, td := range [][2]string{{"Altlast", "2026-10-28"}, {"Zahnarzt", "2026-10-30T15:30"}, {"Müll raus", "2026-10-31"}, {"Später", "2026-11-04"}} { + th, _ := store.CreateThoughtWithDetails(me.ID, td[0], "normal", "manual", td[1], "", "todo", "") + if _, err := store.UpdateThoughtTriage(me.ID, th.ID, "keep", "task", "", td[1], ""); err != nil { // triaged into a task + t.Fatal(err) + } + } + + srv.sendDailySummaries(now.Add(-2 * time.Minute)) // before the chosen time + if len(*sent) != 0 { + t.Fatal("no summary before the chosen time") + } + srv.sendDailySummaries(now) + if len(*sent) != 1 { + t.Fatalf("expected one summary, got %d", len(*sent)) + } + text := (*sent)[0].text + for _, want := range []string{"Überfällig", "Altlast", "Heute, 15:30 Uhr: Zahnarzt", "Morgen: Müll raus", "1 weitere"} { + if !strings.Contains(text, want) { + t.Fatalf("summary lacks %q:\n%s", want, text) + } + } + srv.sendDailySummaries(now.Add(5 * time.Minute)) + if len(*sent) != 1 { + t.Fatal("only one summary per day") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 75e46a0..ae0f1d1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -34,6 +34,10 @@ type Server struct { streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) loginGuard *loginGuard // brute-force protection (loginguard.go) voice *voiceService // self-hosted speech recognition (voice.go) + + // Replace the delivery of bundled notifications in tests (notify.go) + notifyEmailHook func(to, subject, text, html string) error + notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -62,6 +66,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS } if !cfg.DemoMode { go srv.startAlarmTicker() // the demo sends nothing (FLAW-33) + go srv.startNotificationJobs() go srv.startBackupScheduler() } return srv @@ -136,6 +141,8 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Post("/api/v1/documents/{id}/extract", s.handleExtractBranch) + r.Post("/api/v1/tasks/{id}/watch", s.handleWatchTask) + r.Delete("/api/v1/tasks/{id}/watch", s.handleWatchTask) r.Post("/api/v1/documents/{id}/extract/undo", s.handleUndoExtract) r.Post("/api/v1/documents/{id}/merge", s.handleMergeDocuments) r.Post("/api/v1/documents/{id}/merge/undo", s.handleUndoMerge) diff --git a/internal/config/config.go b/internal/config/config.go index a6d94db..df0fff1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,9 @@ type Config struct { // Images and audio in notes (MEDIA_*) Media MediaConfig + + // NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes + NotifyDigestMinutes int } // MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit @@ -237,6 +240,7 @@ func Load() *Config { PerUserPerHour: envInt("STT_PER_USER_PER_HOUR", 60, 1, 100000), TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600), }, + NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440), Media: MediaConfig{ MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000), MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000), diff --git a/internal/domain/models.go b/internal/domain/models.go index 82dd916..26cbbb1 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -135,6 +135,67 @@ type User struct { NotifyBeforeMinutes int `json:"notifyBeforeMinutes"` OrganizationID string `json:"organizationId,omitempty"` CreatedAt string `json:"createdAt"` + // Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings) + NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` +} + +// NotificationSettings: which events reach a user over which channel, the daily summary and a pause. +type NotificationSettings struct { + Paused bool `json:"paused"` + IncludeOwnWatched bool `json:"includeOwnWatched"` + Events NotificationEvents `json:"events"` + DailySummary DailySummary `json:"dailySummary"` +} + +type NotificationEvents struct { + Due ChannelChoice `json:"due"` // task alarms (sent at once) + Shared ChannelChoice `json:"shared"` // something was shared with me + TaskDone ChannelChoice `json:"taskDone"` // a shared task was completed + Watched ChannelChoice `json:"watched"` // a watched task changed +} + +type ChannelChoice struct { + Email bool `json:"email"` + Discord bool `json:"discord"` +} + +type DailySummary struct { + Enabled bool `json:"enabled"` + Time string `json:"time"` // HH:MM, server time zone + Email bool `json:"email"` + Discord bool `json:"discord"` +} + +// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels. +func DefaultNotificationSettings(alarmChannels []string) NotificationSettings { + due := ChannelChoice{} + for _, c := range alarmChannels { + due.Email = due.Email || c == "email" + due.Discord = due.Discord || c == "discord" + } + return NotificationSettings{ + Events: NotificationEvents{ + Due: due, + Shared: ChannelChoice{Email: true}, + Watched: ChannelChoice{Email: true, Discord: true}, + }, + DailySummary: DailySummary{Time: "07:00", Email: true}, + } +} + +// Channels returns the choice for an event kind (due, shared, taskDone, watched). +func (n NotificationSettings) Channels(kind string) ChannelChoice { + switch kind { + case "due": + return n.Events.Due + case "shared": + return n.Events.Shared + case "taskDone": + return n.Events.TaskDone + case "watched": + return n.Events.Watched + } + return ChannelChoice{} } type UserInvite struct { @@ -167,6 +228,8 @@ type SubTask struct { } type Thought struct { + // IsWatching: the requesting user gets notified about changes (task watchers) + IsWatching bool `json:"isWatching,omitempty"` ID string `json:"id"` UserID string `json:"userId"` Content string `json:"content"` diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 7da8890..070d69b 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -27,6 +27,7 @@ var migrations = []migration{ {4, "Sitzungen", (*Store).migrateSessions}, {5, "Medien", (*Store).migrateMedia}, {6, "Link-Vorschauen", (*Store).migrateLinkPreviews}, + {7, "Benachrichtigungen", (*Store).migrateNotifications}, } func (s *Store) migrate() error { diff --git a/internal/storage/notifications.go b/internal/storage/notifications.go new file mode 100644 index 0000000..ce6c5ee --- /dev/null +++ b/internal/storage/notifications.go @@ -0,0 +1,253 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Notifications: per-user settings (JSON on the user), a queue that is sent bundled every few minutes, +// task watchers and the bookkeeping for the daily summary. + +func (s *Store) migrateNotifications() error { + if err := s.addColumnIfMissing("users", "notification_settings", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + if err := s.addColumnIfMissing("users", "last_daily_summary", "TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + return s.execInTx( + stmt(`CREATE TABLE IF NOT EXISTS notification_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0 + )`), + stmt(`CREATE INDEX IF NOT EXISTS idx_notification_queue_user ON notification_queue(user_id, created_at)`), + stmt(`CREATE TABLE IF NOT EXISTS task_watchers ( + task_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (task_id, user_id) + )`), + ) +} + +// NotificationSettingsOf returns the user's settings, defaults filled in for users who never saved any. +func (s *Store) NotificationSettingsOf(userID string) domain.NotificationSettings { + var raw, channels string + _ = s.db.QueryRow(`SELECT notification_settings, alarm_channels FROM users WHERE id = ?`, userID).Scan(&raw, &channels) + out := domain.DefaultNotificationSettings(parseAlarmChannels(channels)) + if raw != "" { + _ = json.Unmarshal([]byte(raw), &out) + } + if !validClock(out.DailySummary.Time) { + out.DailySummary.Time = "07:00" + } + return out +} + +func validClock(v string) bool { + t, err := time.Parse("15:04", v) + return err == nil && t.Format("15:04") == v +} + +func (s *Store) SetNotificationSettings(userID string, n domain.NotificationSettings) error { + if !validClock(n.DailySummary.Time) { + n.DailySummary.Time = "07:00" + } + raw, _ := json.Marshal(n) + _, err := s.db.Exec(`UPDATE users SET notification_settings = ? WHERE id = ?`, string(raw), userID) + return err +} + +// QueuedNotification is one event waiting to be sent bundled. +type QueuedNotification struct { + ID int64 + UserID string + Kind string + Payload map[string]string + CreatedAt string + Attempts int +} + +func (s *Store) QueueNotification(userID, kind string, payload map[string]string) error { + raw, _ := json.Marshal(payload) + _, err := s.db.Exec(`INSERT INTO notification_queue (user_id, kind, payload, created_at) VALUES (?, ?, ?, ?)`, + userID, kind, string(raw), time.Now().UTC().Format(time.RFC3339)) + return err +} + +// DueNotificationUsers lists users whose oldest waiting event is older than the bundling window. +func (s *Store) DueNotificationUsers(olderThan time.Time) ([]string, error) { + rows, err := s.db.Query(`SELECT user_id FROM notification_queue GROUP BY user_id HAVING MIN(created_at) <= ?`, + olderThan.UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out = append(out, id) + } + } + return out, rows.Err() +} + +func (s *Store) QueuedNotifications(userID string) ([]QueuedNotification, error) { + rows, err := s.db.Query(`SELECT id, user_id, kind, payload, created_at, attempts FROM notification_queue WHERE user_id = ? ORDER BY id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []QueuedNotification + for rows.Next() { + var q QueuedNotification + var raw string + if rows.Scan(&q.ID, &q.UserID, &q.Kind, &raw, &q.CreatedAt, &q.Attempts) == nil { + _ = json.Unmarshal([]byte(raw), &q.Payload) + out = append(out, q) + } + } + return out, rows.Err() +} + +func (s *Store) DeleteQueuedNotifications(ids []int64) error { + for _, id := range ids { + if _, err := s.db.Exec(`DELETE FROM notification_queue WHERE id = ?`, id); err != nil { + return err + } + } + return nil +} + +func (s *Store) BumpQueuedNotifications(ids []int64) error { + for _, id := range ids { + if _, err := s.db.Exec(`UPDATE notification_queue SET attempts = attempts + 1 WHERE id = ?`, id); err != nil { + return err + } + } + return nil +} + +// --- Task watchers --- + +func (s *Store) WatchTask(taskID, userID string, on bool) error { + if on { + _, err := s.db.Exec(`INSERT OR IGNORE INTO task_watchers (task_id, user_id, created_at) VALUES (?, ?, ?)`, taskID, userID, nowUTC()) + return err + } + _, err := s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ? AND user_id = ?`, taskID, userID) + return err +} + +func (s *Store) TaskWatchers(taskID string) []string { + rows, err := s.db.Query(`SELECT user_id FROM task_watchers WHERE task_id = ?`, taskID) + if err != nil { + return nil + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out = append(out, id) + } + } + return out +} + +// WatchedTaskIDs is the set of tasks the user watches (to mark them in task lists). +func (s *Store) WatchedTaskIDs(userID string) map[string]bool { + out := map[string]bool{} + rows, err := s.db.Query(`SELECT task_id FROM task_watchers WHERE user_id = ?`, userID) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out[id] = true + } + } + return out +} + +func (s *Store) ForgetTaskWatchers(taskID string) { + _, _ = s.db.Exec(`DELETE FROM task_watchers WHERE task_id = ?`, taskID) +} + +// --- Daily summary --- + +// SummaryTask is an open task with a due date, for the daily summary. +type SummaryTask struct { + Title, DueDate, ListTitle string +} + +// ActiveUserIDs lists all active accounts (the summary job checks their settings). +func (s *Store) ActiveUserIDs() []string { + rows, err := s.db.Query(`SELECT id FROM users WHERE is_active = 1`) + if err != nil { + return nil + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out = append(out, id) + } + } + return out +} + +// OpenTasksDueBefore lists the user's open tasks (own ones and those of shared task lists) due before +// the given day (YYYY-MM-DD, exclusive), oldest first. +func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) { + rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '') + FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id + WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task')) + AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ? + AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN (SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ?))) + ORDER BY t.due_date`, beforeDay, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SummaryTask + for rows.Next() { + var t SummaryTask + if rows.Scan(&t.Title, &t.DueDate, &t.ListTitle) == nil { + t.Title = strings.TrimSpace(strings.SplitN(t.Title, "\n", 2)[0]) + out = append(out, t) + } + } + return out, rows.Err() +} + +// ClaimDailySummary marks today's summary as sent; false when it was already sent today. +func (s *Store) ClaimDailySummary(userID, day string) bool { + res, err := s.db.Exec(`UPDATE users SET last_daily_summary = ? WHERE id = ? AND last_daily_summary != ?`, day, userID, day) + if err != nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + +// SharedFolderName is the display name of a shared workspace folder. +func (s *Store) SharedFolderName(folderID string) string { + var name string + _ = s.db.QueryRow(`SELECT name FROM workspace_shared_folders WHERE id = ?`, folderID).Scan(&name) + if name == "" { + name = "Ordner" + } + return name +} From 6b3c32a370ae997559ee344a5a2eb1365a379f39 Mon Sep 17 00:00:00 2001 From: Sandro <mail@sandrodach.de> Date: Fri, 30 Oct 2026 09:54:10 +0100 Subject: [PATCH 7/8] fix(notify): daily summary includes lists shared through a group Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/api/notify_test.go | 30 ++++++++++++++++++++++++++++++ internal/storage/notifications.go | 11 ++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/internal/api/notify_test.go b/internal/api/notify_test.go index fce410f..5b9e8b3 100644 --- a/internal/api/notify_test.go +++ b/internal/api/notify_test.go @@ -158,3 +158,33 @@ func TestDailySummary(t *testing.T) { t.Fatal("only one summary per day") } } + +func TestDailySummaryIncludesGroupSharedLists(t *testing.T) { + srv, sent := notifyServer(t) + store := srv.store + owner, _ := newTestUser(t, store, "chef@example.com") + me, _ := newTestUser(t, store, "team@example.com") + team, err := store.CreateTeam("", "Arbeit", "") + if err != nil { + t.Fatal(err) + } + for _, uid := range []string{owner.ID, me.ID} { // the owner must see the group to share with it + if err := store.AddTeamMember(team.ID, uid, "member"); err != nil { + t.Fatal(err) + } + } + list, _ := store.CreateTaskList(owner.ID, "", "", "Projekt", "📋", "#000000") + if _, err := store.AddTaskListShare(list.ID, "team", team.ID, "editor", owner.ID); err != nil { + t.Fatal(err) + } + th, _ := store.CreateThoughtWithDetails(owner.ID, "Bericht abgeben", "normal", "manual", "2026-10-30T10:00", list.ID, "todo", "") + _, _ = store.UpdateThoughtTriage(owner.ID, th.ID, "keep", "task", "", "2026-10-30T10:00", list.ID) + + set := store.NotificationSettingsOf(me.ID) + set.DailySummary = domain.DailySummary{Enabled: true, Time: "07:00", Email: true} + _ = store.SetNotificationSettings(me.ID, set) + srv.sendDailySummaries(time.Date(2026, 10, 30, 7, 1, 0, 0, time.Local)) + if len(*sent) != 1 || !strings.Contains((*sent)[0].text, "Bericht abgeben (Projekt)") { + t.Fatalf("group-shared task missing: %+v", *sent) + } +} diff --git a/internal/storage/notifications.go b/internal/storage/notifications.go index ce6c5ee..0892750 100644 --- a/internal/storage/notifications.go +++ b/internal/storage/notifications.go @@ -208,15 +208,20 @@ func (s *Store) ActiveUserIDs() []string { return out } -// OpenTasksDueBefore lists the user's open tasks (own ones and those of shared task lists) due before +// OpenTasksDueBefore lists the user's open tasks (own ones, those of own lists and of lists shared with +// the user directly or through a group) due before // the given day (YYYY-MM-DD, exclusive), oldest first. func (s *Store) OpenTasksDueBefore(userID, beforeDay string) ([]SummaryTask, error) { rows, err := s.db.Query(`SELECT t.content, t.due_date, COALESCE(l.title, '') FROM thoughts t LEFT JOIN task_lists l ON l.id = t.task_list_id WHERE (t.status = 'kept' OR (t.status = 'converted' AND t.converted_type = 'task')) AND t.is_completed = 0 AND t.due_date != '' AND substr(t.due_date, 1, 10) < ? - AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN (SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ?))) - ORDER BY t.due_date`, beforeDay, userID, userID) + AND (t.user_id = ? OR (t.task_list_id != '' AND t.task_list_id IN ( + SELECT id FROM task_lists WHERE user_id = ? + UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'user' AND target_id = ? + UNION SELECT task_list_id FROM task_list_shares WHERE target_type = 'team' + AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))) + ORDER BY t.due_date`, beforeDay, userID, userID, userID, userID) if err != nil { return nil, err } From 6100d339f1e0e9fcbbf4c101ecfb2f112c1f3dba Mon Sep 17 00:00:00 2001 From: Sandro <mail@sandrodach.de> Date: Fri, 30 Oct 2026 10:00:09 +0100 Subject: [PATCH 8/8] chore(release): v0.8.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 5 ++++- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9291e3..56b08d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.8.0] - 2026-10-30 + ### Added - Benachrichtigungen pro Ereignis und Kanal (Kontoeinstellungen → Alarme): fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles. - Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`. @@ -209,7 +211,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...dev +[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0 [0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0 [0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1 [0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fabc7ec..bb8878a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.7.0", + "version": "0.8.0", "license": "Unlicense", "dependencies": { "@types/prismjs": "^1.26.6", diff --git a/frontend/package.json b/frontend/package.json index 91bbc5c..fe3a8e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.7.0", + "version": "0.8.0", "license": "Unlicense", "type": "module", "scripts": {