diff --git a/docs/plans/social-relay-module.md b/docs/plans/social-relay-module.md new file mode 100644 index 000000000..761d6ee6f --- /dev/null +++ b/docs/plans/social-relay-module.md @@ -0,0 +1,269 @@ +# Social Relay — Module Plan + +## Status (2026-04-20) + +**Ideenphase, wartet auf Spaces-Foundation.** Noch kein Code. Nach Einführung der Spaces-Primitive (siehe [`spaces-foundation.md`](./spaces-foundation.md)) wird Edisconet ein Space vom Typ `brand` — der frühere `brands/`-Modul-Vorschlag entfällt. OAuth-Token, Voice-Doc und AI-Persona hängen am Space. Dieser Plan wird nach Phase-2 der Foundation überarbeitet und präzisiert; bis dahin nur konzeptuell gültig. + +--- + +## Ziel + +Ausgewählte LinkedIn-Posts von **Howspace** (Quelle) werden auf Deutsch übersetzt, für **Edisconet** (Ziel, Company Page ID 88888092) tonal angepasst und — **nach menschlichem Review** — auf der Edisconet-Company-Page veröffentlicht. Ursprung (Howspace) bleibt im Post sichtbar. + +Kernfrage: *"Welcher HS-Post lohnt sich für Edisconet, wie klingt er auf Deutsch in unserem Ton, und wie kommt er mit korrekter Attribution raus — ohne dass ich jedes Mal 20 Minuten copy-paste-übersetze?"* + +**Nicht im Scope (bewusst):** +- Automatisches Scraping von LinkedIn (ToS-Verstoß, Ban-Risiko für den Company-Account) +- Vollautomatisches Posten ohne Approval (Reputationsrisiko, KI-Halluzination bei Fachbegriffen) +- Multi-Source / Multi-Target (erst HS→Edisconet, später generalisieren) +- Bild-/Video-Assets aus HS-Posts übernehmen (Copyright) — wir erzeugen eigene Visuals oder postieren Text-only + +## Abgrenzung zu bestehenden Modulen + +| Modul | Unterschied | +|-------|-------------| +| `mana-research` | Research extrahiert Fakten aus vielen Quellen, Social Relay transformiert *einen* spezifischen Post für Republishing | +| `mana-ai` (Mission Runner) | Wird als Execution-Layer genutzt (Queue, Retry, Cron), nicht dupliziert | +| `mana-landing-builder` | Erzeugt Landing Pages, nicht Social-Posts | +| `news-research` | RSS-Feed-Discovery für Consumer, nicht B2B-Social-Republishing | + +--- + +## Architektur-Entscheidung: Weg 3 (Eigenbau, manueller Intake) + +Gegen Scraping, gegen Fire-and-forget. Begründung: + +1. **ToS-konform**: Kein Crawling fremder LinkedIn-Seiten. User pastet Post-URL oder Text manuell. +2. **Review-Gate**: Draft landet in der Mana-UI, geht erst nach Approve live. Schützt vor KI-Fehlern und falscher Tonalität. +3. **Wiederverwendung**: `mana-llm` (Übersetzung+Adaption), `mana-ai` (Queue), `mana-auth` (OAuth-Token-Storage) existieren bereits. +4. **Erweiterbar**: Später zweite Quelle (X, Bluesky, Blog-RSS) oder zweite Ziel-Page ohne Umbau. + +### Komponenten-Übersicht + +``` +┌─────────────────────┐ paste URL/text ┌──────────────────────┐ +│ Mana Web Module │ ───────────────────▶ │ apps/api/social-relay│ +│ (social-relay/) │ │ Hono routes │ +└─────────────────────┘ └──────────┬────────────┘ + ▲ │ + │ Draft-Review, Approve ▼ + │ ┌──────────────────────┐ + │ │ mana-llm │ + │ │ (translate + adapt) │ + │ └──────────┬────────────┘ + │ │ + │ ┌──────────▼────────────┐ + │ │ services/ │ + │ │ mana-social-relay │ ◀── cron: daily digest? + │ │ (publish queue) │ + │ └──────────┬────────────┘ + │ │ + │ webhook: published ▼ + │ ┌──────────────────────┐ + └────────────────────────────────────│ LinkedIn UGC API │ + │ (Edisconet page) │ + └──────────────────────┘ +``` + +--- + +## Intake: wie kommen HS-Posts rein? + +Drei Wege, priorisiert: + +1. **Post-URL paste** (primär): User kopiert `https://www.linkedin.com/posts/howspace_...` → Server holt OpenGraph-Meta (Titel/Beschreibung/Hero-Image URL, Post-Text oft im `og:description`) via `apps/api`-Fetch. Ausreichend für die meisten Text-Posts. +2. **Manueller Text-Paste** (Fallback): Wenn OG-Extraktion scheitert oder der Post Mehrwert im Carousel/Video hat, fügt der User den Text selbst ein. Screenshot optional als Referenz. +3. **Watchlist** (später, Phase 2): User trägt Howspace-URL ein, täglicher Cron fragt die OG-Daten der Company-Page an (oder einen HS-eigenen RSS-Feed/Newsletter, falls verfügbar) und zeigt neue Posts als **Vorschlagsliste** — kein Auto-Pull in die Queue. + +Kein `puppeteer`/`playwright`-Scraping gegen linkedin.com in Phase 1. + +--- + +## Processing: `mana-llm` Pipeline + +Ein neuer Prompt-Template in `services/mana-llm/prompts/social-relay.ts`: + +```ts +// Inputs: sourcePostText (EN), brandVoice ("edisconet"), sourceUrl +// Outputs: { germanPost, suggestedHashtags, attributionFooter, rationale } +``` + +Drei Teilschritte innerhalb *eines* LLM-Calls (günstiger als drei Calls): + +1. **Übersetzung** EN → DE mit B2B-Ton +2. **Adaption**: Howspace-spezifische Referenzen (ihre Produktnamen, ihre Cases) werden generalisiert oder auf Edisconet-Kontext gemappt. Claim-Übernahmen (Statistiken, Kundennamen) werden als `[originalzitat]` markiert, nicht umformuliert. +3. **Attribution-Footer erzwingen**, Template: + > `Inspiriert von einem Post von @Howspace: [sourceUrl]` + Harter System-Prompt-Check + Post-Processing-Assertion: wenn der Footer fehlt, Fehler werfen, nicht stillschweigend posten. + +Model: `claude-sonnet-4-6` (Qualität > Geschwindigkeit, 1–2 Posts/Tag). Temperatur niedrig (0.3) für Konsistenz. Prompt-Cache auf den Brand-Voice-Block, damit wiederholte Läufe günstig bleiben. + +--- + +## Publishing: LinkedIn Community Management API + +**Offizielle API, nicht inoffiziell.** Endpoints: + +- `POST /rest/posts` — Text-Post als Organization +- OAuth 2.0, Scope `w_organization_social` +- Access-Token-Lifetime: 60 Tage, Refresh nötig + +**Onboarding (einmalig):** +1. LinkedIn Developer App erstellen, Edisconet-Company-Page als Organization verifizieren +2. App-Review durchlaufen für `w_organization_social` (2–4 Wochen Bearbeitungszeit — **blockt Go-Live, früh starten**) +3. OAuth-Flow in `services/mana-auth` ergänzen: `auth/linkedin-org/callback` → speichert Token verschlüsselt in `mana_platform.linkedin_tokens` (neue pgSchema) +4. Refresh-Cron in `mana-social-relay` (alle 50 Tage) + +**Kein Draft-Endpoint in der API** — LinkedIn bietet keine server-seitigen Drafts für Organizations. Unser Review-Gate läuft deshalb komplett in Mana: `status='approved'` → erst dann schlägt `publish` zu. + +--- + +## Modul-Struktur (Frontend) + +``` +apps/mana/apps/web/src/lib/modules/social-relay/ +├── types.ts # SourcePost, Draft, PublishedPost, Status +├── collections.ts # Dexie: socialRelayDrafts +├── queries.ts # useDrafts, useDraft(id), usePublished +├── stores/ +│ └── drafts.svelte.ts # ingestUrl, regenerate, approve, reject, publish +├── api.ts # Client: /api/v1/social-relay/* +├── components/ +│ ├── IngestForm.svelte # URL-Paste + Manual-Text-Fallback +│ ├── DraftCard.svelte # Listeneintrag (Quelle + DE-Preview + Status) +│ ├── DraftDetail.svelte # Seitenweise Review, Edit-in-place, Regenerate-Button +│ ├── PublishButton.svelte # approve + publish (mit Bestätigung) +│ └── HistoryView.svelte # veröffentlichte Posts + LinkedIn-Permalinks +└── routes/ + ├── +page.svelte # Liste aller Drafts + └── draft/[id]/+page.svelte # Detail + Review +``` + +## Backend-Routes (`apps/api`) + +``` +apps/api/src/modules/social-relay/routes.ts +├── POST /ingest # { sourceUrl | sourceText } → Draft (kein LLM-Call yet) +├── POST /drafts/:id/generate # triggert mana-llm → schreibt germanPost in Draft +├── PATCH /drafts/:id # User-Edits am germanPost +├── POST /drafts/:id/approve # status='approved' +├── POST /drafts/:id/publish # queue in mana-social-relay → LinkedIn +├── GET /drafts # Liste (paginated) +└── GET /published # Liste veröffentlichter Posts + Permalinks +``` + +Auth: nur `role='founder'` (Admin). Kein Public-Tier. + +## Neuer Service `services/mana-social-relay` + +Analog zu `mana-ai` Mission-Runner, aber klein: + +- Port (neu, in `docs/PORT_SCHEMA.md` eintragen) +- Queue (Redis) für `publish`-Jobs +- LinkedIn-API-Client + Token-Refresh-Cron +- Webhook-Dispatcher zurück an `apps/api` bei Erfolg/Fehler +- Retry mit exponential backoff (LinkedIn API kann 429en) + +Alternative: **kein eigener Service**, Publishing direkt in `apps/api` mit `mana-ai`-Mission als Queue. Erspart Container + Port. Entscheidung aufschieben bis M2 — wenn die Publish-Logik unter ~100 Zeilen bleibt, gewinnt Alternative. + +--- + +## Data Model + +**Dexie (`apps/mana/apps/web/src/lib/data/database.ts`) — v27:** + +```ts +socialRelayDrafts: Dexie.Table + +type LocalSocialRelayDraft = { + id: string + sourceUrl: string // encrypted + sourceText: string // encrypted (OG-extracted or manual) + sourceAuthor: string // "Howspace" — plaintext (brand name) + germanPost: string // encrypted (LLM output, user-editable) + suggestedHashtags: string[] // plaintext + attributionFooter: string // encrypted + status: 'draft' | 'generating' | 'ready' | 'approved' | 'publishing' | 'published' | 'failed' | 'rejected' + linkedInPostId: string | null // plaintext, nach publish + linkedInPermalink: string | null + failureReason: string | null // encrypted + createdAt: number + updatedAt: number + publishedAt: number | null +} +``` + +Encryption-Registry-Entry in `apps/mana/apps/web/src/lib/data/crypto/registry.ts`: Felder mit `encrypted` oben. + +**Postgres (`mana_platform.social_relay`):** + +```sql +CREATE SCHEMA social_relay; +CREATE TABLE social_relay.drafts ( ... ); -- sync target +CREATE TABLE social_relay.linkedin_tokens ( -- OAuth-Tokens + organization_id TEXT PRIMARY KEY, + access_token_encrypted BYTEA NOT NULL, + refresh_token_encrypted BYTEA NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); +``` + +Sync via `mana-sync` wie alle anderen Module; `appId='social-relay'`. + +--- + +## Milestones + +### M1 — Skelett + Intake (1–2 Tage) +- Modul registriert in `mana-apps.ts` (`tier: 'founder'`) +- Route `/social-relay`, leere Liste +- Dexie v27 + Encryption-Registry +- `POST /ingest` mit OG-Extraction (ohne LLM) +- Manuelle Text-Paste als Fallback +- Draft-Liste zeigt Quelle, noch kein generiertes Deutsch + +### M2 — LLM-Pipeline (2–3 Tage) +- `services/mana-llm`: `social-relay.ts` Prompt-Template +- `POST /drafts/:id/generate` +- DraftDetail-View mit Side-by-Side EN/DE, Regenerate-Button +- Attribution-Footer-Check (Assertion im Backend) +- Edit-in-place + +### M3 — LinkedIn OAuth + Publish (Dauer hängt von App-Review ab) +- LinkedIn Developer App einrichten +- **App-Review-Antrag stellen → läuft 2–4 Wochen, blockt M3** (parallel zu M1/M2 starten) +- OAuth-Flow in `mana-auth` +- `POST /drafts/:id/publish` → direkter API-Call (noch ohne Queue) +- Erfolg → Permalink in Draft, Status='published' + +### M4 — Queue + Retry (1 Tag) +- Entscheidung: eigener Service vs. `mana-ai`-Mission +- Retry mit backoff +- History-View mit allen Posts + LinkedIn-Permalinks + +### M5 — Watchlist / Vorschläge (optional, Phase 2) +- Howspace-Watchlist-Eintrag (URL der Company-Page) +- Täglicher Cron → OG-Scrape der öffentlichen Übersicht, Diff gegen bekannte Post-URLs +- Neue Posts als **Vorschläge** in der UI (1-Tap Ingest), kein Auto-Pull + +--- + +## Offene Fragen + +- **LinkedIn App-Review**: Wie streng ist `w_organization_social`-Approval für kleine Firmen? Erfordert u.U. eine Produkt-Demo. → Früh klären, ggf. parallel Weg-1-Workaround (Make.com + LinkedIn-Integration von Make, die eigenen OAuth-Scope mitbringt) als Übergangslösung für M3. +- **Bilder**: HS-Posts haben oft ein Hero-Image. Copyright → wir nutzen sie *nicht*. Eigenes Visual? Dann braucht M2 einen `mana-image-gen`-Hook oder manuellen Upload via `uload`. Phase-1-Entscheidung: **Text-only**, Bildoption in M5. +- **Brand Voice**: Braucht ein Edisconet-Voice-Dokument (Tonalität, Claims, verbotene Wörter) als System-Prompt-Kontext. Vor M2 zu liefern. Ablage: `services/mana-llm/prompts/voices/edisconet.md`. +- **Language-Fallback**: Was wenn HS-Post bereits Deutsch ist? → Erkennen (franc oder LLM selbst), dann nur Adaption, keine Übersetzung. +- **Eigener Service vs. inline in `apps/api`**: In M4 entscheiden. +- **Rate-Limiting**: LinkedIn erlaubt ~25 Posts/Tag/Page. Irrelevant für unseren Use-Case (1–5/Woche), aber in Queue einbauen. +- **Compliance-Footer**: Reicht "Inspiriert von" oder brauchen wir expliziteres "Basierend auf [URL]"? Rechtsmeinung bei Content-Ownership einholen, sobald M2 läuft. + +--- + +## Zusammenfassung für "Was zuerst?" + +Zwei Dinge **parallel** starten, weil LinkedIn App-Review lange dauert: + +1. **Tag 1**: LinkedIn Developer App anlegen, Edisconet-Page verifizieren, App-Review beantragen. +2. **Tag 1–4**: M1 + M2 bauen (Intake + LLM-Pipeline). Ergebnis ist bereits nutzbar: Draft → Copy-to-Clipboard → manuell auf LinkedIn posten. Spart schon ~80% der Zeit gegenüber Vollmanuell. +3. **Sobald Review durch**: M3 (Auto-Publish) aktivieren. diff --git a/docs/plans/spaces-foundation.md b/docs/plans/spaces-foundation.md new file mode 100644 index 000000000..61110aee6 --- /dev/null +++ b/docs/plans/spaces-foundation.md @@ -0,0 +1,340 @@ +# Spaces — Foundation Plan + +## Status (2026-04-20) + +**RFC / Architektur-Entscheidung, Code noch nicht begonnen.** Dieses Dokument fixiert den Umbau des Scope-Modells *bevor* Prod-Daten existieren. Voraussetzung für: Social Relay, `clubs/`-Paket der ClubDesk-Roadmap, Team-/Brand-/Familien-Features generell. + +**Begriff gewählt:** `Space`. Nicht `Workspace` (Slack/Notion-Assoziation zu stark), nicht `Context` (kollidiert mit `kontext/`-Modul), nicht `Org` (impliziert juristische Entität, passt nicht für Familie/Personal). + +--- + +## Ziel + +Den User-zentrierten Daten-Scope ("jeder Record gehört implizit dem eingeloggten User") durch einen **Space-zentrierten** Scope ersetzen: jeder Record gehört einem Space, der User ist Mitglied eines oder mehrerer Spaces. + +**Kernfrage:** *"In welchem Kontext arbeite ich gerade, und wer sieht/darf was?"* — einheitlich beantwortet für Solo-User, Familie, Verein, Brand, Team. + +Damit fällt die Polymorphie `User vs. Org` weg, die wir in der Scope-Debatte als Hauptkomplexitätstreiber identifiziert haben. Industriestandard (Notion, Linear, Slack, Figma) aus gutem Grund. + +--- + +## Leitprinzipien + +1. **Eine Primitive: `Space`.** Nie User vs. Org. User hat von Anfang an mindestens einen Space (`personal`), kann weitere haben. +2. **Jeder Record hat `spaceId`. Keine Ausnahmen.** Auch Mood-Einträge, Schlafdaten, Träume leben im Personal-Space. +3. **Ohne aktiven Space keine Query.** Der Scope-Wrapper erzwingt das strukturell. +4. **Better Auth Organizations ist die Identity-Layer.** Wir bauen auf dem Plugin, nicht daneben. +5. **Privat-innerhalb-Space via `visibility`-Flag**, nicht über Scope-Polymorphie. +6. **Space-Typ bestimmt Modul-Sichtbarkeit.** Ein Brand-Space hat kein `meditate`-Modul, ein Personal-Space kein `club-finance`. + +--- + +## Die Primitive: Space + +Ein `Space` ist eine Better-Auth-Organization mit `metadata.type`. + +```ts +type SpaceType = + | 'personal' // single-member, vom User selbst, existiert automatisch + | 'brand' // externe Kommunikations-Identität (Edisconet, Howspace) + | 'club' // Verein (ClubDesk-Ziel) + | 'family' // WG / Familie + | 'team' // Arbeitsteam / Projekt + | 'practice' // Freelancer-Praxis / Selbstständigkeit + +type Space = { + id: string + slug: string // @edisconet, @mein-verein, @me + name: string + logo: string | null + type: SpaceType + createdAt: number + metadata: { + // Typ-spezifische Zusatzfelder — schwach typisiert, modul-spezifisch + voiceDoc?: string // brand/club + legalEntity?: string // brand/club/practice + uid?: string // brand/club/practice: MwSt / UID + aiPersonaId?: string // optional: 1:1 zu ai-agents-Eintrag + // ... + } +} +``` + +### Space-Typ → Modul-Allowlist + +Jeder Space-Typ schaltet einen Subset der 120+ Module frei. Nicht konfigurierbar pro Space in Phase 1 — erst wenn Bedarf auftaucht. + +```ts +// packages/shared-branding/src/space-modules.ts +const SPACE_MODULES: Record = { + personal: [ /* alle 120+ */ ], + brand: ['social-relay', 'mail', 'landing', 'contacts', 'calendar', 'files', 'tasks', 'ai-agents', 'profile'], + club: ['club-members', 'club-finance', 'calendar', 'events', 'mail', 'landing', 'files', 'tasks', 'profile'], + family: ['calendar', 'events', 'shopping', 'recipes', 'files', 'tasks', 'finance-shared', 'profile'], + team: ['tasks', 'calendar', 'files', 'mail', 'chat', 'ai-agents', 'profile'], + practice: ['invoicing', 'contacts', 'calendar', 'mail', 'finance', 'files', 'tasks', 'profile'], +} +``` + +--- + +## Data Model + +### Jede Tabelle, jeder Record + +```ts +// alle Dexie-Tabellen + alle Postgres-Tabellen +spaceId: string // → organizations.id, NOT NULL, indexed +visibility: 'space' | 'private' // default: 'space' +authorId: string // NOT NULL, wer den Record erstellt hat +``` + +- **`spaceId`**: Scope-Partition. Query startet hier. +- **`visibility: 'private'`**: Record sichtbar nur für `authorId`, auch in Multi-Member-Spaces. Ersetzt den User-vs-Org-Gedanken für den Edge-Fall "persönlicher Draft in geteiltem Space". +- **`authorId`**: unabhängig vom Space-Owner — wichtig für Multi-Member-Audit ("wer hat den Eintrag angelegt"). + +### Better-Auth-Erweiterung + +Better-Auth-Plugin-Hooks: +- **Signup-Hook**: legt `personal`-Space mit Slug `@{user.username || user.id}` an, setzt als `activeOrganizationId`. +- **Organization.create-Hook**: verlangt `type` in `metadata`, validiert gegen enum. +- **Organization.delete-Hook**: verweigert Löschung, wenn `type='personal'`. + +**JWT bleibt minimal.** Laut Architektur-Entscheid 2024-12 (siehe `services/mana-auth/src/auth/better-auth.config.ts:59-74`) stehen Org-Claims bewusst NICHT im JWT-Payload. Der aktive Space wird über die Session (Cookie) + `GET /organization/get-active-member` aufgelöst. Der Scope-Wrapper (siehe unten) liest daher aus einem **client-seitigen `$lib/stores/active-space.svelte.ts`**, der beim Boot einmal den Endpoint anfragt und bei jedem `set-active`-Call refresht. Keine JWT-Erweiterung nötig. + +### Postgres-Layout + +Kein eigenes `spaces`-Schema — wir nutzen Better-Auth's `organization`-Tabelle direkt. Für unsere Ergänzungen: + +```sql +-- im mana_platform-DB, eigenes Schema +CREATE SCHEMA spaces; + +-- Space-Credentials (OAuth-Tokens pro Space, z.B. LinkedIn für Edisconet) +CREATE TABLE spaces.credentials ( + space_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + provider TEXT NOT NULL, -- 'linkedin', 'stripe', 'twilio', ... + access_token_encrypted BYTEA NOT NULL, + refresh_token_encrypted BYTEA, + expires_at TIMESTAMPTZ, + scopes TEXT[], + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (space_id, provider) +); + +-- Per-Modul-Permissions im Space (feingranular über Better-Auth-Rollen hinaus) +CREATE TABLE spaces.module_permissions ( + space_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + role TEXT NOT NULL, -- 'owner' | 'admin' | 'member' | custom + module_id TEXT NOT NULL, -- 'club-finance', 'social-relay', ... + can_read BOOLEAN NOT NULL DEFAULT TRUE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + can_admin BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (space_id, role, module_id) +); +``` + +### mana_sync-DB + +Wird um `space_id`-Spalte erweitert. RLS-Policy: + +```sql +USING ( + space_id IN ( + SELECT organization_id FROM member WHERE user_id = current_user_id() + ) +) +``` + +Der bestehende `appId`-Mechanismus bleibt — die Partition wird von `(user, appId)` zu `(space, appId)`. + +--- + +## Scope-Wrapper (Frontend) + +``` +apps/mana/apps/web/src/lib/data/scope/ +├── active-space.svelte.ts # reactive: { id, slug, type, role, permissions } +├── scoped-db.ts # Proxy um db, alle Queries bekommen spaceId-Filter +├── visibility.ts # applyVisibility(records, currentUserId) +└── migrations/ + └── v27-add-space-scope.ts # Backfill alle existierenden Records → personal-Space +``` + +```ts +// Verwendung in Modulen +import { scoped } from '$lib/data/scope/scoped-db' + +// statt: db.contacts.where({ ... }).toArray() +await scoped.contacts.where({ ... }).toArray() + +// Escape-Hatch für Cross-Space (Home-Dashboard, globale Suche) +await scoped.contacts.acrossSpaces().where({ ... }).toArray() +``` + +Der Proxy wendet auto `where({ spaceId: activeSpace.id })` und filtert `visibility='private' && authorId !== me` heraus. + +### Modul-Zugriff vor dem Wrapper + +Zusätzlich zur `spaceId`-Filterung prüft der Wrapper gegen die `SPACE_MODULES`-Allowlist: + +```ts +// scoped.club-finance in einem Personal-Space → wirft ModuleNotInSpaceError +// scoped.meditate in einem Brand-Space → dito +``` + +Das verhindert, dass ein Modul, das UI-seitig eigentlich ausgeblendet ist, über direkte Store-Calls trotzdem Daten schreibt. + +--- + +## URL-Topologie + +Slack/Linear/Notion-Muster: + +``` +mana.how/@{slug}/{module}/... +mana.how/@me/mood/today ← Personal-Space +mana.how/@edisconet/social-relay ← Brand-Space +mana.how/@turnverein/club-finance ← Club-Space +``` + +- SvelteKit: `+layout.ts` auf `/(app)/@[slug]/+layout.ts` resolved Space über Better-Auth-Slug-API +- Middleware: Non-Member → 404 (nicht 403, um Space-Existenz nicht zu leaken) +- Altes `activeOrganizationId` in Session wird gesetzt, damit Requests gegen `apps/api` den Scope kennen +- Space-Switcher-UI: klickt auf Space → `goto('/@${newSlug}/home')` statt Session-Manipulation + +**Migration bestehender Routes**: alle Routes von `/(app)/{module}` auf `/(app)/@[slug]/{module}` verschieben. `@me` als Auto-Redirect für die alten URLs. + +--- + +## Encryption + +Drei-Stufen-Modell ersetzt die heutige User-Key-Logik: + +1. **Personal-Space**: User-Key wie heute (master-key aus `mana-auth`, `MANA_AUTH_KEK`-gewrappt). Zero-Knowledge bleibt möglich. +2. **Shared-Space** (>1 Member): Space-Key, beim Space-Create erzeugt, KEK-gewrappt an jedes Mitglied über dessen Pubkey. Re-Wrap bei Member-Add. Member-Remove = Key-Rotation-Event (neue Keys für neue Records; alte bleiben lesbar — "eventual re-encryption" als Background-Job). +3. **Private Records innerhalb eines Shared-Space** (`visibility='private'`): zusätzlich mit User-Key re-encrypted, nur Autor kann lesen. + +Die Encryption-Registry (`apps/mana/apps/web/src/lib/data/crypto/registry.ts`) muss für jede Tabelle wissen, welche Felder verschlüsselt werden — das bleibt wie heute. Was sich ändert: `encryptRecord()` wählt den Key aufgrund von `(spaceId, visibility)` statt konstant den User-Key. + +**Phase-1-Kompromiss**: Shared-Space-Daten initial **unverschlüsselt** lassen (nur im Server-RLS-gesichert). Zero-Knowledge für Shared-Spaces kommt als eigenes RFC (`docs/plans/shared-space-encryption.md`), weil Key-Rotation und Pubkey-Infrastruktur für User nicht-trivial sind. Personal-Space behält E2E-Encryption wie heute. + +--- + +## Tier-Logik verschieben + +Heute: `tier` hängt am User (JWT-Claim). Das führt zum aktuellen `MANA_APPS tier patch`-Workaround. + +Neu: **Tier hängt am Space.** + +```ts +// space.metadata.tier: 'guest' | 'public' | 'beta' | 'alpha' | 'founder' +// Modul-Gate: requiredTier wird gegen activeSpace.tier geprüft +``` + +- Personal-Space eines Founder-Users → `tier='founder'`, alle Module +- Ein Brand-Space, den derselbe User anlegt → default `tier='beta'`, je nach Plan +- Admin-API `PUT /api/v1/admin/spaces/:id/tier` ersetzt das alte User-Tier-API + +Das löst den bestehenden Tier-Patch-Workaround gleich mit auf und passt zum Billing-Modell der ClubDesk-Roadmap (Vereine zahlen pro Space, nicht User). + +--- + +## Sync-Engine-Änderung + +`mana-sync` (Go, 3050) bekommt: + +1. **`space_id`-Column** in allen sync-relevanten Tabellen — NOT NULL, indexed. +2. **Partition-Key** wird `(space_id, app_id)` statt `(user_id, app_id)`. +3. **Subscription-Fan-Out**: bei Space-Member-Count > 1 muss ein Change an alle Member gepusht werden (WS/SSE). Heute ist das 1:1 (User pushed to self). +4. **Membership-Resolver**: der Server muss bei jeder Client-Connection wissen, welche Space-IDs der User sehen darf — aus Better-Auth's `member`-Tabelle. + +Keine großen Algorithmus-Änderungen am Field-Level-LWW — der Scope partitioniert nur den Keyspace, die Konflikt-Resolution bleibt identisch. + +--- + +## Migration (alles vor dem ersten Prod-Launch) + +### Phase 1 — Schema + Better Auth (3–4 Tage) +1. `metadata.type` zu Better-Auth-Organization hinzufügen, Enum-Validierung im Signup-Hook +2. `spaces.credentials` + `spaces.module_permissions` Tabellen anlegen +3. Signup-Hook schreibt Personal-Space mit Slug `@{uniqueUsername}` +4. Dexie v28 (v27 ist vergeben): `spaceId`, `visibility`, `authorId` auf alle Tabellen +5. Migration-Script: alle bestehenden Records bekommen `spaceId=personalSpaceId(authorId)`, `visibility='space'`, `authorId=recordUserId` +6. `SPACE_MODULES` Allowlist in `packages/shared-branding` + +### Phase 2 — Scope-Wrapper + eines Pilot-Modul (3–4 Tage) +1. `active-space.svelte.ts`, `scoped-db.ts`, `visibility.ts` +2. `calendar/` als Pilot umstellen: alle Queries auf `scoped.events...` +3. Space-Switcher-Komponente in der Shell +4. `/@[slug]/` Routing-Shell + +### Phase 3 — URL-Refactor + Module-Rolling-Migration (1–2 Wochen) +1. Alle `(app)/*`-Routen nach `(app)/@[slug]/*` verschieben +2. Redirects `/(old-route)` → `/@me/(old-route)` als Übergang (1 Release, dann entfernen) +3. Modul-für-Modul auf `scoped`-Wrapper umstellen — kann parallelisiert werden +4. Tier-Logik von User auf Space verschieben; `MANA_APPS tier patch` auflösen + +### Phase 4 — mana-sync (2–3 Tage) +1. `space_id`-Column + RLS-Policy +2. Partition-Key auf `(space_id, app_id)` +3. Subscription-Fan-Out testen mit 2-Member-Spaces +4. camt-/TWINT-Integration des ClubDesk-Pakets B baut dann direkt auf Space-Scope auf + +### Phase 5 — Shared-Space-Encryption (eigenes RFC, späterer Zeitpunkt) +Siehe Encryption-Abschnitt. Nicht blockierend für Launch, kommt bei Bedarf. + +--- + +## Konsequenzen / neue Anforderungen + +### Space-Create-Flow +UI-Flow "Neuen Space anlegen" muss Typ-Auswahl anbieten mit Kurzbeschreibung. Templates (z.B. "Verein" → lädt `club`-Grundstruktur) wären wünschenswert, aber Phase 2. + +### Cross-Space-Dashboards +- **Home** (`/@me/home`): aggregiert "heute anstehend" aus allen Spaces, die der User sieht. Nutzt `scoped.X.acrossSpaces()`. +- **Globale Suche**: Default = aktiver Space. Toggle "alle Spaces" sichtbar. +- **Notification-Feed**: immer Space-tagged (`[Edisconet] ...`, `[Turnverein] ...`). + +### Space-Discovery / Invite-Flow +Better Auth liefert den Invite-Token-Flow. UI: Share-Link mit Rolle, E-Mail-Invite mit Rolle, Join-Page mit Space-Preview vor Accept. + +### Profile-Modul wird bi-skopig +Das bestehende `profile/`-Modul bekommt zwei Modi: "User-Profil" (persönliche Daten, Timezone, Sprache) und "Space-Profil" (Logo, Voice-Doc, legal entity, Tools). Beide leben im aktiven Space — Space vom Typ `personal` zeigt User-Profil-Variante, andere Typen zeigen Space-Profil-Variante. + +### Module, die fachlich nicht teilbar sind +Via `SPACE_MODULES` in Nicht-Personal-Spaces gar nicht erst erreichbar. Kein Code im Modul selber nötig. + +--- + +## Was NICHT in diesem RFC entschieden wird + +- **Konkrete ClubDesk-Module** (`clubs/`, `club-finance`): folgen einem eigenen Plan, bauen aber auf Spaces auf +- **Social-Relay-Implementierung**: der bestehende `social-relay-module.md` wird nach diesem Foundation-Plan überarbeitet (Brand wird zum Space, nicht eigenes `brands/`-Modul) +- **Shared-Space-Encryption**: eigenes RFC (Phase 5) +- **Billing-Integration** (Stripe pro Space): späterer Plan, braucht Spaces als Voraussetzung +- **Multi-Space-Parallel-Views** (Slack-Stil mit mehreren Spaces gleichzeitig sichtbar): Phase-2-Idee, nicht Launch-kritisch + +--- + +## Offene Fragen + +- **Slug-Uniqueness-Kollision**: User Till mit `@till` kollidiert mit potentiellem Brand `@till`. Lösungsraum: Slugs global unique (einfach, aber Race um beliebte Namen) vs. Slug-Präfixe (`@user/till` vs. `@org/till` — hässlich). Vorschlag: global unique, First-Come-First-Served, User-Slug bei Signup aus E-Mail-Local-Part + Suffix bei Kollision. +- **Default-Visibility neuer Felder**: `visibility='space'` standard. Aber z.B. Mood-Einträge in einem Shared-Family-Space wären `visibility='private'` per Modul-Config sinnvoll. Brauchen wir ein `defaultVisibility` pro Modul-Tabelle? +- **Space-Löschung**: was passiert mit veröffentlichten Inhalten (z.B. LinkedIn-Posts via Social Relay)? Soft-Delete mit Re-Activation-Period? Hard-Delete mit Warnungen? Orga-Entscheidung, nicht Architektur. +- **Anonymes Space-Sharing** (z.B. Event-Anmelde-Link ohne Account — existiert bereits in `mana-events`): bleibt wie heute, ist public-by-token und orthogonal zum Space-Scope. +- **Werden User-Tables in Better-Auth selbst ge-space't?** Nein: User, Session, Account sind identity-global. Nur unsere Domain-Daten werden ge-space't. +- **Slug-Änderungen**: darf ein Space seinen Slug ändern? Ja, mit 301-Redirect über alte Slugs (braucht `space_slugs_history`-Tabelle). Phase 2. +- **Welches Modul bekommt `social-relay` zum ersten Einsatz?** Logisch: nachdem Spaces stehen, Social Relay als *erster* neuer Modul-Code nach der Foundation — das stress-testet das ganze Modell mit einer echten Brand-Space-Anwendung. + +--- + +## Zusammenfassung + +Space-first macht 120+ Module strukturell gleich, eliminiert die User-vs-Org-Polymorphie, nutzt Better Auth maximal, und bildet die Grundlage für alles, was mit Team-/Brand-/Verein-Features kommt. Der Aufwand ist ein einmaliger Schema-Umbau jetzt (geschätzt 3–4 Wochen bei konzentrierter Arbeit), bevor Prod-Daten existieren. Das Alternative (später nachrüsten) wäre ein mehrmonatiges Migrationsprojekt mit Downtime. + +**Reihenfolge kritischer Abhängigkeiten:** +1. Phase 1 (Schema) — blockiert alles weitere +2. Phase 2 (Wrapper + Pilot) — validiert das Modell an einem echten Modul +3. Phase 3 + 4 (Rolling-Migration) — parallelisierbar +4. Social Relay, ClubDesk-Pakete, Billing, Team-Features — alle danach diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 97a884c5b..1d6a8e5ec 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -69,3 +69,14 @@ export { // Types export type { AppId, AppBranding, LogoProps, AppLogoWithNameProps } from './types'; + +// Spaces (multi-tenancy primitive — see docs/plans/spaces-foundation.md) +export { + SPACE_TYPES, + SPACE_TYPE_LABELS, + SPACE_TYPE_DESCRIPTIONS, + SPACE_MODULE_ALLOWLIST, + isModuleAllowedInSpace, + type SpaceType, + type SpaceModuleId, +} from './spaces'; diff --git a/packages/shared-branding/src/spaces.ts b/packages/shared-branding/src/spaces.ts new file mode 100644 index 000000000..bd3dacc5a --- /dev/null +++ b/packages/shared-branding/src/spaces.ts @@ -0,0 +1,232 @@ +/** + * Space Types & Module Allowlist + * + * A "Space" is the unit of data ownership in Mana. Every record belongs to + * exactly one Space. Users join Spaces via Better Auth's `member` relation. + * + * Space = Better Auth Organization with a typed `metadata.type` field. The + * type drives which modules are available inside the space (see + * `SPACE_MODULE_ALLOWLIST` below). + * + * See docs/plans/spaces-foundation.md for the full RFC. + */ + +/** + * The six canonical Space types. Every Better Auth organization must have + * exactly one of these as `metadata.type`. + * + * - `personal` — single-member, auto-created on signup. Holds private data + * like mood, sleep, dreams that don't belong in a shared context. + * - `brand` — external communication identity (e.g. Edisconet, a creator + * persona). Hosts social-relay, mail, landing, public content. + * - `club` — association/Verein. Member management, dues, events, + * governance. Target for the ClubDesk-replacement roadmap. + * - `family` — household/family/WG. Shared calendar, shopping, recipes. + * - `team` — work team / project. Tasks, chat, docs. + * - `practice` — freelancer/solo-business. Invoicing, clients, time tracking. + */ +export type SpaceType = 'personal' | 'brand' | 'club' | 'family' | 'team' | 'practice'; + +export const SPACE_TYPES: readonly SpaceType[] = [ + 'personal', + 'brand', + 'club', + 'family', + 'team', + 'practice', +] as const; + +export const SPACE_TYPE_LABELS = { + de: { + personal: 'Persönlich', + brand: 'Marke', + club: 'Verein', + family: 'Familie', + team: 'Team', + practice: 'Praxis', + }, + en: { + personal: 'Personal', + brand: 'Brand', + club: 'Club', + family: 'Family', + team: 'Team', + practice: 'Practice', + }, +} as const; + +export const SPACE_TYPE_DESCRIPTIONS = { + de: { + personal: 'Dein eigener Bereich — wird beim Signup automatisch angelegt.', + brand: 'Externe Kommunikations-Identität (z.B. eine Marke, ein öffentlicher Account).', + club: 'Vereinsverwaltung mit Mitgliedern, Beiträgen und Events.', + family: 'Geteilter Bereich für Haushalt, Familie oder WG.', + team: 'Arbeitsteam oder Projekt mit mehreren Mitwirkenden.', + practice: 'Freelancer- oder Solo-Business mit Kunden und Rechnungen.', + }, + en: { + personal: 'Your own space — created automatically at signup.', + brand: 'External communication identity (e.g. a brand, a public account).', + club: 'Club management with members, dues, and events.', + family: 'Shared space for household, family, or flatshare.', + team: 'Work team or project with multiple collaborators.', + practice: 'Freelancer or solo business with clients and invoices.', + }, +} as const; + +/** + * Module IDs referenced by the allowlist. Strings (not a strict enum) because + * the allowlist intentionally includes modules that don't exist yet — e.g. + * `club-finance`, `social-relay` — so features can be gated before the code + * lands. + */ +export type SpaceModuleId = string; + +/** + * Which modules are available inside each Space type. + * + * The personal space gets everything (sentinel `'*'`). Other types get a + * curated subset — modules dealing with intimate personal data (mood, + * dreams, period, body measurements, …) are intentionally excluded from + * shared spaces. + * + * Rule of thumb: if a module's data would feel wrong shared with co-workers + * or club members, keep it out. + */ +export const SPACE_MODULE_ALLOWLIST: Record = { + personal: '*', + + brand: [ + 'mana', + 'social-relay', // future — not yet built + 'mail', + 'contacts', + 'calendar', + 'storage', + 'uload', + 'landing', // future + 'presi', + 'cards', + 'picture', + 'quotes', + 'news', + 'news-research', + 'research-lab', + 'ai-agents', + 'companion', + 'times', + 'notes', + 'photos', + 'invoices', + 'activity', + 'goals', + ], + + club: [ + 'mana', + 'contacts', + 'calendar', + 'events', + 'mail', + 'storage', + 'uload', + 'news', + 'research-lab', + 'club-members', // future — ClubDesk Paket A + 'club-finance', // future — ClubDesk Paket B + 'invoices', + 'finance', + 'landing', // future — Paket C (Vereinswebsite) + 'presi', + 'cards', + 'quotes', + 'companion', + 'times', + 'notes', + 'photos', + 'activity', + 'goals', + ], + + family: [ + 'mana', + 'contacts', + 'calendar', + 'events', + 'mail', + 'storage', + 'uload', + 'recipes', + 'food', + 'places', + 'presi', + 'cards', + 'photos', + 'notes', + 'companion', + 'goals', + 'activity', + 'wetter', + 'wisekeep', + 'firsts', + ], + + team: [ + 'mana', + 'contacts', + 'calendar', + 'events', + 'storage', + 'mail', + 'uload', + 'news', + 'news-research', + 'research-lab', + 'presi', + 'cards', + 'picture', + 'notes', + 'quotes', + 'invoices', + 'companion', + 'ai-agents', + 'times', + 'activity', + 'goals', + ], + + practice: [ + 'mana', + 'contacts', + 'calendar', + 'storage', + 'mail', + 'uload', + 'invoices', + 'finance', + 'times', + 'notes', + 'presi', + 'cards', + 'quotes', + 'companion', + 'research-lab', + 'activity', + 'goals', + ], +} as const; + +/** + * Check whether a module is available inside a given Space type. + * + * Used by: + * - Scope wrapper (apps/mana/.../data/scope/scoped-db.ts) to block queries + * against disallowed modules — structural guard against UI bypass. + * - UI module launcher to hide disabled modules in the active space. + * - Route guards that check before mounting a module page. + */ +export function isModuleAllowedInSpace(moduleId: SpaceModuleId, spaceType: SpaceType): boolean { + const allow = SPACE_MODULE_ALLOWLIST[spaceType]; + if (allow === '*') return true; + return allow.includes(moduleId); +}