diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3f65295 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +numolus: spreadsheet à la Apple Numbers (free canvas with table/chart/text/button +objects) with a hand-written formula engine. Branch `with-server` = Rails 8 app +with the Vue 3 frontend in `app/frontend/`; accounts, sharing, live collaboration. +Details for users and architecture: README.md, chapter "Speichern, Konten & +Zusammenarbeit". + +## Branches + +- `main` (tag `client-only`): browser-only app, documents in IndexedDB. Keep it + free of server code; fixes that apply to both go in their own commit so they + can be cherry-picked. +- `with-server`: everything below. + +## Commands + +```bash +bin/setup # gems, npm packages, database +bin/dev # Rails :3000 + Vite dev server :3036 (open localhost:3000) +npm test # Vitest (engine, store, io, collab) +bin/rails test # Minitest (models, API, auth, sharing, DocumentChannel) +npx vue-tsc --noEmit -p . # typecheck +npm run lint # ESLint +npm run build # typecheck + production build into public/vite +bin/brakeman -q --no-pager +``` + +Run all of them before committing. Mails in development: localhost:3000/letter_opener. + +## Layout + +- `app/frontend/engine/` formula engine (framework-agnostic, own README) +- `app/frontend/store/document.ts` the document store (~3300 lines, one open document) +- `app/frontend/store/library.ts` which document is open, local (IndexedDB) vs server, URLs +- `app/frontend/store/session.ts` signed-in user; `io/api.ts` REST client; `io/library.ts` IndexedDB +- `app/frontend/collab/` live collaboration: `ydoc.ts` (store ↔ Yjs), `session.ts` (ActionCable, undo, awareness) +- `app/channels/document_channel.rb` stores/relays Yjs updates, checks access +- `app/models/document.rb` `role_for(user:, token:)` is the one access check + +## Rules that are easy to break + +- **Cell addressing stays positional**: `TableObject.cells["col,row"]` in store, + engine and file format (`.num`/`.json`). Stable row/column ids exist only in + the Yjs layer (`collab/ydoc.ts`); don't change the file format for them. +- **Every content-changing store function calls `markDirty()`** (selection-only + changes call `scheduleSave()`), and must be listed in `READ_ONLY_GUARDED` in + `document.ts`, else viewers of a shared document can change it locally. +- **Mid-table row/column insert/delete must call `collabHooks?.onStructure(...)`** + (as `insertRow`/`deleteRow`/`insertColumn`/`deleteColumn` do). Edge growth/shrink + needs nothing. Without it, concurrent edits land in the wrong row. +- View state is per person: `selection`, `activeSheetId`, `activeObjectId`, sheet + `zoom` are not synced. `applyExternalState` keeps them when others' changes arrive. +- Remote changes: flush pending local changes into Yjs first, then apply, then + rebuild the store (see `CollabSession.process`). Keep that order. +- Local documents persist via `setPersistHandler` (IndexedDB); server documents + only via the collab session. Undo: snapshots locally, `Y.UndoManager` for server docs. +- Solid Queue/Cache/Cable live in the same PostgreSQL database as the app data + (plain migrations in `db/migrate`), no separate databases (user decision). +- Settings and secrets come from `.env` (gem `dotenv`, template `.env.example`), + never committed. +- Share link tokens are stored in plain text on purpose (owner can copy the link + again); magic link tokens/codes only as SHA-256. + +## Conventions + +- UI text and user-facing messages in German; code, comments, commit messages in + English. Comments explain *why*, in the surrounding (fairly thorough) style. +- Commit messages: short imperative subject, body with the reasons. +- The user sometimes commits on their own: check `git status`/`git log` fresh + before committing; don't push unless asked. + +## Environment gotchas + +- `vite-plugin-ruby` makes `app/frontend` the Vite root: Vitest `include` + patterns in `vite.config.ts` are relative to it (`**/*.spec.ts`). +- Vite is pinned to v5 (Vitest 2 and `@vitejs/plugin-vue` 5 don't support newer); + `vite install`/`vite upgrade` may bump it again. +- Gems install into `vendor/gems` (global `BUNDLE_PATH`), ignored by git. +- Node comes from mise; outside the project directory scripts may need + `MISE_NODE_VERSION=22.21.1`. +- Browser checks: there is no Playwright in the repo; drive Chrome + (`/Applications/Google Chrome.app`) with `playwright-core` from a scratch + directory against `bin/dev`. diff --git a/README.md b/README.md index 8f661fb..d4dc3bb 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,12 @@ Link oder per Einladung, mit den Rollen „ansehen“ und „bearbeiten“. Alle Bearbeiten-Recht arbeiten **gleichzeitig** am selben Dokument: Änderungen kommen über ActionCable sofort bei den anderen an (Yjs, siehe [`app/frontend/collab/`](app/frontend/collab/)), jede Person hat ihr eigenes -Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details: [`docs/konzept-teilen-und-zusammenarbeit.md`](docs/konzept-teilen-und-zusammenarbeit.md). +Undo, und die Auswahl der anderen ist als farbiger Rahmen zu sehen. Details: +[Speichern, Konten & Zusammenarbeit](#speichern-konten--zusammenarbeit). **Konfiguration:** Datenbank-Zugang, Server-Name, SMTP und `SECRET_KEY_BASE` -stehen in einer `.env` im Projektverzeichnis – Vorlage kopieren und ausfüllen: -`cp .env.example .env`. Die Datei wird nie committet; echte -Umgebungsvariablen haben Vorrang. Solid Queue, Cache und Cable liegen in -derselben Datenbank wie die Daten. Mails landen in der Entwicklung nicht im -Postfach, sondern unter . +stehen in einer `.env` im Projektverzeichnis: `cp .env.example .env` und +ausfüllen (siehe [Konfiguration & Betrieb](#konfiguration--betrieb)). --- @@ -179,7 +177,7 @@ Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen Undo dieses Feldes benutzen soll. **Performance bei großen Tabellen**: Undo-Tracking und die (debounced) -`localStorage`-Persistenz werden **nicht** über einen `watch(doc, ..., +Speicherung werden **nicht** über einen `watch(doc, ..., {deep:true})` angestoßen — Vues Deep-Watch muss dafür bei *jeder* Mutation den kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung @@ -700,8 +698,8 @@ Zeilen-/Spaltengrenzen selbst ein und wird nicht zusätzlich pixelweise gerastert. Alle diese Einstellungen sind Teil des Dokuments (`DocumentState` / -`Sheet`) und werden damit in **localStorage gespeichert und in `.json` -exportiert** – Round-Trip verlustfrei, ungültige Werte werden beim Import +`Sheet`) und werden damit **mit dem Dokument gespeichert und in `.json` +exportiert** (der Zoom bleibt bei geteilten Dokumenten pro Person) – Round-Trip verlustfrei, ungültige Werte werden beim Import verworfen bzw. geklemmt. ### Zahlenformat (Ländereinstellung) @@ -1112,7 +1110,8 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `` + revoke). ### Import `` **und** Drag & Drop auf die Canvas; `.num` `.json` `.csv` `.xlsx`. -- **`.num`** / **`.json`** → Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*. +- **`.num`** / **`.json`** → *als neues Dokument* öffnen (Standard), das offene + Dokument *ersetzen* (Rückfrage) oder Blätter *anhängen*. **v1-Dokumente** (altes Ein-Raster-Format) werden automatisch nach v2 migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema wird validiert; bei Fehler klare Meldung, **kein Teil-Import**. @@ -1123,14 +1122,263 @@ Alles clientseitig (`Blob` + `URL.createObjectURL` + `` + revoke). - Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh. Schlägt ein Import fehl, bleibt das bestehende Dokument **unverändert**. -## Persistenz +## Speichern, Konten & Zusammenarbeit -Das gesamte Dokument wird (debounced) nach `localStorage` gespiegelt, robust -gegen leeren/kaputten State — Fallback ist ein **leeres Dokument** (ein Blatt -„Blatt 1" mit einer leeren „Tabelle 1"). Ein v1-State im Storage wird beim -Laden migriert. **Datei › Neu** legt ein neues leeres Dokument an, **Datei › -Muster · Umsatzanalyse** lädt das vorbefüllte Musterdokument (beides nach -Rückfrage, die bisherigen Änderungen gehen verloren). +Ohne Konto bleibt alles **im Browser**. Ein Konto braucht es nur, wenn man +seine Dokumente auf mehreren Geräten haben oder sie mit anderen teilen will. +Mit Konto liegen Dokumente auf dem Server und werden **live gemeinsam** +bearbeitet. + +### Grundsätze + +- **Anonym zuerst.** App öffnen, arbeiten, ohne Registrierung. Kein + Gast-Konto auf dem Server. +- **Konto nur bei Bedarf**, Anmeldung nur per **Magic Link** (E-Mail, dazu ein + 6-stelliger Code). Keine Passwörter. Die erste Anmeldung legt das Konto an. +- **Nichts geht verloren.** Nach der ersten Anmeldung fragt die App, welche + lokalen Dokumente ins Konto hochgeladen werden sollen. +- **Links sind Zugänge.** Ein Lesen- oder Bearbeiten-Link funktioniert auch + ohne Konto und lässt sich jederzeit widerrufen. +- **Gleichzeitig statt nacheinander.** Änderungen werden per CRDT (Yjs) + zusammengeführt, ohne Sperren; jede Person hat ihr eigenes Undo. +- **Dateien bleiben.** `.num`, `.json`, CSV und XLSX bleiben als + Import/Export. + +### Wo Dokumente liegen + +| | Lokal | Server | +| --- | --- | --- | +| Wer | alle, auch ohne Konto | mit Konto, oder per Link | +| Wo | IndexedDB dieses Browsers | PostgreSQL | +| Adresse | `/?doc=` | `/d/` (Link: `/d/#k=`) | +| Speichern | automatisch, 250 ms nach der Änderung | live, jede Änderung sofort an alle | +| Undo | Snapshots des Dokuments | pro Person (`Y.UndoManager`) | +| Teilen | – (erst hochladen) | per Link oder Einladung | + +**Datei › Dokumente …** listet alles in bis zu drei Gruppen: *Mein Konto*, +*Mit mir geteilt* (Einladungen und Links) und *In diesem Browser*. Dort lässt +sich öffnen, umbenennen, duplizieren, löschen bzw. aus der Liste entfernen und +ein lokales Dokument ins Konto hochladen. Der Dokumentname steht in der +Toolbar und ist direkt umbenennbar; er ist auch der Dateiname beim Export. +**Neues Dokument** und **Muster** legen je ein weiteres Dokument an: mit Konto +auf dem Server, sonst lokal. Ein alter Einzel-Speicherstand aus +`localStorage` wird beim ersten Start als „Mein Dokument“ übernommen. + +**Mehrere Tabs:** Jeder Tab kann ein anderes Dokument offen haben. Dieselbe +lokale Datei in zwei Tabs folgt den Speicherungen des anderen Tabs +(`BroadcastChannel`); tippen beide gleichzeitig, gewinnt die letzte +Speicherung, darauf weist ein Hinweis hin. Server-Dokumente führen +gleichzeitige Änderungen immer zusammen. + +### Abläufe + +1. **Anonym arbeiten** – Dokumente liegen in IndexedDB. Ein anderer Browser + oder gelöschte Browserdaten bedeuten: diese Dokumente sind dort nicht da. +2. **Anmelden** – E-Mail eingeben, Mail mit Link und Code. Der Link öffnet eine + Bestätigungsseite; angemeldet wird erst per Klick (`POST`), damit + Mail-Scanner, die Links vorab aufrufen, ihn nicht verbrauchen. Alternativ + den Code in der App eingeben (Link am Handy, App am Desktop). Danach: Rückfrage + zum Hochladen lokaler Dokumente; hochgeladene werden lokal entfernt. +3. **Teilen per Link** – *Teilen* → Link „kann ansehen“ bzw. „kann bearbeiten“ + erstellen und kopieren. Funktioniert ohne Konto; der Token wird im Browser + gemerkt (`localStorage`) und nach einer späteren Anmeldung dem Konto + zugeordnet. *Neu* erzeugt einen neuen Link; der alte und alle darüber + entstandenen Zugänge sind dann ungültig. +4. **Einladen** – E-Mail + Rolle. Die Einladung (7 Tage gültig) meldet beim + Öffnen gleichzeitig an und legt bei Bedarf das Konto an. +5. **Gemeinsam bearbeiten** – Änderungen kommen in ~150 ms bei den anderen an. + Die Auswahl der anderen erscheint als farbiger Rahmen mit Namen, oben + rechts die Avatare. Anonyme Personen heißen zwei zufällige Begriffe + („Quelle Mond“). Undo nimmt nur eigene Änderungen zurück. Wer nur lesen + darf, sieht alles live, kann aber nichts ändern („Nur Lesen“). + +Ein lokales Dokument teilen: *Teilen* → ohne Konto erst anmelden, dann +*Hochladen und teilen*. + +### Rollen + +| Aktion | Besitzer | Bearbeiten | Ansehen | +| --- | --- | --- | --- | +| Lesen, Anwesenheit der anderen sehen | ja | ja | ja | +| Bearbeiten, Umbenennen | ja | ja | nein | +| Exportieren, Duplizieren (ins eigene Konto) | ja | ja | ja | +| Links und Personen verwalten | ja | nein | nein | +| Löschen | ja | – | – | +| Aus der eigenen Liste entfernen | – | ja | ja | + +Zugang entsteht durch **Besitz** (wer anlegt), **Einladung** (Mitgliedschaft +`invite`, bleibt beim Widerruf eines Links bestehen) oder **Link** +(angemeldet: Mitgliedschaft `link`; anonym: nur der Token im Browser). Ein +Link schwächt nie eine bestehende Rolle ab. Der Token steht im URL-Fragment +(`#k=…`), das Browser nicht an Server schicken; die App entfernt es nach dem +Einlösen aus der Adresszeile. + +Geprüft wird zentral über `Document#role_for(user:, token:)`: im REST-API per +Controller (kein Zugang → `404`, nicht `403`), im WebSocket beim Verbinden. +Ändern sich Links oder Mitgliedschaften, prüft jede offene Verbindung ihre +Rolle sofort neu: Wer den Zugang verliert, wird getrennt; wer herabgestuft +wird, ist sofort im Nur-Lesen-Modus. + +### Architektur + +``` +┌─ Browser (je Person) ─────────┐ ┌─ Rails 8 ──────────────────────┐ +│ Vue-App (app/frontend) │ HTTPS │ REST-API /api, Login /auth │ ┌────────────┐ +│ Store (DocumentState, │◄────────►│ Magic Link, Liste, Teilen │────►│ PostgreSQL │ +│ positionsbasiert "c,r") │ │ │ │ Daten + │ +│ ▲ ▼ collab/ydoc.ts │ WebSocket│ DocumentChannel (ActionCable) │────►│ Solid Queue│ +│ Y.Doc (feste Zeilen-/ │◄────────►│ speichert + verteilt Updates │ │ Cache,Cable│ +│ Spalten-IDs), Awareness │ /cable │ │ └────────────┘ +│ IndexedDB (lokale Dokumente) │ │ Mailer (Solid Queue) ──► SMTP │ +└───────────────────────────────┘ └────────────────────────────────┘ +``` + +- **Rails 8** mit dem Vue-Frontend über `vite_rails`; `SpaController` liefert + die App für `/` und `/d/:id` und bettet den angemeldeten User ein + (`#numolus-boot`). +- **Solid Queue, Solid Cache, Solid Cable** liegen als normale Migrationen in + **derselben** PostgreSQL-Datenbank wie die Daten; kein Redis. +- Der Server versteht den Inhalt der Yjs-Updates nicht und muss es nicht: er + speichert und verteilt sie nur. Formeln rechnet weiterhin jeder Browser selbst. + +### Live-Bearbeitung (Yjs) + +Der **Store bleibt unverändert positionsbasiert** (`cells["col,row"]`), Engine +und Dateiformat ebenso. Die Schicht [`app/frontend/collab/`](app/frontend/collab/) +übersetzt: + +- [`ydoc.ts`](app/frontend/collab/ydoc.ts) – Yjs-Form des Dokuments und die + Übersetzung in beide Richtungen. Jede Tabelle hat `rowOrder`/`colOrder` mit + **festen IDs**; Zellen, Formate, Breiten und Höhen hängen an diesen IDs + (`"colId|rowId"`). `Binding.sync()` schreibt nur, was sich geändert hat + (eine Zelle, eine Eigenschaft), damit Änderungen verschiedener Personen + zusammengeführt statt überschrieben werden. Zeile/Spalte einfügen oder + löschen melden die vier Store-Funktionen `insertRow`/`deleteRow`/ + `insertColumn`/`deleteColumn` ausdrücklich (`onStructure`), denn aus + verschobenen Zellen allein ist das nicht erkennbar. So landet eine Eingabe + in Zeile 7 in der richtigen Zeile, auch wenn gleichzeitig jemand darüber + eine Zeile einfügt. +- [`session.ts`](app/frontend/collab/session.ts) – eine Sitzung je offenem + Server-Dokument: ActionCable-Abo, `Y.UndoManager` (nur eigene Änderungen), + Awareness (Name, Farbe, Auswahl als Zeilen-/Spalten-IDs). Reihenfolge ist + wichtig: Vor jeder fremden Änderung gehen eigene, noch nicht übertragene + Änderungen zuerst nach Yjs; danach wird der Store mit + `applyExternalState` neu aufgebaut, wobei Auswahl, aktives Blatt und Zoom + der Person erhalten bleiben (die eigene Auswahl folgt eingefügten Zeilen). +- **Erste Umwandlung** eines JSON-Dokuments in Yjs geschieht im Browser, + deterministisch (feste Client-ID, IDs `r0…`/`c0…`): Öffnen zwei Personen es + gleichzeitig, entstehen identische Updates, die Yjs zu einem verschmilzt. +- **Zusammenfassen:** Ab 200 gespeicherten Updates schickt ein Editor den + Gesamtstand; der Server ersetzt genau die aufgelisteten Updates durch ihn. + Editoren halten außerdem `documents.content` als JSON-Snapshot aktuell + (alle 30 s bei Änderung, beim Verlassen, beim Zusammenfassen) – für + Duplizieren, Export und die Liste. +- **Wiederverbinden:** ActionCable verbindet neu, der Server schickt alle + Updates, der Browser schickt, was dem Server fehlt. Anzeige „Offline“ in der + Toolbar, solange die Verbindung weg ist. +- **Zoom** ist pro Person und wird nicht geteilt; Auswahl, aktives Blatt und + aktives Objekt ebenso nicht. Die Z-Reihenfolge (Klick holt ein Objekt nach + vorn) ist Dokumentinhalt; im Nur-Lesen-Modus ändert ein Klick sie nicht. + +**Nur-Lesen im Store:** Ist `store.ui.readOnly` gesetzt, sind alle +inhaltsändernden Store-Funktionen wirkungslos (Liste `READ_ONLY_GUARDED` in +[`document.ts`](app/frontend/store/document.ts)); ein Versuch zeigt den Grund als Hinweis. + +**DocumentChannel** ([`document_channel.rb`](app/channels/document_channel.rb)), +Binärdaten Base64: + +| Richtung | Nachricht | Inhalt | +| --- | --- | --- | +| Browser → Server | `update` | ein Yjs-Update (nur Editoren) | +| Browser → Server | `awareness` | Auswahl/Name, wird nicht gespeichert | +| Browser → Server | `compact` | Gesamtstand + Liste der ersetzten Update-IDs + JSON-Snapshot | +| Browser → Server | `snapshot` | JSON-Snapshot | +| Server → Browser | `init` | Rolle, Titel, alle Updates; solange es keine gibt: das JSON zum Umwandeln | +| Server → Browser | `update`, `awareness`, `compacted` | weitergereicht bzw. bestätigt | +| Server → Browser | `meta`, `role`, `revoked` | Titel geändert, Rolle geändert, Zugang weg | + +### Datenmodell + +| Tabelle | Wichtige Spalten | +| --- | --- | +| `users` | `email` (citext, eindeutig), `display_name`, `color`, `last_seen_at` | +| `sessions` | `user_id`, `user_agent`, `ip_address`, `last_active_at` – signierte Session-ID im Cookie | +| `magic_links` | `email`, `token_digest`, `code_digest` (nur SHA-256), `purpose` (login/invitation), `document_id`, `role`, `invited_by_id`, `return_to`, `failed_attempts`, `expires_at`, `used_at` | +| `documents` | `id` (UUID, in der URL), `owner_id`, `title`, `content` (JSON-Snapshot), `updated_by_id`, `content_updated_at` | +| `document_updates` | `document_id`, `user_id`, `data` (ein Yjs-Update oder ein zusammengefasster Stand) | +| `document_memberships` | `document_id`, `user_id` (eindeutig zusammen), `role` (viewer/editor), `source` (invite/link), `share_link_id` | +| `share_links` | `document_id`, `role`, `token` (Klartext, damit der Besitzer den Link erneut kopieren kann), `created_by_id`, `revoked_at` | + +Der Besitzer steht nur in `documents.owner_id`. Pro Dokument gibt es höchstens +einen aktiven Link je Rolle. + +### API + +REST unter `/api`, JSON, Session-Cookie, CSRF-Token aus dem Meta-Tag; +Link-Zugang über den Header `X-Share-Token`. + +| Methode & Pfad | Zweck | +| --- | --- | +| `POST /api/auth/magic_link` | Link + Code anfordern `{email, return_to}` – Antwort immer gleich | +| `POST /api/auth/code` | mit Code anmelden `{email, code}` | +| `DELETE /api/auth/session` | abmelden | +| `GET/PATCH /api/me` | angemeldeter User / Anzeigename | +| `GET/POST /api/documents` | eigene + geteilte Dokumente / anlegen oder hochladen `{title, content}` | +| `GET /api/documents/:id` | Metadaten, Rolle, JSON-Snapshot (`?content=0` ohne) | +| `PATCH /api/documents/:id` | umbenennen `{title}` | +| `DELETE /api/documents/:id` | Besitzer: löschen; sonst: aus der Liste entfernen | +| `POST /api/documents/:id/join` | Link-Token einlösen `{token}` | +| `GET /api/documents/:id/share` | Links, Mitglieder, offene Einladungen (Besitzer) | +| `POST/DELETE /api/documents/:id/share/links[/:id]` | Link je Rolle neu erzeugen / widerrufen | +| `POST /api/documents/:id/share/invitations` | einladen `{email, role}` | +| `PATCH/DELETE /api/documents/:id/share/members/:user_id` | Rolle ändern / entfernen | +| `GET/POST /auth/verify?token=` | Bestätigungsseite / Anmeldung aus dem Mail-Link | + +### Konfiguration & Betrieb + +Einstellungen stehen in einer **`.env`** im Projektverzeichnis (Gem `dotenv`; +auch `.env.production` usw.). Vorlage: [`.env.example`](.env.example) – +`cp .env.example .env` und ausfüllen. Echte Umgebungsvariablen haben Vorrang; +`.env` wird nie committet. + +| Variable | Wofür | +| --- | --- | +| `NUMOLUS_DATABASE_NAME`, `_HOST`, `_PORT`, `_USER`, `_PASSWORD` | PostgreSQL (Standardname `numolus`; ohne Host/User: lokaler Socket) | +| `NUMOLUS_HOST` | Host in den Links der Mails | +| `SECRET_KEY_BASE` | Produktion (`bin/rails secret`) | +| `NUMOLUS_MAIL_FROM`, `SMTP_ADDRESS`, `SMTP_PORT`, `SMTP_USER_NAME`, `SMTP_PASSWORD` | Mailversand in Produktion | + +In der Entwicklung werden Mails nicht verschickt, sondern unter + angezeigt. Produktion braucht einen +Rails-Server (Puma) mit PostgreSQL; `bin/jobs` bzw. `SOLID_QUEUE_IN_PUMA=1` +für den Mailversand. + +### Sicherheit + +- Magic-Link-Token und Code nur als SHA-256 gespeichert, 15 Minuten gültig, + einmal verwendbar; nach 5 falschen Codes ist der Link verbrannt. +- Rate Limits: 20 Link-Anforderungen pro IP und Stunde (dazu 5 pro E-Mail), + 30 Code-Versuche pro IP in 15 Minuten. +- `return_to` nur als Pfad derselben Seite; `404` statt `403`; UUIDs. +- Cookies `HttpOnly`, `SameSite=Lax`, in Produktion `Secure`; CSRF-Schutz im + REST-API. +- Yjs-Updates max. 10 MB, JSON-Snapshots max. 20 MB und nur, wenn sie wie ein + numolus-Dokument aussehen. + +### Offen + +- **Formeln bei gleichzeitigem Einfügen:** Tippt jemand eine Formel, während + ein anderer eine Zeile einfügt, wird deren Bezug nicht angepasst (wie in + Google Sheets). Später denkbar: Bezüge intern über IDs. +- **Große Dokumente:** Jede fremde Änderung baut den ganzen Store neu auf; + später nur die betroffene Tabelle. +- **Makros aus fremder Hand** laufen im eigenen Browser, sobald man sie + startet; eine Bestätigung für fremde Makros fehlt noch. +- Papierkorb, Aufräumjobs (abgelaufene Magic Links, alte Sessions), + „Überall abmelden“, Konto löschen / Datenexport, Offline-Kopie von + Server-Dokumenten (`y-indexeddb`), Backups und Monitoring. +- Datenschutzerklärung (gespeichert werden E-Mail, Anzeigename, IP und + User-Agent je Session). ## Vorbefülltes Dokument (Datei › Muster) @@ -1148,7 +1396,8 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Tests -`npm run test` — Vitest, 570 Tests: +`npm run test` — Vitest (Engine, Store, IO, Yjs-Abbildung); +`bin/rails test` — Minitest (Models, REST-API, Anmeldung, Teilen, DocumentChannel): - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, @@ -1513,10 +1762,21 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`, ## Projektstruktur ``` +app/ + models/ # User, Session, MagicLink, Document, DocumentUpdate, DocumentMembership, ShareLink + controllers/ # SpaController (liefert die App), AuthController (Mail-Link), api/* (REST) + channels/ # DocumentChannel (Live-Bearbeitung), ApplicationCable::Connection + mailers/, views/ # AuthMailer (Anmeldung, Einladung), Bestätigungsseite, SPA-Seite +config/, db/ # Rails-Konfiguration, Migrationen (inkl. Solid Queue/Cache/Cable) +test/ # Minitest: Models, API, Channel +.env.example # Vorlage für Datenbank, Host, SMTP app/frontend/ + entrypoints/ # application.ts – Einstieg (vite_rails) engine/ # Formel-Engine (Eigenbau) + Unit-Tests - store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung - io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy) + store/ # Dokument-Store (v2 Canvas-Modell), Formatierung, library.ts (lokal/Server), session.ts (Konto) + collab/ # Live-Bearbeitung: ydoc.ts (Yjs-Abbildung), session.ts (ActionCable, Undo, Awareness) + io/ # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy), + # library.ts (IndexedDB), api.ts (REST-Client) components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject, # TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs, # StatusBar, TheLogo, … diff --git a/docs/konzept-teilen-und-zusammenarbeit.md b/docs/konzept-teilen-und-zusammenarbeit.md deleted file mode 100644 index 5c75a5d..0000000 --- a/docs/konzept-teilen-und-zusammenarbeit.md +++ /dev/null @@ -1,731 +0,0 @@ -# numolus: Teilen & gemeinsames Arbeiten – Konzept - -Stand: 2026-09-28 · Autor: Wild Karl-Heinz - -numolus-Dokumente liegen künftig auf einem eigenen Server, werden live gemeinsam -bearbeitet und brauchen **kein Konto**. Ein Konto entsteht nur, wenn jemand es -will oder ein Dokument an bestimmte Personen teilen möchte. Angemeldet wird -ausschließlich per Magic Link (E-Mail). - ---- - -## 1. Ziel & Grundsätze - -- **Anonym zuerst.** App öffnen, Dokument anlegen, arbeiten, ohne Registrierung. - Der Browser bekommt eine Gast-Identität per Cookie. Ein Sheet wird ohne anderslautende - Angaben im Browser gespeichert. -- **Konto nur bei Bedarf.** Es gibt zwei Anlässe: - 1. Man will seine Dokumente auf mehreren Geräten haben bzw. nicht verlieren. - 2. Man will an bestimmte Personen teilen oder deren Rechte verwalten. -- **Login nur per Magic Link.** E-Mail eingeben, Link anklicken, fertig. Keine - Passwörter. -- **Nichts geht verloren.** Beim Anlegen oder Anmelden eines Kontos werden alle - Dokumente des bisherigen Gasts übernommen. -- **Links sind Zugänge.** Ein Bearbeiten- oder Lesen-Link funktioniert auch ohne - Konto und lässt sich jederzeit widerrufen. -- **Live-Zusammenarbeit per CRDT (Yjs).** Gleichzeitige Änderungen werden ohne - Sperren zusammengeführt; jede Person hat ihr eigenes Undo. -- **Lokale Formate bleiben.** `.num`, CSV und XLSX bleiben als Import/Export. - -**Nicht-Ziele (vorerst)** - -- Ende-zu-Ende-Verschlüsselung (siehe Kapitel 12) -- Kommentare und Versionsverlauf in der Oberfläche (das Datenmodell lässt - beides später zu) - ---- - -## 2. Nutzerflüsse - -Sechs Abläufe decken alles ab; nur C und D führen zum Magic Link. - -### A – Anonym anlegen und arbeiten - -1. Person öffnet numolus. Kein Cookie vorhanden → Server legt einen Gast an - und setzt ein langlebiges Cookie. -2. „Neues Dokument“ → Server erzeugt ein Dokument mit UUID, Besitzer = Gast. - Die URL wird `/d/`. -3. Alle Dokumente bleiben generell lokal im Browser. Doch sollte ein User - authentifiziert sein, dann wird jede Änderung geht sofort an den Server. - Kein Speichern-Knopf. -4. Dezenter Hinweis: „Dieses Dokument ist nur in diesem Browser in deiner Liste. - Mit E-Mail sichern, um es überall zu haben.“ - -### B – Wiederfinden (ohne Konto) - -1. Startseite zeigt „Meine Dokumente“: alles, was dem Gast gehört oder was er - über einen Link geöffnet hat. -2. Die Liste liegt serverseitig am Gast, hängt aber am Cookie. Anderer Browser - oder gelöschte Cookies = leere Liste. -3. Ohne Konto kein Restore wenn ein anderer Browser verwendet wird. - -### C – Konto anlegen, weil man es will - -1. „Anmelden“ → E-Mail eingeben → Mail mit Magic Link. -2. Klick auf den Link (im selben oder einem anderen Browser) → angemeldet. -3. War im Browser ein Gast aktiv, werden dessen Dokumente, Mitgliedschaften und - Link-Zugänge ins Konto übernommen (Kapitel 4.4). -4. Weiteres Gerät: E-Mail eingeben, Link klicken → dieselbe Liste. - -Es gibt keine getrennte „Registrierung“: Die erste Anmeldung mit einer neuen -E-Mail legt das Konto an. - -### D – An Personen teilen (erfordert Konto) - -1. „Teilen“ → Dialog. Ohne Konto: „Um an Personen zu teilen, bestätige deine - E-Mail“ → Ablauf C, danach geht der Dialog an derselben Stelle weiter. - Mache darauf aufmerksam, dass fuer das Share die Daten auf dem Server gepeichert - werden müssen. -2. E-Mail der Empfängerin + Rolle (Lesen/Bearbeiten) eingeben → Einladung per - Mail. -3. Die Empfängerin klickt den Link in der Einladung. Die Einladung ist - gleichzeitig ihr Magic Link: Sie ist damit angemeldet (Konto wird bei Bedarf - angelegt), das Dokument öffnet sich und steht in ihrer Liste unter - „Mit mir geteilt“. - -### E – Per Link teilen (kein Konto nötig) - -1. „Teilen“ → „Link kopieren“ mit Auswahl Lesen/Bearbeiten. -2. Wer den Link öffnet, bekommt bei Bedarf eine Gast-Identität. Das Dokument - erscheint danach in dessen Liste. -3. Der Besitzer kann den Link jederzeit neu erzeugen. Der alte Link und alle - darüber entstandenen Zugänge sind dann ungültig. - -Offene Entscheidung: Soll auch Ablauf E ein Konto verlangen? Siehe Kapitel 12. - -### F – Gemeinsam bearbeiten - -1. Alle Beteiligten sehen Änderungen der anderen innerhalb von - Sekundenbruchteilen. -2. Die Auswahl der anderen erscheint als farbiger Rahmen mit Namen - (Konto: Anzeigename; Gast: zufälliger Name wie „Anonymer Fuchs“). -3. Oben rechts: Avatare der gerade Anwesenden. -4. Undo macht nur die eigenen Änderungen rückgängig. -5. Wer nur Leserechte hat, sieht alles live, kann aber nichts ändern - (Eingabe gesperrt, Toolbar reduziert). - ---- - -## 3. Architektur-Überblick - -Ein einziger Rails-8-Server übernimmt Login, Dokumentverwaltung und Live-Sync. -Der Browser hält den Dokumentinhalt als Yjs-CRDT und rechnet Formeln weiterhin -selbst. - -``` -┌─ Browser (je Person) ─────┐ ┌─ Rails 8 (numolus-Server) ──┐ -│ │ HTTPS │ │ ┌────────────┐ -│ Vue-App │◄────────►│ REST-API & Login │─────►│ │ -│ Oberfläche, Liste │ │ Magic Link, Liste, Teilen │ │ PostgreSQL │ -│ │ │ │ │ │ alle Daten │ -│ Store auf Yjs │ WebSocket│ DocumentChannel │─────►│ │ -│ Inhalt als CRDT, │◄────────►│ ActionCable + y-rb │ └────────────┘ -│ Formeln rechnen lokal │ │ │ ┌────────────┐ -│ │ │ │ Jobs & Mailer │─────►│ SMTP │ -│ IndexedDB (Offline) │ │ Kompaktieren, Aufräumen │ └────────────┘ -└───────────────────────────┘ └─────────────────────────────┘ -``` - -| Baustein | Wahl | Begründung | -| --- | --- | --- | -| Backend | Rails 8 | vorhandene Ruby/Rails-Kompetenz und Server | -| Live-Sync Server | ActionCable + `y-rb` (+ `yrb-actioncable`) | Yjs-Protokoll in Ruby, serverseitiges Zusammenfassen möglich | -| Live-Sync Client | `yjs`, `@y-rb/actioncable`, `y-indexeddb` | Standard-CRDT, offline-fähig | -| Datenbank | PostgreSQL | Binärdaten (`bytea`), später Volltextsuche | -| Jobs | Solid Queue (Rails-8-Standard) | kein Redis nötig | -| ActionCable-Adapter | Solid Cable (Rails-8-Standard) | kein Redis nötig; bei vielen Verbindungen später Redis oder AnyCable | -| Mail | Action Mailer über SMTP | Magic Links, Einladungen | -| Hosting | App und API unter einer Domain, z. B. `numolus.wicom.li` | First-Party-Cookies, kein CORS | - -**Auslieferung der App:** Der Vite-Build (`dist/`) wird von Rails bzw. dem -vorgeschalteten Webserver unter derselben Domain ausgeliefert. Alle Pfade -außer `/api/*` und `/cable` liefern `index.html` (Client-Routing für `/d/`). - -**Warum Yjs und nicht selbst gebaut:** Gleichzeitige Änderungen korrekt -zusammenzuführen (inkl. Offline-Phasen) ist der schwierigste Teil. Yjs ist -dafür etabliert; der Server muss das Zusammenführen nicht selbst verstehen, -kann es mit `y-rb` aber (Kompaktierung, Suche, Export). - ---- - -## 4. Identität & Login - -### 4.1 Gast-Identität - -- Keine Anlage eines Benutzers auf dem Server. -- Cookie: `numolus_session`, signiert, `HttpOnly`, `Secure`, `SameSite=Lax`, - Laufzeit 1 Jahr, bei jeder Nutzung verlängert (gleitend). -- Anzeigename für Cursor: zufällig („Anonymer Fuchs“), im Browser änderbar, - am Gast gespeichert. - -### 4.2 Magic Link - -1. `POST /api/auth/magic_link` mit E-Mail (+ optional `return_to`). -2. Server erzeugt ein zufälliges Token (32 Byte), speichert **nur dessen - SHA-256-Hash**, Gültigkeit 15 Minuten, einmal verwendbar. -3. Mail mit Link `https://numolus.wicom.li/auth/verify?token=…`. - Zusätzlich ein 6-stelliger Code in der Mail, damit man sich auch auf einem - Gerät anmelden kann, auf dem die Mail nicht gelesen wird (z. B. Link am - Handy, App am Desktop). -4. Aufruf des Links zeigt eine Bestätigungsseite mit Knopf „Anmelden“ - (`POST`). Grund: Mail-Scanner (Outlook Safe Links u. ä.) rufen Links - automatisch per `GET` auf und würden das Token sonst verbrauchen. -5. Nach dem `POST`: Token als verbraucht markieren, Konto anlegen falls neu, - Gast übernehmen (4.4), neue Session, Weiterleitung zu `return_to`. - -Antwort auf Schritt 1 ist immer gleich („Falls die Adresse gültig ist, ist eine -Mail unterwegs“), damit nicht abgefragt werden kann, welche Adressen ein Konto -haben. - -### 4.3 Sessions - -- Eine Session pro Browser/Gerät, in der Tabelle `sessions` (Rails-8-Generator - `bin/rails generate authentication` als Basis, Passwort-Teil entfernt). -- „Abmelden“ löscht die Session. „Überall abmelden“ löscht alle Sessions des - Kontos. -- Nach dem Abmelden ist der Browser wieder anonym, bekommt aber **nicht** den - alten Gast zurück (der ist ins Konto übergegangen). - -### 4.4 Übernahme Gast → Konto - -Beim erfolgreichen Magic-Link-Login in einem Browser mit Gast-Session: - -1. Dokumente werden nach Rückfrage auf dem Server gespeichert. -2. Alle `document_memberships` des Gasts → auf das Konto umhängen; bei - Konflikt (Konto hat schon eine Mitgliedschaft) gilt die höhere Rolle. -3. Gast-Datensatz löschen, Gast-Session durch Konto-Session ersetzen. -4. Alles in einer Transaktion. - -Existiert das Konto bereits (Anmeldung auf zweitem Gerät), passiert dasselbe: -Die Gast-Dokumente des zweiten Geräts wandern ebenfalls ins Konto. - ---- - -## 5. Datenmodell - -``` -users - id bigint PK - email citext, unique, null bei Gästen - display_name string - color string -- Cursorfarbe - last_seen_at datetime - created_at, updated_at - -sessions - id bigint PK - user_id → users - token_digest string, unique -- Cookie enthält nur das Token, DB den Hash - user_agent, ip_address - last_active_at datetime - created_at - -magic_links - id bigint PK - email citext - token_digest string, unique - code_digest string -- 6-stelliger Code - purpose enum: login | invitation - document_id → documents, null -- bei Einladungen - role enum: viewer | editor -- bei Einladungen - return_to string - expires_at datetime - used_at datetime - created_at - -documents - id uuid PK -- steht in der URL - owner_id → users - title string - state bytea -- kompaktierter Yjs-Stand - state_vector bytea -- für effizientes Nachladen - schema_version integer -- Version des Yjs-Datenlayouts (Kap. 7.1) - size_bytes integer - last_opened_at datetime - deleted_at datetime -- Papierkorb - created_at, updated_at - -document_updates -- noch nicht kompaktierte Änderungen - id bigint PK - document_id → documents - user_id → users - data bytea -- ein Yjs-Update - created_at - -document_memberships - id bigint PK - document_id → documents - user_id → users - role enum: viewer | editor - source enum: invite | link - share_link_id → share_links, null -- für Widerruf per Link - last_opened_at datetime - unique (document_id, user_id) - -share_links - id bigint PK - document_id → documents - role enum: viewer | editor - token_digest string, unique - created_by_id → users - revoked_at datetime - expires_at datetime, null - created_at -``` - -**Hinweise** - -- Der Besitzer steht in `documents.owner_id`, nicht in `document_memberships`. - Genau ein Besitzer pro Dokument; Übertragen ist eine spätere Funktion. -- Pro Dokument höchstens ein aktiver Link je Rolle (einfachere Oberfläche). -- Tokens (Session, Magic Link, Share Link) werden nur als Hash gespeichert. - Ein Datenbank-Leak gibt damit keine gültigen Zugänge preis. -- Der Titel liegt als Spalte vor (für die Liste) und wird aus dem Yjs-Dokument - synchron gehalten. - ---- - -## 6. Zugriff & Rechte - -### 6.1 Rollen - -| Aktion | Besitzer | Editor | Viewer | -| --- | --- | --- | --- | -| Lesen, Cursor der anderen sehen | ja | ja | ja | -| Bearbeiten | ja | ja | nein | -| Exportieren (`.num`, CSV, XLSX) | ja | ja | ja | -| Per Link teilen / Link widerrufen | ja | nein | nein | -| An Personen einladen / entfernen | ja (mit Konto) | nein | nein | -| Umbenennen | ja | ja | nein | -| Löschen (Papierkorb) | ja | nein | nein | -| Aus eigener Liste entfernen | – | ja | ja | - -Später denkbar: Editoren dürfen ebenfalls teilen (Schalter pro Dokument). - -### 6.2 Wie Zugang entsteht - -1. **Besitz**: Wer anlegt, ist Besitzer. -2. **Einladung**: Magic Link mit `purpose = invitation` → beim Einlösen - entsteht eine Mitgliedschaft mit `source = invite`. -3. **Link**: Aufruf von `/d/#k=` → Client schickt Token an - `POST /api/documents/:id/join` → Mitgliedschaft mit `source = link` und - Verweis auf den Link. - -Der Link-Token steht im **Fragment** (`#`). Browser senden Fragmente nicht an -den Server; das Token landet also nicht in Access-Logs oder Referrer-Headern. -Nach dem Einlösen entfernt die App das Fragment aus der Adresszeile. - -### 6.3 Prüfung - -Eine zentrale Methode `Document#role_for(user)` → `:owner | :editor | :viewer | nil`. - -- **REST**: jeder Controller über `before_action`; kein Zugang → `404` - (nicht `403`, damit die Existenz einer UUID nicht bestätigt wird). -- **WebSocket**: `ApplicationCable::Connection` identifiziert den User über - das Session-Cookie. `DocumentChannel#subscribed` prüft die Rolle und lehnt - ab (`reject`). Viewer-Nachrichten, die Änderungen enthalten, werden - verworfen und protokolliert. -- **Rechte ändern sich live**: Wird eine Mitgliedschaft entfernt oder ein Link - widerrufen, schickt der Server den betroffenen Verbindungen ein - `access_revoked` und schließt sie. Rollenwechsel Editor → Viewer ebenso - (Client lädt im Lesemodus neu). - -### 6.4 Widerruf - -- **Link neu erzeugen**: alter Link `revoked_at` gesetzt, alle Mitgliedschaften - mit `share_link_id` = alter Link werden gelöscht. Wer weiter Zugang haben - soll, muss den neuen Link bekommen oder eingeladen werden. -- **Person entfernen**: Mitgliedschaft löschen. -- Eingeladene Personen (`source = invite`) sind vom Link-Widerruf nicht - betroffen. - ---- - -## 7. Echtzeit-Sync - -### 7.1 Yjs-Datenlayout - -Das heutige `DocumentState` (`src/store/types.ts`) wird auf Yjs-Typen -abgebildet. Wichtig: Alles, was mehrere Personen gleichzeitig ändern können, -muss eine eigene Yjs-Struktur sein, damit Änderungen feingranular -zusammengeführt werden. - -``` -Y.Doc -├─ meta: Y.Map { title, locale, csvDelimiter, schemaVersion } -├─ sheetOrder: Y.Array -├─ sheets: Y.Map -│ └─ { name, objectOrder: Y.Array, objects: Y.Map } -│ └─ Tabelle: { -│ kind, name, x, y, view…, -│ rowOrder: Y.Array, ← stabile IDs statt Indizes -│ colOrder: Y.Array, -│ rowHeights: Y.Map, -│ colWidths: Y.Map, -│ cells: Y.Map<"colId:rowId", string>, -│ formats: Y.Map<"colId:rowId", Y.Map> -│ } -├─ namedRanges: Y.Map -├─ customFunctions: Y.Map -└─ macros: Y.Map -``` - -**Nicht im Yjs-Dokument**: `selection`, `activeSheetId`, `activeObjectId`. -Das ist Zustand pro Person (Kapitel 7.4 und 8.3). - -**Zelltext**: Eine Zelle ist ein einfacher String (letzte Änderung gewinnt). -Zwei Personen, die gleichzeitig **dieselbe** Zelle ändern, erzeugen keinen -Mischtext; eine Fassung gewinnt. Das entspricht Google Sheets und ist für -Tabellen richtig. - -### 7.2 Protokoll über ActionCable - -Standard-Yjs-Sync (wie `y-websocket`), transportiert über `DocumentChannel`, -Binärdaten Base64-kodiert: - -1. **Beitritt**: Client abonniert `DocumentChannel` mit `document_id`. - Server prüft die Rolle, schickt `sync_step1` (Server-State-Vector). -2. Client antwortet mit den Änderungen, die dem Server fehlen (Offline-Arbeit), - und seinem State-Vector; Server schickt, was dem Client fehlt. -3. **Laufend**: Jede lokale Änderung → `update` an den Server → Server - speichert in `document_updates` und broadcastet an alle anderen - Abonnenten dieses Dokuments. -4. **Awareness**: Cursor/Auswahl/Name → `awareness` an den Server → nur - broadcasten, nie speichern. - -`yrb-actioncable` liefert Schritte 1–4 fertig; vor der Umsetzung ist zu -prüfen, ob das Gem aktiv gepflegt ist (Kapitel 12). Fallback: dieselbe -Logik direkt mit `y-rb` im eigenen Channel (überschaubar, ca. 150 Zeilen). - -### 7.3 Speicherung & Kompaktierung - -- Jedes Update wird sofort als Zeile in `document_updates` angehängt - (schnell, kein Lesen nötig). -- **Kompaktierung** (Solid-Queue-Job): wenn ein Dokument > 200 offene Updates - hat oder 30 s nach der letzten Änderung. Der Job lädt `documents.state` - + alle offenen Updates in `y-rb`, fasst sie zu einem neuen `state` - zusammen, schreibt `state`, `state_vector`, `size_bytes`, `title` und - löscht die verarbeiteten Updates, alles in einer Transaktion. -- Beim Beitritt lädt der Server `state` + offene Updates. -- **Grenze**: Dokumente über 20 MB Yjs-Stand werden abgelehnt (anpassbar). - Anlass: Missbrauchsschutz und Speicherbedarf im Browser. - -### 7.4 Awareness (Anwesenheit) - -Pro Verbindung ein kleines Objekt, nur live verteilt: - -```json -{ "user": { "name": "Anonymer Fuchs", "color": "#e67e22" }, - "sheetId": "…", "objectId": "…", - "selection": { "anchor": "colId:rowId", "focus": "colId:rowId" }, - "editing": true } -``` - -- Rahmen der anderen nur zeichnen, wenn sie auf demselben Sheet sind. -- Avatarleiste oben rechts; Klick auf einen Avatar springt zu dessen Auswahl. - ---- - -## 8. Frontend-Umbau - -### 8.1 Store auf Yjs - -Heute ist `doc` ein reaktives Vue-Objekt, das die Store-Funktionen in -`src/store/document.ts` direkt verändern, und das als Ganzes in -`localStorage` gespeichert wird. - -Neu: - -- **Quelle der Wahrheit** ist das `Y.Doc`. -- **Schreiben**: Jede Store-Funktion (`setCell`, `insertRows`, `fillSelection`, - …) wird zu einer Transaktion auf dem `Y.Doc` - (`ydoc.transact(() => …, origin)`). -- **Lesen**: Eine Vue-Spiegelung des `Y.Doc` (per `observeDeep`), damit alle - Komponenten und das Formel-Engine unverändert auf reaktive Daten zugreifen. - Die Spiegelung wird nur an den geänderten Stellen aktualisiert. -- Vorgehen schrittweise: erst eine Adapter-Schicht, die das heutige - `DocumentState`-Format aus dem `Y.Doc` liefert; dann Funktion für Funktion - die Schreibpfade umstellen. Tests in `src/**/__tests__` sichern jeden - Schritt ab. -- `localStorage`-Speicherung entfällt; an ihre Stelle tritt `y-indexeddb` - (Offline-Kopie pro Dokument). - -### 8.2 Undo pro Person - -Heute: Snapshots des ganzen Dokuments (`snapshot()`, `undo()`), die bei -gemeinsamer Arbeit fremde Änderungen zurückdrehen würden. - -Neu: `Y.UndoManager` über die geteilten Strukturen, beschränkt auf lokale -Transaktionen (`trackedOrigins`). Das heutige Zusammenfassen schneller -Änderungen (`HISTORY_COALESCE_MS`) entspricht `captureTimeout`. Die Auswahl -wird als Metadaten pro Undo-Schritt gespeichert, damit Undo auch die Auswahl -zurücksetzt. - -### 8.3 Auswahl aus dem Dokument lösen - -`selection`, `activeSheetId`, `activeObjectId` wandern aus `DocumentState` in -einen lokalen View-Store. Sie werden zusätzlich in die Awareness gespiegelt -(als `colId:rowId`, damit sie bei Einfügungen anderer nicht verrutschen). - -### 8.4 Stabile Zeilen- und Spalten-IDs - -Der schwierigste Teil. Heute sind Zellen über Positionen adressiert -(`cells["c,r"]`), und Einfügen/Löschen verschiebt Schlüssel und -Formelbezüge. Bei gleichzeitiger Arbeit führt das zu falsch platzierten Werten. - -Neu: - -- Jede Zeile und Spalte hat eine stabile ID; `rowOrder`/`colOrder` legen die - Reihenfolge fest. Einfügen = ID in die Liste einfügen; kein Verschieben von - Zellen. -- Zellen sind über `colId:rowId` adressiert. -- **Formeln** bleiben im Text als `A1`-Bezüge gespeichert (so zeigt sie die - Formelleiste). Beim Einfügen/Löschen passt heute die Store-Logik die - Bezüge an; das bleibt so, läuft aber in derselben Transaktion wie die - Strukturänderung. Rest-Risiko: Person A fügt eine Zeile ein, während - Person B gleichzeitig eine Formel tippt, die sich auf spätere Zeilen bezieht. - B's Formel wird dann nicht angepasst. Das ist bei Google Sheets ebenso und - wird akzeptiert. Langfristige Alternative: Bezüge intern als IDs speichern - und nur zur Anzeige in `A1` übersetzen. -- Das Formel-Engine rechnet weiter mit Positionen; die Spiegelung (8.1) - liefert ihm das heutige Positionsformat. - -### 8.5 Makros - -Makros laufen nur bei der Person, die sie startet. Alle Änderungen eines -Makrolaufs sind **eine** Yjs-Transaktion und damit ein Undo-Schritt, der sich -wie jede andere Änderung verteilt. Viewer können keine Makros starten. - -### 8.6 Offline - -- `y-indexeddb` hält eine Kopie jedes geöffneten Dokuments. -- Ohne Verbindung kann weitergearbeitet werden; die Statusleiste zeigt - „Offline – Änderungen werden später übertragen“. -- Beim Wiederverbinden gleicht das Sync-Protokoll (7.2) automatisch ab. - -### 8.7 Neue Oberflächenteile - -| Teil | Inhalt | -| --- | --- | -| Startseite / Dokumentliste | „Meine Dokumente“, „Mit mir geteilt“, Suche nach Titel, Neu, Import | -| Teilen-Dialog | Link (Lesen/Bearbeiten) kopieren und neu erzeugen; Personen einladen, Rolle ändern, entfernen | -| Anmelden-Dialog | E-Mail, Hinweis „Mail ist unterwegs“, Code-Eingabe | -| Konto-Menü | Anzeigename, Abmelden, Überall abmelden, Konto löschen | -| Anwesenheit | Avatare, farbige Auswahlrahmen, Namensschild am Cursor | -| Status | Verbunden / Offline / Synchronisiert | -| Lesemodus | Eingabe gesperrt, Hinweis „Nur Lesen“ | - ---- - -## 9. API - -### 9.1 REST (`/api`, JSON, Session-Cookie, CSRF-Token für schreibende Aufrufe) - -| Methode | Pfad | Zweck | -| --- | --- | --- | -| `GET` | `/api/me` | aktueller User (Gast oder Konto), legt keinen Gast an | -| `PATCH` | `/api/me` | Anzeigename, Farbe | -| `POST` | `/api/auth/magic_link` | Magic Link anfordern `{email, return_to}` | -| `POST` | `/api/auth/verify` | Token oder Code einlösen `{token}` / `{email, code}` | -| `DELETE` | `/api/auth/session` | Abmelden | -| `DELETE` | `/api/auth/sessions` | Überall abmelden | -| `GET` | `/api/documents` | Liste (eigene + geteilte), Metadaten, Rolle | -| `POST` | `/api/documents` | neues Dokument, optional mit Inhalt (Import) | -| `GET` | `/api/documents/:id` | Metadaten + eigene Rolle | -| `PATCH` | `/api/documents/:id` | Titel | -| `DELETE` | `/api/documents/:id` | in den Papierkorb (Besitzer) bzw. aus Liste entfernen | -| `POST` | `/api/documents/:id/restore` | aus dem Papierkorb | -| `POST` | `/api/documents/:id/join` | Link-Token einlösen `{token}` | -| `GET` | `/api/documents/:id/export?format=num` | Export serverseitig (optional) | -| `GET` | `/api/documents/:id/share` | Links und Mitglieder | -| `POST` | `/api/documents/:id/share_links` | Link (neu) erzeugen `{role}` → Token einmalig zurück | -| `DELETE` | `/api/documents/:id/share_links/:link_id` | Link widerrufen | -| `POST` | `/api/documents/:id/invitations` | Person einladen `{email, role}` | -| `PATCH` | `/api/documents/:id/members/:user_id` | Rolle ändern | -| `DELETE` | `/api/documents/:id/members/:user_id` | Person entfernen | - -### 9.2 WebSocket (`/cable`, `DocumentChannel`) - -| Richtung | Nachricht | Inhalt | -| --- | --- | --- | -| Client → Server | subscribe | `{document_id}` | -| beide | `sync_step1` | State-Vector (Base64) | -| beide | `sync_step2` | fehlende Updates (Base64) | -| beide | `update` | ein Yjs-Update (Base64) | -| beide | `awareness` | Awareness-Update (Base64) | -| Server → Client | `access_revoked` | `{reason}` → Client schließt Dokument | -| Server → Client | `role_changed` | `{role}` → Client lädt neu | -| Server → Client | `error` | `{code}` z. B. `too_large`, `read_only` | - ---- - -## 10. Sicherheit, Datenschutz & Betrieb - -### 10.1 Sicherheit - -- **Tokens**: 32 Byte aus `SecureRandom`, in der DB nur SHA-256-Hash; - Vergleich zeitkonstant. -- **Rate Limits** (Rails 8 `rate_limit`): - - Magic Link: 5 pro E-Mail und 20 pro IP je Stunde - - Code-Eingabe: 5 Fehlversuche, dann Code ungültig - - Dokument anlegen: 60 pro User/IP je Stunde - - Link einlösen: 30 pro IP je Minute - - WebSocket-Updates: Größe pro Nachricht max. 1 MB, Frequenz begrenzen -- **Cookies**: `HttpOnly`, `Secure`, `SameSite=Lax`; CSRF-Schutz für REST. -- **ActionCable**: `allowed_request_origins` auf die eigene Domain. -- **Enumeration**: UUIDs v4, `404` statt `403`, gleiche Antwort beim - Magic-Link-Anfordern. -- **Makros/Custom Functions** laufen im Browser. Da jetzt fremde Personen - Makros in ein gemeinsames Dokument schreiben können, muss geprüft werden, - wie Makros heute ausgeführt werden und ob sie auf Browser-APIs zugreifen - können. Falls ja: Ausführung isolieren oder Makros aus fremder Hand erst - nach Bestätigung ausführen (offene Frage, Kapitel 12). -- **Inhalte**: Zelltexte werden weiterhin nie als HTML gerendert. - -### 10.2 Datenschutz (DSGVO) - -- Gespeicherte Personendaten: E-Mail, Anzeigename, IP und User-Agent je - Session, Zeitstempel. Keine Tracker. -- Datenschutzerklärung und Impressum auf der Startseite verlinkt. -- Gast-Cookie ist technisch notwendig (Zugang zu eigenen Dokumenten) → - kein Einwilligungsbanner nötig; in der Datenschutzerklärung erklären. -- **Konto löschen**: löscht eigene Dokumente (nach Warnung), Mitgliedschaften, - Sessions. Dokumente, die mit anderen geteilt sind: Option „Besitz an - Editor übertragen“ oder löschen. -- **Export aller Daten**: ZIP mit allen eigenen Dokumenten als `.num`. -- IP-Adressen in Sessions nach 30 Tagen kürzen/entfernen. -- Mailversand über einen Anbieter in der EU/Schweiz. - -### 10.3 Aufräumjobs (Solid Queue, täglich) - -| Job | Regel | -| --- | --- | -| Gäste | Gast ohne Dokumente und ohne Aktivität seit 30 Tagen → löschen | -| Gast-Dokumente | Besitzer ist Gast, nicht geöffnet seit 12 Monaten → löschen (Mitglieder vorher nicht warnbar) | -| Papierkorb | `deleted_at` älter als 30 Tage → endgültig löschen | -| Magic Links | abgelaufen oder verbraucht, älter als 7 Tage → löschen | -| Sessions | inaktiv seit 1 Jahr → löschen | -| Kompaktierung | Dokumente mit offenen Updates, die der Live-Job verpasst hat | - -### 10.4 Betrieb - -- **Backups**: tägliches `pg_dump`, 30 Tage aufbewahrt, Wiederherstellung - einmal pro Quartal testen. -- **Monitoring**: Anzahl WebSocket-Verbindungen, offene Updates je Dokument, - Job-Warteschlange, Mail-Zustellfehler, Fehler-Tracking (z. B. Sentry o. ä.). -- **Deployment**: Kamal (Rails-8-Standard) oder bestehender Server; der - heutige `npm run sync`-Schritt wird durch das Ausliefern des Builds über - die Rails-App ersetzt. -- **Skalierung**: Ein Prozess reicht für den Anfang. Bei vielen gleichzeitigen - Verbindungen: AnyCable (WebSocket-Server in Go, Rails-Logik bleibt). - ---- - -## Umsetzungsstand (Branch `with-server`, 2026-09-29) - -Umgesetzt sind Phasen 1–4 in angepasster Form, einschließlich Live-Bearbeitung mit Yjs. - -| Thema | Umsetzung | Abweichung vom Konzept | -| --- | --- | --- | -| Anonym | Dokumente bleiben lokal in IndexedDB (`/?doc=`) | kein Gast-Konto auf dem Server | -| Konto | Magic Link + 6-stelliger Code, Konto entsteht bei der ersten Anmeldung | – | -| Übernahme | nach der ersten Anmeldung Rückfrage, welche lokalen Dokumente hochgeladen werden; hochgeladene werden lokal entfernt | statt automatischer Übernahme | -| Server-Dokumente | Inhalt = Yjs-Updates (`document_updates`), `documents.content` als JSON-Snapshot (Export, Duplizieren, erste Umwandlung); `/d/` | – | -| Gleichzeitiges Bearbeiten | live über ActionCable (`DocumentChannel`), ~150 ms; feste Zeilen-/Spalten-IDs; Undo pro Person (`Y.UndoManager`); Auswahl der anderen als farbiger Rahmen | Store bleibt positionsbasiert, eine Binding-Schicht übersetzt (`app/frontend/collab/`) | -| Erste Umwandlung | JSON → Yjs deterministisch im Browser (feste Client-ID, IDs `r0…`/`c0…`), gleichzeitige Umwandlungen verschmelzen | statt serverseitig | -| Zusammenfassen | ab 200 Updates schickt ein Editor den Gesamtstand, der Server ersetzt die gelisteten Updates | clientseitig, kein `y-rb` nötig | -| Teilen per Link | ohne Konto nutzbar; Token im Browser gespeichert (localStorage), nach Anmeldung dem Konto zugeordnet | Link-Token im Klartext gespeichert, damit der Besitzer den Link erneut kopieren kann | -| Einladungen | Mail mit Link (7 Tage gültig), meldet gleichzeitig an | – | -| Sessions | signierte Session-ID im Cookie (Rails-8-Standard) | statt Token-Digest | -| Datenbank | App-Daten, Solid Queue, Cache und Cable in **einer** PostgreSQL-Datenbank | – | -| Papierkorb, Aufräumjobs, Konto löschen | noch offen | – | - -## 11. Umsetzung in Phasen - -Jede Phase ist für sich nutzbar und wird einzeln ausgeliefert. - -### Phase 1 – Server-Dokumente, anonym (ohne Live-Sync) - -- Rails-App: `users` (nur Gäste), `sessions`, `documents` mit Inhalt als JSON - (heutiges `.num`-Format, `src/io/json.ts`), REST für Liste/Anlegen/Laden/ - Speichern/Löschen. -- Frontend: Dokumentliste, URL `/d/`, automatisches Speichern an den - Server statt `localStorage`. Bestehende `localStorage`-Dokumente werden beim - ersten Start einmalig hochgeladen. -- Konflikte: Versionsnummer am Dokument; veraltete Speicherung wird - abgelehnt und der Client lädt neu (es arbeitet ohnehin nur eine Person). - -**Fertig, wenn**: Dokument anonym anlegen, Browser schließen, wieder öffnen → -Dokument in der Liste; `.num`-Import/Export funktioniert weiter. - -### Phase 2 – Konto & Magic Link - -- `magic_links`, Login-Ablauf (4.2), Übernahme Gast → Konto (4.4), - Mailer, Konto-Menü, Abmelden. - -**Fertig, wenn**: Gast legt Dokument an, meldet sich an, öffnet auf zweitem -Gerät per Magic Link → Dokument ist da. Mail-Scanner verbrauchen den Link -nicht (Bestätigungsseite). - -### Phase 3 – Teilen (noch ohne Live-Sync) - -- `share_links`, `document_memberships`, Einladungen, Teilen-Dialog, - Rollenprüfung, Lesemodus. -- Gleichzeitiges Bearbeiten: einfache Sperre („Anna bearbeitet gerade – nur - Lesen“), Freigabe nach 2 Minuten Inaktivität. - -**Fertig, wenn**: Link teilen, zweite Person öffnet ohne Konto; Einladung -per Mail kommt an und öffnet das Dokument; Link widerrufen sperrt sofort. - -### Phase 4 – Live-Zusammenarbeit - -- Store auf Yjs (8.1), stabile Zeilen-/Spalten-IDs (8.4), Undo pro Person - (8.2), Auswahl lösen (8.3), `DocumentChannel`, Kompaktierung, Awareness, - Offline. Einmalige Umwandlung aller JSON-Dokumente in Yjs-Stände. -- Sperre aus Phase 3 entfällt. - -**Fertig, wenn**: Zwei Browser bearbeiten gleichzeitig verschiedene und -dieselben Zellen, fügen gleichzeitig Zeilen ein → beide sehen dasselbe -Ergebnis, kein Wert landet in einer falschen Zeile; Undo betrifft nur eigene -Änderungen; 10 Minuten offline arbeiten und wieder verbinden → alles -übernommen. - -### Phase 5 – Feinschliff - -- Papierkorb-Oberfläche, Suche, Konto löschen / Datenexport, Anzeige - „zuletzt geändert von“, Monitoring, Aufräumjobs scharf schalten. - -**Aufwand grob**: Phasen 1–3 sind überwiegend Standard-Rails-Arbeit plus -überschaubare Frontend-Ergänzungen. Phase 4 ist der größte Brocken, weil -fast jede Funktion in `src/store/document.ts` betroffen ist; sie sollte in -kleinen, jeweils getesteten Schritten erfolgen. - ---- - -## 12. Offene Fragen & Risiken - -**Entscheidungen** - -1. **Link-Teilen ohne Konto?** Konzept: Ja (Ablauf E). Alternative: Auch - Link-Teilen erfordert ein Konto, damit jedes geteilte Dokument einen - erreichbaren Besitzer hat. Vorteil: klare Verantwortung, bessere - Missbrauchskontrolle. Nachteil: eine Hürde mehr. -2. **Aufbewahrung von Gast-Dokumenten**: 12 Monate ohne Öffnen, ok? -3. **Server**: bestehender Rails-Server (`rails.wicom.li`) oder eigene - Instanz für numolus? -4. **Mailanbieter**: welcher SMTP-Dienst? -5. **Editoren dürfen teilen?** Konzept: Nein, nur Besitzer. -6. **Ende-zu-Ende-Verschlüsselung** als spätere Option? Würde serverseitige - Kompaktierung, Suche und Export ausschließen. - -**Risiken** - -| Risiko | Auswirkung | Gegenmaßnahme | -| --- | --- | --- | -| `y-rb` / `yrb-actioncable` wenig gepflegt | Sync-Server ohne Updates | vor Phase 4 prüfen; Fallback: eigener Channel mit `y-rb`, oder Node-Sidecar mit Hocuspocus nur für `/cable` | -| Store-Umbau größer als erwartet | Phase 4 verzögert sich | Adapter-Schicht, schrittweise Umstellung, Phasen 1–3 bringen vorher schon Nutzen | -| Formelbezüge bei gleichzeitigem Einfügen | vereinzelt falsche Bezüge | akzeptiert (wie Google Sheets); langfristig ID-basierte Bezüge | -| Makros aus fremder Hand | Code einer anderen Person läuft im eigenen Browser | Ausführungsmodell prüfen, ggf. isolieren oder Bestätigung verlangen | -| Mail landet im Spam | Login unmöglich | SPF/DKIM/DMARC, seriöser Anbieter, Code als Alternative | -| Gast verliert Cookie | Liste leer | deutlicher Hinweis auf Konto; URL bleibt gültig | -| Große Dokumente | langsamer Sync | Größenlimit, Kompaktierung, Messung in Phase 4 |