From aea4324c20876da8cb1ff1ebeb5d0ab7d8cc2115 Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 14:35:59 +0100 Subject: [PATCH] refactor(notes): remove legacy whole-document saves; all block changes go through sync Phase 5 of the collaboration plan: - Remove PATCH /documents/{id}/blocks, PatchDocumentBlocks, the DOC_BLOCKS_PATCHED event and its types, FlushDocumentState and LoadDocumentState. PUT /documents/{id} rejects blocks (400) and only changes title, icon, folder and pin; editors may now change title/icon. - The to-do overview ticks via a single operation on /docs/{id}/sync (new skipLog flag). Task links set the block tick with a targeted operation and notify open editors; creating a linked task needs edit rights. - Keep the three share tables: DocRole already decides centrally, and rewriting the visibility queries would only add risk (documented in plan). - Tests adapted to the new paths; share e2e test now also ticks a to-do in the overview. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 6 +- Doku/api/websocket.md | 3 +- Doku/modules/workspace.md | 4 +- Doku/plans/kollaboration-notizen.md | 2 +- e2e/README.md | 2 +- e2e/share.e2e.mjs | 19 +- .../src/features/workspace/WorkspaceView.tsx | 6 +- frontend/src/lib/api.ts | 22 +- frontend/src/lib/docSync/oneoff.ts | 18 ++ frontend/src/lib/docSync/transport.ts | 1 + internal/api/docsync.go | 9 +- internal/api/server.go | 55 ++-- internal/api/server_test.go | 26 +- internal/domain/models.go | 19 -- internal/storage/docsync.go | 22 +- internal/storage/docsync_test.go | 8 +- internal/storage/sqlite.go | 245 ++---------------- internal/storage/sqlite_test.go | 64 ++--- 18 files changed, 155 insertions(+), 376 deletions(-) create mode 100644 frontend/src/lib/docSync/oneoff.ts diff --git a/Doku/api/rest.md b/Doku/api/rest.md index e9b630b..4fcdb19 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -71,13 +71,12 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details | | PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) | | GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) | -| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen | -| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, `blockOrder`); läuft intern über das Änderungs-Log; löst `DOC_BLOCKS_PATCHED` aus | +| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen | | PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) | | POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) | | GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | -Die Endpunkte `PUT /documents/{id}` und `PATCH /documents/{id}/blocks` sind der bisherige Speicherweg (ganzes Dokument). Sie werden intern in Operationen umgerechnet und landen im selben Log wie `/sync`; der letzte Schreiber gewinnt. Sie bleiben, bis der Editor auf `/sync` umgestellt ist. +Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet. ## Gemeinsames Bearbeiten von Notizen @@ -107,6 +106,7 @@ Grundlage: [plans/kollaboration-notizen.md](../plans/kollaboration-notizen.md). - Operationsarten: `insert`, `set_content`, `set_props` (`patch`: `type`, `checked`, `collapsed`, `lang`, `clearConflict`), `move`, `delete`, `restore` (`ids`), `set_title` (`baseVersion`, `title`). - `leases.hold` ist die vollständige Liste der Blöcke, die der Client halten will; alle anderen Leases des Clients werden freigegeben. Neu eingefügte Blöcke und Konfliktkopien bekommt der Autor automatisch. - Höchstens 500 Operationen pro Anfrage. Leser dürfen keine Operationen senden (`403`), aber abfragen. +- `skipLog: true` liefert kein Log zurück; gedacht für einzelne Änderungen ohne geöffneten Editor (z. B. Abhaken in der To-Do-Übersicht). Antwort: `results` (je Operation `status` `applied`/`conflict`/`rejected`, ggf. `conflictBlockId`, `version`, `seq`), `ops` (Log-Einträge `{seq, opId, userId, clientId, effect}` ab `lastSeq`, höchstens 1000; `hasMore` bei mehr), `headSeq`, `resync` (Client ist dem Server voraus, Snapshot neu laden), `role`, `shared`, `sessions`, `presence`, `leases` (`granted`, `denied`, `others`). diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index 52a3c88..3a16545 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -51,7 +51,6 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}` | `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen; Payload nur `{id}`, Clients laden ihre Listen neu | Benutzer vor und nach der Änderung | | `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer | | `DOC_SEQ` | Änderung an Notiz, Leases oder Präsenz (`docId`, `headSeq`); Clients holen die Daten per `/docs/{id}/sync` | Raum `doc:` | -| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:` und berechtigte Benutzer | | `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer selbst; bei geteilten Ordnern Eigentümer und alle Beteiligten | | `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste | | `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste | @@ -62,4 +61,4 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":""}` - Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand. - Nach einem Abbruch verbindet sich der Client mit Verzögerung neu. -- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt). +- Notiz-Inhalte kommen nie per Push, sondern immer über `POST /docs/{id}/sync`; `DOC_SEQ` ist nur der Weckruf. Konflikte entscheidet der Server (Versionen und Sperren, siehe [plans/kollaboration-notizen.md](../plans/kollaboration-notizen.md)). diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 502dcb9..065b2c8 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -30,7 +30,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Präsenz:** Im Kopf der Notiz steht, wer sie gerade sonst noch geöffnet hat. - **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“. - **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen. -- Den bisherigen Speicherweg (`PUT /documents/{id}`) nutzt der Editor nur noch für Symbol und Anheften; Titel und Inhalt laufen über die Engine. `PATCH /documents/{id}/blocks` nutzt das To-Do-Rollup; beide Wege landen im selben Änderungs-Log. +- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab. ## Explorer, Tags, To-Dos @@ -43,7 +43,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)). - Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt. -- Änderungen erreichen offene Fenster über `DOC_BLOCKS_PATCHED`/`DOC_UPDATED` ([websocket.md](../api/websocket.md)). +- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)). ## Grenzen diff --git a/Doku/plans/kollaboration-notizen.md b/Doku/plans/kollaboration-notizen.md index d790cc6..06162ea 100644 --- a/Doku/plans/kollaboration-notizen.md +++ b/Doku/plans/kollaboration-notizen.md @@ -282,7 +282,7 @@ Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im | 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt. **Erledigt.** Abweichung: Die Editor-Komponenten arbeiten weiter mit der flachen Liste (Ebene + Reihenfolge); die Engine übersetzt jede Änderung per Vergleich in Operationen (`lib/docSync/diff.ts`). Sperren werden bereits gehalten (Fokus, 20 s Nachlauf, neue Bullets), aber noch nicht angezeigt. Ende-zu-Ende-Test mit zwei Browsern: `e2e/`. | Bearbeiten funktioniert überall, auch offline | | 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen, FLAW-25/26. **Erledigt.** Ergänzungen: Enter in einem fremd gesperrten Bullet legt einen neuen Bullet darunter an (der gesperrte bleibt unberührt); „Übernehmen“/„Verwerfen“ prüfen auch die Sperre der Kopie selbst. | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | | 4 | Push-Stufen WebSocket → SSE → Long-Polling → Intervall mit automatischer Erkennung. **Erledigt.** Ergänzungen: Herzschlag `PING`/`PONG` erkennt WebSockets, die angenommen werden, aber nichts weiterleiten (Wechsel auf SSE nach 35 s ohne Antwort); Sperren werden beim Fokuswechsel sofort angemeldet. Abweichung: Kein automatischer erneuter WebSocket-Versuch alle 5 Minuten – die Erkennung „WebSocket bzw. SSE blockiert“ gilt für die Sitzung des Tabs, ein neuer Tab versucht es erneut. Browser-Test: `e2e/channels.e2e.mjs`. | Live-Gefühl, wo das Netz es zulässt | -| 5 | Freigabe-Dialog mit Bearbeiter-Rolle (**vorgezogen und erledigt**, für Notizen und geteilte Ordner), Ordnerfreigaben, Altlasten entfernen (`PUT`-Blockersatz, `PATCH /blocks`, `doc_shares`, `doc_team_shares`, Zwangsmigration auf „read“) | Feature komplett | +| 5 | Freigabe-Dialog mit Bearbeiter-Rolle (vorgezogen), Altlasten entfernen. **Erledigt:** `PATCH /documents/{id}/blocks`, `PatchDocumentBlocks`, `DOC_BLOCKS_PATCHED`, Blöcke in `PUT /documents/{id}`, `FlushDocumentState`/`LoadDocumentState` und die Zwangsmigration auf „read“ sind entfernt; das To-Do-Rollup und die Aufgaben-Verknüpfungen schreiben gezielte Operationen. **Bewusst nicht umgesetzt:** Zusammenlegung von `doc_shares`, `doc_team_shares` und `workspace_folder_shares` zu `workspace_access`. Rechte entscheidet bereits zentral `DocRole`; das Umschreiben der rund 20 Sichtbarkeitsabfragen brächte keinen Nutzen, aber das Risiko, dass geteilte Notizen falsch angezeigt werden. | Feature komplett | | 6 | Härtung: Playwright-Tests, Log-Kompaktierung (Snapshot statt Log für Clients mit sehr altem `lastSeq`), Aufräumen der Grabsteine | Langzeitbetrieb | Phase 2 ist bewusst vor den Push-Kanälen: Steht die Bearbeitung über reine HTTP-Abfragen, ist der schwierigste Fall (Proxy blockt alles außer HTTP) von Anfang an abgedeckt. WebSocket und SSE machen es danach nur schneller. diff --git a/e2e/README.md b/e2e/README.md index 4a030fe..ec35639 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -26,7 +26,7 @@ Zwei Browser (Eigentümer und Bearbeiter) arbeiten in derselben Notiz. Geprüft ## share.e2e.mjs -Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeiten“; geprüft wird, dass die Freigabe als `write` gespeichert, im Dialog als „Bearbeiter“ angezeigt wird und der Empfänger die Notiz bearbeiten kann. +Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeiten“; geprüft wird, dass die Freigabe als `write` gespeichert, im Dialog als „Bearbeiter“ angezeigt wird und der Empfänger die Notiz bearbeiten kann. Zum Schluss hakt der Eigentümer ein To-Do in der To-Do-Übersicht ab. ## lock.e2e.mjs diff --git a/e2e/share.e2e.mjs b/e2e/share.e2e.mjs index 3ff1337..24c67c0 100644 --- a/e2e/share.e2e.mjs +++ b/e2e/share.e2e.mjs @@ -45,13 +45,18 @@ async function openDoc(browser, token, title) { } const TITLE = `Freigabe-Test ${Date.now()}`; +const TODO_ID = `fr_todo_${Date.now()}`; +const TODO_TEXT = `Rollup-Aufgabe ${Date.now()}`; const main = async () => { const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token; const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token; const doc = await api(admin, 'POST', '/api/v1/documents', { title: TITLE, - blocks: [{ id: `fr_${Date.now()}`, type: 'bullet', content: 'Vom Eigentümer', level: 0 }], + blocks: [ + { id: `fr_${Date.now()}`, type: 'bullet', content: 'Vom Eigentümer', level: 0 }, + { id: TODO_ID, type: 'todo', content: TODO_TEXT, level: 0 }, + ], }); const browser = await chromium.launch(); @@ -97,6 +102,18 @@ const main = async () => { }, 6000); check(!!saved, 'Bearbeiter kann die geteilte Notiz bearbeiten'); + // Ticking a to-do in the overview goes through the sync endpoint as well + await A.page.bringToFront(); + await A.page.getByRole('button', { name: 'Schließen', exact: true }).last().click(); // the share dialog is still open + await A.page.locator('button:visible', { hasText: 'To-Dos' }).first().click(); + await A.page.getByTitle('Alle To-Dos als einfache Liste').click(); + await A.page.locator('div', { hasText: TODO_TEXT }).last().locator('button').first().click(); + const ticked = await waitFor(async () => { + const s = await api(admin, 'GET', `/api/v1/docs/${doc.id}/snapshot`); + return s.blocks.find((b) => b.id === TODO_ID)?.props?.checked === true; + }, 5000); + check(!!ticked, 'Abhaken in der To-Do-Übersicht wird gespeichert'); + await browser.close(); console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN'); process.exit(failures ? 1 : 0); diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 5759d12..374cc4e 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -49,6 +49,7 @@ import { RealtimeTransport, } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; +import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; @@ -789,9 +790,8 @@ export const WorkspaceView: React.FC = ({ prev.map((d) => (d.id === doc.id ? { ...d, blocks: updatedBlocks } : d)) ); try { - await api.patchDocumentBlocks(doc.id, { - upserted: [{ id: blockId, checked: !currentChecked, type: 'todo', content: '' }], - }); + const [result] = await sendDocOps(doc.id, [{ kind: 'set_props', blockId, patch: { checked: !currentChecked } }]); + if (result?.status !== 'applied') loadWorkspaceData(); } catch { loadWorkspaceData(); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bb37b3a..ae1f5f8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -595,11 +595,10 @@ export const api = { title?: string; icon?: string; folderPath?: string; - blocks?: DocumentBlock[]; - tags?: string[]; isPinned?: boolean; } ): Promise { + // Block content is saved through the note sync engine (lib/docSync), not here const d = await request(`/api/v1/documents/${id}`, { method: 'PUT', body: JSON.stringify(patch), @@ -611,25 +610,6 @@ export const api = { }; }, - async patchDocumentBlocks( - id: string, - payload: { - upserted?: DocumentBlock[]; - deletedIds?: string[]; - blockOrder?: string[]; - } - ): Promise { - const d = await request(`/api/v1/documents/${id}/blocks`, { - method: 'PATCH', - body: JSON.stringify(payload), - }); - return { - ...d, - blocks: ensureArray(d.blocks), - tags: ensureArray(d.tags), - }; - }, - async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> { return request<{ success: boolean }>('/api/v1/events/rooms', { method: 'POST', diff --git a/frontend/src/lib/docSync/oneoff.ts b/frontend/src/lib/docSync/oneoff.ts new file mode 100644 index 0000000..e2ccfa8 --- /dev/null +++ b/frontend/src/lib/docSync/oneoff.ts @@ -0,0 +1,18 @@ +// Sends a few operations for a note that is not open in an editor (e.g. ticking a to-do in the overview). +// Goes through the same sync endpoint and rules as the editor, so the change is logged and pushed to +// everyone who has the note open. +import type { Op, Result } from './model'; +import { httpTransport } from './transport'; + +export async function sendDocOps(docId: string, ops: Omit[]): Promise { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + const clientId = `once_${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`; + const res = await httpTransport.sync(docId, { + clientId, + lastSeq: 0, + ops: ops.map((op, i) => ({ ...op, opId: `${clientId}.${i}` }) as Op), + skipLog: true, + }); + return res.results; +} diff --git a/frontend/src/lib/docSync/transport.ts b/frontend/src/lib/docSync/transport.ts index 127f6ce..fee9d11 100644 --- a/frontend/src/lib/docSync/transport.ts +++ b/frontend/src/lib/docSync/transport.ts @@ -37,6 +37,7 @@ export interface SyncRequest { lastSeq: number; ops: Op[]; leases?: { hold: string[]; release: string[] }; + skipLog?: boolean; // one-off changes do not need the log back } export interface SyncResponse { diff --git a/internal/api/docsync.go b/internal/api/docsync.go index fea628f..ec06d1f 100644 --- a/internal/api/docsync.go +++ b/internal/api/docsync.go @@ -86,6 +86,7 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) { Hold []string `json:"hold"` Release []string `json:"release"` } `json:"leases"` + SkipLog bool `json:"skipLog"` // one-off changes (e.g. ticking a to-do in the overview) need no log } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, syncMaxBody)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -149,12 +150,16 @@ func (s *Server) handleDocSync(w http.ResponseWriter, r *http.Request) { s.afterDocChange(docID, req.Ops, resp.Results) } - entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, syncMaxLog) + logLimit := syncMaxLog + if req.SkipLog { + logLimit = 0 + } + entries, head, err := s.store.GetDocOpsSince(docID, req.LastSeq, logLimit) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - if req.LastSeq > head { + if req.LastSeq > head && !req.SkipLog { resp.Resync = true entries = []storage.DocLogEntry{} } diff --git a/internal/api/server.go b/internal/api/server.go index 9c49d8d..c83b45d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -159,7 +159,6 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents", s.handleCreateDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) - r.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask) @@ -903,7 +902,10 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { writeStoreError(w, http.StatusBadRequest, err) return } - s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted) + if docID := s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted); docID != "" { + _, head, _ := s.store.GetDocOpsSince(docID, 0, 0) + s.notifyDocChanged(docID, head) + } s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated) writeJSON(w, http.StatusOK, updated) } @@ -1019,59 +1021,32 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { Icon *string `json:"icon"` FolderPath *string `json:"folderPath"` Blocks *[]domain.DocumentBlock `json:"blocks"` - Tags *[]string `json:"tags"` IsPinned *bool `json:"isPinned"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } + if req.Blocks != nil { + writeError(w, http.StatusBadRequest, "Blöcke werden über POST /api/v1/docs/{id}/sync gespeichert") + return + } - doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.Blocks, req.Tags, req.IsPinned) + doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.FolderPath, req.IsPinned) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } - if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { + if req.Title != nil { + _, head, _ := s.store.GetDocOpsSince(docID, 0, 0) + s.notifyDocChanged(docID, head) + } + if req.Title != nil || req.Icon != nil || req.FolderPath != nil { s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } writeJSON(w, http.StatusOK, doc) } -func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - - var req domain.DocBlocksPatchRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "Ungültiges JSON") - return - } - - doc, mergedOrder, err := s.store.PatchDocumentBlocks(userID, docID, req) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - patchEvent := domain.DocBlocksPatchEvent{ - DocID: docID, - UpdatedBy: userID, - UpdatedAt: doc.UpdatedAt, - Upserted: req.Upserted, - DeletedIDs: req.DeletedIDs, - BlockOrder: mergedOrder, - } - s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent) - } - } - - writeJSON(w, http.StatusOK, doc) -} - func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") @@ -1106,6 +1081,8 @@ func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request writeError(w, http.StatusBadRequest, err.Error()) return } + _, head, _ := s.store.GetDocOpsSince(docID, 0, 0) + s.notifyDocChanged(docID, head) s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought) writeJSON(w, http.StatusCreated, thought) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 1eb0a72..de5a382 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/collab" "github.com/sandrodach/mindsift/internal/config" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" @@ -1269,17 +1270,30 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) { t.Fatalf("expected error for User B updating document, got 200 OK") } - // 3. User B tries to patch document blocks -> must fail (not owner) - patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{ - Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}}, + // 3. User B tries to change blocks through the sync API -> must fail (read-only) + hacked := "Hacked by User B" + syncBody, _ := json.Marshal(map[string]interface{}{ + "clientId": "tab-b", + "ops": []collab.Op{{OpID: "b-hack", Kind: collab.OpSetContent, BlockID: "b2", BaseVersion: 1, Content: &hacked}}, }) - req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody)) + req = httptest.NewRequest(http.MethodPost, "/api/v1/docs/"+doc.ID+"/sync", bytes.NewReader(syncBody)) req.Header.Set("Authorization", "Bearer "+tokenB) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - t.Fatalf("expected error for User B patching document blocks, got 200 OK") + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for User B changing blocks, got %d", rec.Code) + } + + // The old whole-document save no longer accepts blocks, not even from the owner + blocksBody, _ := json.Marshal(map[string]interface{}{"blocks": []domain.DocumentBlock{{ID: "b1", Content: "x"}}}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(blocksBody)) + req.Header.Set("Authorization", "Bearer "+tokenA) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for blocks via PUT, got %d", rec.Code) } // 4. User A (owner) can successfully update document diff --git a/internal/domain/models.go b/internal/domain/models.go index b837d2c..eb87967 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -309,25 +309,6 @@ func CompileBlocksToMarkdown(blocks []DocumentBlock) string { return sb.String() } -type DocBlocksPatchRequest struct { - Upserted []DocumentBlock `json:"upserted,omitempty"` - DeletedIDs []string `json:"deletedIds,omitempty"` - BlockOrder []string `json:"blockOrder,omitempty"` - Title *string `json:"title,omitempty"` - Icon *string `json:"icon,omitempty"` -} - -type DocBlocksPatchEvent struct { - DocID string `json:"docId"` - Upserted []DocumentBlock `json:"upserted,omitempty"` - DeletedIDs []string `json:"deletedIds,omitempty"` - BlockOrder []string `json:"blockOrder,omitempty"` - Title *string `json:"title,omitempty"` - Icon *string `json:"icon,omitempty"` - UpdatedBy string `json:"updatedBy"` - UpdatedAt string `json:"updatedAt"` -} - type DocShare struct { ID string `json:"id"` DocID string `json:"docId"` diff --git a/internal/storage/docsync.go b/internal/storage/docsync.go index 31dd7fd..495ee97 100644 --- a/internal/storage/docsync.go +++ b/internal/storage/docsync.go @@ -565,12 +565,26 @@ func (s *Store) IsDocShared(docID string) bool { return n > 0 } -// --- Adapter for the existing whole-document API (PUT /documents/{id}) --- +// SetBlockChecked ticks or unticks a block through the log (server-side change, e.g. a linked task was +// completed). Returns true if the block changed. +func (s *Store) SetBlockChecked(userID, docID, blockID string, checked bool) bool { + a := collab.Actor{UserID: userID, ClientID: "server"} + results, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op { + b, ok := d.Blocks[blockID] + if !ok || b.Deleted || b.Props.Checked == checked { + return nil + } + return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpSetProps, BlockID: blockID, Patch: &collab.PropsPatch{Checked: &checked}}} + }) + return err == nil && len(results) > 0 && results[0].Seq > 0 +} + +// --- Writing a complete block list (new notes, imports) --- const legacyClientID = "legacy" -// replaceDocBlocksInDB stores a complete flat block list by diffing it against the current tree and -// applying the difference as regular operations, so it shows up in the log like any other change. +// replaceDocBlocksInDB stores a complete flat block list (initial blocks of a new note, old blocks_json data) +// by diffing it against the current tree and applying the difference as regular operations. func (s *Store) replaceDocBlocksInDB(docID, userID string, blocks []domain.DocumentBlock, _ string) { blocks = s.renameForeignBlockIDs(docID, blocks) a := collab.Actor{UserID: userID, ClientID: legacyClientID} @@ -602,7 +616,7 @@ func (s *Store) renameForeignBlockIDs(docID string, blocks []domain.DocumentBloc return out } -// applyLegacyTitle records a title change from the whole-document API in the log. +// applyLegacyTitle records a title change made through PUT /documents/{id} in the log. func (s *Store) applyLegacyTitle(docID, userID, title string) { a := collab.Actor{UserID: userID, ClientID: legacyClientID} if _, _, err := s.applyDocChange(docID, a, nil, func(d *collab.Doc) []collab.Op { diff --git a/internal/storage/docsync_test.go b/internal/storage/docsync_test.go index e5318e5..31ea776 100644 --- a/internal/storage/docsync_test.go +++ b/internal/storage/docsync_test.go @@ -77,9 +77,7 @@ func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) { // Saving the same content again must not produce any log entries head := headSeq(t, s, doc.ID) - if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &initial, nil, nil); err != nil { - t.Fatal(err) - } + s.replaceDocBlocksInDB(doc.ID, u.ID, initial, "") if h := headSeq(t, s, doc.ID); h != head { t.Fatalf("unchanged save wrote %d log entries", h-head) } @@ -92,9 +90,7 @@ func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) { [3]interface{}{"c", 1, "Detail"}, [3]interface{}{"n", 2, "Neu"}, ) - if _, err := s.UpdateDocument(u.ID, doc.ID, nil, nil, nil, &changed, nil, nil); err != nil { - t.Fatal(err) - } + s.replaceDocBlocksInDB(doc.ID, u.ID, changed, "") got, _ = s.GetDocumentByID(u.ID, doc.ID) sameOutline(t, got.Blocks, changed) diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index fbcc110..f251553 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -1471,97 +1471,6 @@ func (s *Store) loadDocBlocks(docID string, fallbackJSON string) []domain.Docume return blocks } -func (s *Store) LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) { - var title, blocksJSON string - err := s.db.QueryRow(`SELECT title, blocks_json FROM documents WHERE id = ?`, docID).Scan(&title, &blocksJSON) - if err != nil { - return "", nil, err - } - blocks := s.loadDocBlocks(docID, blocksJSON) - return title, blocks, nil -} - -// FlushDocumentState stores a complete block list (and optionally the title) through the operation log. -func (s *Store) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error { - s.replaceDocBlocksInDB(docID, "", blocks, domain.NowISO()) - if strings.TrimSpace(title) != "" { - s.applyLegacyTitle(docID, "", title) - } - return nil -} - -// PatchDocumentBlocks applies block upserts, deletions and an optional order to a note through the -// operation log. Blocks not mentioned keep their position relative to each other. -func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPatchRequest) (*domain.Document, []string, error) { - if !CanEditDoc(s.DocRole(userID, docID)) { - return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") - } - doc, err := s.GetDocumentByID(userID, docID) - if err != nil { - return nil, nil, err - } - - deleted := make(map[string]bool, len(req.DeletedIDs)) - for _, id := range req.DeletedIDs { - deleted[strings.TrimSpace(id)] = true - } - upserts := make(map[string]domain.DocumentBlock, len(req.Upserted)) - for _, b := range req.Upserted { - if id := strings.TrimSpace(b.ID); id != "" && !deleted[id] { - upserts[id] = b - } - } - - merged := make([]domain.DocumentBlock, 0, len(doc.Blocks)+len(upserts)) - used := make(map[string]bool, len(upserts)) - for _, b := range doc.Blocks { - if deleted[b.ID] { - continue - } - if up, ok := upserts[b.ID]; ok { - merged = append(merged, up) - used[b.ID] = true - continue - } - merged = append(merged, b) - } - for _, b := range req.Upserted { - if id := strings.TrimSpace(b.ID); id != "" && !used[id] && !deleted[id] { - merged = append(merged, b) - used[id] = true - } - } - if len(req.BlockOrder) > 0 { - rank := make(map[string]int, len(req.BlockOrder)) - for i, id := range req.BlockOrder { - rank[id] = i - } - sort.SliceStable(merged, func(i, j int) bool { - ri, okI := rank[merged[i].ID] - rj, okJ := rank[merged[j].ID] - if okI && okJ { - return ri < rj - } - return okI && !okJ - }) - } - - s.replaceDocBlocksInDB(docID, userID, merged, domain.NowISO()) - if req.Title != nil { - s.applyLegacyTitle(docID, userID, strings.TrimSpace(*req.Title)) - } - - updated, err := s.GetDocumentByID(userID, docID) - if err != nil { - return nil, nil, err - } - order := make([]string, len(updated.Blocks)) - for i, b := range updated.Blocks { - order[i] = b.ID - } - return updated, order, nil -} - // GetAuthorizedDocUsers returns all user IDs who have read/write/owner access to docID. func (s *Store) GetAuthorizedDocUsers(docID string) ([]string, error) { userSet := make(map[string]bool) @@ -2137,16 +2046,16 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error) return d, nil } -func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, blocks *[]domain.DocumentBlock, tags *[]string, isPinned *bool) (*domain.Document, error) { +func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *string, isPinned *bool) (*domain.Document, error) { doc, err := s.GetDocumentByID(userID, docID) if err != nil { return nil, err } - isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil - isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil - - if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove { + // Pins and folder placement are personal; title and icon need edit rights. The content (blocks) is + // only changed through the operation log (POST /docs/{id}/sync). + changesContent := title != nil || icon != nil + if changesContent && !CanEditDoc(s.DocRole(userID, docID)) { return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } @@ -2165,10 +2074,6 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st } } - if isOnlyPinChange { - return doc, nil - } - if folderPath != nil { movedDoc, moveErr := s.MoveDocument(userID, docID, *folderPath) if moveErr != nil { @@ -2180,10 +2085,9 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st doc.IsSharedFolder = movedDoc.IsSharedFolder } - if isOnlyFolderMove { + if !changesContent { return doc, nil } - if title != nil { doc.Title = strings.TrimSpace(*title) s.applyLegacyTitle(docID, userID, doc.Title) @@ -2191,68 +2095,11 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st if icon != nil { doc.Icon = *icon } - if tags != nil { - doc.Tags = stripFolderTags(*tags) - } - doc.UpdatedAt = domain.NowISO() - if blocks != nil { - doc.Blocks = *blocks - s.replaceDocBlocksInDB(docID, userID, doc.Blocks, doc.UpdatedAt) - var plainBuilder strings.Builder - for _, b := range doc.Blocks { - if b.Content != "" { - plainBuilder.WriteString(b.Content) - plainBuilder.WriteString("\n") - } - } - doc.Content = plainBuilder.String() - } - - blocksJSON, _ := json.Marshal(doc.Blocks) - tagsJSON, _ := json.Marshal(doc.Tags) - - // IMPORTANT: Never overwrite documents.folder_path here! Folder placement is managed exclusively by MoveDocument. - _, err = s.db.Exec( - `UPDATE documents SET title = ?, icon = ?, content = ?, blocks_json = ?, tags = ?, updated_at = ? - WHERE id = ?`, - doc.Title, doc.Icon, doc.Content, string(blocksJSON), string(tagsJSON), doc.UpdatedAt, docID, - ) - if err != nil { + if _, err := s.db.Exec(`UPDATE documents SET icon = ?, updated_at = ? WHERE id = ?`, doc.Icon, doc.UpdatedAt, docID); err != nil { return nil, err } - if blocks != nil { - _ = s.indexDocBlockTags(doc.ID, doc.UserID, doc.Blocks) - for _, b := range doc.Blocks { - if b.TaskID != "" { - s.SyncTaskFromBlockCheck(doc.UserID, b.TaskID, b.Checked) - } - } - - // When a block is deleted from the note, unlink the corresponding task in thoughts without deleting the task - remainingBlockIDs := make(map[string]bool) - for _, b := range doc.Blocks { - remainingBlockIDs[b.ID] = true - } - rows, err := s.db.Query(`SELECT id, converted_ref_id FROM thoughts WHERE converted_ref_id LIKE ? AND converted_type = 'document_block'`, docID+":%") - if err == nil { - for rows.Next() { - var tid, refID string - if err := rows.Scan(&tid, &refID); err == nil { - bid := "" - if strings.Contains(refID, ":") { - parts := strings.SplitN(refID, ":", 2) - bid = parts[1] - } - if !remainingBlockIDs[bid] { - _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, tid) - } - } - } - _ = rows.Close() - } - } - return doc, nil + return s.GetDocumentByID(userID, docID) } func (s *Store) DeleteDocument(userID, docID string) error { @@ -4025,9 +3872,9 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string, } func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, checked bool) (*domain.Thought, error) { - doc, err := s.GetDocumentByID(userID, docID) - if err != nil { - return nil, err + // Linking changes the block (its tick follows the task), so edit rights are needed + if !CanEditDoc(s.DocRole(userID, docID)) { + return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } t, err := s.CreateThoughtWithDetails(userID, content, "normal", "manual", "", "", "todo", "") if err != nil { @@ -4047,76 +3894,30 @@ func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, c _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`, t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID) - var updatedBlocks []domain.DocumentBlock - found := false - for _, b := range doc.Blocks { - if b.ID == blockID { - b.TaskID = t.ID - b.Checked = checked - found = true - } - updatedBlocks = append(updatedBlocks, b) - } - if found { - _, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil) - } + s.SetBlockChecked(userID, docID, blockID, checked) return t, nil } -func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) { +// SyncDocBlockAndTask ticks the block a task was created from when the task is completed (or reopened). +// Returns the note ID if the block changed, so the caller can notify its viewers. +func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) string { t, err := s.GetThoughtByID(userID, taskID) if err != nil || t == nil || t.ConvertedType != "document_block" || !strings.Contains(t.ConvertedRefID, ":") { - return + return "" } - parts := strings.SplitN(t.ConvertedRefID, ":", 2) - docID, blockID := parts[0], parts[1] - doc, err := s.GetDocumentByID(userID, docID) - if err != nil || doc == nil { - return - } - var updatedBlocks []domain.DocumentBlock - found := false - for _, b := range doc.Blocks { - if b.ID == blockID { - if b.Checked != isCompleted { - b.Checked = isCompleted - found = true - } - } - updatedBlocks = append(updatedBlocks, b) - } - if found { - _, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil) + docID, blockID, _ := strings.Cut(t.ConvertedRefID, ":") + if s.SetBlockChecked(userID, docID, blockID, isCompleted) { + return docID } + return "" } +// UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's +// task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change. func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) { - t, err := s.GetThoughtByID(userID, taskID) - if err != nil || t == nil { + if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil { return } - docID := t.DocID - blockID := t.BlockID - if (docID == "" || blockID == "") && strings.Contains(t.ConvertedRefID, ":") { - parts := strings.SplitN(t.ConvertedRefID, ":", 2) - docID, blockID = parts[0], parts[1] - } - if docID != "" { - if doc, err := s.GetDocumentByID(userID, docID); err == nil && doc != nil { - var updatedBlocks []domain.DocumentBlock - found := false - for _, b := range doc.Blocks { - if b.ID == blockID || b.TaskID == taskID { - b.TaskID = "" - found = true - } - updatedBlocks = append(updatedBlocks, b) - } - if found { - _, _ = s.UpdateDocument(userID, docID, nil, nil, nil, &updatedBlocks, nil, nil) - } - } - } _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID) } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 067e4f9..33cfb1e 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/collab" "github.com/sandrodach/mindsift/internal/domain" ) @@ -208,13 +209,13 @@ func TestStoreLifecycle(t *testing.T) { // user2 tries to edit read-only doc -> must fail updatedTitle := "Hacked Title" - _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil) + _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil) if err == nil { t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded") } // user2 is a read-only collaborator -> editing must fail - _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil) + _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil) if err == nil { t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded") } @@ -271,7 +272,7 @@ func TestPerUserDocPinning(t *testing.T) { // user2 pins the shared document (even with read-only access) pinTrue := true - updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue) + updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, &pinTrue) if err != nil { t.Fatalf("user2 pinning read-only doc failed: %v", err) } @@ -290,7 +291,7 @@ func TestPerUserDocPinning(t *testing.T) { // user1 unpins the doc pinFalse := false - _, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse) + _, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, &pinFalse) if err != nil { t.Fatalf("user1 unpinning doc failed: %v", err) } @@ -518,10 +519,7 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) { updatedBlocks := []domain.DocumentBlock{ {ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0}, } - _, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil) - if err != nil { - t.Fatalf("UpdateDocument failed: %v", err) - } + store.replaceDocBlocksInDB(doc.ID, user.ID, updatedBlocks, "") tagsAfter, err := store.GetWorkspaceTags(user.ID) if err != nil { @@ -585,43 +583,22 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { t.Fatalf("MoveDocument by userB failed: %v", err) } - // User B is a read-only collaborator -> PatchDocumentBlocks must fail - _, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{ - Upserted: []domain.DocumentBlock{ - {ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0}, - {ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0}, - }, - BlockOrder: []string{"b1", "b2", "b3"}, - }) - if err == nil { - t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded") - } - - // User A's client only knew about b1, b2 when sending its patch (adds b4) - patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{ - Upserted: []domain.DocumentBlock{ - {ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0}, - {ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0}, - }, - BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3! - }) - if err != nil { - t.Fatalf("PatchDocumentBlocks by userA failed: %v", err) - } - - // Verify User A's blocks are preserved: b1, b2, b4 (3 blocks) - if len(patchedDocA.Blocks) != 3 { - t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) + // User A edits b1 and adds b4 through the operation log; blocks A did not touch stay + editedB1, newB4, top := "Zeile 1 von A bearbeitet", "Zeile 4 neu von A", "" + res, _, err := store.ApplyDocOps(doc.ID, collab.Actor{UserID: userA.ID, ClientID: "tab-a"}, []collab.Op{ + {OpID: "a-b1", Kind: collab.OpSetContent, BlockID: "b1", BaseVersion: 1, Content: &editedB1}, + {OpID: "a-b4", Kind: collab.OpInsert, BlockID: "b4", ParentID: &top, OrderKey: "a9", Content: &newB4}, + }, nil) + if err != nil || res[0].Status != collab.StatusApplied || res[1].Status != collab.StatusApplied { + t.Fatalf("ApplyDocOps by userA failed: %+v %v", res, err) } + patchedDocA, _ := store.GetDocumentByID(userA.ID, doc.ID) blockContent := make(map[string]string) for _, b := range patchedDocA.Blocks { blockContent[b.ID] = b.Content } - if blockContent["b1"] != "Zeile 1 von A bearbeitet" { - t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"]) - } - if blockContent["b4"] != "Zeile 4 neu von A" { - t.Fatalf("expected b4 added by A, got %q", blockContent["b4"]) + if blockContent["b1"] != editedB1 || blockContent["b4"] != newB4 || blockContent["b2"] == "" { + t.Fatalf("unexpected blocks after edit: %+v", blockContent) } // Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang" @@ -710,7 +687,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { } } -func TestFlushDocumentStateAndSchema(t *testing.T) { +func TestBlockListAndDerivedData(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db") @@ -746,9 +723,8 @@ func TestFlushDocumentStateAndSchema(t *testing.T) { }, } - if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil { - t.Fatalf("FlushDocumentState failed: %v", err) - } + store.replaceDocBlocksInDB(doc.ID, user.ID, blocks, "") + store.applyLegacyTitle(doc.ID, user.ID, "Architektur Notiz") updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID) if err != nil {