feat(community): Phase 2.2 - YouTube Auto-Sync und AI Reply Suggestions

Implementiert automatische YouTube-Kommentar-Synchronisation und
KI-gestützte Antwortvorschläge für das Community Management.

Neue Features:
- Cron-Endpoint für externen Scheduler (/api/cron/youtube-sync)
- ClaudeReplyService für AI-generierte Antworten (3 Tonalitäten)
- Sync Status API mit Live-Polling
- AI Reply Suggestions UI mit Varianten-Auswahl
- Job Logger für strukturiertes Logging von Background Jobs

Änderungen:
- ClaudeAnalysisService: Model-Update auf claude-3-5-haiku-20241022
- CommunityInbox: Sync Status Badge, AI Reply Suggestions Integration
- SCSS: Styles für Sync-Indicator und Suggestion Cards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-16 15:44:06 +00:00
parent a5e634ccaf
commit 3464494b14
13 changed files with 1839 additions and 17 deletions

View file

@ -0,0 +1,244 @@
# Phase 2.2 Prompt - Korrektur-Bericht
**Datum:** 16. Januar 2026
**Erstellt von:** Claude Code (Opus 4.5)
**Prompt-Datei:** `/prompts/Phase2.2 youtube sync ai replies prompt.md`
---
## Zusammenfassung
Der Phase 2.2 Prompt beschreibt die Implementierung von:
1. **YouTube Auto-Sync** - Automatischer Kommentar-Import
2. **AI Reply Suggestions** - KI-Antwortvorschläge mit Claude
3. **UI Integration** - Erweiterungen für CommunityInbox
4. **Cron-Konfiguration** - Automatische Sync-Jobs
**Ergebnis:** Der Prompt enthält mehrere Annahmen über existierende Infrastruktur, die nicht zutreffen. **Korrekturen sind erforderlich.**
---
## KRITISCHE Korrektur: ClaudeAnalysisService fehlt
### Problem
Der Prompt geht davon aus, dass `ClaudeAnalysisService.ts` bereits existiert:
```typescript
// Prompt Zeile 44-50:
// src/lib/integrations/claude/ClaudeAnalysisService.ts
// - analyzeSentiment(message: string): Promise<SentimentResult>
// - extractTopics(message: string): Promise<string[]>
// - detectMedicalContent(message: string): Promise<boolean>
```
**Realität:** Das Verzeichnis `src/lib/integrations/claude/` ist LEER.
### Auswirkung
Der existierende `CommentsSyncService.ts` importiert `ClaudeAnalysisService`:
```typescript
// src/lib/integrations/youtube/CommentsSyncService.ts, Zeile 5:
import { ClaudeAnalysisService } from '../claude/ClaudeAnalysisService'
```
**Der Code kann nicht kompiliert/ausgeführt werden, solange ClaudeAnalysisService fehlt!**
### Lösung
`ClaudeAnalysisService.ts` muss ZUERST erstellt werden, bevor der Sync-Service funktioniert.
Der Prompt enthält bereits den vollständigen Code dafür (Zeilen 1236-1339).
---
## Bestandsaufnahme: Was existiert bereits?
### Vorhandene Dateien
| Datei | Status | Anmerkungen |
|-------|--------|-------------|
| `src/lib/integrations/youtube/YouTubeClient.ts` | Existiert | Leicht andere Signatur als im Prompt |
| `src/lib/integrations/youtube/CommentsSyncService.ts` | Existiert | Importiert fehlenden ClaudeAnalysisService |
| `src/lib/integrations/youtube/oauth.ts` | Existiert | Vollständig funktional |
| `src/lib/integrations/claude/` | LEER | ClaudeAnalysisService fehlt! |
| `src/collections/CommunityInteractions.ts` | Existiert | 568 Zeilen, vollständig |
| `src/collections/SocialAccounts.ts` | Existiert | Vollständig |
| `src/collections/SocialPlatforms.ts` | Existiert | Vollständig |
| `src/lib/services/RulesEngine.ts` | Existiert | 9054 Bytes |
### Vorhandene API Routes
| Route | Status | Anmerkungen |
|-------|--------|-------------|
| `/api/community/sync-comments/` | Existiert | POST - Manueller Sync (pro Account) |
| `/api/community/analytics/*` | Existiert | 8 Endpoints (Phase 2.1) |
| `/api/community/export/` | Existiert | Export-Funktionalität |
| `/api/community/reply/` | Existiert | Antworten senden |
| `/api/community/stats/` | Existiert | Statistiken |
---
## Korrekturen am Prompt
### 1. ClaudeAnalysisService muss erstellt werden (nicht "bereits implementiert")
**Prompt-Text ändern von:**
```markdown
### Claude Integration (bereits implementiert)
// src/lib/integrations/claude/ClaudeAnalysisService.ts
```
**Ändern zu:**
```markdown
### Claude Integration (MUSS ERSTELLT WERDEN)
// src/lib/integrations/claude/ClaudeAnalysisService.ts
// HINWEIS: Diese Datei existiert noch nicht und muss als erstes erstellt werden!
```
### 2. API Route Namenskonvention anpassen
**Prompt schlägt vor:**
```
/api/community/sync/route.ts
```
**Bereits existiert:**
```
/api/community/sync-comments/route.ts
```
**Empfehlung:** Den existierenden Pfad beibehalten oder Prompt aktualisieren.
### 3. YouTubeClient Signatur-Unterschiede
**Im Prompt:**
```typescript
class YouTubeClient {
constructor(accessToken: string, accountId: number) { ... }
static async getValidClient(accountId: number): Promise<YouTubeClient | null>
}
```
**Existierende Version:**
```typescript
class YouTubeClient {
constructor(credentials: YouTubeCredentials, payload: Payload) { ... }
// Keine statische getValidClient Methode
}
```
**Empfehlung:** Die existierende Version erweitern, nicht ersetzen.
### 4. Implementierungsreihenfolge aktualisieren
**Neue empfohlene Reihenfolge:**
1. **ClaudeAnalysisService erstellen** (KRITISCH - ohne dies funktioniert nichts!)
2. Type Definitions (`youtube.ts`)
3. YouTubeClient erweitern (getValidClient Methode hinzufügen)
4. CommentsSyncService prüfen/anpassen
5. Job Runner + Logger
6. Cron Endpoint
7. ClaudeReplyService
8. API Endpoint `/generate-reply`
9. UI Integration
10. SCSS Ergänzungen
---
## Fehlende Komponenten
### Laut Prompt zu erstellen
| Komponente | Pfad | Priorität |
|------------|------|-----------|
| ClaudeAnalysisService | `src/lib/integrations/claude/ClaudeAnalysisService.ts` | KRITISCH |
| ClaudeReplyService | `src/lib/integrations/claude/ClaudeReplyService.ts` | Hoch |
| Type Definitions | `src/types/youtube.ts` | Mittel |
| Job Runner | `src/lib/jobs/syncAllComments.ts` | Mittel |
| Job Logger | `src/lib/jobs/JobLogger.ts` | Mittel |
| Cron Endpoint | `src/app/(payload)/api/cron/youtube-sync/route.ts` | Mittel |
| Generate Reply | `src/app/(payload)/api/community/generate-reply/route.ts` | Hoch |
### UI-Erweiterungen
- AI Reply Section in CommunityInbox
- Sync Status Badge
- SCSS Ergänzungen (~100 Zeilen)
---
## Collection-Feldabgleich
### CommunityInteractions - Prompt vs. Realität
| Feld (Prompt) | Vorhanden | Anmerkungen |
|---------------|-----------|-------------|
| `externalId` | Ja | Einzigartig, indiziert |
| `platform` | Ja | Relationship |
| `socialAccount` | Ja | Relationship |
| `linkedContent` | Ja | Relationship zu youtube-content |
| `type` | Ja | comment, reply, dm, mention, review, question |
| `author.*` | Ja | Vollständig |
| `message` | Ja | Textarea |
| `analysis.*` | Ja | Sentiment, Score, Confidence, Topics |
| `flags.*` | Ja | isMedicalQuestion, requiresEscalation, isSpam, isFromInfluencer |
| `status` | Ja | new, in_review, waiting, replied, resolved, archived, spam |
| `priority` | Ja | urgent, high, normal, low |
| `response.*` | Ja | text, usedTemplate, sentAt, sentBy |
**Ergebnis:** Collection ist vollständig kompatibel mit dem Prompt.
---
## Umgebungsvariablen prüfen
Der Prompt erfordert folgende Umgebungsvariablen:
| Variable | Beschreibung | Status |
|----------|--------------|--------|
| `ANTHROPIC_API_KEY` | Claude API Key | Prüfen in .env |
| `YOUTUBE_CLIENT_ID` | YouTube OAuth Client ID | Prüfen in .env |
| `YOUTUBE_CLIENT_SECRET` | YouTube OAuth Client Secret | Prüfen in .env |
| `YOUTUBE_REDIRECT_URI` | OAuth Callback URL | Prüfen in .env |
| `CRON_SECRET` | Auth-Token für Cron-Endpoint | NEU - hinzufügen |
---
## Empfohlene nächste Schritte
1. **Sofort:** `ClaudeAnalysisService.ts` erstellen (Code aus Prompt Zeilen 1236-1339)
2. **Dann:** Build testen - `pnpm build`
3. **Danach:** Sync-Funktionalität manuell testen
4. **Fortfahren:** Restliche Komponenten implementieren
---
## Hinweise für die Implementierung
### Claude API Modell
Der Prompt verwendet `claude-3-haiku-20240307`. Dieses Modell ist schnell und kostengünstig, aber veraltet. Aktuelle Alternativen:
- `claude-3-5-haiku-20241022` - Neuere Version
- `claude-3-5-sonnet-20241022` - Bessere Qualität bei höheren Kosten
### Rate Limiting
Der Prompt enthält Rate-Limiting zwischen API-Calls:
- 100ms zwischen Kommentaren
- 200ms zwischen Videos
Dies ist wichtig für YouTube API Quota und Claude API Kosten.
### Error Handling
Die existierende `CommentsSyncService` hat Fallback-Analyse bei Claude-Fehlern - das ist gut implementiert.
---
*Generiert am 16. Januar 2026 von Claude Code (Opus 4.5)*

