managarten/docs/plans/social-relay-module.md
Till JS b249345174 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>
2026-04-20 15:57:57 +02:00

14 KiB
Raw Blame History

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) 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:

// 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, 12 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 (24 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:

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):

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 (12 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 (23 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 24 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 (15/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 14: 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.