diff --git a/admin-core/.dockerignore b/admin-core/.dockerignore new file mode 100644 index 0000000..e421be9 --- /dev/null +++ b/admin-core/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +README.md +*.log +.env.local +.env.*.local diff --git a/admin-core/Dockerfile b/admin-core/Dockerfile new file mode 100644 index 0000000..08ee6c8 --- /dev/null +++ b/admin-core/Dockerfile @@ -0,0 +1,51 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build arguments for environment variables +ARG NEXT_PUBLIC_API_URL + +# Set environment variables for build +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Set to production +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Set hostname +ENV HOSTNAME="0.0.0.0" + +# Start the application +CMD ["node", "server.js"] diff --git a/admin-core/app/(admin)/communication/alerts/page.tsx b/admin-core/app/(admin)/communication/alerts/page.tsx new file mode 100644 index 0000000..3d73dd3 --- /dev/null +++ b/admin-core/app/(admin)/communication/alerts/page.tsx @@ -0,0 +1,912 @@ +'use client' + +/** + * Alerts Monitoring Admin Page (migrated from website/admin/alerts) + * + * Google Alerts & Feed-Ueberwachung Dashboard + * Provides inbox management, topic configuration, rule builder, and relevance profiles + */ + +import { useEffect, useState, useCallback } from 'react' +import { PagePurpose } from '@/components/common/PagePurpose' + +// Types +interface AlertItem { + id: string + title: string + url: string + snippet: string + topic_name: string + relevance_score: number | null + relevance_decision: string | null + status: string + fetched_at: string + published_at: string | null + matched_rule: string | null + tags: string[] +} + +interface Topic { + id: string + name: string + feed_url: string + feed_type: string + is_active: boolean + fetch_interval_minutes: number + last_fetched_at: string | null + alert_count: number +} + +interface Rule { + id: string + name: string + topic_id: string | null + conditions: Array<{ + field: string + operator: string + value: string | number + }> + action_type: string + action_config: Record + priority: number + is_active: boolean +} + +interface Profile { + priorities: string[] + exclusions: string[] + positive_examples: Array<{ title: string; url: string }> + negative_examples: Array<{ title: string; url: string }> + policies: { + keep_threshold: number + drop_threshold: number + } +} + +interface Stats { + total_alerts: number + new_alerts: number + kept_alerts: number + review_alerts: number + dropped_alerts: number + total_topics: number + active_topics: number + total_rules: number +} + +// Tab type +type TabId = 'dashboard' | 'inbox' | 'topics' | 'rules' | 'profile' | 'audit' | 'documentation' + +export default function AlertsPage() { + const [activeTab, setActiveTab] = useState('dashboard') + const [stats, setStats] = useState(null) + const [alerts, setAlerts] = useState([]) + const [topics, setTopics] = useState([]) + const [rules, setRules] = useState([]) + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [inboxFilter, setInboxFilter] = useState('all') + + const API_BASE = '/api/alerts' + + const fetchData = useCallback(async () => { + try { + const [statsRes, alertsRes, topicsRes, rulesRes, profileRes] = await Promise.all([ + fetch(`${API_BASE}/stats`), + fetch(`${API_BASE}/inbox?limit=50`), + fetch(`${API_BASE}/topics`), + fetch(`${API_BASE}/rules`), + fetch(`${API_BASE}/profile`), + ]) + + if (statsRes.ok) setStats(await statsRes.json()) + if (alertsRes.ok) { + const data = await alertsRes.json() + setAlerts(data.items || []) + } + if (topicsRes.ok) { + const data = await topicsRes.json() + setTopics(data.topics || data.items || []) + } + if (rulesRes.ok) { + const data = await rulesRes.json() + setRules(data.rules || data.items || []) + } + if (profileRes.ok) setProfile(await profileRes.json()) + + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Verbindungsfehler') + // Set demo data + setStats({ + total_alerts: 147, + new_alerts: 23, + kept_alerts: 89, + review_alerts: 12, + dropped_alerts: 23, + total_topics: 5, + active_topics: 4, + total_rules: 8, + }) + setAlerts([ + { + id: 'demo_1', + title: 'Neue Studie zur digitalen Bildung an Schulen', + url: 'https://example.com/artikel1', + snippet: 'Eine aktuelle Studie zeigt, dass digitale Lernmittel den Lernerfolg steigern koennen...', + topic_name: 'Digitale Bildung', + relevance_score: 0.85, + relevance_decision: 'KEEP', + status: 'new', + fetched_at: new Date().toISOString(), + published_at: null, + matched_rule: null, + tags: ['bildung', 'digital'], + }, + { + id: 'demo_2', + title: 'Inklusion: Fortbildungen fuer Lehrkraefte', + url: 'https://example.com/artikel2', + snippet: 'Das Kultusministerium bietet neue Fortbildungsangebote zum Thema Inklusion an...', + topic_name: 'Inklusion', + relevance_score: 0.72, + relevance_decision: 'KEEP', + status: 'new', + fetched_at: new Date(Date.now() - 3600000).toISOString(), + published_at: null, + matched_rule: null, + tags: ['inklusion'], + }, + ]) + setTopics([ + { + id: 'topic_1', + name: 'Digitale Bildung', + feed_url: 'https://google.com/alerts/feeds/123', + feed_type: 'rss', + is_active: true, + fetch_interval_minutes: 60, + last_fetched_at: new Date().toISOString(), + alert_count: 47, + }, + { + id: 'topic_2', + name: 'Inklusion', + feed_url: 'https://google.com/alerts/feeds/456', + feed_type: 'rss', + is_active: true, + fetch_interval_minutes: 60, + last_fetched_at: new Date(Date.now() - 1800000).toISOString(), + alert_count: 32, + }, + ]) + setRules([ + { + id: 'rule_1', + name: 'Stellenanzeigen ausschliessen', + topic_id: null, + conditions: [{ field: 'title', operator: 'contains', value: 'Stellenangebot' }], + action_type: 'drop', + action_config: {}, + priority: 10, + is_active: true, + }, + ]) + setProfile({ + priorities: ['Inklusion', 'digitale Bildung'], + exclusions: ['Stellenanzeigen', 'Werbung'], + positive_examples: [], + negative_examples: [], + policies: { keep_threshold: 0.7, drop_threshold: 0.3 }, + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const formatTimeAgo = (dateStr: string | null) => { + if (!dateStr) return '-' + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + + if (diffMins < 1) return 'gerade eben' + if (diffMins < 60) return `vor ${diffMins} Min.` + if (diffMins < 1440) return `vor ${Math.floor(diffMins / 60)} Std.` + return `vor ${Math.floor(diffMins / 1440)} Tagen` + } + + const getScoreBadge = (score: number | null) => { + if (score === null) return null + const pct = Math.round(score * 100) + let cls = 'bg-slate-100 text-slate-600' + if (pct >= 70) cls = 'bg-green-100 text-green-800' + else if (pct >= 40) cls = 'bg-amber-100 text-amber-800' + else cls = 'bg-red-100 text-red-800' + return {pct}% + } + + const getDecisionBadge = (decision: string | null) => { + if (!decision) return null + const styles: Record = { + KEEP: 'bg-green-100 text-green-800', + REVIEW: 'bg-amber-100 text-amber-800', + DROP: 'bg-red-100 text-red-800', + } + return ( + + {decision} + + ) + } + + const filteredAlerts = alerts.filter((alert) => { + if (inboxFilter === 'all') return true + if (inboxFilter === 'new') return alert.status === 'new' + if (inboxFilter === 'keep') return alert.relevance_decision === 'KEEP' + if (inboxFilter === 'review') return alert.relevance_decision === 'REVIEW' + return true + }) + + const tabs: { id: TabId; label: string; badge?: number }[] = [ + { id: 'dashboard', label: 'Dashboard' }, + { id: 'inbox', label: 'Inbox', badge: stats?.new_alerts || 0 }, + { id: 'topics', label: 'Topics' }, + { id: 'rules', label: 'Regeln' }, + { id: 'profile', label: 'Profil' }, + { id: 'audit', label: 'Audit' }, + { id: 'documentation', label: 'Dokumentation' }, + ] + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Page Purpose */} + + + {/* Stats Overview */} +
+
+
{stats?.total_alerts || 0}
+
Alerts gesamt
+
+
+
{stats?.new_alerts || 0}
+
Neue Alerts
+
+
+
{stats?.kept_alerts || 0}
+
Relevant
+
+
+
{stats?.review_alerts || 0}
+
Zur Pruefung
+
+
+ + {/* Tab Navigation */} +
+
+ +
+ +
+ {/* Dashboard Tab */} + {activeTab === 'dashboard' && ( +
+ {/* Quick Actions */} +
+
+

Aktive Topics

+
+ {topics.slice(0, 5).map((topic) => ( +
+
+
{topic.name}
+
{topic.alert_count} Alerts
+
+ + {topic.is_active ? 'Aktiv' : 'Pausiert'} + +
+ ))} + {topics.length === 0 && ( +
Keine Topics konfiguriert
+ )} +
+
+ +
+

Letzte Alerts

+
+ {alerts.slice(0, 5).map((alert) => ( +
+
{alert.title}
+
+ {alert.topic_name} + {getScoreBadge(alert.relevance_score)} +
+
+ ))} + {alerts.length === 0 && ( +
Keine Alerts vorhanden
+ )} +
+
+
+ + {error && ( +
+

+ Hinweis: API nicht erreichbar. Demo-Daten werden angezeigt. +

+
+ )} +
+ )} + + {/* Inbox Tab */} + {activeTab === 'inbox' && ( +
+ {/* Filters */} +
+ {['all', 'new', 'keep', 'review'].map((filter) => ( + + ))} +
+ + {/* Alerts Table */} +
+ + + + + + + + + + + + {filteredAlerts.map((alert) => ( + + + + + + + + ))} + {filteredAlerts.length === 0 && ( + + + + )} + +
AlertTopicScoreDecisionZeit
+ + {alert.title} + +

{alert.snippet}

+
{alert.topic_name}{getScoreBadge(alert.relevance_score)}{getDecisionBadge(alert.relevance_decision)}{formatTimeAgo(alert.fetched_at)}
+ Keine Alerts gefunden +
+
+
+ )} + + {/* Topics Tab */} + {activeTab === 'topics' && ( +
+
+

Feed Topics

+ +
+ +
+ {topics.map((topic) => ( +
+
+
+ + + +
+ + {topic.is_active ? 'Aktiv' : 'Pausiert'} + +
+

{topic.name}

+

{topic.feed_url}

+
+
+ {topic.alert_count} + Alerts +
+
+ {formatTimeAgo(topic.last_fetched_at)} +
+
+
+ ))} + {topics.length === 0 && ( +
+ Keine Topics konfiguriert +
+ )} +
+
+ )} + + {/* Rules Tab */} + {activeTab === 'rules' && ( +
+
+

Filterregeln

+ +
+ +
+ {rules.map((rule) => ( +
+
+ + + +
+
+
{rule.name}
+
+ Wenn: {rule.conditions[0]?.field} {rule.conditions[0]?.operator} "{rule.conditions[0]?.value}" +
+
+ + {rule.action_type} + +
+
+
+
+ ))} + {rules.length === 0 && ( +
+ Keine Regeln konfiguriert +
+ )} +
+
+ )} + + {/* Profile Tab */} + {activeTab === 'profile' && ( +
+
+

Relevanzprofil

+ +
+
+ +