View file

@ -41,6 +41,7 @@
"@types/pdfkit": "^0.17.4", "@types/pdfkit": "^0.17.4",
"bullmq": "^5.65.1", "bullmq": "^5.65.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"googleapis": "^170.0.0", "googleapis": "^170.0.0",
@ -53,6 +54,7 @@
"pdfkit": "^0.17.2", "pdfkit": "^0.17.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.6.0",
"sharp": "0.34.5" "sharp": "0.34.5"
}, },
"devDependencies": { "devDependencies": {

View file

@ -50,6 +50,9 @@ importers:
cross-env: cross-env:
specifier: ^7.0.3 specifier: ^7.0.3
version: 7.0.3 version: 7.0.3
date-fns:
specifier: ^4.1.0
version: 4.1.0
dotenv: dotenv:
specifier: 16.4.7 specifier: 16.4.7
version: 16.4.7 version: 16.4.7
@ -86,6 +89,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.3 specifier: 19.2.3
version: 19.2.3(react@19.2.3) version: 19.2.3(react@19.2.3)
recharts:
specifier: ^3.6.0
version: 3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
sharp: sharp:
specifier: 0.34.5 specifier: 0.34.5
version: 0.34.5 version: 0.34.5
@ -1346,6 +1352,17 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-beta.11': '@rolldown/pluginutils@1.0.0-beta.11':
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==} resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
@ -1640,6 +1657,9 @@ packages:
'@standard-schema/spec@1.0.0': '@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@ -1670,6 +1690,33 @@ packages:
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@ -1743,6 +1790,9 @@ packages:
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@10.0.0': '@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
@ -2321,6 +2371,50 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8: damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@ -2376,6 +2470,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0: decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@ -2599,6 +2696,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.43.0:
resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==}
esbuild-register@3.6.0: esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies: peerDependencies:
@ -2750,6 +2850,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
exceljs@4.4.0: exceljs@4.4.0:
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
engines: {node: '>=8.3.0'} engines: {node: '>=8.3.0'}
@ -3072,6 +3175,12 @@ packages:
immediate@3.0.6: immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
immutable@4.3.7: immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
@ -3094,6 +3203,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ioredis@5.8.2: ioredis@5.8.2:
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
@ -4054,6 +4167,18 @@ packages:
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.17.0: react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4092,6 +4217,14 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
recharts@3.6.0:
resolution: {integrity: sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redis-errors@1.2.0: redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -4100,6 +4233,14 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'} engines: {node: '>=4'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4112,6 +4253,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -4435,6 +4579,9 @@ packages:
tiny-inflate@1.0.3: tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -4629,6 +4776,11 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utf8-byte-length@1.0.5: utf8-byte-length@1.0.5:
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
@ -4657,6 +4809,9 @@ packages:
vfile-message@4.0.3: vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-tsconfig-paths@6.0.0: vite-tsconfig-paths@6.0.0:
resolution: {integrity: sha512-0lGkM62rud1ShKWLbJpbTHPoJuZIL9QW1ecCueDhqxWrStIRsyHapBQ4eV05tBqrW9z6jkp9ybBVgLSWp+Mv1A==} resolution: {integrity: sha512-0lGkM62rud1ShKWLbJpbTHPoJuZIL9QW1ecCueDhqxWrStIRsyHapBQ4eV05tBqrW9z6jkp9ybBVgLSWp+Mv1A==}
peerDependencies: peerDependencies:
@ -6444,6 +6599,18 @@ snapshots:
dependencies: dependencies:
playwright: 1.57.0 playwright: 1.57.0
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
dependencies:
'@standard-schema/spec': 1.0.0
'@standard-schema/utils': 0.3.0
immer: 11.1.3
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-beta.11': {} '@rolldown/pluginutils@1.0.0-beta.11': {}
'@rollup/rollup-android-arm-eabi@4.53.3': '@rollup/rollup-android-arm-eabi@4.53.3':
@ -6792,6 +6959,8 @@ snapshots:
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -6837,6 +7006,30 @@ snapshots:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
assertion-error: 2.0.1 assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.12': '@types/debug@4.1.12':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
@ -6911,6 +7104,8 @@ snapshots:
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@10.0.0': {} '@types/uuid@10.0.0': {}
'@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)':
@ -7543,6 +7738,44 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {} damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}
@ -7588,6 +7821,8 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {} decimal.js@10.6.0: {}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
@ -7797,6 +8032,8 @@ snapshots:
is-date-object: 1.1.0 is-date-object: 1.1.0
is-symbol: 1.1.1 is-symbol: 1.1.1
es-toolkit@1.43.0: {}
esbuild-register@3.6.0(esbuild@0.25.12): esbuild-register@3.6.0(esbuild@0.25.12):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@ -8069,6 +8306,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
eventemitter3@5.0.1: {}
exceljs@4.4.0: exceljs@4.4.0:
dependencies: dependencies:
archiver: 5.3.2 archiver: 5.3.2
@ -8427,6 +8666,10 @@ snapshots:
immediate@3.0.6: {} immediate@3.0.6: {}
immer@10.2.0: {}
immer@11.1.3: {}
immutable@4.3.7: {} immutable@4.3.7: {}
import-fresh@3.3.1: import-fresh@3.3.1:
@ -8449,6 +8692,8 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 side-channel: 1.1.0
internmap@2.0.3: {}
ioredis@5.8.2: ioredis@5.8.2:
dependencies: dependencies:
'@ioredis/commands': 1.4.0 '@ioredis/commands': 1.4.0
@ -9605,6 +9850,15 @@ snapshots:
react-is@16.13.1: {} react-is@16.13.1: {}
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
redux: 5.0.1
react-refresh@0.17.0: {} react-refresh@0.17.0: {}
react-select@5.9.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): react-select@5.9.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@ -9661,12 +9915,38 @@ snapshots:
real-require@0.2.0: {} real-require@0.2.0: {}
recharts@3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.43.0
eventemitter3: 5.0.1
immer: 10.2.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-is: 16.13.1
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.3)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redis-errors@1.2.0: {} redis-errors@1.2.0: {}
redis-parser@3.0.0: redis-parser@3.0.0:
dependencies: dependencies:
redis-errors: 1.2.0 redis-errors: 1.2.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -9689,6 +9969,8 @@ snapshots:
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@ -10079,6 +10361,8 @@ snapshots:
tiny-inflate@1.0.3: {} tiny-inflate@1.0.3: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@1.0.2: {} tinyexec@1.0.2: {}
@ -10314,6 +10598,10 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.7 '@types/react': 19.2.7
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
utf8-byte-length@1.0.5: {} utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
@ -10339,6 +10627,23 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0 unist-util-stringify-position: 4.0.0
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-tsconfig-paths@6.0.0(typescript@5.9.3)(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6)): vite-tsconfig-paths@6.0.0(typescript@5.9.3)(vite@7.2.4(@types/node@22.19.1)(sass@1.77.4)(tsx@4.20.6)):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3

