mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
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:
parent
a5e634ccaf
commit
3464494b14
13 changed files with 1839 additions and 17 deletions
244
docs/reports/phase2.2-corrections-report.md
Normal file
244
docs/reports/phase2.2-corrections-report.md
Normal 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)*
|
||||
|
|
@ -41,6 +41,7 @@
|
|||
"@types/pdfkit": "^0.17.4",
|
||||
"bullmq": "^5.65.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"googleapis": "^170.0.0",
|
||||
|
|
@ -53,6 +54,7 @@
|
|||
"pdfkit": "^0.17.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.6.0",
|
||||
"sharp": "0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
305
pnpm-lock.yaml
305
pnpm-lock.yaml
|
|
@ -50,6 +50,9 @@ importers:
|
|||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
dotenv:
|
||||
specifier: 16.4.7
|
||||
version: 16.4.7
|
||||
|
|
@ -86,6 +89,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: 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:
|
||||
specifier: 0.34.5
|
||||
version: 0.34.5
|
||||
|
|
@ -1346,6 +1352,17 @@ packages:
|
|||
engines: {node: '>=18'}
|
||||
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':
|
||||
resolution: {integrity: sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==}
|
||||
|
||||
|
|
@ -1640,6 +1657,9 @@ packages:
|
|||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
|
|
@ -1670,6 +1690,33 @@ packages:
|
|||
'@types/chai@5.2.3':
|
||||
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':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
|
|
@ -1743,6 +1790,9 @@ packages:
|
|||
'@types/unist@3.0.3':
|
||||
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':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
|
|
@ -2321,6 +2371,50 @@ packages:
|
|||
csstype@3.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
|
|
@ -2376,6 +2470,9 @@ packages:
|
|||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
|
|
@ -2599,6 +2696,9 @@ packages:
|
|||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.43.0:
|
||||
resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==}
|
||||
|
||||
esbuild-register@3.6.0:
|
||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||
peerDependencies:
|
||||
|
|
@ -2750,6 +2850,9 @@ packages:
|
|||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
exceljs@4.4.0:
|
||||
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
|
|
@ -3072,6 +3175,12 @@ packages:
|
|||
immediate@3.0.6:
|
||||
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:
|
||||
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
||||
|
||||
|
|
@ -3094,6 +3203,10 @@ packages:
|
|||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ioredis@5.8.2:
|
||||
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
|
@ -4054,6 +4167,18 @@ packages:
|
|||
react-is@16.13.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -4092,6 +4217,14 @@ packages:
|
|||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -4100,6 +4233,14 @@ packages:
|
|||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -4112,6 +4253,9 @@ packages:
|
|||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -4435,6 +4579,9 @@ packages:
|
|||
tiny-inflate@1.0.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
|
|
@ -4629,6 +4776,11 @@ packages:
|
|||
'@types/react':
|
||||
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:
|
||||
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
||||
|
||||
|
|
@ -4657,6 +4809,9 @@ packages:
|
|||
vfile-message@4.0.3:
|
||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-tsconfig-paths@6.0.0:
|
||||
resolution: {integrity: sha512-0lGkM62rud1ShKWLbJpbTHPoJuZIL9QW1ecCueDhqxWrStIRsyHapBQ4eV05tBqrW9z6jkp9ybBVgLSWp+Mv1A==}
|
||||
peerDependencies:
|
||||
|
|
@ -6444,6 +6599,18 @@ snapshots:
|
|||
dependencies:
|
||||
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': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.53.3':
|
||||
|
|
@ -6792,6 +6959,8 @@ snapshots:
|
|||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -6837,6 +7006,30 @@ snapshots:
|
|||
'@types/deep-eql': 4.0.2
|
||||
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':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
|
@ -6911,6 +7104,8 @@ snapshots:
|
|||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@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)':
|
||||
|
|
@ -7543,6 +7738,44 @@ snapshots:
|
|||
|
||||
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: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
|
@ -7588,6 +7821,8 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
|
|
@ -7797,6 +8032,8 @@ snapshots:
|
|||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
es-toolkit@1.43.0: {}
|
||||
|
||||
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
@ -8069,6 +8306,8 @@ snapshots:
|
|||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
exceljs@4.4.0:
|
||||
dependencies:
|
||||
archiver: 5.3.2
|
||||
|
|
@ -8427,6 +8666,10 @@ snapshots:
|
|||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.3: {}
|
||||
|
||||
immutable@4.3.7: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
|
|
@ -8449,6 +8692,8 @@ snapshots:
|
|||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ioredis@5.8.2:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.4.0
|
||||
|
|
@ -9605,6 +9850,15 @@ snapshots:
|
|||
|
||||
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-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: {}
|
||||
|
||||
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-parser@3.0.0:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
|
|
@ -9689,6 +9969,8 @@ snapshots:
|
|||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
|
@ -10079,6 +10361,8 @@ snapshots:
|
|||
|
||||
tiny-inflate@1.0.3: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
|
@ -10314,6 +10598,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@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: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
|
@ -10339,6 +10627,23 @@ snapshots:
|
|||
'@types/unist': 3.0.3
|
||||
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)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
|
|
|
|||
|
|
@ -96,6 +96,22 @@ export const CommunityInbox: React.FC = () => {
|
|||
const [exportFormat, setExportFormat] = useState<'pdf' | 'excel' | 'csv'>('excel')
|
||||
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
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
|
|
@ -226,22 +242,52 @@ export const CommunityInbox: React.FC = () => {
|
|||
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 () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const response = await fetch('/api/community/sync-comments', {
|
||||
const response = await fetch('/api/community/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ analyzeWithAI: true }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
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()
|
||||
// Refresh sync status
|
||||
const statusResponse = await fetch('/api/community/sync')
|
||||
if (statusResponse.ok) {
|
||||
const status = await statusResponse.json()
|
||||
setSyncStatus(status)
|
||||
}
|
||||
} else {
|
||||
throw new Error('Sync failed')
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Sync failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
|
|
@ -386,27 +432,48 @@ export const CommunityInbox: React.FC = () => {
|
|||
}
|
||||
|
||||
// Generate AI reply
|
||||
const generateAIReply = async () => {
|
||||
const generateAIReply = async (variants: boolean = false) => {
|
||||
if (!selectedInteraction) return
|
||||
|
||||
setIsGeneratingReply(true)
|
||||
setGeneratedReplies([])
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/community/generate-reply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
interactionId: selectedInteraction.id,
|
||||
variants,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
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) {
|
||||
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
|
||||
const selectInteraction = (interaction: Interaction) => {
|
||||
setSelectedInteraction(interaction)
|
||||
|
|
@ -736,8 +803,12 @@ export const CommunityInbox: React.FC = () => {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn-ai" onClick={generateAIReply}>
|
||||
🤖 KI
|
||||
<button
|
||||
className="btn-ai"
|
||||
onClick={() => generateAIReply(false)}
|
||||
disabled={isGeneratingReply}
|
||||
>
|
||||
{isGeneratingReply ? '...' : '🤖 KI'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
|
|
@ -975,14 +1046,27 @@ export const CommunityInbox: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
📈 Analytics
|
||||
</a>
|
||||
<button className="btn" onClick={() => setShowExportModal(true)}>
|
||||
📊 Export
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSync} disabled={syncing}>
|
||||
{syncing ? '⏳ Syncing...' : '🔄 Sync'}
|
||||
<button className="btn btn-primary" onClick={handleSync} disabled={syncing || syncStatus?.isRunning}>
|
||||
{syncing || syncStatus?.isRunning ? '⏳ Syncing...' : '🔄 Sync'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1395,10 +1479,60 @@ export const CommunityInbox: React.FC = () => {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-ai" onClick={generateAIReply}>
|
||||
🤖 KI-Antwort
|
||||
<div className="ai-buttons">
|
||||
<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>
|
||||
</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
|
||||
value={replyText}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
src/app/(payload)/api/community/generate-reply/route.ts
Normal file
138
src/app/(payload)/api/community/generate-reply/route.ts
Normal 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'
|
||||
112
src/app/(payload)/api/community/sync/route.ts
Normal file
112
src/app/(payload)/api/community/sync/route.ts
Normal 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'
|
||||
65
src/app/(payload)/api/cron/youtube-sync/route.ts
Normal file
65
src/app/(payload)/api/cron/youtube-sync/route.ts
Normal 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)
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
@ -56,7 +63,7 @@ Antworte NUR mit dem JSON-Objekt, kein anderer Text.`
|
|||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-3-haiku-20240307',
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 500,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
|
|
@ -112,7 +119,7 @@ Antworte NUR mit dem Antworttext, kein anderer Text.`
|
|||
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: 'claude-3-haiku-20240307',
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 200,
|
||||
system: systemPrompt,
|
||||
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 }
|
||||
|
|
|
|||
243
src/lib/integrations/claude/ClaudeReplyService.ts
Normal file
243
src/lib/integrations/claude/ClaudeReplyService.ts
Normal 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
80
src/lib/jobs/JobLogger.ts
Normal 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
|
||||
172
src/lib/jobs/syncAllComments.ts
Normal file
172
src/lib/jobs/syncAllComments.ts
Normal 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
104
src/types/youtube.ts
Normal 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[]
|
||||
}
|
||||
Loading…
Reference in a new issue