714 lines
31 KiB
Markdown
714 lines
31 KiB
Markdown
# 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 |
|