View file

@ -96,6 +96,22 @@ export const CommunityInbox: React.FC = () => {
const [exportFormat, setExportFormat] = useState<'pdf' | 'excel' | 'csv'>('excel') const [exportFormat, setExportFormat] = useState<'pdf' | 'excel' | 'csv'>('excel')
const [exportDateRange, setExportDateRange] = useState({ from: '', to: '' }) const [exportDateRange, setExportDateRange] = useState({ from: '', to: '' })
// AI Reply state
const [isGeneratingReply, setIsGeneratingReply] = useState(false)
const [generatedReplies, setGeneratedReplies] = useState<Array<{
text: string
tone: string
confidence: number
warnings: string[]
}>>([])
// Sync status state
const [syncStatus, setSyncStatus] = useState<{
isRunning: boolean
lastRunAt: string | null
nextRunAt: string | null
} | null>(null)
// Refs // Refs
const listRef = useRef<HTMLUListElement>(null) const listRef = useRef<HTMLUListElement>(null)
@ -226,22 +242,52 @@ export const CommunityInbox: React.FC = () => {
loadInteractions() loadInteractions()
}, [loadInteractions]) }, [loadInteractions])
// Sync comments // Fetch Sync Status
useEffect(() => {
const fetchSyncStatus = async () => {
try {
const response = await fetch('/api/community/sync')
if (response.ok) {
const status = await response.json()
setSyncStatus(status)
}
} catch (error) {
console.error('Failed to fetch sync status:', error)
}
}
fetchSyncStatus()
const interval = setInterval(fetchSyncStatus, 60000) // Jede Minute
return () => clearInterval(interval)
}, [])
// Sync comments (all accounts)
const handleSync = async () => { const handleSync = async () => {
setSyncing(true) setSyncing(true)
try { try {
const response = await fetch('/api/community/sync-comments', { const response = await fetch('/api/community/sync', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ analyzeWithAI: true }),
}) })
if (response.ok) { if (response.ok) {
const result = await response.json() const result = await response.json()
alert(`Sync complete: ${result.created} new, ${result.updated} updated`) if (result.success && result.results) {
const totalNew = result.results.reduce((sum: number, r: { newComments: number }) => sum + r.newComments, 0)
const totalUpdated = result.results.reduce((sum: number, r: { updatedComments: number }) => sum + r.updatedComments, 0)
alert(`Sync abgeschlossen: ${totalNew} neue, ${totalUpdated} aktualisierte Kommentare`)
}
loadInteractions() loadInteractions()
// Refresh sync status
const statusResponse = await fetch('/api/community/sync')
if (statusResponse.ok) {
const status = await statusResponse.json()
setSyncStatus(status)
}
} else { } else {
throw new Error('Sync failed') const error = await response.json()
throw new Error(error.error || 'Sync failed')
} }
} catch (error) { } catch (error) {
console.error('Sync error:', error) console.error('Sync error:', error)
@ -386,27 +432,48 @@ export const CommunityInbox: React.FC = () => {
} }
// Generate AI reply // Generate AI reply
const generateAIReply = async () => { const generateAIReply = async (variants: boolean = false) => {
if (!selectedInteraction) return if (!selectedInteraction) return
setIsGeneratingReply(true)
setGeneratedReplies([])
try { try {
const response = await fetch('/api/community/generate-reply', { const response = await fetch('/api/community/generate-reply', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
interactionId: selectedInteraction.id, interactionId: selectedInteraction.id,
variants,
}), }),
}) })
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
setReplyText(data.reply)
if (variants && data.variants) {
setGeneratedReplies(data.variants)
} else if (data.reply) {
// Einzelne Antwort direkt übernehmen
setReplyText(data.reply.text)
}
} else {
throw new Error('Generation failed')
} }
} catch (error) { } catch (error) {
console.error('AI generation error:', error) console.error('AI generation error:', error)
alert('KI-Antwortgenerierung fehlgeschlagen')
} finally {
setIsGeneratingReply(false)
} }
} }
// Use generated reply
const useGeneratedReply = (reply: { text: string }) => {
setReplyText(reply.text)
setGeneratedReplies([])
}
// Select interaction // Select interaction
const selectInteraction = (interaction: Interaction) => { const selectInteraction = (interaction: Interaction) => {
setSelectedInteraction(interaction) setSelectedInteraction(interaction)
@ -736,8 +803,12 @@ export const CommunityInbox: React.FC = () => {
</option> </option>
))} ))}
</select> </select>
<button className="btn-ai" onClick={generateAIReply}> <button
🤖 KI className="btn-ai"
onClick={() => generateAIReply(false)}
disabled={isGeneratingReply}
>
{isGeneratingReply ? '...' : '🤖 KI'}
</button> </button>
</div> </div>
<textarea <textarea
@ -975,14 +1046,27 @@ export const CommunityInbox: React.FC = () => {
</div> </div>
</div> </div>
<div className="inbox-actions"> <div className="inbox-actions">
{/* Sync Status Badge */}
{syncStatus && (
<div className="sync-status">
<span className={`sync-indicator ${syncStatus.isRunning ? 'active' : ''}`} />
<span className="sync-text">
{syncStatus.isRunning
? 'Sync läuft...'
: syncStatus.lastRunAt
? `Letzter Sync: ${new Date(syncStatus.lastRunAt).toLocaleTimeString('de-DE')}`
: 'Noch nicht synchronisiert'}
</span>
</div>
)}
<a href="/admin/views/community/analytics" className="btn"> <a href="/admin/views/community/analytics" className="btn">
📈 Analytics 📈 Analytics
</a> </a>
<button className="btn" onClick={() => setShowExportModal(true)}> <button className="btn" onClick={() => setShowExportModal(true)}>
📊 Export 📊 Export
</button> </button>
<button className="btn btn-primary" onClick={handleSync} disabled={syncing}> <button className="btn btn-primary" onClick={handleSync} disabled={syncing || syncStatus?.isRunning}>
{syncing ? '⏳ Syncing...' : '🔄 Sync'} {syncing || syncStatus?.isRunning ? '⏳ Syncing...' : '🔄 Sync'}
</button> </button>
</div> </div>
</div> </div>
@ -1395,10 +1479,60 @@ export const CommunityInbox: React.FC = () => {
</option> </option>
))} ))}
</select> </select>
<button className="btn btn-ai" onClick={generateAIReply}> <div className="ai-buttons">
🤖 KI-Antwort <button
className="btn btn-ai"
onClick={() => generateAIReply(false)}
disabled={isGeneratingReply}
>
{isGeneratingReply ? '...' : '🤖 KI-Antwort'}
</button>
<button
className="btn btn-ai-variants"
onClick={() => generateAIReply(true)}
disabled={isGeneratingReply}
title="3 Varianten generieren"
>
{isGeneratingReply ? '...' : '🤖 3 Varianten'}
</button> </button>
</div> </div>
</div>
{/* AI Generated Replies */}
{generatedReplies.length > 0 && (
<div className="ai-reply-suggestions">
<h5>🤖 KI-Antwortvorschläge</h5>
<div className="suggestions-grid">
{generatedReplies.map((reply, index) => (
<div key={index} className="suggestion-card">
{reply.tone && (
<span className="suggestion-tone">{reply.tone}</span>
)}
<p className="suggestion-text">{reply.text}</p>
{reply.warnings?.length > 0 && (
<div className="suggestion-warnings">
{reply.warnings.map((w, i) => (
<span key={i} className="warning-tag"> {w}</span>
))}
</div>
)}
<button
className="btn btn-use-suggestion"
onClick={() => useGeneratedReply(reply)}
>
Übernehmen
</button>
</div>
))}
</div>
<button
className="btn btn-clear-suggestions"
onClick={() => setGeneratedReplies([])}
>
Vorschläge ausblenden
</button>
</div>
)}
<textarea <textarea
value={replyText} value={replyText}

