Add concept for sharing and collaborative editing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,713 @@
|
||||
# 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/<uuid>`.
|
||||
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/<uuid>`).
|
||||
|
||||
**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. Alle Dokumente mit `owner = Gast` → `owner = Konto`.
|
||||
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
|
||||
kind enum: guest | account
|
||||
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/<uuid>#k=<token>` → 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<sheetId>
|
||||
├─ sheets: Y.Map<sheetId, Y.Map>
|
||||
│ └─ { name, objectOrder: Y.Array<objectId>, objects: Y.Map<objectId, Y.Map> }
|
||||
│ └─ Tabelle: {
|
||||
│ kind, name, x, y, view…,
|
||||
│ rowOrder: Y.Array<rowId>, ← stabile IDs statt Indizes
|
||||
│ colOrder: Y.Array<colId>,
|
||||
│ rowHeights: Y.Map<rowId, number>,
|
||||
│ colWidths: Y.Map<colId, number>,
|
||||
│ cells: Y.Map<"colId:rowId", string>,
|
||||
│ formats: Y.Map<"colId:rowId", Y.Map>
|
||||
│ }
|
||||
├─ namedRanges: Y.Map<id, Y.Map>
|
||||
├─ customFunctions: Y.Map<id, Y.Map>
|
||||
└─ macros: Y.Map<id, 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).
|
||||
|
||||
---
|
||||
|
||||
## 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/<uuid>`, 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 |
|
||||
Reference in New Issue
Block a user