Übersicht

18. LinkedIn 🚧

Implementiert, aber ohne LinkedIn-Freigabe noch nicht produktiv nutzbar. Verbinden, Profil-/Organisationsanzeige, Content-Erstellung (echt + Mock-KI), EntwĂŒrfe, Planung und Kalender sind gebaut, getestet und lauffĂ€hig. Was fehlt, sind zwei getrennte Freigaben von LinkedIn: eine App-Konfiguration (LINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET/ LINKEDIN_STATE_SECRET als Umgebungsvariablen) fĂŒr die Verbindung selbst, und zusĂ€tzlich eine manuell von LinkedIn geprĂŒfte Bewerbung um das Produkt „Community Management API" fĂŒr das tatsĂ€chliche Veröffentlichen. Solange Letztere fehlt, zeigt die OberflĂ€che ehrlich „Berechtigung fehlt" statt eine Veröffentlichung vorzutĂ€uschen.

Anders als bei Instagram sind bei LinkedIn IdentitĂ€t (Verbinden, Profil anzeigen) und Veröffentlichen zwei komplett getrennte Freigabestufen — die erste ist self-serve, die zweite erfordert eine manuelle LinkedIn-PrĂŒfung.

Was existiert

BereichRouteFunktion
Konto verbinden/linkedin/einstellungenDirekter LinkedIn-OAuth-Login, Status/Berechtigungs-/Organisationsanzeige, erneut autorisieren, Trennen
Content erstellen/linkedinÜbersicht aller EntwĂŒrfe (Filter/Suche/Sortierung), „Neuer Beitrag"
Entwurf bearbeiten/linkedin/entwurf/:idZielkonto (Profil/Organisation), Bild-Upload, Text/Hashtags/CTA, KI-Erstellung aus Stichpunkten (echt + Mock-Vorschau), Überarbeiten (kĂŒrzen/erweitern/professioneller/Hook), Live-Vorschau, Speichern/Planen/Abbrechen/Direkt veröffentlichen
Content Kalender/linkedin/kalenderMonatskalender + Liste geplanter BeitrÀge
Veröffentlichte BeitrÀge/linkedin/veroeffentlichtListe bereits veröffentlichter BeitrÀge

