mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(spaces): add space types + module allowlist as multi-tenancy foundation
Introduces SpaceType ('personal' | 'brand' | 'club' | 'family' | 'team' |
'practice') and SPACE_MODULE_ALLOWLIST as the shared-branding primitives
for the Spaces refactor that replaces the user-vs-org polymorphy with a
single tenancy primitive (Notion/Linear pattern).
Pure additive — no runtime behaviour change yet. Better Auth config,
Dexie migration, scope wrapper and rolling module migration follow in
separate commits.
Plan: docs/plans/spaces-foundation.md
Social-relay plan now defers brand storage to the Spaces primitive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2dc298a796
commit
b249345174
4 changed files with 852 additions and 0 deletions
269
docs/plans/social-relay-module.md
Normal file
269
docs/plans/social-relay-module.md
Normal file
|
|
@ -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<LocalSocialRelayDraft, string>
|
||||
|
||||
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.
|
||||
340
docs/plans/spaces-foundation.md
Normal file
340
docs/plans/spaces-foundation.md
Normal file
|
|
@ -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<SpaceType, readonly ModuleId[]> = {
|
||||
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
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
232
packages/shared-branding/src/spaces.ts
Normal file
232
packages/shared-branding/src/spaces.ts
Normal file
|
|
@ -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<SpaceType, readonly SpaceModuleId[] | '*'> = {
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue