Files
deklarix/internal/store/platformconnection.go
noroot 5813e6209c feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.

- internal/socialconnect: Connector-Interface + InstagramConnector/
  TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
  Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
  TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
  gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
  API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
  Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
  finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
  Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
  nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
  (State-Cookie gegen CSRF, Redirect zum Consent-Screen),
  GET /oauth/{platform}/callback (State prüfen, Code tauschen,
  Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
  Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
  "nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.

Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
2026-08-28 09:32:43 +02:00

109 lines
4.2 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// PlatformConnection ist die per OAuth hergestellte Verbindung eines
// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
// internal/socialconnect). Anders als asset/finding/extraction NICHT
// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte,
// eine getrennte Verbindung wird wirklich gelöscht.
type PlatformConnection struct {
ID string
AccountID string
Platform string
PlatformUserID string
AccessToken string
RefreshToken string
ExpiresAt *time.Time
ConnectedAt time.Time
}
// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die
// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem
// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde).
func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) {
var c PlatformConnection
err := s.Pool.QueryRow(ctx, `
INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (account_id, platform) DO UPDATE SET
platform_user_id = EXCLUDED.platform_user_id,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
connected_at = now()
RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
`, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan(
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
)
if err != nil {
return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err)
}
return c, nil
}
// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen
// eines Accounts.
func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
FROM platform_connection WHERE account_id = $1 ORDER BY platform
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list platform connections: %w", err)
}
defer rows.Close()
var out []PlatformConnection
for rows.Next() {
var c PlatformConnection
if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil {
return nil, fmt.Errorf("store: scan platform connection: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list platform connections: %w", err)
}
return out, nil
}
// GetPlatformConnection liest die Verbindung eines Accounts zu einer
// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung
// besteht — der Normalfall, solange der Kunde nichts verbunden hat.
func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) {
var c PlatformConnection
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
FROM platform_connection WHERE account_id = $1 AND platform = $2
`, accountID, platform).Scan(
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return PlatformConnection{}, ErrNotFound
}
if err != nil {
return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err)
}
return c, nil
}
// DeletePlatformConnection trennt eine Plattform-Verbindung.
func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform)
if err != nil {
return fmt.Errorf("store: delete platform connection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}