Backend: src/lib/linkedin-*.server.ts/*.functions.ts, OAuth-Callback unter /api/public/oauth/linkedin/callback, Scheduler-Endpunkt unter /api/public/linkedin/scheduled-publish/run, Datenmodell in den Migrationen 20260924210000_..._linkedinkern001.sql (Tabellen) und 20260925090000_..._linkedinschedulercron001.sql (pg_cron-Job fĂŒr geplante BeitrĂ€ge).

Berechtigungen

Zwei getrennte LinkedIn-Produkte:

BerechtigungProduktWofĂŒr
openid, profile, emailSign In with LinkedIn (OpenID Connect) — self-serveKonto verbinden, Name/Profilbild/E-Mail anzeigen
w_member_socialCommunity Management API — manuelle Freigabe nötigBeitrĂ€ge im Namen des persönlichen Profils veröffentlichen
w_organization_socialCommunity Management API — manuelle Freigabe nötigBeitrĂ€ge im Namen einer Unternehmensseite veröffentlichen (nur fĂŒr Mitglieder mit Rolle ADMINISTRATOR/CONTENT_ADMIN/DIRECT_SPONSORED_CONTENT_POSTER auf dieser Seite)
r_organization_socialCommunity Management API — manuelle Freigabe nötigOrganisations-BeitrĂ€ge lesen (Vorbereitung fĂŒr spĂ€tere Erweiterungen)
r_organization_adminseparates Organisations-Zugriffsprodukt — noch nicht live verifiziertListe der Unternehmensseiten abrufen, auf die das Konto Zugriff hat (organizationAcls) — best effort, blockiert nie die Grundverbindung, siehe Code-Kommentar in linkedin-scopes.ts

OAuth-Flow

Klassischer serverseitiger Redirect-Flow — strukturell identisch zur bestehenden Google-/Instagram-Anbindung:

  1. „Mit LinkedIn verbinden" ruft startLinkedinConnect auf (signierte, 10 Minuten gĂŒltige state-URL) und leitet direkt auf https://www.linkedin.com/oauth/v2/authorization weiter.
  2. LinkedIn leitet nach Zustimmung auf /api/public/oauth/linkedin/callback?code=
 zurĂŒck.
  3. Server tauscht den Code gegen einen Access-Token (POST https://www.linkedin.com/oauth/v2/accessToken) — die tatsĂ€chlich gewĂ€hrten Scopes kommen im scope-Feld der Antwort mit, unabhĂ€ngig von den angefragten.
  4. Server liest IdentitĂ€t ĂŒber den OIDC-Userinfo-Endpunkt (GET https://api.linkedin.com/v2/userinfo).
  5. Server listet (best effort) Unternehmensseiten ĂŒber organizationAcls.
  6. Token wird in linkedin_accounts gespeichert. Fehlt w_member_social/ w_organization_social, wird der Status permission_missing gesetzt — die Verbindung selbst gilt trotzdem als hergestellt, nur Veröffentlichen ist blockiert.
  7. Redirect-URI wird immer aus PRONTO_APP_URL abgeleitet, nie aus dem eingehenden Host-Header (Lektion aus der Instagram-Anbindung: verhindert „Invalid redirect_uri" bei www/non-www-Abweichungen).

Veröffentlichungs-Flow (Posts API)

veroeffentlicheLinkedinContent() (linkedin-publish.server.ts):

  1. Optionales Bild: POST /rest/images?action=initializeUpload → uploadUrl + urn:li:image:
 → Bilddaten per PUT auf die uploadUrl → Status wird gepollt, bis AVAILABLE (max. ~10 Sekunden, sonst Fehler statt endlosem Warten).
  2. POST https://api.linkedin.com/rest/posts mit author (Person- oder Organisations-URN je nach gewĂ€hltem Zielkonto), commentary (Text + Hashtags + CTA — LinkedIn hat kein separates Hashtag-/CTA-Feld beim Erstellen, deshalb Teil des sichtbaren Texts, siehe baueCommentary()), visibility: PUBLIC, lifecycleState: PUBLISHED.
  3. Erfolg = HTTP 201 und Post-ID im Header x-restli-id — nur dann wird der Entwurf auf veroeffentlicht gesetzt und eine AktivitĂ€t im bestehenden aktivitaeten-Verlauf protokolliert (kein kĂŒnstlicher Lead, lead_id bleibt NULL).
  4. Jeder andere Ausgang (fehlende Verbindung/Berechtigung/API-Fehler) Ă€ndert den Draft-Status nie fĂ€lschlich auf „veröffentlicht" — ein blockierter Versuch (keine Verbindung/Berechtigung) setzt den Status auch nicht auf „fehlgeschlagen" zurĂŒck, sondern auf den Stand vor dem Versuch (kein echter API-Versuch im eigentlichen Sinn).

Statusmodell: entwurf → geplant → wird_veroeffentlicht (wĂ€hrend des Versuchs, sowohl beim manuellen Button als auch beim Scheduler) → veroeffentlicht/fehlgeschlagen. veroeffentlicht ist ein Endzustand — serverseitig (darfStatusWechseln()) kann ein veröffentlichter Beitrag nie mehr zu einem Entwurf zurĂŒckgestuft werden. abgebrochen ist ein reiner Nutzer-Aktions-Status (manuelles Abbrechen eines geplanten Beitrags), wird nie von einem API-Ergebnis gesetzt.

Scheduler fĂŒr geplante BeitrĂ€ge

verarbeiteLinkedinGeplanteBeitraege() (linkedin-scheduler.server.ts) wird alle 5 Minuten per pg_cron ĂŒber /api/public/linkedin/scheduled-publish/run aufgerufen (Secret-Header x-pronto-cron-secret, wiederverwendet dieselbe Vault-Secret-Infrastruktur wie die bestehenden Follow-up-/IMAP-Poll-Jobs — keine neue Umgebungsvariable). Findet alle EntwĂŒrfe mit status = 'geplant' und scheduled_at <= now() (max. 50 pro Lauf), veröffentlicht sie ĂŒber dieselbe Kernfunktion wie der manuelle Button (veroeffentlicheLinkedinDraftIntern) — ein defekter Entwurf blockiert nie den restlichen Sweep.

FĂŒr den Tester: TestfĂ€lle

  1. Unter /linkedin/einstellungen „Mit LinkedIn verbinden" — Status wechselt auf „Verbunden", zeigt Name/E-Mail/Profilbild/erteilte Berechtigungen/verfĂŒgbare Unternehmensseiten.
  2. Unter /linkedin „Neuer Beitrag" → im Editor Stichpunkte eingeben → „Testvorschau (ohne KI)" erzeugt einen eindeutig als „[Testvorschau]" markierten Platzhalter ohne Credit-Verbrauch; „Mit KI erstellen" verbraucht einen Credit und erzeugt einen echten Vorschlag (erfordert konfigurierten KI-Anbieter).
  3. Bild hochladen, Hashtags/CTA setzen, Zielkonto (Profil oder eine der angezeigten Unternehmensseiten) wĂ€hlen — Vorschau rechts aktualisiert sich live.
  4. „Als Entwurf speichern" oder „Zur Veröffentlichung planen" (mit Zeitpunkt in der Zukunft — ein Zeitpunkt in der Vergangenheit wird serverseitig abgelehnt).
  5. „Direkt veröffentlichen" — erfordert die LinkedIn-Freigabe fĂŒr die Community Management API; ohne sie erscheint eine klare Fehlermeldung statt eines vorgetĂ€uschten Erfolgs.
  6. Ein geplanter Beitrag wird automatisch zum gewÀhlten Zeitpunkt vom Scheduler veröffentlicht (max. 5 Minuten Verzögerung durch den Cron-Takt).
  7. Nach erfolgreicher Veröffentlichung: Eintrag unter /linkedin/veroeffentlicht mit Link zum echten LinkedIn-Post, sowie eine neue AktivitÀt im CRM-AktivitÀtsverlauf.

Bekannte EinschrÀnkungen

  • Nur ein Bild pro Beitrag, kein Video/Dokument/Carousel — die Datenbank (content_type, MAX_MEDIEN) ist so angelegt, dass diese spĂ€ter ergĂ€nzt werden können, ohne bestehende Daten zu brechen.
  • Kein automatischer Token-Refresh: LinkedIn-Access-Tokens laufen nach der von LinkedIn angegebenen GĂŒltigkeitsdauer ab; eine Refresh-Funktion existiert noch nicht (analog zur offenen Instagram-EinschrĂ€nkung) — Nutzer verbinden nach Ablauf manuell neu.
  • Token-VerschlĂŒsselung: Access-Tokens liegen wie bei Instagram/WhatsApp im Klartext in der Datenbank (RLS + ausschließlich service_role-Zugriff, nie ans Frontend ausgegeben) — siehe 25-security.md.
  • r_organization_admin-Scope fĂŒr die Organisationsliste ist gegen keinen echten LinkedIn-Account verifiziert (siehe linkedin-scopes.ts) — vor der App-Review-Einreichung erneut gegen die dann aktuelle Doku prĂŒfen.
  • Keine Datenlöschungs-Anfrage-Endpunkt speziell fĂŒr LinkedIn implementiert — die bestehende „Trennen"-Funktion löscht den Access-Token, aber keine historischen Content-EntwĂŒrfe automatisch. Offene LĂŒcke vor einer tatsĂ€chlichen App-Review-Einreichung.

Datenschutz-/Nutzungsbedingungen-Anforderungen (fĂŒr die App-Review-Einreichung)

LinkedIn verlangt bei der Bewerbung um „Community Management API" typischerweise:

  • Eine öffentlich erreichbare DatenschutzerklĂ€rung, die beschreibt, dass LinkedIn- Profildaten (Name, E-Mail, Profilbild) sowie Beitragsinhalte verarbeitet werden.
  • Eine Beschreibung des konkreten Anwendungsfalls (Content-Erstellung/-Planung fĂŒr Unternehmen) inkl. Screenshots/Demo-Video des Flows oben.
  • Nachweis, dass die App nur die tatsĂ€chlich benötigten Berechtigungen anfragt (siehe Tabelle oben — keine Berechtigung wird fĂŒr ungenutzte Funktionen angefragt).

Offene manuelle Schritte (LinkedIn-Konfiguration)

  1. Im LinkedIn Developer Portal eine neue App anlegen, „Sign In with LinkedIn using OpenID Connect" aktivieren (self-serve).
  2. Unter „Auth" die Redirect-URL exakt eintragen: https://<domain>/api/public/oauth/linkedin/callback.
  3. LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET (Werte aus dem Developer Portal) und LINKEDIN_STATE_SECRET (beliebiger zufĂ€lliger String, nur fĂŒr die CSRF-Signatur) als Umgebungsvariablen hinterlegen (Vercel-Projekteinstellungen).
  4. Bewerbung um das Produkt „Community Management API" einreichen (manuelle LinkedIn-PrĂŒfung, nicht self-serve) — erst danach funktioniert „Direkt veröffentlichen" live.
  5. Migration 20260925090000_..._linkedinschedulercron001.sql in Supabase ausfĂŒhren, damit geplante BeitrĂ€ge automatisch veröffentlicht werden — vor dem AusfĂŒhren prĂŒfen, ob die darin hinterlegte Produktions-URL (pronto.zentime.io, identisch zu allen bestehenden Cron-Jobs in diesem Projekt) noch die tatsĂ€chlich erreichbare Backend-Domain ist, da an anderer Stelle im Projekt inzwischen auch trypronto.site als Domain verwendet wird.
  6. Bis zur Freigabe: Verbinden/Profil anzeigen/EntwĂŒrfe/Planen funktionieren bereits vollstĂ€ndig — das deckt alle TestfĂ€lle bis auf den tatsĂ€chlichen Veröffentlichungs-Schritt ab.