View file

@ -1153,3 +1153,218 @@
} }
} }
} }
// ==============================================
// Sync Status Badge
// ==============================================
.sync-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--theme-elevation-50);
border-radius: 6px;
font-size: 0.75rem;
color: var(--theme-elevation-600);
.sync-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--theme-elevation-300);
transition: background 0.2s ease;
&.active {
background: var(--color-success);
animation: pulse 1.5s ease-in-out infinite;
}
}
.sync-text {
white-space: nowrap;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
}
// ==============================================
// AI Buttons (Reply Generation)
// ==============================================
.ai-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
.btn-ai-variants {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
svg {
width: 14px;
height: 14px;
}
}
}
// ==============================================
// AI Reply Suggestions
// ==============================================
.ai-reply-suggestions {
margin-top: 1.25rem;
padding: 1rem;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
border: 1px solid rgba(99, 102, 241, 0.15);
border-radius: 8px;
h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-elevation-800);
svg {
width: 16px;
height: 16px;
color: #6366f1;
}
}
.suggestions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.875rem;
}
.suggestion-card {
background: white;
border: 1px solid var(--theme-elevation-150);
border-radius: 8px;
padding: 1rem;
transition: all 0.2s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.suggestion-tone {
display: inline-block;
padding: 0.25rem 0.5rem;
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.625rem;
}
.suggestion-text {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--theme-elevation-700);
margin-bottom: 0.75rem;
white-space: pre-wrap;
}
.suggestion-warnings {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
.warning-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
svg {
width: 12px;
height: 12px;
}
}
}
.btn-use-suggestion {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--theme-elevation-100);
color: var(--theme-elevation-700);
border: 1px solid var(--theme-elevation-200);
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #6366f1;
color: white;
border-color: #6366f1;
}
}
}
.btn-clear-suggestions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
margin-top: 1rem;
padding: 0.5rem 1rem;
background: transparent;
color: var(--theme-elevation-500);
border: 1px dashed var(--theme-elevation-200);
border-radius: 6px;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--theme-elevation-700);
border-color: var(--theme-elevation-400);
}
svg {
width: 14px;
height: 14px;
}
}
}

