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.
109 lines
4.2 KiB
Go
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
|
|
}
|