View file

@ -0,0 +1,138 @@
// src/app/(payload)/api/community/generate-reply/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { ClaudeReplyService } from '@/lib/integrations/claude/ClaudeReplyService'
import type { ReplyContext } from '@/types/youtube'
/**
* POST /api/community/generate-reply
* Generiert KI-Antwortvorschläge für Community-Interaktionen
*/
export async function POST(request: NextRequest) {
try {
const payload = await getPayload({ config })
// Auth prüfen
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Berechtigungen prüfen
const userWithRoles = user as {
isSuperAdmin?: boolean
communityRole?: string
}
const hasAccess =
userWithRoles.isSuperAdmin ||
userWithRoles.communityRole === 'manager' ||
userWithRoles.communityRole === 'moderator' ||
userWithRoles.communityRole === 'responder'
if (!hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { interactionId, variants = false } = body
if (!interactionId) {
return NextResponse.json(
{ error: 'interactionId is required' },
{ status: 400 }
)
}
// Hole Interaction mit Relations
const interaction = await payload.findByID({
collection: 'community-interactions',
id: interactionId,
depth: 2,
})
if (!interaction) {
return NextResponse.json(
{ error: 'Interaction not found' },
{ status: 404 }
)
}
// Bestimme Channel-Typ basierend auf Account
const socialAccount = interaction.socialAccount as {
displayName?: string
id?: number
} | null
const channelType = determineChannelType(socialAccount)
// Linked Content
const linkedContent = interaction.linkedContent as {
title?: string
topics?: Array<{ topic: string }>
} | null
// Analysis
const analysis = interaction.analysis as {
sentiment?: string
topics?: Array<{ topic: string }>
} | null
// Flags
const flags = interaction.flags as {
isMedicalQuestion?: boolean
} | null
// Author
const author = interaction.author as {
name?: string
} | null
// Baue Kontext
const context: ReplyContext = {
channelName: socialAccount?.displayName || 'YouTube Channel',
channelType,
videoTitle: linkedContent?.title || 'Video',
videoTopics: linkedContent?.topics?.map((t) => t.topic) || [],
commentText: interaction.message as string,
commentSentiment: analysis?.sentiment || 'neutral',
authorName: author?.name || 'User',
isMedicalQuestion: flags?.isMedicalQuestion || false,
}
const replyService = new ClaudeReplyService()
if (variants) {
// Generiere mehrere Varianten
const replies = await replyService.generateReplyVariants(context, 3)
return NextResponse.json({ variants: replies })
} else {
// Einzelne Antwort
const reply = await replyService.generateReply(context)
return NextResponse.json({ reply })
}
} catch (error: unknown) {
console.error('[GenerateReply] Error:', error)
const message = error instanceof Error ? error.message : 'Failed to generate reply'
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* Bestimmt den Channel-Typ basierend auf Account-Name
*/
function determineChannelType(
account: { displayName?: string } | null
): 'corporate' | 'lifestyle' | 'business' {
const name = account?.displayName?.toLowerCase() || ''
if (name.includes('blogwoman') || name.includes('lifestyle')) {
return 'lifestyle'
}
if (name.includes('business') || name.includes('ccs business')) {
return 'business'
}
return 'corporate'
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,112 @@
// src/app/(payload)/api/community/sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
/**
* POST /api/community/sync
* Manueller Sync-Trigger für alle YouTube-Accounts
*/
export async function POST(request: NextRequest) {
try {
const payload = await getPayload({ config })
// Auth prüfen
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Berechtigungen prüfen
const userWithRoles = user as {
isSuperAdmin?: boolean
youtubeRole?: string
communityRole?: string
}
const hasAccess =
userWithRoles.isSuperAdmin ||
userWithRoles.youtubeRole === 'manager' ||
userWithRoles.communityRole === 'manager'
if (!hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Prüfe ob bereits ein Sync läuft
const status = getSyncStatus()
if (status.isRunning) {
return NextResponse.json(
{
error: 'Sync already in progress',
status,
},
{ status: 409 }
)
}
console.log(`[Sync] Manual sync triggered by user ${user.id}`)
const result = await runSync()
return NextResponse.json({
success: result.success,
results: result.results,
error: result.error,
})
} catch (error: unknown) {
console.error('[Sync] Error:', error)
const message = error instanceof Error ? error.message : 'Unknown error'
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* GET /api/community/sync
* Sync-Status abfragen
*/
export async function GET(request: NextRequest) {
try {
const payload = await getPayload({ config })
// Auth prüfen
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const status = getSyncStatus()
return NextResponse.json({
...status,
nextRunAt: calculateNextRun(),
})
} catch (error: unknown) {
console.error('[Sync] Status error:', error)
const message = error instanceof Error ? error.message : 'Unknown error'
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* Berechnet den nächsten geplanten Sync-Zeitpunkt
* (Basierend auf 15-Minuten-Intervall)
*/
function calculateNextRun(): string {
const now = new Date()
const minutes = now.getMinutes()
const nextInterval = Math.ceil(minutes / 15) * 15
const next = new Date(now)
next.setMinutes(nextInterval, 0, 0)
// Falls wir genau auf einem Intervall sind, nächstes nehmen
if (next <= now) {
next.setMinutes(next.getMinutes() + 15)
}
return next.toISOString()
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,65 @@
// src/app/(payload)/api/cron/youtube-sync/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { runSync, getSyncStatus } from '@/lib/jobs/syncAllComments'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
/**
* GET /api/cron/youtube-sync
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
*/
export async function GET(request: NextRequest) {
// Auth prüfen wenn CRON_SECRET gesetzt
if (CRON_SECRET) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to youtube-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
console.log('[Cron] Starting scheduled YouTube sync')
const result = await runSync()
if (result.success) {
const totalNew = result.results?.reduce((sum, r) => sum + r.newComments, 0) || 0
const totalUpdated = result.results?.reduce((sum, r) => sum + r.updatedComments, 0) || 0
return NextResponse.json({
success: true,
message: `Sync completed: ${totalNew} new, ${totalUpdated} updated comments`,
results: result.results,
})
}
return NextResponse.json(
{
success: false,
error: result.error,
},
{ status: 500 }
)
}
/**
* HEAD /api/cron/youtube-sync
* Status-Check für Monitoring
*/
export async function HEAD() {
const status = getSyncStatus()
return new NextResponse(null, {
status: status.isRunning ? 423 : 200, // 423 = Locked (running)
headers: {
'X-Sync-Running': status.isRunning.toString(),
'X-Last-Run': status.lastRunAt || 'never',
},
})
}
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5 Minuten max (Vercel)

View file

@ -30,6 +30,13 @@ export class ClaudeAnalysisService {
}) })
} }
/**
* Alias für analyzeComment (Konsistenz mit Prompt)
*/
async analyzeInteraction(message: string): Promise<CommentAnalysis> {
return this.analyzeComment(message)
}
/** /**
* Kommentar analysieren * Kommentar analysieren
*/ */
@ -56,7 +63,7 @@ Antworte NUR mit dem JSON-Objekt, kein anderer Text.`
try { try {
const response = await this.client.messages.create({ const response = await this.client.messages.create({
model: 'claude-3-haiku-20240307', model: 'claude-3-5-haiku-20241022',
max_tokens: 500, max_tokens: 500,
system: systemPrompt, system: systemPrompt,
messages: [ messages: [
@ -112,7 +119,7 @@ Antworte NUR mit dem Antworttext, kein anderer Text.`
try { try {
const response = await this.client.messages.create({ const response = await this.client.messages.create({
model: 'claude-3-haiku-20240307', model: 'claude-3-5-haiku-20241022',
max_tokens: 200, max_tokens: 200,
system: systemPrompt, system: systemPrompt,
messages: [ messages: [
@ -164,4 +171,5 @@ Antworte NUR mit dem Antworttext, kein anderer Text.`
} }
} }
export type { CommentAnalysis, ReplyContext } // Export types - AnalysisResult ist Alias für CommentAnalysis (Konsistenz mit Prompt)
export type { CommentAnalysis, CommentAnalysis as AnalysisResult, ReplyContext }

View file

@ -0,0 +1,243 @@
// src/lib/integrations/claude/ClaudeReplyService.ts
import Anthropic from '@anthropic-ai/sdk'
import type { ReplyContext, GeneratedReply } from '@/types/youtube'
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
export class ClaudeReplyService {
private model: string
constructor(model?: string) {
this.model = model || 'claude-3-5-haiku-20241022'
}
/**
* Generiere Antwortvorschlag basierend auf Kontext
*/
async generateReply(context: ReplyContext): Promise<GeneratedReply> {
const systemPrompt = this.buildSystemPrompt(context)
const userPrompt = this.buildUserPrompt(context)
try {
const response = await anthropic.messages.create({
model: this.model,
max_tokens: 500,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
return this.parseReplyResponse(content.text, context)
} catch (error: unknown) {
console.error('[ClaudeReply] Generation failed:', error)
throw error
}
}
/**
* Generiere mehrere Varianten für A/B-Testing
*/
async generateReplyVariants(
context: ReplyContext,
count: number = 3
): Promise<GeneratedReply[]> {
const variants: GeneratedReply[] = []
const tones = ['freundlich', 'professionell', 'empathisch']
for (let i = 0; i < Math.min(count, tones.length); i++) {
const contextWithTone = {
...context,
requestedTone: tones[i],
} as ReplyContext & { requestedTone: string }
const reply = await this.generateReplyWithTone(contextWithTone, tones[i])
reply.tone = tones[i]
variants.push(reply)
// Rate limiting
await this.sleep(100)
}
return variants
}
private async generateReplyWithTone(
context: ReplyContext & { requestedTone?: string },
tone: string
): Promise<GeneratedReply> {
const systemPrompt = this.buildSystemPrompt(context, tone)
const userPrompt = this.buildUserPrompt(context)
const response = await anthropic.messages.create({
model: this.model,
max_tokens: 500,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
return this.parseReplyResponse(content.text, context)
}
private buildSystemPrompt(context: ReplyContext, requestedTone?: string): string {
const basePrompt = `Du bist der Community Manager für "${context.channelName}", einen deutschen YouTube-Kanal.
KANAL-KONTEXT:
${this.getChannelContext(context.channelType)}
WICHTIGE REGELN:
1. Antworte IMMER auf Deutsch
2. Verwende "Du" (nicht "Sie")
3. Halte Antworten kurz (max. 2-3 Sätze für normale Kommentare)
4. Unterschreibe mit "— ${this.getSignature(context.channelType)}"
5. Sei authentisch, nicht roboterhaft
6. Verwende KEINE Emojis außer: (sparsam)
${context.isMedicalQuestion ? this.getMedicalWarning() : ''}
TONALITÄT:
${this.getToneGuidelines(context.channelType, context.commentSentiment, requestedTone)}`
return basePrompt
}
private buildUserPrompt(context: ReplyContext): string {
return `KOMMENTAR VON "${context.authorName}":
"${context.commentText}"
VIDEO: "${context.videoTitle}"
SENTIMENT: ${context.commentSentiment}
${context.isMedicalQuestion ? 'MEDIZINISCHE FRAGE ERKANNT' : ''}
Generiere eine passende Antwort. Antworte NUR mit dem Antworttext, keine Erklärungen.`
}
private getChannelContext(channelType: string): string {
switch (channelType) {
case 'corporate':
return `Kanal: zweitmeinu.ng (Complex Care Solutions)
Thema: Medizinische Zweitmeinungen, Patientenrechte, Gesundheitsentscheidungen
Zielgruppe: Patienten, Angehörige, Krankenkassen
Persona: Dr. Caroline Porwoll (Gesundheitsberaterin, NICHT Ärztin)
Wichtig: Keine medizinische Beratung geben, auf Hotline verweisen`
case 'lifestyle':
return `Kanal: BlogWoman by Caroline Porwoll
Thema: Premium-Lifestyle für Karrierefrauen (Mode, Systeme, Regeneration)
Zielgruppe: Berufstätige Mütter 35-45
Persona: Caroline Porwoll (Unternehmerin, Mutter, Stilexpertin)
Wichtig: Editorial-Premium-Ton, keine Influencer-Sprache`
case 'business':
return `Kanal: CCS Business (English)
Thema: B2B Second Opinion Programs, ROI, Healthcare Governance
Zielgruppe: International Healthcare Decision-Makers
Persona: Dr. Caroline Porwoll (CEO, Healthcare Expert)
Wichtig: Professionell, faktenbasiert, auf Demo/Whitepaper verweisen`
default:
return 'Allgemeiner YouTube-Kanal'
}
}
private getToneGuidelines(
channelType: string,
sentiment: string,
requestedTone?: string
): string {
const baseGuidelines: Record<string, string> = {
corporate: 'Warm, empathisch, ermutigend, professionell',
lifestyle: 'Editorial Premium, systemorientiert, "Performance & Pleasure"',
business: 'Professional, fact-based, ROI-oriented',
}
const sentimentAdjustments: Record<string, string> = {
positive: 'Teile die Freude, bedanke dich herzlich',
negative: 'Zeige Verständnis, biete Hilfe an, eskaliere nicht',
question: 'Beantworte klar und hilfreich, verweise auf Ressourcen',
gratitude: 'Bedanke dich aufrichtig, zeige dass es geschätzt wird',
frustration: 'Validiere Gefühle, biete konkrete Lösung, bleib ruhig',
neutral: 'Freundlich und hilfsbereit',
}
let guidelines = `Grundton: ${baseGuidelines[channelType] || 'Freundlich'}
Bei ${sentiment}: ${sentimentAdjustments[sentiment] || sentimentAdjustments['neutral']}`
if (requestedTone) {
guidelines += `\nAngeforderter Ton: ${requestedTone}`
}
return guidelines
}
private getMedicalWarning(): string {
return `
MEDIZINISCHE FRAGE - BESONDERE VORSICHT:
- Gib KEINE medizinische Beratung
- Gib KEINE Diagnosen oder Behandlungsempfehlungen
- Verweise IMMER auf professionelle Beratung
- Standardformulierung: "Für eine persönliche Einschätzung Ihrer Situation empfehlen wir ein Gespräch mit unserem Beratungsteam unter 0800-XXX oder auf unserer Website."
`
}
private getSignature(channelType: string): string {
switch (channelType) {
case 'corporate':
return 'Caroline'
case 'lifestyle':
return 'Caroline'
case 'business':
return 'The CCS Team'
default:
return 'Das Team'
}
}
private parseReplyResponse(text: string, context: ReplyContext): GeneratedReply {
// Bereinige die Antwort
let cleanedText = text.trim()
// Entferne mögliche Anführungszeichen am Anfang/Ende
cleanedText = cleanedText.replace(/^["']|["']$/g, '')
const warnings: string[] = []
// Prüfe auf potenzielle Probleme
if (context.isMedicalQuestion && !cleanedText.toLowerCase().includes('beratung')) {
warnings.push('Medizinische Frage: Verweis auf Beratung fehlt möglicherweise')
}
if (cleanedText.length > 500) {
warnings.push('Antwort ist möglicherweise zu lang')
}
if (cleanedText.length < 20) {
warnings.push('Antwort ist sehr kurz')
}
return {
text: cleanedText,
tone: 'standard',
confidence: 85,
alternatives: [],
warnings,
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export default ClaudeReplyService

80
src/lib/jobs/JobLogger.ts Normal file
View file

@ -0,0 +1,80 @@
// src/lib/jobs/JobLogger.ts
/**
* Logger für Hintergrund-Jobs mit strukturiertem Output
*/
export class JobLogger {
private jobName: string
private startTime: number
constructor(jobName: string) {
this.jobName = jobName
this.startTime = Date.now()
}
info(message: string, data?: unknown): void {
console.log(
`[${this.timestamp()}] [${this.jobName}] INFO: ${message}`,
data !== undefined ? data : ''
)
}
warn(message: string, data?: unknown): void {
console.warn(
`[${this.timestamp()}] [${this.jobName}] WARN: ${message}`,
data !== undefined ? data : ''
)
}
error(message: string, error?: unknown): void {
console.error(
`[${this.timestamp()}] [${this.jobName}] ERROR: ${message}`,
error !== undefined ? error : ''
)
}
debug(message: string, data?: unknown): void {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG) {
console.log(
`[${this.timestamp()}] [${this.jobName}] DEBUG: ${message}`,
data !== undefined ? data : ''
)
}
}
/**
* Log mit Dauer seit Job-Start
*/
elapsed(message: string): void {
const elapsed = Date.now() - this.startTime
console.log(
`[${this.timestamp()}] [${this.jobName}] INFO: ${message} (${elapsed}ms elapsed)`
)
}
/**
* Zusammenfassung am Ende des Jobs
*/
summary(results: {
success: boolean
processed?: number
errors?: number
duration?: number
}): void {
const duration = results.duration || Date.now() - this.startTime
const status = results.success ? 'SUCCESS' : 'FAILED'
console.log(
`[${this.timestamp()}] [${this.jobName}] ${status}: ` +
`Processed: ${results.processed || 0}, ` +
`Errors: ${results.errors || 0}, ` +
`Duration: ${duration}ms`
)
}
private timestamp(): string {
return new Date().toISOString()
}
}
export default JobLogger

View file

@ -0,0 +1,172 @@
// src/lib/jobs/syncAllComments.ts
import { getPayload } from 'payload'
import config from '@payload-config'
import { CommentsSyncService } from '../integrations/youtube/CommentsSyncService'
import { JobLogger } from './JobLogger'
import type { SyncResult } from '@/types/youtube'
// Global state für Sync-Status
let isRunning = false
let lastRunAt: Date | null = null
let lastResult: SyncResult[] | null = null
/**
* Führt Sync für alle aktiven YouTube-Accounts durch
*/
export async function runSync(): Promise<{
success: boolean
results?: SyncResult[]
error?: string
}> {
if (isRunning) {
return { success: false, error: 'Sync already running' }
}
isRunning = true
const logger = new JobLogger('youtube-sync')
const results: SyncResult[] = []
try {
logger.info('Starting YouTube comments sync')
const payload = await getPayload({ config })
// Finde alle aktiven YouTube-Accounts
const platformResult = await payload.find({
collection: 'social-platforms',
where: { slug: { equals: 'youtube' } },
limit: 1,
})
if (!platformResult.docs[0]) {
logger.warn('No YouTube platform found in database')
return { success: true, results: [] }
}
const accounts = await payload.find({
collection: 'social-accounts',
where: {
and: [
{ isActive: { equals: true } },
{ platform: { equals: platformResult.docs[0].id } },
],
},
limit: 100,
depth: 2,
})
logger.info(`Found ${accounts.docs.length} active YouTube accounts`)
if (accounts.docs.length === 0) {
return { success: true, results: [] }
}
const syncService = new CommentsSyncService(payload)
// Sync jeden Account
for (const account of accounts.docs) {
const startTime = Date.now()
const accountName = (account.displayName as string) || `Account ${account.id}`
logger.info(`Syncing account: ${accountName}`)
const result: SyncResult = {
accountId: account.id as number,
accountName,
videosProcessed: 0,
newComments: 0,
updatedComments: 0,
errors: [],
duration: 0,
}
try {
// Hole letzten Sync-Zeitpunkt
const stats = account.stats as { lastSyncedAt?: string } | undefined
const sinceDate = stats?.lastSyncedAt
? new Date(stats.lastSyncedAt)
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // Default: letzte 7 Tage
const syncResult = await syncService.syncComments({
socialAccountId: account.id as number,
sinceDate,
maxComments: 500,
analyzeWithAI: true,
})
result.newComments = syncResult.newComments
result.updatedComments = syncResult.updatedComments
result.errors = syncResult.errors
if (!syncResult.success) {
result.errors.push('Sync completed with errors')
}
logger.info(
`Account ${accountName}: ${syncResult.newComments} new, ${syncResult.updatedComments} updated`
)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
result.errors.push(message)
logger.error(`Failed to sync account ${accountName}`, error)
}
result.duration = Date.now() - startTime
results.push(result)
// Rate limiting zwischen Accounts
await sleep(1000)
}
// Statistiken berechnen
const totalNew = results.reduce((sum, r) => sum + r.newComments, 0)
const totalUpdated = results.reduce((sum, r) => sum + r.updatedComments, 0)
const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0)
logger.summary({
success: true,
processed: totalNew + totalUpdated,
errors: totalErrors,
})
lastRunAt = new Date()
lastResult = results
return { success: true, results }
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.error('Sync failed', error)
return { success: false, error: message }
} finally {
isRunning = false
}
}
/**
* Gibt den aktuellen Sync-Status zurück
*/
export function getSyncStatus(): {
isRunning: boolean
lastRunAt: string | null
lastResult: SyncResult[] | null
} {
return {
isRunning,
lastRunAt: lastRunAt?.toISOString() || null,
lastResult,
}
}
/**
* Setzt den Sync-Status zurück (für Tests)
*/
export function resetSyncStatus(): void {
isRunning = false
lastRunAt = null
lastResult = null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

104
src/types/youtube.ts Normal file
View file

@ -0,0 +1,104 @@
// src/types/youtube.ts
/**
* YouTube API Type Definitions
* Für Community Management Phase 2.2
*/
export interface YouTubeComment {
id: string
snippet: {
videoId: string
topLevelComment: {
id: string
snippet: {
textDisplay: string
textOriginal: string
authorDisplayName: string
authorProfileImageUrl: string
authorChannelUrl: string
authorChannelId: { value: string }
likeCount: number
publishedAt: string
updatedAt: string
}
}
totalReplyCount: number
canReply: boolean
}
}
export interface YouTubeCommentThread {
kind: string
etag: string
id: string
snippet: YouTubeComment['snippet']
replies?: {
comments: Array<{
id: string
snippet: {
textDisplay: string
textOriginal: string
authorDisplayName: string
authorProfileImageUrl: string
authorChannelUrl: string
authorChannelId: { value: string }
parentId: string
likeCount: number
publishedAt: string
updatedAt: string
}
}>
}
}
export interface SyncResult {
accountId: number
accountName: string
videosProcessed: number
newComments: number
updatedComments: number
errors: string[]
duration: number
}
export interface SyncJobStatus {
isRunning: boolean
lastRunAt: string | null
lastResult: SyncResult[] | null
nextRunAt: string | null
}
export interface YouTubeCredentials {
clientId: string
clientSecret: string
accessToken: string
refreshToken: string
}
export interface YouTubeTokens {
access_token: string
refresh_token?: string
expires_in?: number
token_type?: string
}
export interface ReplyContext {
channelName: string
channelType: 'corporate' | 'lifestyle' | 'business'
videoTitle: string
videoTopics: string[]
commentText: string
commentSentiment: string
authorName: string
isMedicalQuestion: boolean
previousReplies?: string[]
}
export interface GeneratedReply {
text: string
tone: string
confidence: number
alternatives: string[]
warnings: string[]
}