From 9e56f1b5a3082d78b23e93ae25d7b0e2f9b85737 Mon Sep 17 00:00:00 2001 From: MBO-Tech-IT Date: Mon, 27 Apr 2026 19:41:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20integrate=20modules=2001=E2=80=9303,=20?= =?UTF-8?q?06=E2=80=9307=20=E2=80=94=20email,=20admin=20auth,=20analytics,?= =?UTF-8?q?=20kunden-portal,=20statistik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modul 01 (Email): mailer.ts mit Nodemailer + Supabase-Queue, SMTP-Test-Route - Modul 02 (Admin-Auth): JWT-Sessions, Rate-Limiting, Token-Blacklist, Audit-Logs, Login-Route - Modul 03 (Analytics): PageTracker, page_views/phone_clicks Tracking, Admin-Analytics-Seite - Modul 06 (Kunden-Portal): Supabase Auth Login/Registrierung, Kundendashboard, Middleware - Modul 07 (KPI-Dashboard): Admin-Statistik mit Anfragen-Übersicht und Monats-Diagramm - contact/route.ts: speichert Anfragen jetzt in Supabase - Supabase-Types für alle neuen Tabellen ergänzt - app/admin/page.tsx: Redirect zu /admin/analytics - AnalyticsTabs: Design-Inkonsistenz behoben (Light-Mode-Karten auf Dark umgestellt) - layout.tsx: Umlauts-Fehler in Metadata-Description behoben - lib/audit-log.ts: TypeScript null→undefined Mapping für reason-Feld - modules/: Wiederverwendbare Modul-Templates mit Migrations und Integrations-Prompts Co-Authored-By: Claude Sonnet 4.6 --- app/admin/analytics/page.tsx | 425 ++++++++ app/admin/audit-logs/page.tsx | 163 +++ app/admin/layout.tsx | 5 + app/admin/login/page.tsx | 109 ++ app/admin/page.tsx | 5 + app/admin/statistik/page.tsx | 154 +++ app/api/admin/analytics/phone-calls/route.ts | 133 +++ app/api/admin/anfragen-action/route.ts | 43 + app/api/admin/email-queue/route.ts | 28 +- app/api/admin/login/route.ts | 99 ++ app/api/admin/smtp-test/route.ts | 17 +- app/api/admin/statistik/route.ts | 44 + app/api/analytics/track-phone-click/route.ts | 44 + app/api/analytics/track/route.ts | 71 ++ app/api/contact/route.ts | 8 + app/api/kunden/anfragen/route.ts | 40 + app/api/kunden/registrieren/route.ts | 50 + app/auth/callback/page.tsx | 88 ++ app/kunden/dashboard/page.tsx | 166 ++++ app/kunden/login/page.tsx | 114 +++ app/kunden/registrieren/page.tsx | 180 ++++ app/layout.tsx | 4 +- components/Contact.tsx | 1 + components/admin/AdminNav.tsx | 49 + components/admin/AnalyticsTabs.tsx | 218 ++++ components/admin/SessionTimeoutProvider.tsx | 47 + components/analytics/PageTracker.tsx | 79 ++ lib/admin-auth.ts | 145 +++ lib/analytics.ts | 47 + lib/audit-log.ts | 91 ++ lib/mailer.ts | 51 + lib/rate-limit.ts | 71 ++ lib/supabase.ts | 256 ++++- lib/token-blacklist.ts | 88 ++ middleware.ts | 32 + modules/01-email-system/TEMPLATE.md | 188 ++++ .../files/app/api/admin/email-queue/route.ts | 59 ++ .../files/app/api/admin/smtp-test/route.ts | 65 ++ .../01-email-system/files/lib/email-queue.ts | 176 ++++ modules/01-email-system/files/lib/mailer.ts | 932 ++++++++++++++++++ modules/02-admin-auth/TEMPLATE.md | 235 +++++ .../files/app/admin/audit-logs/page.tsx | 204 ++++ .../files/app/admin/login/page.tsx | 115 +++ .../app/api/admin/anfragen-action/route.ts | 165 ++++ .../files/app/api/admin/login/route.ts | 117 +++ .../admin/SessionTimeoutProvider.tsx | 66 ++ modules/02-admin-auth/files/lib/admin-auth.ts | 174 ++++ modules/02-admin-auth/files/lib/audit-log.ts | 154 +++ modules/02-admin-auth/files/lib/rate-limit.ts | 107 ++ .../files/lib/token-blacklist.ts | 115 +++ .../migrations/MIGRATIONS_AUDIT_LOGS.sql | 31 + .../migrations/MIGRATIONS_TOKEN_BLACKLIST.sql | 66 ++ modules/03-analytics/TEMPLATE.md | 181 ++++ .../files/app/admin/analytics/page.tsx | 494 ++++++++++ .../api/admin/analytics/phone-calls/route.ts | 240 +++++ .../api/analytics/track-phone-click/route.ts | 64 ++ .../files/app/api/analytics/track/route.ts | 87 ++ .../components/analytics/PageTracker.tsx | 128 +++ modules/03-analytics/files/lib/analytics.ts | 72 ++ .../migrations/MIGRATIONS_PAGE_VIEWS.sql | 36 + .../migrations/MIGRATIONS_PHONE_CLICKS.sql | 29 + modules/06-kunden-portal/TEMPLATE.md | 239 +++++ .../files/app/api/kunden/anfragen/route.ts | 56 ++ .../app/api/kunden/registrieren/route.ts | 53 + .../files/app/auth/callback/page.tsx | 96 ++ .../files/app/kunden/dashboard/page.tsx | 203 ++++ .../files/app/kunden/login/page.tsx | 119 +++ .../files/app/kunden/registrieren/page.tsx | 176 ++++ modules/07-kpi-dashboard/TEMPLATE.md | 169 ++++ .../app/admin/statistik/GanttKalender.tsx | 366 +++++++ .../files/app/admin/statistik/page.tsx | 343 +++++++ .../files/app/api/admin/statistik/route.ts | 175 ++++ package-lock.json | 182 +++- package.json | 5 + 74 files changed, 9603 insertions(+), 44 deletions(-) create mode 100644 app/admin/analytics/page.tsx create mode 100644 app/admin/audit-logs/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/login/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/statistik/page.tsx create mode 100644 app/api/admin/analytics/phone-calls/route.ts create mode 100644 app/api/admin/anfragen-action/route.ts create mode 100644 app/api/admin/login/route.ts create mode 100644 app/api/admin/statistik/route.ts create mode 100644 app/api/analytics/track-phone-click/route.ts create mode 100644 app/api/analytics/track/route.ts create mode 100644 app/api/kunden/anfragen/route.ts create mode 100644 app/api/kunden/registrieren/route.ts create mode 100644 app/auth/callback/page.tsx create mode 100644 app/kunden/dashboard/page.tsx create mode 100644 app/kunden/login/page.tsx create mode 100644 app/kunden/registrieren/page.tsx create mode 100644 components/admin/AdminNav.tsx create mode 100644 components/admin/AnalyticsTabs.tsx create mode 100644 components/admin/SessionTimeoutProvider.tsx create mode 100644 components/analytics/PageTracker.tsx create mode 100644 lib/admin-auth.ts create mode 100644 lib/analytics.ts create mode 100644 lib/audit-log.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/token-blacklist.ts create mode 100644 middleware.ts create mode 100644 modules/01-email-system/TEMPLATE.md create mode 100644 modules/01-email-system/files/app/api/admin/email-queue/route.ts create mode 100644 modules/01-email-system/files/app/api/admin/smtp-test/route.ts create mode 100644 modules/01-email-system/files/lib/email-queue.ts create mode 100644 modules/01-email-system/files/lib/mailer.ts create mode 100644 modules/02-admin-auth/TEMPLATE.md create mode 100644 modules/02-admin-auth/files/app/admin/audit-logs/page.tsx create mode 100644 modules/02-admin-auth/files/app/admin/login/page.tsx create mode 100644 modules/02-admin-auth/files/app/api/admin/anfragen-action/route.ts create mode 100644 modules/02-admin-auth/files/app/api/admin/login/route.ts create mode 100644 modules/02-admin-auth/files/components/admin/SessionTimeoutProvider.tsx create mode 100644 modules/02-admin-auth/files/lib/admin-auth.ts create mode 100644 modules/02-admin-auth/files/lib/audit-log.ts create mode 100644 modules/02-admin-auth/files/lib/rate-limit.ts create mode 100644 modules/02-admin-auth/files/lib/token-blacklist.ts create mode 100644 modules/02-admin-auth/migrations/MIGRATIONS_AUDIT_LOGS.sql create mode 100644 modules/02-admin-auth/migrations/MIGRATIONS_TOKEN_BLACKLIST.sql create mode 100644 modules/03-analytics/TEMPLATE.md create mode 100644 modules/03-analytics/files/app/admin/analytics/page.tsx create mode 100644 modules/03-analytics/files/app/api/admin/analytics/phone-calls/route.ts create mode 100644 modules/03-analytics/files/app/api/analytics/track-phone-click/route.ts create mode 100644 modules/03-analytics/files/app/api/analytics/track/route.ts create mode 100644 modules/03-analytics/files/components/analytics/PageTracker.tsx create mode 100644 modules/03-analytics/files/lib/analytics.ts create mode 100644 modules/03-analytics/migrations/MIGRATIONS_PAGE_VIEWS.sql create mode 100644 modules/03-analytics/migrations/MIGRATIONS_PHONE_CLICKS.sql create mode 100644 modules/06-kunden-portal/TEMPLATE.md create mode 100644 modules/06-kunden-portal/files/app/api/kunden/anfragen/route.ts create mode 100644 modules/06-kunden-portal/files/app/api/kunden/registrieren/route.ts create mode 100644 modules/06-kunden-portal/files/app/auth/callback/page.tsx create mode 100644 modules/06-kunden-portal/files/app/kunden/dashboard/page.tsx create mode 100644 modules/06-kunden-portal/files/app/kunden/login/page.tsx create mode 100644 modules/06-kunden-portal/files/app/kunden/registrieren/page.tsx create mode 100644 modules/07-kpi-dashboard/TEMPLATE.md create mode 100644 modules/07-kpi-dashboard/files/app/admin/statistik/GanttKalender.tsx create mode 100644 modules/07-kpi-dashboard/files/app/admin/statistik/page.tsx create mode 100644 modules/07-kpi-dashboard/files/app/api/admin/statistik/route.ts diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx new file mode 100644 index 0000000..d5f1696 --- /dev/null +++ b/app/admin/analytics/page.tsx @@ -0,0 +1,425 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { verifySessionToken } from "@/lib/admin-auth"; +import { createServiceClient } from "@/lib/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; +import { AnalyticsTabs } from "@/components/admin/AnalyticsTabs"; +import { AdminNav } from "@/components/admin/AdminNav"; + +export const metadata: Metadata = { title: "Web-Analytics – MBO Tech IT" }; +export const dynamic = "force-dynamic"; + +interface PageView { + id: string; + path: string; + timestamp: string; + device_type: string | null; + browser: string | null; + os: string | null; + session_id: string; + duration_ms: number | null; +} + +interface FilterOption { + label: string; + tage: number; + beschreibung: string; +} + +const FILTER_OPTIONS: FilterOption[] = [ + { label: "Heute", tage: 1, beschreibung: "letzter Tag" }, + { label: "Woche", tage: 7, beschreibung: "letzte 7 Tage" }, + { label: "Monat", tage: 30, beschreibung: "letzte 30 Tage" }, + { label: "3 Monate", tage: 90, beschreibung: "letzte 90 Tage" }, + { label: "Jahr", tage: 365, beschreibung: "letztes Jahr" }, +]; + +function balkenBreite(wert: number, max: number): string { + if (max === 0) return "0%"; + return `${Math.max(4, Math.round((wert / max) * 100))}%`; +} + +function fmtDuration(ms: number | null): string { + if (!ms) return "–"; + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +} + +function fmtDateDE(isoDate: string): string { + const [year, month, day] = isoDate.split("-"); + return `${day}.${month}.${year}`; +} + +async function AnalyticsContent({ + vonISO, + bisISO, + tage = null, +}: { + vonISO: string; + bisISO: string; + tage?: number | null; +}) { + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + + if (!token) redirect("/admin/login"); + + const session = await verifySessionToken(token); + if (!session) redirect("/admin/login"); + + const db = createServiceClient(); + const heute = new Date().toISOString().slice(0, 10); + + const vonTimestamp = vonISO + "T00:00:00Z"; + const bisTimestamp = bisISO + "T23:59:59.999Z"; + + const { data: allViews, error } = await db + .from("page_views") + .select("id, path, timestamp, device_type, browser, os, session_id, duration_ms") + .eq("is_bot", false) + .gte("timestamp", vonTimestamp) + .lte("timestamp", bisTimestamp) + .order("timestamp", { ascending: false }); + + const views: PageView[] = allViews ?? []; + + const heuteViews = views.filter((v) => v.timestamp.startsWith(heute)); + const uniqueSessionsHeute = new Set(heuteViews.map((v) => v.session_id)).size; + const mitDauer = views.filter((v) => v.duration_ms != null && v.duration_ms > 0); + const avgDuration = + mitDauer.length > 0 + ? Math.round(mitDauer.reduce((s, v) => s + v.duration_ms!, 0) / mitDauer.length) + : 0; + const mobileCount = views.filter((v) => v.device_type === "mobile").length; + const mobileRate = views.length > 0 ? Math.round((mobileCount / views.length) * 100) : 0; + + const tagesMap: Record = {}; + for (const v of views) { + const tag = v.timestamp.slice(0, 10); + tagesMap[tag] = (tagesMap[tag] ?? 0) + 1; + } + const tagesStats = Object.entries(tagesMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); + const maxTagesWert = Math.max(0, ...tagesStats.map((s) => s.count)); + + const pathMap: Record = {}; + for (const v of views) { + if (!pathMap[v.path]) pathMap[v.path] = { count: 0, durSum: 0, durCount: 0 }; + pathMap[v.path].count++; + if (v.duration_ms) { + pathMap[v.path].durSum += v.duration_ms; + pathMap[v.path].durCount++; + } + } + const topSeiten = Object.entries(pathMap) + .map(([path, d]) => ({ + path, + count: d.count, + avgDuration: d.durCount > 0 ? Math.round(d.durSum / d.durCount) : null, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + const maxPathCount = Math.max(0, ...topSeiten.map((s) => s.count)); + + const browserMap: Record = {}; + for (const v of views) browserMap[v.browser ?? "Other"] = (browserMap[v.browser ?? "Other"] ?? 0) + 1; + const sortedBrowsers = Object.entries(browserMap).sort((a, b) => b[1] - a[1]).slice(0, 8); + + const deviceMap: Record = {}; + for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1; + + const osMap: Record = {}; + for (const v of views) osMap[v.os ?? "Other"] = (osMap[v.os ?? "Other"] ?? 0) + 1; + const sortedOs = Object.entries(osMap).sort((a, b) => b[1] - a[1]).slice(0, 8); + + let filterDesc: string; + if (tage !== null) { + filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`; + } else { + filterDesc = `${fmtDateDE(vonISO)} – ${fmtDateDE(bisISO)}`; + } + + if (error) { + return ( +
+
+

Fehler beim Laden der Daten:

+

{error.message}

+
+
+ ); + } + + const overviewContent = ( +
+
+
+
+

Web-Analytics

+

{filterDesc} · Bots gefiltert · IPs anonymisiert

+
+ + + Zurück + +
+ +
+
+ {FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} +
+ +
+
+ + +
+
+ + +
+ +
+
+
+ +
+ {[ + { label: "Seitenaufrufe heute", wert: heuteViews.length }, + { label: "Unique Sessions heute", wert: uniqueSessionsHeute }, + { label: "Ø Verweildauer", wert: fmtDuration(avgDuration) }, + { label: "Mobile-Anteil", wert: `${mobileRate}%` }, + ].map((k) => ( +
+
{k.label}
+
{k.wert}
+
+ ))} +
+ +
+

Seitenaufrufe ({filterDesc})

+
+ {tagesStats.length === 0 ? ( +

Keine Daten vorhanden

+ ) : ( + tagesStats.map(({ date, count }) => ( +
+
{date}
+
+
+
+
{count}
+
+ )) + )} +
+
+ +
+
+

Top-Seiten

+
+
+ + + + + + + + + + {topSeiten.length === 0 ? ( + + + + ) : ( + topSeiten.map(({ path, count, avgDuration }) => ( + + + + + + )) + )} + +
SeiteAufrufeØ Verweildauer
Keine Daten vorhanden
{path} +
+
+
+
+ {count} +
+
{fmtDuration(avgDuration)}
+
+
+ +
+
+

Browser

+
+ {sortedBrowsers.length === 0 ? ( +

Keine Daten vorhanden

+ ) : ( + sortedBrowsers.map(([browser, count]) => ( +
+
{browser}
+
+
s[1]))) }} + /> +
+
{count}
+
+ )) + )} +
+
+ +
+

Betriebssystem

+
+ {sortedOs.length === 0 ? ( +

Keine Daten vorhanden

+ ) : ( + sortedOs.map(([os, count]) => ( +
+
{os}
+
+
s[1]))) }} + /> +
+
{count}
+
+ )) + )} +
+
+
+ +
+

Geräte-Verteilung

+
+ {Object.entries(deviceMap).map(([device, count]) => ( +
+
{count}
+
+ {device === "desktop" ? "Desktop" : device === "tablet" ? "Tablet" : "Mobile"} +
+
+ {views.length > 0 ? `${Math.round((count / views.length) * 100)}%` : "0%"} +
+
+ ))} +
+
+ +
+

+ Datenschutz: IP-Adressen werden anonymisiert (letztes Oktett = 0). Keine Cookies für Analytics. + Session-IDs sind Tab-gebunden und nicht persistent. +

+
+
+ ); + + return ; +} + +export default async function AnalyticsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const von = String(params.von || ""); + const bis = String(params.bis || ""); + const tageParam = String(params.tage || ""); + + const heute = new Date().toISOString().slice(0, 10); + + function datumMinusTage(isoDate: string, tage: number): string { + const [year, month, day] = isoDate.split("-").map(Number); + const date = new Date(year, month - 1, day); + date.setDate(date.getDate() - tage); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + + let vonISO: string; + let bisISO: string; + let tage: number | null; + + if (von && bis) { + vonISO = von; + bisISO = bis; + tage = null; + } else if (tageParam) { + tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30)); + bisISO = heute; + vonISO = datumMinusTage(heute, tage); + } else { + tage = 30; + bisISO = heute; + vonISO = datumMinusTage(heute, 30); + } + + return ( +
+ +
+ +

Lädt Analytics-Daten…

+
+ } + > + + +
+
+ ); +} diff --git a/app/admin/audit-logs/page.tsx b/app/admin/audit-logs/page.tsx new file mode 100644 index 0000000..9bc1b9d --- /dev/null +++ b/app/admin/audit-logs/page.tsx @@ -0,0 +1,163 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { verifySessionToken } from "@/lib/admin-auth"; +import { getAuditLogs } from "@/lib/audit-log"; +import { cookies } from "next/headers"; +import { AdminNav } from "@/components/admin/AdminNav"; + +async function AuditLogsContent() { + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + + if (!token) { + return ( +
+

Nicht authentifiziert

+ + Zurück zum Login + +
+ ); + } + + const session = await verifySessionToken(token); + if (!session) { + return ( +
+

Session abgelaufen

+ + Erneut anmelden + +
+ ); + } + + const logs = await getAuditLogs({ limit: 100, offset: 0 }); + + const successCount = logs.filter((l) => l.success).length; + const failedCount = logs.filter((l) => !l.success).length; + const uniqueIps = new Set(logs.map((l) => l.ip_addr)).size; + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const recentLogs = logs.filter((l) => l.timestamp > oneHourAgo); + const recentFailed = recentLogs.filter((l) => !l.success).length; + + return ( +
+
+

Audit-Logs

+

Login-Aktivitäten und Sicherheitsereignisse

+
+ +
+ {[ + { label: "Erfolgreich (Gesamt)", wert: successCount, color: "text-green-400" }, + { label: "Fehlgeschlagen (Gesamt)", wert: failedCount, color: "text-red-400" }, + { label: "Fehlgeschlagen (1h)", wert: recentFailed, color: recentFailed >= 5 ? "text-red-400" : "text-green-400" }, + { label: "Eindeutige IPs", wert: uniqueIps, color: "text-white" }, + ].map((k) => ( +
+
{k.label}
+
{k.wert}
+
+ ))} +
+ + {recentFailed >= 5 && ( +
+

⚠️ Verdächtige Aktivität erkannt

+

+ {recentFailed} fehlgeschlagene Login-Versuche in der letzten Stunde +

+
+ )} + +
+
+ + + + + + + + + + + + {logs.length === 0 ? ( + + + + ) : ( + logs.map((log) => ( + + + + + + + + )) + )} + +
ZeitstempelE-MailIP-AdresseStatusGrund
+ Keine Audit-Logs vorhanden +
+ {new Date(log.timestamp).toLocaleString("de-DE")} + {log.email}{log.ip_addr} + + {log.success ? "✓ Erfolgreich" : "✗ Fehlgeschlagen"} + + + {log.reason ? formatReason(log.reason) : "–"} +
+
+
+ +
+

+ Hinweis: Detaillierte Geräte-Informationen sind in der Supabase-Tabelle{" "} + + admin_audit_logs + {" "} + gespeichert. +

+
+
+ ); +} + +function formatReason(reason: string): string { + const reasons: Record = { + invalid_password: "Falsches Passwort", + user_not_found_or_inactive: "User nicht gefunden / inaktiv", + missing_credentials: "Fehlende Anmeldedaten", + invalid_token: "Ungültiger Token", + token_expired: "Token abgelaufen", + }; + return reasons[reason] || reason; +} + +export default function AuditLogsPage() { + return ( +
+ +
+ +

Lädt Audit-Logs…

+
+ } + > + + +
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..747e085 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,5 @@ +import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..e1d8316 --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +function isInternalUrl(url: string): boolean { + if (!url.startsWith("/")) return false; + return url.startsWith("/admin/") || url.startsWith("/kunden/"); +} + +function AdminLoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const rawFrom = searchParams.get("from"); + const from = rawFrom && isInternalUrl(rawFrom) ? rawFrom : "/admin/analytics"; + const sessionExpired = searchParams.get("session_expired") === "true"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState( + sessionExpired ? "Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an." : "" + ); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (res.ok) { + router.push(from); + } else { + setError("Ungültige Zugangsdaten"); + } + setLoading(false); + } + + return ( +
+
+
+
+ + + +
+

Admin · MBO Tech IT

+

Bitte anmelden

+
+ +
+
+ + setEmail(e.target.value)} + autoComplete="email" + placeholder="admin@mbo-tech-it.de" + required + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +} + +export default function AdminLoginPage() { + return ( + + + + ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..8b7493f --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminPage() { + redirect("/admin/analytics"); +} diff --git a/app/admin/statistik/page.tsx b/app/admin/statistik/page.tsx new file mode 100644 index 0000000..7bf3bd0 --- /dev/null +++ b/app/admin/statistik/page.tsx @@ -0,0 +1,154 @@ +import { createServiceClient } from "@/lib/supabase"; +import { verifySessionToken } from "@/lib/admin-auth"; +import { AdminNav } from "@/components/admin/AdminNav"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Statistik – MBO Tech IT" }; +export const dynamic = "force-dynamic"; + +function fmt(n: number) { + return n.toLocaleString("de-DE"); +} + +export default async function StatistikPage() { + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + if (!token) redirect("/admin/login"); + const session = await verifySessionToken(token); + if (!session) redirect("/admin/login?session_expired=true"); + + const db = createServiceClient(); + + const { data: alleAnfragen } = await db + .from("anfragen") + .select("id, status, created_at, name, betreff"); + + const anfragen = alleAnfragen ?? []; + const gesamt = anfragen.length; + const offen = anfragen.filter((a) => a.status === "offen").length; + const inBearbeitung = anfragen.filter((a) => a.status === "in_bearbeitung").length; + const abgeschlossen = anfragen.filter((a) => a.status === "abgeschlossen").length; + + const { data: recentRaw } = await db + .from("anfragen") + .select("id, created_at, name, betreff, status, email") + .order("created_at", { ascending: false }) + .limit(20); + + const recentAnfragen = recentRaw ?? []; + + const heute = new Date(); + const monatsStats: { monat: string; label: string; count: number }[] = []; + for (let i = 5; i >= 0; i--) { + const d = new Date(heute); + d.setMonth(d.getMonth() - i); + const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" }); + const count = anfragen.filter((a) => a.created_at.startsWith(monat)).length; + monatsStats.push({ monat, label, count }); + } + const maxCount = Math.max(...monatsStats.map((m) => m.count), 1); + + return ( +
+ + +
+
+

Statistik

+

Anfragen-Übersicht

+
+ +
+ {[ + { label: "Anfragen gesamt", wert: fmt(gesamt), farbe: "border-l-slate-400" }, + { label: "Offen", wert: fmt(offen), farbe: "border-l-amber-500" }, + { label: "In Bearbeitung", wert: fmt(inBearbeitung), farbe: "border-l-blue-500" }, + { label: "Abgeschlossen", wert: fmt(abgeschlossen), farbe: "border-l-green-500" }, + ].map((k) => ( +
+

{k.label}

+

{k.wert}

+
+ ))} +
+ +
+

Anfragen letzte 6 Monate

+
+ {monatsStats.map((m) => ( +
+ + {m.count > 0 ? m.count : "–"} + +
+
0 ? 8 : 0, Math.round((m.count / maxCount) * 80))}px`, + }} + /> +
+ {m.label} +
+ ))} +
+
+ +
+
+

Letzte 20 Anfragen

+
+
+ + + + + + + + + + + {recentAnfragen.length === 0 ? ( + + + + ) : ( + recentAnfragen.map((a) => ( + + + + + + + )) + )} + +
DatumNameBetreffStatus
+ Keine Anfragen vorhanden +
+ {new Date(a.created_at).toLocaleDateString("de-DE")} + {a.name}{a.betreff} + + {a.status === "offen" + ? "Offen" + : a.status === "in_bearbeitung" + ? "In Bearbeitung" + : "Abgeschlossen"} + +
+
+
+
+
+ ); +} diff --git a/app/api/admin/analytics/phone-calls/route.ts b/app/api/admin/analytics/phone-calls/route.ts new file mode 100644 index 0000000..42462d3 --- /dev/null +++ b/app/api/admin/analytics/phone-calls/route.ts @@ -0,0 +1,133 @@ +import { NextResponse, NextRequest } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { requireAdmin } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +type DateRange = "today" | "7days" | "30days"; + +function getDateRange(range: DateRange): { start: string; end: string } { + const end = new Date(); + const start = new Date(); + switch (range) { + case "today": + start.setHours(0, 0, 0, 0); + break; + case "7days": + start.setDate(start.getDate() - 7); + break; + case "30days": + start.setDate(start.getDate() - 30); + break; + } + return { start: start.toISOString(), end: end.toISOString() }; +} + +export async function GET(request: NextRequest) { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const searchParams = request.nextUrl.searchParams; + const range = (searchParams.get("range") || "today") as DateRange; + const { start, end } = getDateRange(range); + + try { + const db = createServiceClient(); + + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const todayISO = todayStart.toISOString(); + + const { count: todayCount } = await db + .from("phone_clicks") + .select("*", { count: "exact" }) + .gte("timestamp", todayISO) + .lte("timestamp", new Date().toISOString()); + + const callsToday = todayCount || 0; + + const yesterdayStart = new Date(todayStart); + yesterdayStart.setDate(yesterdayStart.getDate() - 1); + const yesterdayEnd = new Date(yesterdayStart); + yesterdayEnd.setDate(yesterdayEnd.getDate() + 1); + + const { count: yesterdayCount } = await db + .from("phone_clicks") + .select("*", { count: "exact" }) + .gte("timestamp", yesterdayStart.toISOString()) + .lt("timestamp", yesterdayEnd.toISOString()); + + const callsYesterday = yesterdayCount || 0; + const callsTodayTrend = + callsYesterday > 0 + ? Math.round(((callsToday - callsYesterday) / callsYesterday) * 100) + : callsToday > 0 ? 100 : 0; + + const { data: uniqueData } = await db + .from("phone_clicks") + .select("phone_number") + .gte("timestamp", start) + .lte("timestamp", end); + + const uniqueNumbers = new Set((uniqueData || []).map((d) => d.phone_number)).size; + + const { data: elementData } = await db + .from("phone_clicks") + .select("source_element") + .gte("timestamp", start) + .lte("timestamp", end); + + const elementCounts: Record = {}; + (elementData || []).forEach((item) => { + elementCounts[item.source_element] = (elementCounts[item.source_element] || 0) + 1; + }); + + const totalClicks = elementData?.length || 0; + const elementEntries = Object.entries(elementCounts).sort(([, a], [, b]) => b - a); + const [topSourceElement, topSourceElementCount] = elementEntries[0] || [null, 0]; + const topSourceElementPercent = + totalClicks > 0 ? Math.round((topSourceElementCount / totalClicks) * 100) : 0; + + const numberCounts: Record = {}; + (uniqueData || []).forEach((item) => { + numberCounts[item.phone_number] = (numberCounts[item.phone_number] || 0) + 1; + }); + + const phoneNumbers = Object.entries(numberCounts) + .map(([phone_number, click_count]) => ({ phone_number, click_count })) + .sort((a, b) => b.click_count - a.click_count); + + const elements = Object.entries(elementCounts) + .map(([source_element, count]) => ({ + source_element, + count, + percent: totalClicks > 0 ? Math.round((count / totalClicks) * 100) : 0, + })) + .sort((a, b) => b.count - a.count); + + const { data: timeseriesRaw } = await db + .from("phone_clicks") + .select("timestamp") + .gte("timestamp", start) + .lte("timestamp", end) + .order("timestamp", { ascending: true }); + + const timeseriesMap: Record = {}; + (timeseriesRaw || []).forEach((item) => { + const date = item.timestamp.split("T")[0]; + timeseriesMap[date] = (timeseriesMap[date] || 0) + 1; + }); + + const timeseries = Object.entries(timeseriesMap).map(([date, count]) => ({ date, count })); + + return NextResponse.json({ + kpis: { callsToday, callsTodayTrend, uniqueNumbers, topSourceElement, topSourceElementPercent }, + phoneNumbers, + elements, + timeseries, + }); + } catch (error) { + console.error("Phone calls analytics error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/admin/anfragen-action/route.ts b/app/api/admin/anfragen-action/route.ts new file mode 100644 index 0000000..302bc03 --- /dev/null +++ b/app/api/admin/anfragen-action/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { verifyActionToken } from "@/lib/admin-auth"; +import { markActionTokenUsed } from "@/lib/token-blacklist"; + +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get("token"); + + if (!token) { + return NextResponse.json({ error: "Token erforderlich" }, { status: 400 }); + } + + const actionToken = await verifyActionToken(token); + if (!actionToken) { + return NextResponse.json({ error: "Token ungültig oder abgelaufen" }, { status: 400 }); + } + + const { anfrageId, status } = actionToken; + const appUrl = process.env.APP_URL ?? "https://mbo-tech-it.de"; + const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown"; + + const [, tokenSig] = token.split("."); + await markActionTokenUsed(tokenSig, anfrageId, status, ipAddr); + + try { + const db = createServiceClient(); + + const { error } = await db + .from("anfragen") + .update({ status }) + .eq("id", anfrageId); + + if (error) { + console.error(`[Action] Fehler beim Update von Anfrage ${anfrageId}:`, error); + return NextResponse.json({ error: "Statusaktualisierung fehlgeschlagen" }, { status: 500 }); + } + + return NextResponse.redirect(`${appUrl}/admin/statistik?action=done`); + } catch (err) { + console.error("[Action] Unerwarteter Fehler:", err); + return NextResponse.json({ error: "Ein Fehler ist aufgetreten" }, { status: 500 }); + } +} diff --git a/app/api/admin/email-queue/route.ts b/app/api/admin/email-queue/route.ts index cd5c38f..ffc6df5 100644 --- a/app/api/admin/email-queue/route.ts +++ b/app/api/admin/email-queue/route.ts @@ -1,19 +1,8 @@ import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; export const dynamic = "force-dynamic"; -// Optionaler Schutz via ADMIN_SECRET env-Variable. -// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist. -function checkAuth(request: Request): NextResponse | null { - const secret = process.env.ADMIN_SECRET; - if (!secret) return null; - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${secret}`) { - return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 }); - } - return null; -} - function isSupabaseConfigured() { return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY); } @@ -25,10 +14,9 @@ function supabaseNotReadyResponse() { ); } -// GET: Queue-Status abrufen export async function GET(request: Request) { - const authError = checkAuth(request); - if (authError) return authError; + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); const { createServiceClient } = await import("@/lib/supabase"); @@ -43,10 +31,9 @@ export async function GET(request: Request) { return NextResponse.json(data); } -// POST: Alle pending Mails sofort neu versuchen export async function POST(request: Request) { - const authError = checkAuth(request); - if (authError) return authError; + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); const { createServiceClient } = await import("@/lib/supabase"); @@ -66,10 +53,9 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true, updated: count ?? 0 }); } -// DELETE: Fehlgeschlagene Mails löschen export async function DELETE(request: Request) { - const authError = checkAuth(request); - if (authError) return authError; + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; if (!isSupabaseConfigured()) return supabaseNotReadyResponse(); const { createServiceClient } = await import("@/lib/supabase"); diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..65c1ebc --- /dev/null +++ b/app/api/admin/login/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { createServiceClient } from "@/lib/supabase"; +import { createSessionToken, verifySessionToken } from "@/lib/admin-auth"; +import { logLoginAttempt, getFailedLoginCount, sendSecurityAlert } from "@/lib/audit-log"; +import { checkRateLimit, resetRateLimit } from "@/lib/rate-limit"; +import { revokeSessionToken } from "@/lib/token-blacklist"; + +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown"; + const userAgent = req.headers.get("user-agent") || "unknown"; + + if (!email || !password) { + await logLoginAttempt("", ipAddr, false, userAgent, "missing_credentials"); + return NextResponse.json({ error: "E-Mail und Passwort erforderlich" }, { status: 400 }); + } + + const rateLimitKey = `login:${email.toLowerCase()}:${ipAddr}`; + const { allowed, delayMs, locked } = checkRateLimit(rateLimitKey); + + if (locked) { + await logLoginAttempt(email, ipAddr, false, userAgent, "rate_limit_locked"); + const res = NextResponse.json({ error: "Zu viele Anmeldeversuche. Bitte später versuchen." }, { status: 429 }); + res.headers.set("Retry-After", String(Math.ceil(delayMs / 1000))); + return res; + } + + if (!allowed && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + const db = createServiceClient(); + const { data: admin } = await db + .from("admin_users") + .select("id, email, name, password_hash, aktiv") + .eq("email", email.toLowerCase().trim()) + .single(); + + if (!admin || !admin.aktiv) { + await logLoginAttempt(email, ipAddr, false, userAgent, "user_not_found_or_inactive"); + const failedCount = await getFailedLoginCount(email, "email", 60); + if (failedCount >= 10) { + await sendSecurityAlert( + "⚠️ Viele fehlgeschlagene Login-Versuche", + `Email: ${email}\nVersuche in der letzten Stunde: ${failedCount}` + ); + } + return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 }); + } + + const valid = await bcrypt.compare(password, admin.password_hash); + if (!valid) { + await logLoginAttempt(email, ipAddr, false, userAgent, "invalid_password"); + const failedCount = await getFailedLoginCount(email, "email", 60); + if (failedCount >= 10) { + await sendSecurityAlert( + "⚠️ Viele fehlgeschlagene Login-Versuche (falsches Passwort)", + `Email: ${email}\nIP: ${ipAddr}\nVersuche in der letzten Stunde: ${failedCount}` + ); + } + return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 }); + } + + await logLoginAttempt(email, ipAddr, true, userAgent); + resetRateLimit(rateLimitKey); + + const token = await createSessionToken({ id: admin.id, email: admin.email, name: admin.name ?? "" }); + + const res = NextResponse.json({ success: true }); + res.cookies.set("admin_session", token, { + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 2, + path: "/", + secure: process.env.NODE_ENV === "production", + }); + return res; +} + +export async function DELETE(req: NextRequest) { + const token = req.cookies.get("admin_session")?.value; + + if (token) { + try { + const session = await verifySessionToken(token); + if (session) { + const [, sig] = token.split("."); + await revokeSessionToken(sig, session.id, "logout"); + } + } catch { + // Logout auch bei Fehler durchführen + } + } + + const res = NextResponse.json({ success: true }); + res.cookies.delete("admin_session"); + return res; +} diff --git a/app/api/admin/smtp-test/route.ts b/app/api/admin/smtp-test/route.ts index c620abf..7d4b928 100644 --- a/app/api/admin/smtp-test/route.ts +++ b/app/api/admin/smtp-test/route.ts @@ -1,23 +1,12 @@ import { NextResponse } from "next/server"; import nodemailer from "nodemailer"; +import { requireAdmin } from "@/lib/admin-auth"; export const dynamic = "force-dynamic"; -// Optionaler Schutz via ADMIN_SECRET env-Variable. -// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist. -function checkAuth(request: Request): NextResponse | null { - const secret = process.env.ADMIN_SECRET; - if (!secret) return null; - const auth = request.headers.get("authorization"); - if (auth !== `Bearer ${secret}`) { - return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 }); - } - return null; -} - export async function GET(request: Request) { - const authError = checkAuth(request); - if (authError) return authError; + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; const config = { host: process.env.SMTP_HOST ?? "(nicht gesetzt)", diff --git a/app/api/admin/statistik/route.ts b/app/api/admin/statistik/route.ts new file mode 100644 index 0000000..ff7de3e --- /dev/null +++ b/app/api/admin/statistik/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { requireAdmin } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const db = createServiceClient(); + + const { data: alleAnfragen } = await db + .from("anfragen") + .select("id, status, created_at, name, betreff, email"); + + const anfragen = alleAnfragen ?? []; + + const kpi = { + gesamt: anfragen.length, + offen: anfragen.filter((a) => a.status === "offen").length, + inBearbeitung: anfragen.filter((a) => a.status === "in_bearbeitung").length, + abgeschlossen: anfragen.filter((a) => a.status === "abgeschlossen").length, + }; + + const heute = new Date(); + const monatsStats: { monat: string; label: string; count: number }[] = []; + for (let i = 5; i >= 0; i--) { + const d = new Date(heute); + d.setMonth(d.getMonth() - i); + const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" }); + const count = anfragen.filter((a) => a.created_at.startsWith(monat)).length; + monatsStats.push({ monat, label, count }); + } + + const { data: recentAnfragen } = await db + .from("anfragen") + .select("id, created_at, name, betreff, status, email") + .order("created_at", { ascending: false }) + .limit(20); + + return NextResponse.json({ kpi, monatsStats, recentAnfragen: recentAnfragen ?? [] }); +} diff --git a/app/api/analytics/track-phone-click/route.ts b/app/api/analytics/track-phone-click/route.ts new file mode 100644 index 0000000..a3b965d --- /dev/null +++ b/app/api/analytics/track-phone-click/route.ts @@ -0,0 +1,44 @@ +import { createServiceClient } from "@/lib/supabase"; +import { isBot, anonymizeIp, parseDevice, parseBrowser, parseOs } from "@/lib/analytics"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { phone_number, source_page, source_element, session_id } = body; + + if (!phone_number || !source_page || !source_element) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0].trim() || + request.headers.get("x-real-ip") || + "0.0.0.0"; + + const ua = request.headers.get("user-agent") || ""; + + if (isBot(ua)) { + return Response.json({ ok: true }); + } + + const supabase = createServiceClient(); + const { error } = await supabase.from("phone_clicks").insert({ + phone_number, + source_page, + source_element, + session_id: session_id || null, + ip_anonymized: anonymizeIp(ip), + device_type: parseDevice(ua), + browser: parseBrowser(ua), + os: parseOs(ua), + }); + + if (error) { + console.error("Phone click tracking error:", error); + } + + return Response.json({ ok: true }); + } catch { + return Response.json({ ok: true }); + } +} diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts new file mode 100644 index 0000000..a8d8966 --- /dev/null +++ b/app/api/analytics/track/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { anonymizeIp, isBot, parseDevice, parseBrowser, parseOs } from "@/lib/analytics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface TrackBody { + path: string; + session_id: string; + referrer?: string; + view_id?: string; + duration_ms?: number; +} + +export async function POST(req: NextRequest) { + try { + const body: TrackBody = await req.json(); + + if (!body.path || !body.session_id) { + return NextResponse.json({ ok: false }, { status: 400 }); + } + + const ua = req.headers.get("user-agent") ?? ""; + + if (isBot(ua)) { + return NextResponse.json({ ok: true, bot: true }); + } + + const db = createServiceClient(); + + if (body.view_id && body.duration_ms !== undefined) { + await db + .from("page_views") + .update({ duration_ms: body.duration_ms }) + .eq("id", body.view_id) + .is("duration_ms", null); + return NextResponse.json({ ok: true }); + } + + const rawIp = + req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? + req.headers.get("x-real-ip") ?? + "0.0.0.0"; + + const { data, error } = await db + .from("page_views") + .insert({ + path: body.path, + session_id: body.session_id, + referrer: body.referrer ?? null, + ip_anon: anonymizeIp(rawIp), + device_type: parseDevice(ua), + browser: parseBrowser(ua), + os: parseOs(ua), + is_bot: false, + }) + .select("id") + .single(); + + if (error) { + console.error("[analytics/track] DB error:", error); + return NextResponse.json({ ok: false }, { status: 500 }); + } + + return NextResponse.json({ ok: true, view_id: data.id }); + } catch (err) { + console.error("[analytics/track] Error:", err); + return NextResponse.json({ ok: false }, { status: 500 }); + } +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 6153063..5b47398 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { sendeKontaktEmail } from "@/lib/mailer"; +import { createServiceClient } from "@/lib/supabase"; export async function POST(request: Request) { let body: Record; @@ -16,6 +17,13 @@ export async function POST(request: Request) { const result = await sendeKontaktEmail({ name, email, betreff, nachricht, telefon }); + try { + const db = createServiceClient(); + await db.from("anfragen").insert({ name, email, betreff, nachricht: nachricht || null, status: "offen" }); + } catch (err) { + console.error("[Contact] Supabase insert error:", err); + } + if (!result.sent && !result.queued) { console.error( `[Contact] UNZUSTELLBAR – Anfrage konnte weder gesendet noch in Queue gespeichert werden:\n` + diff --git a/app/api/kunden/anfragen/route.ts b/app/api/kunden/anfragen/route.ts new file mode 100644 index 0000000..801cae8 --- /dev/null +++ b/app/api/kunden/anfragen/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; +import { createServiceClient } from "@/lib/supabase"; + +async function getKundeEmail(authHeader: string | null): Promise { + if (!authHeader?.startsWith("Bearer ")) return null; + const token = authHeader.slice(7); + + const anonClient = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + const { + data: { user }, + error, + } = await anonClient.auth.getUser(token); + if (error || !user?.email) return null; + return user.email; +} + +export async function GET(req: NextRequest) { + const email = await getKundeEmail(req.headers.get("authorization")); + if (!email) { + return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 }); + } + + const db = createServiceClient(); + + const { data: anfragen, error } = await db + .from("anfragen") + .select("id, created_at, status, name, betreff, nachricht, email") + .eq("email", email) + .order("created_at", { ascending: false }); + + if (error) { + return NextResponse.json({ error: "Datenbankfehler" }, { status: 500 }); + } + + return NextResponse.json({ anfragen: anfragen ?? [] }); +} diff --git a/app/api/kunden/registrieren/route.ts b/app/api/kunden/registrieren/route.ts new file mode 100644 index 0000000..07438f0 --- /dev/null +++ b/app/api/kunden/registrieren/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { sendeRegistrierungsBestaetigung } from "@/lib/mailer"; + +export async function POST(req: NextRequest) { + let body: { email?: string; password?: string; firma?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Ungültige Eingabe" }, { status: 422 }); + } + + const { email, password, firma } = body; + if (!email || !password || password.length < 8) { + return NextResponse.json({ error: "Ungültige Eingabe" }, { status: 422 }); + } + + const appUrl = process.env.APP_URL ?? "https://mbo-tech-it.de"; + const db = createServiceClient(); + + const { data: linkData, error } = await db.auth.admin.generateLink({ + type: "signup", + email, + password, + options: { + data: { firma: firma ?? "" }, + redirectTo: `${appUrl}/auth/callback`, + }, + }); + + if (error) { + if ( + error.message.includes("already registered") || + error.message.includes("already been registered") + ) { + return NextResponse.json({ error: "already_registered" }, { status: 409 }); + } + console.error("[Registrierung] Fehler:", error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const bestaetigungsLink = linkData.properties?.action_link; + if (!bestaetigungsLink) { + return NextResponse.json({ error: "Kein Bestätigungslink erhalten" }, { status: 500 }); + } + + await sendeRegistrierungsBestaetigung({ email, firma, bestaetigungsLink }); + + return NextResponse.json({ success: true }); +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000..64fa05f --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { CheckCircle2, XCircle } from "lucide-react"; +import Link from "next/link"; +import { createBrowserSupabaseClient } from "@/lib/supabase"; + +export default function AuthCallbackPage() { + const router = useRouter(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + const supabase = createBrowserSupabaseClient(); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { + if ((event === "SIGNED_IN" || event === "TOKEN_REFRESHED") && session) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } + }); + + supabase.auth.getSession().then(({ data: { session } }) => { + if (session) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } else { + setTimeout(() => { + supabase.auth.getSession().then(({ data: { session: s } }) => { + if (s) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } else { + setStatus("error"); + } + }); + }, 2000); + } + }); + + return () => subscription.unsubscribe(); + }, [router]); + + if (status === "loading") { + return ( +
+
+
+

E-Mail-Adresse wird bestätigt…

+
+
+ ); + } + + if (status === "success") { + return ( +
+
+
+ +
+

E-Mail bestätigt!

+

Sie werden automatisch weitergeleitet…

+
+
+ ); + } + + return ( +
+
+
+ +
+

Bestätigung fehlgeschlagen

+

+ Der Bestätigungslink ist abgelaufen oder ungültig. Bitte registrieren Sie sich erneut. +

+ + Zur Registrierung + +
+
+ ); +} diff --git a/app/kunden/dashboard/page.tsx b/app/kunden/dashboard/page.tsx new file mode 100644 index 0000000..86e5876 --- /dev/null +++ b/app/kunden/dashboard/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { createBrowserSupabaseClient } from "@/lib/supabase"; +import type { User } from "@supabase/supabase-js"; + +interface Anfrage { + id: string; + created_at: string; + status: string; + name: string; + betreff: string; + nachricht: string | null; + email: string; +} + +function formatDatum(iso: string) { + return new Date(iso).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + offen: "bg-amber-500/10 text-amber-400 border border-amber-500/20", + in_bearbeitung: "bg-blue-500/10 text-blue-400 border border-blue-500/20", + abgeschlossen: "bg-green-500/10 text-green-400 border border-green-500/20", + }; + const labels: Record = { + offen: "Offen", + in_bearbeitung: "In Bearbeitung", + abgeschlossen: "Abgeschlossen", + }; + return ( + + {labels[status] ?? status} + + ); +} + +export default function KundenDashboardPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [anfragen, setAnfragen] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const supabase = createBrowserSupabaseClient(); + + async function init() { + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + router.push("/kunden/login"); + return; + } + setUser(session.user); + + const res = await fetch("/api/kunden/anfragen", { + headers: { Authorization: `Bearer ${session.access_token}` }, + }); + if (res.ok) { + const json = await res.json(); + setAnfragen(json.anfragen ?? []); + } + setLoading(false); + } + init(); + }, [router]); + + async function handleLogout() { + const supabase = createBrowserSupabaseClient(); + await supabase.auth.signOut(); + router.push("/kunden/login"); + } + + if (loading) { + return ( +
+

Wird geladen…

+
+ ); + } + + return ( +
+
+
+
+

Mein Bereich

+

{user?.email}

+
+ +
+ +
+ +
+
+

Meine IT-Anfragen

+ + + Neue Anfrage + +
+ + {anfragen.length === 0 ? ( +
+ + + +

Noch keine Anfragen vorhanden

+

+ Kontaktieren Sie uns über das Kontaktformular auf der Startseite. +

+ + Zum Kontaktformular + +
+ ) : ( +
+ {anfragen.map((anfrage) => ( +
+
+
+ + {formatDatum(anfrage.created_at)} +
+ + #{anfrage.id.slice(0, 8)} + +
+ +
+

{anfrage.betreff}

+ {anfrage.nachricht && ( +

{anfrage.nachricht}

+ )} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/app/kunden/login/page.tsx b/app/kunden/login/page.tsx new file mode 100644 index 0000000..6d3101c --- /dev/null +++ b/app/kunden/login/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { createBrowserSupabaseClient } from "@/lib/supabase"; + +export default function KundenLoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [passwort, setPasswort] = useState(""); + const [fehler, setFehler] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + setFehler(""); + setLoading(true); + + const supabase = createBrowserSupabaseClient(); + const { error } = await supabase.auth.signInWithPassword({ email, password: passwort }); + + if (error) { + if (error.message.toLowerCase().includes("email not confirmed")) { + setFehler("Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse."); + } else { + setFehler("E-Mail oder Passwort ungültig."); + } + } else { + router.push("/kunden/dashboard"); + } + setLoading(false); + } + + return ( +
+
+
+
+
+ + + +
+

Kunden-Login

+

Melde dich an, um deine IT-Anfragen einzusehen

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="ihre@email.de" + required + autoComplete="email" + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+
+ + setPasswort(e.target.value)} + placeholder="••••••••" + required + autoComplete="current-password" + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+ + {fehler && ( +

+ {fehler} +

+ )} + + +
+ +
+

+ Noch kein Konto?{" "} + + Jetzt registrieren + +

+

+ Fragen?{" "} + + +49 171 9345193 + +

+
+
+
+
+ ); +} diff --git a/app/kunden/registrieren/page.tsx b/app/kunden/registrieren/page.tsx new file mode 100644 index 0000000..9adbb68 --- /dev/null +++ b/app/kunden/registrieren/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +export default function KundenRegistrierenPage() { + const [email, setEmail] = useState(""); + const [passwort, setPasswort] = useState(""); + const [passwortWdh, setPasswortWdh] = useState(""); + const [firma, setFirma] = useState(""); + const [fehler, setFehler] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + async function handleRegistrieren(e: React.FormEvent) { + e.preventDefault(); + setFehler(""); + + if (passwort.length < 8) { + setFehler("Das Passwort muss mindestens 8 Zeichen lang sein."); + return; + } + if (passwort !== passwortWdh) { + setFehler("Die Passwörter stimmen nicht überein."); + return; + } + + setLoading(true); + + const res = await fetch("/api/kunden/registrieren", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: passwort, firma }), + }); + const json = await res.json(); + + if (!res.ok) { + if (json.error === "already_registered") { + setFehler("Diese E-Mail ist bereits registriert. Bitte direkt anmelden."); + } else { + setFehler(`Registrierung fehlgeschlagen: ${json.error}`); + } + } else { + setSuccess(true); + } + setLoading(false); + } + + if (success) { + return ( +
+
+
+ + + +
+

Registrierung erfolgreich!

+

+ Bitte bestätigen Sie Ihre E-Mail-Adresse. Nach der Bestätigung können Sie sich anmelden. +

+ + Zur Anmeldung + +
+
+ ); + } + + return ( +
+
+
+
+
+ + + +
+

Konto erstellen

+

Registrieren, um IT-Anfragen zu verfolgen

+
+ +
+
+ + setFirma(e.target.value)} + placeholder="Muster GmbH oder Max Muster" + autoComplete="organization" + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+
+ + setEmail(e.target.value)} + placeholder="ihre@email.de" + required + autoComplete="email" + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+
+ + setPasswort(e.target.value)} + placeholder="••••••••" + required + autoComplete="new-password" + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+
+ + setPasswortWdh(e.target.value)} + placeholder="••••••••" + required + autoComplete="new-password" + className="w-full px-4 py-3 rounded-xl bg-[#111925] border border-gray-700 text-white placeholder-slate-600 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-colors" + /> +
+ + {fehler && ( +

+ {fehler} +

+ )} + + +
+ +
+

+ Bereits registriert?{" "} + + Jetzt anmelden + +

+

+ Fragen?{" "} + + +49 171 9345193 + +

+
+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 2fe9fa7..2f33146 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; import "./globals.css"; import Providers from "@/components/Providers"; +import { PageTracker } from "@/components/analytics/PageTracker"; export const metadata: Metadata = { title: "MBO-Tech-IT | Docker, Kubernetes & Cloud-Infrastruktur", description: - "Ihr Experte fur Docker-Installationen, Kubernetes-Orchestrierung, Proxmox-Virtualisierung und Hetzner Cloud-Infrastruktur. IT-Losungen fur Hard- und Software.", + "Ihr Experte für Docker-Installationen, Kubernetes-Orchestrierung, Proxmox-Virtualisierung und Hetzner Cloud-Infrastruktur. IT-Lösungen für Hard- und Software.", keywords: [ "Docker", "Kubernetes", @@ -27,6 +28,7 @@ export default function RootLayout({ return ( + {children} diff --git a/components/Contact.tsx b/components/Contact.tsx index 1d4ffd7..0d453f4 100644 --- a/components/Contact.tsx +++ b/components/Contact.tsx @@ -208,6 +208,7 @@ export default function Contact() { diff --git a/components/admin/AdminNav.tsx b/components/admin/AdminNav.tsx new file mode 100644 index 0000000..d42ea7e --- /dev/null +++ b/components/admin/AdminNav.tsx @@ -0,0 +1,49 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; + +const navLinks = [ + { href: "/admin/analytics", label: "Analytics" }, + { href: "/admin/statistik", label: "Statistik" }, + { href: "/admin/audit-logs", label: "Audit-Logs" }, +]; + +export function AdminNav() { + const pathname = usePathname(); + const router = useRouter(); + + async function handleLogout() { + await fetch("/api/admin/login", { method: "DELETE" }); + router.push("/admin/login"); + } + + return ( + + ); +} diff --git a/components/admin/AnalyticsTabs.tsx b/components/admin/AnalyticsTabs.tsx new file mode 100644 index 0000000..3effa38 --- /dev/null +++ b/components/admin/AnalyticsTabs.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { ReactNode, useState, useEffect } from "react"; + +interface PhoneKpis { + callsToday: number; + callsTodayTrend: number; + uniqueNumbers: number; + topSourceElement: string | null; + topSourceElementPercent: number; +} + +interface PhoneNumber { + phone_number: string; + click_count: number; +} + +interface ElementStat { + source_element: string; + count: number; + percent: number; +} + +interface TimeseriesEntry { + date: string; + count: number; +} + +interface PhoneData { + kpis: PhoneKpis; + phoneNumbers: PhoneNumber[]; + elements: ElementStat[]; + timeseries: TimeseriesEntry[]; +} + +function balkenBreite(wert: number, max: number): string { + if (max === 0) return "0%"; + return `${Math.max(4, Math.round((wert / max) * 100))}%`; +} + +function PhoneCallsView() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [range, setRange] = useState("30days"); + + useEffect(() => { + setLoading(true); + fetch(`/api/admin/analytics/phone-calls?range=${range}`) + .then((r) => r.json()) + .then((d) => { setData(d); setLoading(false); }) + .catch(() => setLoading(false)); + }, [range]); + + if (loading) { + return
Lädt Phone-Click-Daten…
; + } + + if (!data) { + return
Fehler beim Laden der Daten
; + } + + const maxTimeseries = Math.max(0, ...data.timeseries.map((t) => t.count)); + const maxElements = Math.max(0, ...data.elements.map((e) => e.count)); + + return ( +
+ {/* Header */} +
+
+
+

Phone-Click-Tracking

+

Telefon-Link-Klicks · DSGVO-konform

+
+
+
+ {[["today", "Heute"], ["7days", "7 Tage"], ["30days", "30 Tage"]].map(([val, label]) => ( + + ))} +
+
+ + {/* KPI-Cards */} +
+
+
Klicks heute
+
{data.kpis.callsToday}
+ {data.kpis.callsTodayTrend !== 0 && ( +
0 ? "text-green-400" : "text-red-400"}`}> + {data.kpis.callsTodayTrend > 0 ? "+" : ""}{data.kpis.callsTodayTrend}% vs. gestern +
+ )} +
+
+
Eindeutige Nummern
+
{data.kpis.uniqueNumbers}
+
+
+
Top Quelle
+
{data.kpis.topSourceElement ?? "–"}
+ {data.kpis.topSourceElementPercent > 0 && ( +
{data.kpis.topSourceElementPercent}% aller Klicks
+ )} +
+
+
Klicks gesamt
+
+ {data.phoneNumbers.reduce((s, p) => s + p.click_count, 0)} +
+
+
+ + {/* Zeitreihe */} + {data.timeseries.length > 0 && ( +
+

Klicks über Zeit

+
+ {data.timeseries.map(({ date, count }) => ( +
+
{date}
+
+
+
+
{count}
+
+ ))} +
+
+ )} + + {/* Quellen */} + {data.elements.length > 0 && ( +
+

Klicks nach Quelle

+
+ {data.elements.map(({ source_element, count }) => ( +
+
{source_element}
+
+
+
+
{count}
+
+ ))} +
+
+ )} + + {/* Nummern-Tabelle */} + {data.phoneNumbers.length > 0 && ( +
+
+

Gerufene Nummern

+
+ + + + + + + + + {data.phoneNumbers.map((p) => ( + + + + + ))} + +
TelefonnummerKlicks
{p.phone_number}{p.click_count}
+
+ )} + + {data.phoneNumbers.length === 0 && ( +
+ Noch keine Phone-Klicks im gewählten Zeitraum +
+ )} +
+ ); +} + +export function AnalyticsTabs({ overviewContent }: { overviewContent: ReactNode }) { + const [activeTab, setActiveTab] = useState<"overview" | "phone">("overview"); + + return ( +
+
+ {[ + { key: "overview", label: "Seitenaufrufe" }, + { key: "phone", label: "Phone-Klicks" }, + ].map((tab) => ( + + ))} +
+
+ {activeTab === "overview" ? overviewContent : } +
+
+ ); +} diff --git a/components/admin/SessionTimeoutProvider.tsx b/components/admin/SessionTimeoutProvider.tsx new file mode 100644 index 0000000..fd15065 --- /dev/null +++ b/components/admin/SessionTimeoutProvider.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +const INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000; +const WARNING_BEFORE_LOGOUT_MS = 15 * 60 * 1000; + +export function SessionTimeoutProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const timeoutRef = useRef(null); + const warningTimeoutRef = useRef(null); + const warningShownRef = useRef(false); + + function resetTimeout() { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + + warningShownRef.current = false; + + warningTimeoutRef.current = setTimeout(() => { + if (!warningShownRef.current) { + warningShownRef.current = true; + alert("⚠️ Ihre Session läuft in 15 Minuten ab. Bitte klicken Sie auf eine Taste oder bewegen Sie die Maus, um weiterzuarbeiten."); + } + }, INACTIVITY_TIMEOUT_MS - WARNING_BEFORE_LOGOUT_MS); + + timeoutRef.current = setTimeout(async () => { + await fetch("/api/admin/login", { method: "DELETE" }); + router.push("/admin/login?session_expired=true"); + }, INACTIVITY_TIMEOUT_MS); + } + + useEffect(() => { + const events = ["mousedown", "keydown", "scroll", "touchstart"]; + const handleActivity = () => resetTimeout(); + events.forEach((e) => window.addEventListener(e, handleActivity)); + resetTimeout(); + return () => { + events.forEach((e) => window.removeEventListener(e, handleActivity)); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + }; + }, [router]); + + return <>{children}; +} diff --git a/components/analytics/PageTracker.tsx b/components/analytics/PageTracker.tsx new file mode 100644 index 0000000..8091495 --- /dev/null +++ b/components/analytics/PageTracker.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; + +const EXCLUDED_PREFIXES = ["/admin", "/api", "/_next"]; + +export function PageTracker() { + const pathname = usePathname(); + const viewIdRef = useRef(null); + const startTimeRef = useRef(Date.now()); + + function getSessionId(): string { + let sid = sessionStorage.getItem("_mpv_sid"); + if (!sid) { + sid = crypto.randomUUID(); + sessionStorage.setItem("_mpv_sid", sid); + } + return sid; + } + + useEffect(() => { + if (EXCLUDED_PREFIXES.some((p) => pathname.startsWith(p))) return; + + startTimeRef.current = Date.now(); + viewIdRef.current = null; + + fetch("/api/analytics/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: pathname, + session_id: getSessionId(), + referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined, + }), + }) + .then((r) => r.json()) + .then((d) => { viewIdRef.current = d.view_id ?? null; }) + .catch(() => {}); + + function sendDuration() { + if (!viewIdRef.current) return; + const ms = Date.now() - startTimeRef.current; + const blob = new Blob( + [JSON.stringify({ path: pathname, session_id: getSessionId(), view_id: viewIdRef.current, duration_ms: ms })], + { type: "application/json" } + ); + navigator.sendBeacon("/api/analytics/track", blob); + } + + function trackPhoneClick(event: Event) { + const target = event.target as HTMLElement; + const link = target.closest('a[href^="tel:"]'); + if (!link) return; + const phoneNumber = link.getAttribute("href")?.replace("tel:", "").trim(); + if (!phoneNumber) return; + const sourceElement = + link.getAttribute("data-source-element") || + link.closest("[data-source-element]")?.getAttribute("data-source-element") || + "unknown"; + fetch("/api/analytics/track-phone-click", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone_number: phoneNumber, source_page: pathname, source_element: sourceElement, session_id: getSessionId() }), + }).catch(() => {}); + } + + window.addEventListener("pagehide", sendDuration); + document.addEventListener("click", trackPhoneClick); + + return () => { + document.removeEventListener("click", trackPhoneClick); + window.removeEventListener("pagehide", sendDuration); + sendDuration(); + }; + }, [pathname]); + + return null; +} diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts new file mode 100644 index 0000000..5dee9a3 --- /dev/null +++ b/lib/admin-auth.ts @@ -0,0 +1,145 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { isSessionTokenRevoked, isActionTokenUsed } from "./token-blacklist"; + +const encoder = new TextEncoder(); + +interface AdminSession { + id: string; + email: string; + name: string; + exp: number; +} + +interface ActionToken { + anfrageId: string; + status: "in_bearbeitung" | "abgeschlossen"; + exp: number; +} + +function toBase64Url(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function fromBase64Url(str: string): ArrayBuffer { + const b64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + return bytes.buffer as ArrayBuffer; +} + +async function getKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); +} + +export async function createSessionToken( + session: Omit +): Promise { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const payload: AdminSession = { + ...session, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 2, + }; + const data = toBase64Url(encoder.encode(JSON.stringify(payload))); + const key = await getKey(secret); + const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data))); + return `${data}.${sig}`; +} + +export async function requireAdmin(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + + if (!token) { + return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 }); + } + + const session = await verifySessionToken(token); + if (!session) { + return NextResponse.json({ error: "Session ungültig" }, { status: 401 }); + } + + return session; +} + +export async function verifySessionToken(token: string): Promise { + try { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const [data, sig] = token.split("."); + if (!data || !sig) return null; + + if (await isSessionTokenRevoked(sig)) return null; + + const key = await getKey(secret); + const valid = await crypto.subtle.verify( + "HMAC", + key, + fromBase64Url(sig), + encoder.encode(data) + ); + if (!valid) return null; + + const session: AdminSession = JSON.parse( + new TextDecoder().decode(new Uint8Array(fromBase64Url(data))) + ); + if (session.exp < Math.floor(Date.now() / 1000)) return null; + + return session; + } catch { + return null; + } +} + +export async function createActionToken( + anfrageId: string, + status: "in_bearbeitung" | "abgeschlossen", + ablaufTage = 7 +): Promise { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const payload: ActionToken = { + anfrageId, + status, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * ablaufTage, + }; + const data = toBase64Url(encoder.encode(JSON.stringify(payload))); + const key = await getKey(secret); + const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data))); + return `${data}.${sig}`; +} + +export async function verifyActionToken( + token: string +): Promise<{ anfrageId: string; status: string } | null> { + try { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const [data, sig] = token.split("."); + if (!data || !sig) return null; + + if (await isActionTokenUsed(sig)) return null; + + const key = await getKey(secret); + const valid = await crypto.subtle.verify( + "HMAC", + key, + fromBase64Url(sig), + encoder.encode(data) + ); + if (!valid) return null; + + const actionToken: ActionToken = JSON.parse( + new TextDecoder().decode(new Uint8Array(fromBase64Url(data))) + ); + if (actionToken.exp < Math.floor(Date.now() / 1000)) return null; + + return { anfrageId: actionToken.anfrageId, status: actionToken.status }; + } catch { + return null; + } +} diff --git a/lib/analytics.ts b/lib/analytics.ts new file mode 100644 index 0000000..5ef2eb9 --- /dev/null +++ b/lib/analytics.ts @@ -0,0 +1,47 @@ +const BOT_PATTERNS = + /bot|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegram|pinterest|slack|discordbot|applebot|bingpreview|google-read-aloud|ia_archiver|mj12bot|ahrefs|semrush|dotbot|rogerbot|screaming\.?frog/i; + +export function isBot(ua: string): boolean { + return BOT_PATTERNS.test(ua); +} + +export function anonymizeIp(ip: string): string { + const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+)\.\d+$/i); + if (mapped) return `${mapped[1]}.0`; + + const v4 = ip.match(/^(\d+\.\d+\.\d+)\.\d+$/); + if (v4) return `${v4[1]}.0`; + + const v6parts = ip.split(":"); + if (v6parts.length >= 3) return `${v6parts.slice(0, 3).join(":")}::`; + + return "0.0.0.0"; +} + +export type DeviceType = "mobile" | "tablet" | "desktop"; +export type BrowserName = "Chrome" | "Firefox" | "Safari" | "Edge" | "Opera" | "Other"; +export type OsName = "Windows" | "macOS" | "iOS" | "Android" | "Linux" | "Other"; + +export function parseDevice(ua: string): DeviceType { + if (/tablet|ipad|playbook|silk/i.test(ua)) return "tablet"; + if (/mobile|android.*mobile|iphone|ipod|blackberry|windows phone/i.test(ua)) return "mobile"; + return "desktop"; +} + +export function parseBrowser(ua: string): BrowserName { + if (/edg\//i.test(ua)) return "Edge"; + if (/opr\//i.test(ua)) return "Opera"; + if (/firefox\//i.test(ua)) return "Firefox"; + if (/chrome\//i.test(ua)) return "Chrome"; + if (/safari\//i.test(ua)) return "Safari"; + return "Other"; +} + +export function parseOs(ua: string): OsName { + if (/windows/i.test(ua)) return "Windows"; + if (/iphone|ipad|ipod/i.test(ua)) return "iOS"; + if (/android/i.test(ua)) return "Android"; + if (/mac os x|macintosh/i.test(ua)) return "macOS"; + if (/linux/i.test(ua)) return "Linux"; + return "Other"; +} diff --git a/lib/audit-log.ts b/lib/audit-log.ts new file mode 100644 index 0000000..55ef9f1 --- /dev/null +++ b/lib/audit-log.ts @@ -0,0 +1,91 @@ +import { createServiceClient } from "@/lib/supabase"; + +export interface AuditLogEntry { + id: string; + email: string; + ip_addr: string; + user_agent: string; + success: boolean; + timestamp: string; + reason?: string; +} + +export async function logLoginAttempt( + email: string, + ipAddr: string, + success: boolean, + userAgent: string, + reason?: string +): Promise { + try { + const db = createServiceClient(); + await db.from("admin_audit_logs").insert({ + email: email.toLowerCase().trim(), + ip_addr: ipAddr, + user_agent: userAgent, + success, + reason, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("Fehler beim Audit-Logging:", error); + } +} + +export async function getFailedLoginCount( + emailOrIp: string, + type: "email" | "ip" = "email", + timeWindowMinutes = 60 +): Promise { + try { + const db = createServiceClient(); + const since = new Date(Date.now() - timeWindowMinutes * 60 * 1000).toISOString(); + const column = type === "email" ? "email" : "ip_addr"; + const { count } = await db + .from("admin_audit_logs") + .select("id", { count: "exact" }) + .eq(column, emailOrIp) + .eq("success", false) + .gt("timestamp", since); + return count || 0; + } catch { + return 0; + } +} + +export async function getAuditLogs(options?: { + email?: string; + ipAddr?: string; + successOnly?: boolean; + limit?: number; + offset?: number; +}): Promise { + try { + const db = createServiceClient(); + const limit = options?.limit ?? 100; + const offset = options?.offset ?? 0; + + let query = db + .from("admin_audit_logs") + .select("*") + .order("timestamp", { ascending: false }) + .range(offset, offset + limit - 1); + + if (options?.email) query = query.eq("email", options.email.toLowerCase().trim()); + if (options?.ipAddr) query = query.eq("ip_addr", options.ipAddr); + if (options?.successOnly !== undefined) query = query.eq("success", options.successOnly); + + const { data, error } = await query; + if (error) return []; + return (data ?? []).map((row) => ({ ...row, reason: row.reason ?? undefined })); + } catch { + return []; + } +} + +export async function sendSecurityAlert( + subject: string, + message: string +): Promise { + console.warn(`⚠️ SECURITY ALERT: ${subject}\n${message}`); +} diff --git a/lib/mailer.ts b/lib/mailer.ts index 0d451aa..11a355a 100644 --- a/lib/mailer.ts +++ b/lib/mailer.ts @@ -64,6 +64,57 @@ async function sendWithFallback( } } +export interface RegistrierungData { + email: string; + firma?: string; + bestaetigungsLink: string; +} + +export async function sendeRegistrierungsBestaetigung( + data: RegistrierungData +): Promise { + const html = ` + + + + +
+

MBO Tech IT

+

Kunden-Portal

+
+
+
+

MBO Tech IT · ${process.env.APP_URL ?? "https://mbo-tech-it.de"}

+
+ +`; + + await sendWithFallback( + { + from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`, + to: data.email, + subject: "Bitte bestätigen Sie Ihre E-Mail – MBO Tech IT", + text: `Hallo,\n\nBitte bestätigen Sie Ihre E-Mail-Adresse:\n${data.bestaetigungsLink}\n\nDieser Link ist 24 Stunden gültig.\n\nMBO Tech IT`, + html, + }, + `Registrierungsbestätigung ${data.email}` + ); +} + export async function sendeKontaktEmail( data: KontaktEmailData ): Promise<{ sent: boolean; queued: boolean }> { diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..c6e5eb7 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,71 @@ +interface RateLimitEntry { + count: number; + lastAttempt: number; + locked: boolean; + lockedUntil?: number; +} + +const attempts = new Map(); + +const CLEANUP_INTERVAL = 10 * 60 * 1000; +const RESET_WINDOW = 15 * 60 * 1000; +const MAX_ATTEMPTS = 5; +const LOCK_THRESHOLD = 10; +const LOCK_DURATION = 15 * 60 * 1000; + +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of attempts.entries()) { + if (now - entry.lastAttempt > RESET_WINDOW) { + attempts.delete(key); + } + } +}, CLEANUP_INTERVAL); + +export function checkRateLimit(identifier: string) { + const now = Date.now(); + let entry = attempts.get(identifier); + + if (!entry) { + attempts.set(identifier, { count: 1, lastAttempt: now, locked: false }); + return { allowed: true, delayMs: 0, locked: false }; + } + + if (now - entry.lastAttempt > RESET_WINDOW) { + attempts.set(identifier, { count: 1, lastAttempt: now, locked: false }); + return { allowed: true, delayMs: 0, locked: false }; + } + + if (entry.locked && entry.lockedUntil) { + if (now < entry.lockedUntil) { + return { allowed: false, delayMs: entry.lockedUntil - now, locked: true }; + } else { + entry.locked = false; + entry.count = 1; + entry.lastAttempt = now; + return { allowed: true, delayMs: 0, locked: false }; + } + } + + entry.count++; + entry.lastAttempt = now; + + const delayMs = Math.min(Math.pow(2, entry.count - 2) * 1000, 30000); + + if (entry.count >= LOCK_THRESHOLD) { + entry.locked = true; + entry.lockedUntil = now + LOCK_DURATION; + return { allowed: false, delayMs: LOCK_DURATION, locked: true }; + } + + const allowed = entry.count <= MAX_ATTEMPTS; + return { allowed, delayMs: allowed ? 0 : delayMs, locked: false }; +} + +export function resetRateLimit(identifier: string) { + attempts.delete(identifier); +} + +export function getAttemptCount(identifier: string): number { + return attempts.get(identifier)?.count ?? 0; +} diff --git a/lib/supabase.ts b/lib/supabase.ts index 11a8929..6435048 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -1,11 +1,255 @@ -// STUB – wird ersetzt wenn Supabase eingerichtet ist (Modul: Supabase-Integration) -// Aktuell wirft createServiceClient() einen Fehler – email-queue.ts fängt diesen ab. +import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import { createBrowserClient } from "@supabase/ssr"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type SupabaseServiceClient = any; +export type SupabaseServiceClient = SupabaseClient; + +export interface Database { + public: { + Tables: { + email_queue: { + Row: { + id: string; + mail_from: string; + mail_to: string; + reply_to: string | null; + subject: string; + html: string; + body_text: string; + status: string; + retry_count: number; + next_retry_at: string; + error_last: string | null; + created_at: string; + }; + Insert: { + id?: string; + mail_from: string; + mail_to: string; + reply_to?: string | null; + subject: string; + html: string; + body_text: string; + status?: string; + retry_count?: number; + next_retry_at?: string; + error_last?: string | null; + created_at?: string; + }; + Update: { + status?: string; + retry_count?: number; + next_retry_at?: string; + error_last?: string | null; + }; + Relationships: []; + }; + admin_users: { + Row: { + id: string; + email: string; + name: string | null; + password_hash: string; + aktiv: boolean; + created_at: string; + }; + Insert: { + id?: string; + email: string; + name?: string | null; + password_hash: string; + aktiv?: boolean; + created_at?: string; + }; + Update: { + email?: string; + name?: string | null; + password_hash?: string; + aktiv?: boolean; + }; + Relationships: []; + }; + admin_audit_logs: { + Row: { + id: string; + email: string; + ip_addr: string; + user_agent: string; + success: boolean; + reason: string | null; + timestamp: string; + }; + Insert: { + id?: string; + email: string; + ip_addr: string; + user_agent: string; + success: boolean; + reason?: string | null; + timestamp?: string; + }; + Update: Record; + Relationships: []; + }; + admin_session_blacklist: { + Row: { + id: string; + admin_id: string; + token_signature: string; + revoked_at: string; + reason: string; + notes: string | null; + }; + Insert: { + id?: string; + admin_id: string; + token_signature: string; + revoked_at?: string; + reason: string; + notes?: string | null; + }; + Update: Record; + Relationships: []; + }; + action_token_blacklist: { + Row: { + id: string; + anfrage_id: string; + token_signature: string; + action_type: string; + used_at: string; + used_by_ip: string | null; + notes: string | null; + }; + Insert: { + id?: string; + anfrage_id: string; + token_signature: string; + action_type: string; + used_at?: string; + used_by_ip?: string | null; + notes?: string | null; + }; + Update: Record; + Relationships: []; + }; + page_views: { + Row: { + id: string; + path: string; + timestamp: string; + ip_anon: string | null; + device_type: string | null; + browser: string | null; + os: string | null; + referrer: string | null; + session_id: string; + duration_ms: number | null; + is_bot: boolean; + }; + Insert: { + id?: string; + path: string; + timestamp?: string; + ip_anon?: string | null; + device_type?: string | null; + browser?: string | null; + os?: string | null; + referrer?: string | null; + session_id: string; + duration_ms?: number | null; + is_bot?: boolean; + }; + Update: { + duration_ms?: number | null; + }; + Relationships: []; + }; + phone_clicks: { + Row: { + id: number; + phone_number: string; + source_page: string; + source_element: string; + session_id: string | null; + ip_anonymized: string | null; + device_type: string | null; + browser: string | null; + os: string | null; + timestamp: string; + }; + Insert: { + phone_number: string; + source_page: string; + source_element: string; + session_id?: string | null; + ip_anonymized?: string | null; + device_type?: string | null; + browser?: string | null; + os?: string | null; + timestamp?: string; + }; + Update: Record; + Relationships: []; + }; + anfragen: { + Row: { + id: string; + name: string; + email: string; + betreff: string; + nachricht: string | null; + status: string; + admin_notizen: string | null; + kunde_id: string | null; + created_at: string; + }; + Insert: { + id?: string; + name: string; + email: string; + betreff: string; + nachricht?: string | null; + status?: string; + admin_notizen?: string | null; + kunde_id?: string | null; + created_at?: string; + }; + Update: { + status?: string; + admin_notizen?: string | null; + }; + Relationships: []; + }; + }; + Views: Record; + Functions: Record; + Enums: Record; + CompositeTypes: Record; + }; +} + +let serviceClient: SupabaseServiceClient | null = null; export function createServiceClient(): SupabaseServiceClient { - throw new Error( - "Supabase noch nicht konfiguriert. lib/supabase.ts implementieren und @supabase/supabase-js installieren." + if (serviceClient) return serviceClient; + + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!url || !key) { + throw new Error("SUPABASE_URL oder SUPABASE_SERVICE_ROLE_KEY fehlt in .env.local"); + } + + serviceClient = createClient(url, key, { + auth: { persistSession: false }, + }); + + return serviceClient; +} + +export function createBrowserSupabaseClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); } diff --git a/lib/token-blacklist.ts b/lib/token-blacklist.ts new file mode 100644 index 0000000..d8be087 --- /dev/null +++ b/lib/token-blacklist.ts @@ -0,0 +1,88 @@ +import { createServiceClient } from "./supabase"; + +export async function revokeSessionToken( + tokenSignature: string, + adminId: string, + reason: "logout" | "password_changed" | "suspicious_activity" = "logout", + notes?: string +): Promise { + try { + const db = createServiceClient(); + const { error } = await db.from("admin_session_blacklist").insert({ + admin_id: adminId, + token_signature: tokenSignature, + reason, + notes, + }); + if (error) { + console.error("Failed to revoke session token:", error); + return false; + } + return true; + } catch (error) { + console.error("Error revoking session token:", error); + return false; + } +} + +export async function isSessionTokenRevoked(tokenSignature: string): Promise { + try { + const db = createServiceClient(); + const { data, error } = await db + .from("admin_session_blacklist") + .select("id") + .eq("token_signature", tokenSignature) + .single(); + + if (error && error.code !== "PGRST116") { + console.error("Error checking token revocation:", error); + return false; + } + return data?.id != null; + } catch { + return false; + } +} + +export async function markActionTokenUsed( + tokenSignature: string, + anfrageId: string, + actionType: string, + ipAddr?: string +): Promise { + try { + const db = createServiceClient(); + const { error } = await db.from("action_token_blacklist").insert({ + token_signature: tokenSignature, + anfrage_id: anfrageId, + action_type: actionType, + used_by_ip: ipAddr, + }); + if (error) { + console.error("Failed to mark action token as used:", error); + return false; + } + return true; + } catch { + return false; + } +} + +export async function isActionTokenUsed(tokenSignature: string): Promise { + try { + const db = createServiceClient(); + const { data, error } = await db + .from("action_token_blacklist") + .select("id") + .eq("token_signature", tokenSignature) + .single(); + + if (error && error.code !== "PGRST116") { + console.error("Error checking action token usage:", error); + return false; + } + return data?.id != null; + } catch { + return false; + } +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..d7bcc6b --- /dev/null +++ b/middleware.ts @@ -0,0 +1,32 @@ +import { createServerClient } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)); + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ); + }, + }, + } + ); + + await supabase.auth.getUser(); + return supabaseResponse; +} + +export const config = { + matcher: ["/kunden/:path*", "/auth/:path*"], +}; diff --git a/modules/01-email-system/TEMPLATE.md b/modules/01-email-system/TEMPLATE.md new file mode 100644 index 0000000..c8f4544 --- /dev/null +++ b/modules/01-email-system/TEMPLATE.md @@ -0,0 +1,188 @@ +# Modul: Email-System + +> SMTP-Versand mit Nodemailer (STARTTLS Port 587), Queue-Fallback in Supabase bei Versandfehlern, exponentielles Retry (max 10 Versuche). Unterstützt HTML + Plaintext, HMAC-signierte Action-Buttons in Admin-Mails (7 Tage gültig, One-Time-Use). + +--- + +## Enthaltene Dateien + +| Ziel im neuen Projekt | Quelle | +|---|---| +| `lib/mailer.ts` | Haupt-Mailer (Nodemailer + alle Email-Funktionen) | +| `lib/email-queue.ts` | Queue-System (Supabase-Fallback + Worker) | +| `app/api/admin/email-queue/route.ts` | Admin-API: Queue-Status anzeigen | +| `app/api/admin/smtp-test/route.ts` | Admin-API: SMTP-Verbindung testen | + +**Hinweis:** `[id]`-Ordner in `files/` entsprechen Next.js Dynamic-Route-Ordnern `[id]`. + +--- + +## Voraussetzungen + +```bash +npm install nodemailer +npm install -D @types/nodemailer +``` + +Benötigt außerdem: +- `lib/supabase.ts` (Service Client für Queue-Speicherung) +- `lib/admin-auth.ts` (für `createActionToken` in Admin-Mails, optional) + +--- + +## Umgebungsvariablen (`.env.local`) + +```env +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASS=password +SMTP_FROM=noreply@example.com # Absender +SMTP_TO=admin@example.com # Admin-Inbox +COMPANY_PHONE=+49123456789 # Wird in Mails angezeigt +APP_URL=https://example.com # Basis-URL für Links in Mails +NEXTAUTH_SECRET=your-secret # HMAC-Key für Action-Tokens +``` + +--- + +## Datenbank-Migration (Supabase) + +Tabelle `email_queue` muss in `lib/supabase.ts` als Type definiert und in Supabase existieren: + +```sql +CREATE TABLE email_queue ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + to_addr text NOT NULL, + subject text NOT NULL, + html text NOT NULL, + text text, + attempts int DEFAULT 0, + max_attempts int DEFAULT 10, + status text DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')), + last_error text, + next_retry_at timestamptz DEFAULT now(), + created_at timestamptz DEFAULT now(), + sent_at timestamptz +); +CREATE INDEX idx_email_queue_pending ON email_queue(status, next_retry_at) WHERE status = 'pending'; +``` + +--- + +## Einbindung Schritt für Schritt + +### 1. Dateien kopieren +Alle Dateien aus `files/` in die entsprechenden Pfade des neuen Projekts kopieren. + +### 2. Mailer an Projekt anpassen (`lib/mailer.ts`) +Die Datei enthält projektspezifische Email-Funktionen (Anfragen, Kunden-Bestätigungen etc.). Folgende Funktionen beibehalten oder anpassen: +- `sendeKontaktEmail(data)` → bleibt meist unverändert +- Alle anderen Funktionen nach Bedarf umbenennen/entfernen +- Firmenname/Branding in HTML-Templates ersetzen (Suche nach `Mietpark Hahn`) + +### 3. Queue-Worker starten (`instrumentation.ts`) +```ts +// instrumentation.ts (Projekt-Root) +import { startEmailQueueWorker } from "@/lib/email-queue"; +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + startEmailQueueWorker(); + } +} +``` + +### 4. Email-Queue Typ in `lib/supabase.ts` ergänzen +Im `Database`-Typ die `email_queue` Tabelle mit den Feldern aus der Migration ergänzen. + +### 5. Action-Buttons (optional) +Wenn Admin-Mails HMAC-signierte Buttons enthalten sollen (z.B. "Bestätigen / Ablehnen"): +- Modul `02-admin-auth` muss eingebunden sein (liefert `createActionToken`) +- Buttons verweisen auf `GET /api/admin/anfragen-action?token=...` + +### 6. SMTP testen +```bash +curl -X POST http://localhost:3000/api/admin/smtp-test \ + -H "Content-Type: application/json" \ + -d '{"to":"test@example.com"}' +``` + +--- + +## Anpassungspunkte + +| Was | Wo | +|---|---| +| HTML-Templates (Firmenname, Farben) | `lib/mailer.ts` – alle `html`-Variablen | +| Email-Betreffs | `lib/mailer.ts` – alle `subject`-Zeilen | +| Retry-Intervalle | `lib/email-queue.ts` – `calcNextRetry()` | +| Max. Versuche | `lib/email-queue.ts` – `maxAttempts: 10` | +| Queue-Worker-Intervall | `lib/email-queue.ts` – `setInterval(60_000)` | +| SMTP Port (465 statt 587) | `lib/mailer.ts` – `secure: true, port: 465` | + +--- + +## Integrations-Prompt + +Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`. + +``` +Ich integriere das Email-System-Modul (Nodemailer + Supabase Queue) in mein bestehendes Next.js/Supabase-Projekt. + +PROJEKT-KONTEXT: +- Projektname: [PROJEKTNAME] +- Admin-Email (Empfänger): [ADMIN_EMAIL] +- SMTP-Absender: [SMTP_FROM_EMAIL] +- App-URL (für Links in Mails): [https://beispiel.de] + +BEREITS KOPIERTE DATEIEN (aus modules/01-email-system/files/): +- lib/mailer.ts +- lib/email-queue.ts +- app/api/admin/email-queue/route.ts +- app/api/admin/smtp-test/route.ts + +AUFGABEN – führe sie der Reihe nach aus: + +1. SUPABASE-MIGRATION: Führe folgendes SQL im Supabase SQL-Editor aus: + CREATE TABLE email_queue ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + to_addr text NOT NULL, subject text NOT NULL, + html text NOT NULL, text text, + attempts int DEFAULT 0, max_attempts int DEFAULT 10, + status text DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')), + last_error text, next_retry_at timestamptz DEFAULT now(), + created_at timestamptz DEFAULT now(), sent_at timestamptz + ); + CREATE INDEX idx_email_queue_pending ON email_queue(status, next_retry_at) WHERE status = 'pending'; + +2. SUPABASE-TYPEN: Lies lib/supabase.ts und ergänze in Database.public.Tables: + email_queue: { Row: { id: string; to_addr: string; subject: string; html: string; text: string | null; attempts: number; max_attempts: number; status: string; last_error: string | null; next_retry_at: string; created_at: string; sent_at: string | null } } + +3. BRANDING: Ersetze in lib/mailer.ts alle Vorkommen von "Mietpark Hahn" durch "[PROJEKTNAME]". + Passe außerdem die Farben im HTML-Template an (aktuell #f7d334 für Buttons). + +4. QUEUE-WORKER: Lies instrumentation.ts (oder erstelle sie im Projekt-Root falls nicht vorhanden). + Ergänze/ersetze den Inhalt um: + import { startEmailQueueWorker } from "@/lib/email-queue"; + export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") startEmailQueueWorker(); + } + +5. ENV-VARIABLEN: Ergänze .env.local um: + SMTP_HOST=[SMTP_HOST] + SMTP_PORT=587 + SMTP_USER=[SMTP_USER] + SMTP_PASS=[SMTP_PASS] + SMTP_FROM=[SMTP_FROM_EMAIL] + SMTP_TO=[ADMIN_EMAIL] + COMPANY_PHONE=[TELEFON] + APP_URL=[https://beispiel.de] + NEXTAUTH_SECRET=[MIN_32_ZEICHEN_ZUFAELLIGER_STRING] + +6. TEST: Starte den Dev-Server und sende eine Test-Email: + curl -X POST http://localhost:3000/api/admin/smtp-test \ + -H "Content-Type: application/json" \ + -d '{"to":"[ADMIN_EMAIL]"}' + +Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind. +``` diff --git a/modules/01-email-system/files/app/api/admin/email-queue/route.ts b/modules/01-email-system/files/app/api/admin/email-queue/route.ts new file mode 100644 index 0000000..83dad31 --- /dev/null +++ b/modules/01-email-system/files/app/api/admin/email-queue/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { requireAdmin } from "@/lib/admin-auth"; + +// Kein statischer Build – braucht Env-Vars zur Laufzeit +export const dynamic = "force-dynamic"; + +// GET: Status der Queue abrufen +export async function GET() { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const supabase = createServiceClient(); + const { data, error } = await supabase + .from("email_queue") + .select("id, created_at, next_retry_at, retry_count, max_retries, status, error_last, mail_to, subject") + .order("created_at", { ascending: false }) + .limit(50); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json(data); +} + +// POST: Alle pending Mails sofort erneut versuchen (manueller Trigger) +export async function POST() { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const supabase = createServiceClient(); + const { error, count } = await supabase + .from("email_queue") + .update({ next_retry_at: new Date().toISOString() }) + .eq("status", "pending"); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + + // Worker-Lauf anstoßen (falls noch nicht gestartet) + try { + const { startEmailQueueWorker } = await import("../../../../lib/email-queue"); + startEmailQueueWorker(); + } catch {} + + return NextResponse.json({ ok: true, updated: count ?? 0 }); +} + +// DELETE: Fehlgeschlagene Mails aus Queue entfernen +export async function DELETE() { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const supabase = createServiceClient(); + const { error, count } = await supabase + .from("email_queue") + .delete() + .eq("status", "failed"); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ ok: true, deleted: count ?? 0 }); +} diff --git a/modules/01-email-system/files/app/api/admin/smtp-test/route.ts b/modules/01-email-system/files/app/api/admin/smtp-test/route.ts new file mode 100644 index 0000000..281a3cb --- /dev/null +++ b/modules/01-email-system/files/app/api/admin/smtp-test/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import nodemailer from "nodemailer"; +import { requireAdmin } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +// Schneller Diagnose-Endpunkt – nur im Admin nutzbar, nicht öffentlich verlinkt +// Aufruf: GET /api/admin/smtp-test +export async function GET() { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const config = { + host: process.env.SMTP_HOST ?? "(nicht gesetzt)", + port: Number(process.env.SMTP_PORT ?? 587), + user: process.env.SMTP_USER ?? "(nicht gesetzt)", + from: process.env.SMTP_FROM ?? "(nicht gesetzt)", + to: process.env.SMTP_TO ?? "(nicht gesetzt)", + }; + + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + connectionTimeout: 10000, + greetingTimeout: 8000, + socketTimeout: 12000, + tls: { rejectUnauthorized: false }, + }); + + try { + await transporter.verify(); + + // Test-Mail senden + await transporter.sendMail({ + from: `"Mietpark Hahn TEST" <${process.env.SMTP_FROM}>`, + to: process.env.SMTP_TO, + subject: `✓ SMTP-Test erfolgreich – ${new Date().toLocaleString("de-DE")}`, + text: "Diese Test-Mail wurde automatisch von /api/admin/smtp-test gesendet.\n\nSMTP-Verbindung und Mail-Versand funktionieren korrekt.", + html: `

Diese Test-Mail wurde automatisch von /api/admin/smtp-test gesendet.

✓ SMTP-Verbindung und Mail-Versand funktionieren korrekt.

`, + }); + + return NextResponse.json({ + ok: true, + message: "SMTP-Verbindung erfolgreich – Test-Mail gesendet", + config: { ...config, pass: "***" }, + }); + } catch (err) { + const e = err as Error & { code?: string; command?: string }; + return NextResponse.json( + { + ok: false, + error: e.message, + code: e.code, + command: e.command, + config: { ...config, pass: "***" }, + }, + { status: 500 } + ); + } +} diff --git a/modules/01-email-system/files/lib/email-queue.ts b/modules/01-email-system/files/lib/email-queue.ts new file mode 100644 index 0000000..4de7165 --- /dev/null +++ b/modules/01-email-system/files/lib/email-queue.ts @@ -0,0 +1,176 @@ +/** + * Email-Queue: Speichert fehlgeschlagene E-Mails in der Datenbank + * und versucht sie regelmäßig erneut zu senden. + * + * WICHTIG: Kein process.env.NEXT_PUBLIC_* verwenden – diese Werte + * werden beim Build eingebettet und enthalten die öffentliche URL + * (supabase.demo.mbo-tech-it.de), die vom Docker-Container aus nicht + * erreichbar ist. Stattdessen createServiceClient() nutzen, der intern + * SUPABASE_INTERNAL_URL verwendet (Runtime-Variable, nie eingebettet). + * + * Retry-Strategie: exponentielles Backoff + * Versuch 1 → sofort + * Versuch 2 → 1 Min + * Versuch 3 → 2 Min + * Versuch 4 → 4 Min + * ...bis max. 60 Min zwischen Versuchen, dann Status "failed" + */ + +import nodemailer from "nodemailer"; +import { createServiceClient } from "./supabase"; + +export interface QueuedMail { + mail_from: string; + mail_to: string; + reply_to?: string; + subject: string; + html: string; + body_text: string; +} + +// ─── Datenbank-Operationen via Supabase-Client (interne URL) ────────────── + +async function dbInsert(mail: QueuedMail) { + const db = createServiceClient(); + const { error } = await db.from("email_queue").insert({ + ...mail, + status: "pending", + retry_count: 0, + next_retry_at: new Date().toISOString(), + }); + if (error) throw new Error(`Queue-Insert fehlgeschlagen: ${error.message}`); +} + +async function dbFetchPending(): Promise< + Array +> { + const db = createServiceClient(); + const now = new Date().toISOString(); + const { data, error } = await db + .from("email_queue") + .select("*") + .eq("status", "pending") + .lte("next_retry_at", now) + .order("next_retry_at", { ascending: true }) + .limit(20); + + if (error) { + console.error("[EmailQueue] Fetch pending fehlgeschlagen:", error.message); + return []; + } + return (data ?? []) as Array; +} + +async function dbMarkSent(id: string) { + const db = createServiceClient(); + await db + .from("email_queue") + .update({ status: "sent", error_last: null }) + .eq("id", id); +} + +async function dbMarkRetry(id: string, retryCount: number, error: string) { + const db = createServiceClient(); + const nextCount = retryCount + 1; + const maxRetries = 10; + + if (nextCount >= maxRetries) { + await db + .from("email_queue") + .update({ status: "failed", retry_count: nextCount, error_last: error.slice(0, 500) }) + .eq("id", id); + console.error( + `[EmailQueue] Mail ${id} endgültig fehlgeschlagen nach ${nextCount} Versuchen: ${error}` + ); + return; + } + + // Exponentielles Backoff: 1, 2, 4, 8, 16, 32, 60, 60, 60... Minuten + const minutesDelay = Math.min(Math.pow(2, nextCount - 1), 60); + const nextRetry = new Date(Date.now() + minutesDelay * 60 * 1000).toISOString(); + + await db + .from("email_queue") + .update({ retry_count: nextCount, next_retry_at: nextRetry, error_last: error.slice(0, 500) }) + .eq("id", id); + + console.warn( + `[EmailQueue] Versuch ${nextCount}/${maxRetries} fehlgeschlagen – nächster Retry in ${minutesDelay} Min. Fehler: ${error}` + ); +} + +// ─── Queue-Eintrag speichern ─────────────────────────────────────────────── + +export async function queueEmail(mail: QueuedMail): Promise { + try { + await dbInsert(mail); + console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`); + // Sofort-Retry: nicht auf das 60s-Intervall warten + processQueue().catch(() => {}); + } catch (err) { + console.error("[EmailQueue] Konnte Mail NICHT in Queue speichern:", err); + } +} + +// ─── Worker: Pending Mails senden ───────────────────────────────────────── + +let transporter: nodemailer.Transporter | null = null; + +function getTransporter() { + if (!transporter) { + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT ?? 587), + secure: false, + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + connectionTimeout: 15000, + greetingTimeout: 10000, + socketTimeout: 20000, + tls: { rejectUnauthorized: false, ciphers: "SSLv3" }, + }); + } + return transporter; +} + +async function processQueue(): Promise { + const pending = await dbFetchPending(); + if (pending.length === 0) return; + + console.log(`[EmailQueue] Verarbeite ${pending.length} ausstehende Mail(s)...`); + + for (const mail of pending) { + try { + await getTransporter().sendMail({ + from: mail.mail_from, + to: mail.mail_to, + replyTo: mail.reply_to, + subject: mail.subject, + html: mail.html, + text: mail.body_text, + }); + await dbMarkSent(mail.id); + console.log(`[EmailQueue] ✓ "${mail.subject}" → "${mail.mail_to}" gesendet`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await dbMarkRetry(mail.id, mail.retry_count, msg); + } + } +} + +// ─── Worker starten (Singleton) ─────────────────────────────────────────── + +let workerStarted = false; + +export function startEmailQueueWorker(): void { + if (workerStarted) return; + workerStarted = true; + + console.log("[EmailQueue] Worker gestartet – prüft alle 60 Sekunden"); + + // Sofort beim Start: Mails aus vorherigen Abstürzen verarbeiten + processQueue().catch((e) => console.error("[EmailQueue] Initialer Lauf fehlgeschlagen:", e)); + + setInterval(() => { + processQueue().catch((e) => console.error("[EmailQueue] Lauf fehlgeschlagen:", e)); + }, 60_000); +} diff --git a/modules/01-email-system/files/lib/mailer.ts b/modules/01-email-system/files/lib/mailer.ts new file mode 100644 index 0000000..e05aca9 --- /dev/null +++ b/modules/01-email-system/files/lib/mailer.ts @@ -0,0 +1,932 @@ +import nodemailer from "nodemailer"; +import { queueEmail } from "./email-queue"; + +// Port 587 = STARTTLS (bestätigt erreichbar vom Server + Docker-Container) +// Port 465 = SSL/TLS (auf diesem Server geblockt – nicht verwenden) +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT ?? 587), + secure: false, // STARTTLS auf Port 587 + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + connectionTimeout: 15000, // 15 s (war default 2 min – schlägt jetzt schneller fehl) + greetingTimeout: 10000, + socketTimeout: 20000, + tls: { + rejectUnauthorized: false, + ciphers: "SSLv3", // Kompatibilitätsmodus für ältere SMTP-Server + }, +}); + +export interface AnfrageEmailData { + anfrageId: string; + firma: string; + telefon: string; + email: string; + positionen: { + maschineName: string; + mietbeginn: string; + mietende: string; + gesamtTage: number; + lieferung: boolean; + lieferadresse: string; + anmerkung: string; + tagessatz: number | null; + preisStufe?: "tag" | "woche" | "monat" | null; + zubehoer?: { + id: string; + name: string; + preisTag: number | null; + preisWoche?: number | null; + preisMonat?: number | null; + }[]; + }[]; +} + +// ─── Preisberechnung ────────────────────────────────────────────────────── +const VERSICHERUNG_PROZENT = 7.5; +const MWST_PROZENT = 19; + +function fmt(n: number) { + return n.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function formatDatum(iso: string) { + return new Date(iso).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +/** Alle Sonntage zwischen von und bis (inkl.) als formatierte Strings */ +function getSonntage(von: string, bis: string): string[] { + const result: string[] = []; + const end = new Date(bis); + for (let d = new Date(von); d <= end; d.setDate(d.getDate() + 1)) { + if (d.getDay() === 0) { + result.push( + new Date(d).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }) + ); + } + } + return result; +} + +/** Kalendertage (inkl. Sonntage) zwischen von und bis */ +export function getKalenderTage(von: string, bis: string): number { + const start = new Date(von); + const end = new Date(bis); + return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; +} + +function zubehoerTagessatz( + z: { preisTag: number | null; preisWoche?: number | null; preisMonat?: number | null }, + stufe: string | null | undefined +): number | null { + if (stufe === "monat" && z.preisMonat != null) return z.preisMonat; + if (stufe === "woche" && z.preisWoche != null) return z.preisWoche; + return z.preisTag; +} + +function positionNetto(p: AnfrageEmailData["positionen"][number]): number { + const maschine = p.tagessatz != null ? p.tagessatz * p.gesamtTage : 0; + const zubehoer = (p.zubehoer ?? []).reduce((sum, z) => { + const rate = zubehoerTagessatz(z, p.preisStufe); + return sum + (rate != null ? rate * p.gesamtTage : 0); + }, 0); + return maschine + zubehoer; +} + +function buildPreisBlock(positionen: AnfrageEmailData["positionen"]) { + const gesamtNetto = positionen.reduce((s, p) => s + positionNetto(p), 0); + const versicherungBetrag = gesamtNetto * (VERSICHERUNG_PROZENT / 100); + const mwstBetrag = (gesamtNetto + versicherungBetrag) * (MWST_PROZENT / 100); + const gesamtBrutto = gesamtNetto + versicherungBetrag + mwstBetrag; + + const posRows = positionen.map((p, i) => { + const hatPreis = p.tagessatz != null; + const netto = hatPreis ? positionNetto(p) : null; + const zubehoerMitPreis = (p.zubehoer ?? []).filter( + (z) => zubehoerTagessatz(z, p.preisStufe) != null + ); + + // Sonntage berechnen + const kalenderTage = getKalenderTage(p.mietbeginn, p.mietende); + const sonntage = getSonntage(p.mietbeginn, p.mietende); + const hatSonntage = sonntage.length > 0 && kalenderTage !== p.gesamtTage; + + const zubehoerRows = zubehoerMitPreis + .map((z) => { + const rate = zubehoerTagessatz(z, p.preisStufe)!; + return ` + ↳ ${z.name} + ${fmt(rate * p.gesamtTage)} € + `; + }) + .join(""); + + const sonntageHtml = hatSonntage + ? ` + ${kalenderTage} Kalendertage – ${sonntage.length} Sonntag${sonntage.length > 1 ? "e" : ""} nicht berechnet + (${sonntage.join(", ")}) + ` + : ""; + + const details = [ + p.tagessatz ? `${p.tagessatz} €/Tag · ${p.gesamtTage} berechnete Tag${p.gesamtTage !== 1 ? "e" : ""}` : "", + p.lieferung ? `Lieferung: ${p.lieferadresse}` : "", + p.anmerkung ? `Anmerkung: ${p.anmerkung}` : "", + ] + .filter(Boolean) + .map((d) => `${d}`) + .join(""); + + return ` + + ${i + 1}. ${p.maschineName} + + + ${formatDatum(p.mietbeginn)} – ${formatDatum(p.mietende)} + ${details} + ${sonntageHtml} + + + ${netto != null ? `${fmt(netto)} €` : "Auf Anfrage"} + + ${zubehoerRows}`; + }).join(""); + + const html = ` + + + + + + + + + ${posRows} +
Maschine / GerätZeitraum & DetailsMietpreis
+ + + + + + + + + + + + + + + + + + + + + +
Zwischensumme (netto)${fmt(gesamtNetto)} €
+ Versicherung (${VERSICHERUNG_PROZENT} %)${fmt(versicherungBetrag)} €
Summe netto inkl. Versicherung${fmt(gesamtNetto + versicherungBetrag)} €
+ MwSt. (${MWST_PROZENT} %)${fmt(mwstBetrag)} €
Gesamtbetrag (brutto)${fmt(gesamtBrutto)} €
`; + + const text = + positionen + .map((p, i) => { + const netto = positionNetto(p); + const kalenderTage = getKalenderTage(p.mietbeginn, p.mietende); + const sonntage = getSonntage(p.mietbeginn, p.mietende); + const hatSonntage = sonntage.length > 0 && kalenderTage !== p.gesamtTage; + const lines = [ + `${i + 1}. ${p.maschineName}`, + ` ${formatDatum(p.mietbeginn)} – ${formatDatum(p.mietende)}`, + hatSonntage + ? ` ${kalenderTage} Kalendertage – ${sonntage.length} Sonntag${sonntage.length > 1 ? "e" : ""} nicht berechnet (${sonntage.join(", ")})` + : null, + p.tagessatz + ? ` ${p.tagessatz} €/Tag × ${p.gesamtTage} Tage = ${fmt(p.tagessatz * p.gesamtTage)} € Maschinenmiete` + : " Preis auf Anfrage", + ...(p.zubehoer ?? []) + .filter((z) => zubehoerTagessatz(z, p.preisStufe) != null) + .map((z) => { + const rate = zubehoerTagessatz(z, p.preisStufe)!; + return ` + ${z.name}: ${fmt(rate * p.gesamtTage)} €`; + }), + p.lieferung ? ` Lieferung: ${p.lieferadresse}` : null, + p.anmerkung ? ` Anmerkung: ${p.anmerkung}` : null, + ` Positionssumme: ${fmt(netto)} €`, + ].filter((l): l is string => l != null && l !== ""); + return lines.join("\n"); + }) + .join("\n\n") + + `\n\n${"─".repeat(40)}\nZwischensumme (netto): ${fmt(gesamtNetto)} €\n+ Versicherung ${VERSICHERUNG_PROZENT} %: ${fmt(versicherungBetrag)} €\n+ MwSt. ${MWST_PROZENT} %: ${fmt(mwstBetrag)} €\n${"─".repeat(40)}\nGesamtbetrag (brutto): ${fmt(gesamtBrutto)} €`; + + return { html, text, gesamtNetto, versicherungBetrag, mwstBetrag, gesamtBrutto }; +} + +// ─── Robuster Send mit Queue-Fallback ──────────────────────────────────── +interface MailOptions { + from: string; + to: string | undefined; + replyTo?: string; + subject: string; + text: string; + html: string; +} + +async function sendWithFallback(options: MailOptions, label: string) { + if (!options.to) { + console.error(`[Mailer] Kein Empfänger für "${label}" – Mail übersprungen`); + return; + } + try { + await transporter.sendMail(options); + console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) – in Queue gespeichert`); + await queueEmail({ + mail_from: options.from, + mail_to: options.to, + reply_to: options.replyTo, + subject: options.subject, + html: options.html, + body_text: options.text, + }); + } +} + +// ─── Kontaktformular ────────────────────────────────────────────────────── +export interface KontaktEmailData { + name: string; + anrede?: string; + telefon: string; + email: string; + betreff: string; + nachricht?: string; +} + +export async function sendeKontaktEmail(data: KontaktEmailData) { + const anrede = data.anrede + ? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} ` + : ""; + const html = ` + + + + +
+

Mietpark Hahn

+

Neue Kontaktanfrage

+
+
+

Kontaktanfrage von ${anrede}${data.name}

+ + + + + +
Name${anrede}${data.name}
Telefon${data.telefon}
E-Mail${data.email}
Betreff${data.betreff}
+ ${data.nachricht ? `

${data.nachricht}

` : ""} +
+ +`; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: process.env.SMTP_TO, + replyTo: data.email, + subject: `Kontaktanfrage: ${anrede}${data.name} – ${data.betreff} – Mietpark Hahn`, + text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`, + html, + }, `Kontaktanfrage ${data.name}`); +} + +// ─── Kunden-Eingangsbestätigung ─────────────────────────────────────────── +export async function sendeKundenEingangsbestaetigung(data: AnfrageEmailData) { + const { html: preisHtml, text: preisText } = buildPreisBlock(data.positionen); + + // Build a simple equipment list + const equipmentHtml = ` +
+

+ Ihre gemieteten Geräte +

+
    + ${data.positionen + .map((p) => ` +
  • + + ${p.maschineName} + · ${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)} +
  • `) + .join("")} +
+
+ `; + + const html = ` + + + + +
+

Mietpark Hahn

+

Ihre Mietanfrage ist eingegangen

+
+
+

Vielen Dank für Ihre Anfrage!

+

+ Guten Tag ${data.firma},
+ wir haben Ihre Mietanfrage erhalten und werden uns schnellstmöglich bei Ihnen melden. +

+ + ${equipmentHtml} + +

+ Detaillierte Preisübersicht +

+ ${preisHtml} + +
+

+ Hinweis: Die angezeigten Preise sind Mietpreise inkl. ${VERSICHERUNG_PROZENT} % Versicherung und ${MWST_PROZENT} % MwSt. + Die Bestätigung erfolgt nach Prüfung durch den Verleih. +

+
+ +

+ Bei Fragen erreichen Sie uns unter + ${process.env.COMPANY_PHONE ?? ""} + oder per E-Mail an + ${process.env.SMTP_FROM ?? ""} +

+
+
+

+ Mit der Nutzung unserer Dienste akzeptieren Sie unsere + Allgemeinen Geschäftsbedingungen. +

+

+ Mietpark Hahn · Anfrage-ID: ${data.anfrageId} +

+
+ +`; + + const equipmentList = data.positionen + .map((p) => `• ${p.maschineName} (${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)})`) + .join("\n"); + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: data.email, + subject: `Ihre Mietanfrage ist eingegangen – Mietpark Hahn`, + text: `Guten Tag ${data.firma},\n\nvielen Dank für Ihre Mietanfrage. Wir werden uns schnellstmöglich bei Ihnen melden.\n\nIhre gemieteten Geräte:\n${equipmentList}\n\nDetaillierte Preisübersicht:\n\n${preisText}\n\nHinweis: Die angezeigten Preise sind Mietpreise inkl. ${VERSICHERUNG_PROZENT} % Versicherung und ${MWST_PROZENT} % MwSt. Die Bestätigung erfolgt nach Prüfung durch den Verleih.\n\nMit freundlichen Grüßen\nMietpark Hahn\nAnfrage-ID: ${data.anfrageId}\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`, + html, + }, `Kundeneingang ${data.firma}`); +} + +// ─── Admin-Benachrichtigung (Vermieter) ─────────────────────────────────── +export async function sendeAnfrageEmail(data: AnfrageEmailData) { + const { html: preisHtml, text: preisText } = buildPreisBlock(data.positionen); + + // Action-Tokens für direkte Email-Links generieren + const { createActionToken } = await import("@/lib/admin-auth"); + const [tokenBestaetigt, tokenAbgelehnt, tokenAbgeschlossen] = await Promise.all([ + createActionToken(data.anfrageId, "bestaetigt"), + createActionToken(data.anfrageId, "abgelehnt"), + createActionToken(data.anfrageId, "abgeschlossen"), + ]); + + const baseUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de"; + const urlBestaetigt = `${baseUrl}/api/admin/anfragen-action?token=${tokenBestaetigt}`; + const urlAbgelehnt = `${baseUrl}/api/admin/anfragen-action?token=${tokenAbgelehnt}`; + const urlAbgeschlossen = `${baseUrl}/api/admin/anfragen-action?token=${tokenAbgeschlossen}`; + + const html = ` + + + + +
+

Mietpark Hahn

+

Neue Mietanfrage eingegangen

+
+
+ +

Neue Mietanfrage

+ +

+ Kontaktdaten Kunde +

+ + + + + + + + + + + + + +
Firma / Name${data.firma}
Telefon + ${data.telefon} +
E-Mail + ${data.email} +
+ +

+ Angefragte Maschinen & Preisübersicht +

+ ${preisHtml} + + + +
+

+ Anfrage-ID: ${data.anfrageId}
+ → Anfrage im Admin öffnen +

+
+
+ +`; + + const posText = `Kontakt:\nFirma/Name: ${data.firma}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\n\nMaschinen & Preise:\n\n${preisText}`; + + const actionLinks = `\nQuickaktion aus Email:\n✓ Bestätigen: ${urlBestaetigt}\n✕ Ablehnen: ${urlAbgelehnt}\n✓ Abschließen: ${urlAbgeschlossen}`; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: process.env.SMTP_TO, + subject: `Neue Mietanfrage: ${data.firma} – ${data.positionen.length} Gerät${data.positionen.length !== 1 ? "e" : ""} – Mietpark Hahn`, + text: `Neue Mietanfrage\n\n${posText}\n\nAdmin: ${baseUrl}/admin/anfragen/${data.anfrageId}${actionLinks}`, + html, + }, `Admin-Anfrage ${data.firma}`); +} + +// ─── Kunden-Statusbenachrichtigung ──────────────────────────────────────── +export interface StatusEmailData { + anfrageId: string; + firma: string; + email: string; + neuerStatus: "bestaetigt" | "abgelehnt" | "abgeschlossen"; + notizen?: string; + positionen?: { + maschineName: string; + mietbeginn: string; + mietende: string; + gesamtTage: number; + lieferung: boolean; + lieferadresse: string; + anmerkung: string; + tagessatz: number | null; + preisStufe?: "tag" | "woche" | "monat" | null; + zubehoer?: { + id: string; + name: string; + preisTag: number | null; + preisWoche?: number | null; + preisMonat?: number | null; + }[]; + }[]; +} + +const STATUS_TEXTE: Record< + StatusEmailData["neuerStatus"], + { betreff: string; headline: string; text: string; farbe: string } +> = { + bestaetigt: { + betreff: "Ihre Mietanfrage wurde bestätigt – Mietpark Hahn", + headline: "Ihre Anfrage ist bestätigt!", + text: "Wir freuen uns, Ihnen mitteilen zu können, dass Ihre Mietanfrage bestätigt wurde. Wir setzen uns zur Vorbereitung mit Ihnen in Verbindung.", + farbe: "#16a34a", + }, + abgelehnt: { + betreff: "Zu Ihrer Mietanfrage – Mietpark Hahn", + headline: "Zu Ihrer Mietanfrage", + text: "Leider können wir Ihre Mietanfrage im angefragten Zeitraum nicht erfüllen. Bitte kontaktieren Sie uns für alternative Termine.", + farbe: "#dc2626", + }, + abgeschlossen: { + betreff: "Ihre Miete wurde abgeschlossen – Mietpark Hahn", + headline: "Vielen Dank!", + text: "Ihre Miete wurde erfolgreich abgeschlossen. Wir hoffen, Sie waren mit unserem Service zufrieden und freuen uns auf Ihre nächste Anfrage.", + farbe: "#475569", + }, +}; + +export async function sendeKundenStatusEmail(data: StatusEmailData) { + const info = STATUS_TEXTE[data.neuerStatus]; + + // Geräte-Übersicht (falls vorhanden) + const equipmentHtml = data.positionen ? ` +
+

+ Ihre gemieteten Geräte +

+
    + ${data.positionen + .map((p) => ` +
  • + + ${p.maschineName} + · ${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)} +
  • `) + .join("")} +
+
+ ` : ""; + + // Preisblock (falls vorhanden) + const preisBlock = data.positionen ? buildPreisBlock(data.positionen) : null; + const preisHtml = preisBlock?.html ?? ""; + + const html = ` + + + + +
+

Mietpark Hahn

+

Update zu Ihrer Mietanfrage

+
+
+
+

${info.headline}

+

Guten Tag ${data.firma},
${info.text}

+
+ + ${equipmentHtml} + + ${data.positionen && preisHtml ? ` +

+ Detaillierte Preisübersicht +

+ ${preisHtml} + ` : ""} + + ${ + data.notizen + ? `
+

NACHRICHT VOM VERLEIH

+

${data.notizen}

+
` + : "" + } +

+ Bei Fragen erreichen Sie uns unter + ${process.env.SMTP_FROM ?? ""} +

+
+
+

+ Mit der Nutzung unserer Dienste akzeptieren Sie unsere + Allgemeinen Geschäftsbedingungen. +

+
+ +`; + + const equipmentList = data.positionen + ? data.positionen + .map((p) => `• ${p.maschineName} (${formatDatum(p.mietbeginn)} bis ${formatDatum(p.mietende)})`) + .join("\n") + : ""; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: data.email, + subject: info.betreff, + text: `Guten Tag ${data.firma},\n\n${info.text}${equipmentList ? `\n\nIhre gemieteten Geräte:\n${equipmentList}` : ""}${data.notizen ? `\n\nNachricht vom Verleih:\n${data.notizen}` : ""}\n\nMit freundlichen Grüßen\nMietpark Hahn\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`, + html, + }, `Status-${data.neuerStatus} ${data.firma}`); +} + +// ─── Registrierungsbestätigung ───────────────────────────────────────────── +export async function sendeRegistrierungsBestaetigung(data: { + email: string; + firma?: string; + bestaetigungsLink: string; +}) { + const name = data.firma || data.email; + const html = ` + + + + +
+

Mietpark Hahn

+

E-Mail-Adresse bestätigen

+
+
+

Guten Tag ${name},

+

+ vielen Dank für Ihre Registrierung bei Mietpark Hahn. Bitte bestätigen Sie Ihre + E-Mail-Adresse, um Zugang zu Ihrem Kundenbereich zu erhalten. +

+ + E-Mail-Adresse bestätigen → + +

+ Dieser Link ist 24 Stunden gültig. Falls Sie sich nicht registriert haben, können Sie + diese E-Mail ignorieren. +

+
+
+

+ Mit der Nutzung unserer Dienste akzeptieren Sie unsere + Allgemeinen Geschäftsbedingungen. +

+
+ +`; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: data.email, + subject: "Bitte bestätigen Sie Ihre E-Mail-Adresse – Mietpark Hahn", + text: `Guten Tag ${name},\n\nbitte bestätigen Sie Ihre E-Mail-Adresse:\n\n${data.bestaetigungsLink}\n\nDieser Link ist 24 Stunden gültig.\n\nMit freundlichen Grüßen\nMietpark Hahn\n\nUnsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`, + html, + }, `Registrierung ${name}`); +} + +// ─── Maschinen-Bedarfscheck ──────────────────────────────────────────────── +export async function sendeBedarfscheckAnKunde(data: { + name: string; + email: string; +}) { + const html = ` + + + + +
+

Mietpark Hahn

+

Ihr kostenloser Maschinen-Bedarfscheck

+
+
+

Guten Tag ${data.name},

+

+ vielen Dank für Ihre Anfrage! Hier sind die Antworten zu den 7 wichtigsten Fragen vor der Maschinen-Miete: +

+ +
    +
  1. + Welche Maschinenklasse passt zu meinem Projekt?
    + Minibagger (1,5–3t) für Garten- und enge Baustellenarbeiten, Kettenbagger (5–16t) für Baugruben und Schachtarbeiten, Radlader für Materialtransport und Verdichtung. +
  2. +
  3. + Welches Zubehör brauche ich?
    + Tieflöffel für Erde, Grabenräumlöffel für Leitungsgräben, Abbruchhammer für Beton/Asphalt, Greifer für Schüttgut. Die genaue Auswahl beraten wir gerne persönlich. +
  4. +
  5. + Wie plane ich Mietdauer & Lieferung richtig?
    + Immer 1 Puffertag einkalkulieren. Lieferung mindestens 1 Werktag vorab anfragen. In Frühjahr/Herbst frühzeitig reservieren, da die Nachfrage hoch ist. +
  6. +
  7. + Was muss ich bei der Übergabe prüfen?
    + Betriebsstunden und Ölstände notieren, Schäden fotografieren, Bedienungsanleitung mitnehmen. Dies schützt Sie vor Haftungsansprüchen. +
  8. +
  9. + Welche Versicherung ist sinnvoll?
    + Privatpersonen sollten ihre Haftpflichtversicherung prüfen. Firmen benötigen Baugeräteversicherung. Eine Maschinenbruchversicherung über uns ist optional, aber empfohlen. +
  10. +
  11. + Wie spare ich durch Wochenmiete?
    + Wochentarife sind ca. 30–40% günstiger als 5× Tagessatz. Schon ab 4 Tagen Einsatz lohnt sich die Wochenmiete gegenüber Tagesmietung. +
  12. +
  13. + Was tun, wenn die Maschine ausfällt?
    + Sofort den Verleih anrufen. Wenn kein Verschulden vorliegt, entstehen keine Kosten während des Ausfalls. Ersatz wird schnellstmöglich bereitgestellt. +
  14. +
+ +
+

+ Nächste Schritte: Rufen Sie uns an oder stellen Sie eine Anfrage mit den gewünschten Maschinen. Unser Team prüft sofort die Verfügbarkeit und meldet sich bei Ihnen! +

+
+ +

+ Bei Fragen erreichen Sie uns unter + ${process.env.COMPANY_PHONE ?? ""} + oder per E-Mail an + ${process.env.SMTP_FROM ?? ""} +

+
+
+

+ Mit der Nutzung unserer Dienste akzeptieren Sie unsere + Allgemeinen Geschäftsbedingungen. +

+
+ +`; + + const text = `Guten Tag ${data.name}, + +vielen Dank für Ihre Anfrage! Hier sind die Antworten zu den 7 wichtigsten Fragen vor der Maschinen-Miete: + +1. Welche Maschinenklasse passt zu meinem Projekt? + Minibagger (1,5–3t) für Garten- und enge Baustellenarbeiten, Kettenbagger (5–16t) für Baugruben und Schachtarbeiten, Radlader für Materialtransport und Verdichtung. + +2. Welches Zubehör brauche ich? + Tieflöffel für Erde, Grabenräumlöffel für Leitungsgräben, Abbruchhammer für Beton/Asphalt, Greifer für Schüttgut. Die genaue Auswahl beraten wir gerne persönlich. + +3. Wie plane ich Mietdauer & Lieferung richtig? + Immer 1 Puffertag einkalkulieren. Lieferung mindestens 1 Werktag vorab anfragen. In Frühjahr/Herbst frühzeitig reservieren, da die Nachfrage hoch ist. + +4. Was muss ich bei der Übergabe prüfen? + Betriebsstunden und Ölstände notieren, Schäden fotografieren, Bedienungsanleitung mitnehmen. Dies schützt Sie vor Haftungsansprüchen. + +5. Welche Versicherung ist sinnvoll? + Privatpersonen sollten ihre Haftpflichtversicherung prüfen. Firmen benötigen Baugeräteversicherung. Eine Maschinenbruchversicherung über uns ist optional, aber empfohlen. + +6. Wie spare ich durch Wochenmiete? + Wochentarife sind ca. 30–40% günstiger als 5× Tagessatz. Schon ab 4 Tagen Einsatz lohnt sich die Wochenmiete gegenüber Tagesmietung. + +7. Was tun, wenn die Maschine ausfällt? + Sofort den Verleih anrufen. Wenn kein Verschulden vorliegt, entstehen keine Kosten während des Ausfalls. Ersatz wird schnellstmöglich bereitgestellt. + +Nächste Schritte: Rufen Sie uns an oder stellen Sie eine Anfrage mit den gewünschten Maschinen. + +Mit freundlichen Grüßen +Mietpark Hahn + +Unsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: data.email, + subject: "Ihr kostenloser Maschinen-Bedarfscheck – Mietpark Hahn", + text, + html, + }, `Bedarfscheck ${data.name}`); +} + +export async function sendeBedarfscheckAnVermieter(data: { + name: string; + email: string; + telefon: string; + adresse: string | null; +}) { + const html = ` + + + + +
+

Mietpark Hahn

+

Neuer Lead aus Bedarfscheck

+
+
+

Neuer Lead aus Maschinen-Bedarfscheck

+ +

+ Kontaktdaten +

+ + + + + + + + + + + + + + ${ + data.adresse + ? ` + + + ` + : "" + } +
Name / Firma${data.name}
E-Mail + ${data.email} +
Telefon + ${data.telefon} +
Adresse${data.adresse}
+ +
+

+ Quelle: Maschinen-Bedarfscheck (Lead Magnet) +

+
+
+ +`; + + const text = `Neuer Lead aus Maschinen-Bedarfscheck + +Kontaktdaten: +Name/Firma: ${data.name} +E-Mail: ${data.email} +Telefon: ${data.telefon} +${data.adresse ? `Adresse: ${data.adresse}` : ""} + +Quelle: Maschinen-Bedarfscheck`; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: process.env.SMTP_TO, + subject: `Neuer Lead: ${data.name} – Maschinen-Bedarfscheck`, + text, + html, + }, `Lead ${data.name}`); +} + +// ─── Email-Änderungsbestätigung (Admin-Profil) ──────────────────────────────── +export async function sendeEmailAenderungsBestaetigung(data: { + adminEmail: string; + adminName: string; + bestaetigungsLink: string; +}) { + const html = ` + + + + +
+

Mietpark Hahn

+

Admin-Bereich: E-Mail-Änderung

+
+
+

Guten Tag ${data.adminName},

+

+ Sie haben eine neue E-Mail-Adresse für Ihr Admin-Konto hinterlegt. + Bitte bestätigen Sie diese Änderung, um die neue E-Mail-Adresse zu aktivieren. +

+ + E-Mail-Adresse bestätigen → + +

+ Wichtig: Ihre bisherige E-Mail-Adresse bleibt aktiv, bis Sie diesen Link klicken. + Dieser Link ist 24 Stunden gültig. +

+

+ Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. + Ihre E-Mail-Adresse wird nicht geändert. +

+
+
+

+ Mit der Nutzung unserer Dienste akzeptieren Sie unsere + Allgemeinen Geschäftsbedingungen. +

+
+ +`; + + const text = `Guten Tag ${data.adminName}, + +Sie haben eine neue E-Mail-Adresse für Ihr Admin-Konto hinterlegt. +Bitte bestätigen Sie diese Änderung unter folgendem Link: + +${data.bestaetigungsLink} + +Dieser Link ist 24 Stunden gültig. + +WICHTIG: Ihre bisherige E-Mail-Adresse bleibt aktiv, bis Sie den Link oben bestätigen. + +Mit freundlichen Grüßen +Mietpark Hahn + +Unsere AGB finden Sie unter: ${process.env.APP_URL ?? "https://www.mietparkhahn.de"}/agb`; + + await sendWithFallback({ + from: `"Mietpark Hahn" <${process.env.SMTP_FROM}>`, + to: data.adminEmail, + subject: "Neue E-Mail-Adresse bestätigen – Mietpark Hahn", + text, + html, + }, `Email-Änderung ${data.adminName}`); +} diff --git a/modules/02-admin-auth/TEMPLATE.md b/modules/02-admin-auth/TEMPLATE.md new file mode 100644 index 0000000..100fc4a --- /dev/null +++ b/modules/02-admin-auth/TEMPLATE.md @@ -0,0 +1,235 @@ +# Modul: Admin-Auth & Security + +> Vollständige Admin-Authentifizierung: HMAC-SHA256 Session-Tokens (2h), Brute-Force-Schutz (Rate-Limit + exponentieller Backoff), Token-Blacklist (Logout-Revocation), One-Time-Use Email-Action-Links, Session-Timeout-Provider (15-Min-Warnung), Audit-Logging aller Login-Versuche. + +--- + +## Enthaltene Dateien + +| Ziel im neuen Projekt | Inhalt | +|---|---| +| `lib/admin-auth.ts` | Token-Erzeugung/Verifikation (HMAC-SHA256), `requireAdmin()` Middleware | +| `lib/rate-limit.ts` | In-Memory Rate-Limiting mit Backoff (5 Versuche/15 Min, Lock nach 10) | +| `lib/token-blacklist.ts` | Session-Token Revocation + Action-Token One-Time-Use (Supabase) | +| `lib/audit-log.ts` | Login-Audit-Logging + Brute-Force-Erkennung | +| `app/api/admin/login/route.ts` | POST: Login, DELETE: Logout | +| `app/api/admin/anfragen-action/route.ts` | GET: Email-Action-Link verarbeiten (HMAC-Token) | +| `app/admin/login/page.tsx` | Login-Seite (Open-Redirect-Schutz) | +| `app/admin/audit-logs/page.tsx` | Admin-Dashboard: Login-Überwachung | +| `components/admin/SessionTimeoutProvider.tsx` | Client-seitiger Inaktivitäts-Tracker (Warnung + Auto-Logout) | +| `migrations/MIGRATIONS_TOKEN_BLACKLIST.sql` | Tabellen: `admin_session_blacklist`, `action_token_blacklist` | +| `migrations/MIGRATIONS_AUDIT_LOGS.sql` | Tabelle: `admin_audit_logs` | + +--- + +## Voraussetzungen + +```bash +npm install bcryptjs +npm install -D @types/bcryptjs +``` + +Benötigt außerdem: +- `lib/supabase.ts` mit Service Client +- Supabase-Tabelle `benutzer` (Admin-User-Tabelle, siehe unten) + +--- + +## Umgebungsvariablen (`.env.local`) + +```env +NEXTAUTH_SECRET=min-32-zeichen-zufaelliger-string # HMAC-Schlüssel für Tokens +``` + +--- + +## Datenbank-Migrationen (Supabase) + +### 1. Migrations aus `migrations/` ausführen +``` +MIGRATIONS_AUDIT_LOGS.sql → Tabelle admin_audit_logs +MIGRATIONS_TOKEN_BLACKLIST.sql → Tabellen admin_session_blacklist, action_token_blacklist +``` + +### 2. Admin-User-Tabelle (muss im Projekt existieren) +```sql +CREATE TABLE benutzer ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, + password_hash text NOT NULL, -- bcrypt-Hash + name text, + active boolean DEFAULT true, + created_at timestamptz DEFAULT now() +); +``` + +### 3. Supabase-Typen ergänzen (`lib/supabase.ts`) +In `Database.public.Tables` ergänzen: +```ts +benutzer: { Row: { id: string; email: string; password_hash: string; active: boolean } } +admin_audit_logs: { Row: { id: string; email: string; ip_addr: string; user_agent: string; success: boolean; reason: string | null; timestamp: string } } +admin_session_blacklist: { Row: { id: string; admin_id: string; token_signature: string; revoked_at: string; reason: string } } +action_token_blacklist: { Row: { id: string; anfrage_id: string; token_signature: string; action_type: string; used_at: string } } +``` + +--- + +## Einbindung Schritt für Schritt + +### 1. Dateien kopieren +Alle `files/` in entsprechende Projektpfade. + +### 2. Admin-Layout absichern (`app/admin/layout.tsx`) +```tsx +import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider"; + +export default function AdminLayout({ children }) { + return ( + + {children} + + ); +} +``` + +### 3. Admin-API-Routes schützen +```ts +import { requireAdmin } from "@/lib/admin-auth"; + +export async function GET(req: Request) { + const admin = await requireAdmin(req); + if (!admin.ok) return admin.response; + // ... Route-Logik +} +``` + +### 4. Login-Route in Nav verlinken +```tsx +// Redirect nach Login zu /admin (oder ?from=/admin/anfragen) +Admin Login +``` + +### 5. Ersten Admin-User anlegen +```sql +-- bcrypt-Hash generieren (cost factor 12) und direkt eintragen +INSERT INTO benutzer (email, password_hash) +VALUES ('admin@example.com', '$2b$12$...'); +``` +Oder via Script: +```ts +import bcrypt from "bcryptjs"; +const hash = await bcrypt.hash("password", 12); +``` + +### 6. Action-Links (für Email-Buttons) +Wenn Action-Links aus Emails verarbeitet werden sollen (z.B. Anfrage bestätigen per Klick): +- `app/api/admin/anfragen-action/route.ts` verarbeitet `GET ?token=...` +- Token erzeugen mit `createActionToken(id, "bestaetigt")` aus `lib/admin-auth.ts` +- Route anpassen: Was passiert nach erfolgreicher Aktion (DB-Update + Redirect) + +--- + +## Anpassungspunkte + +| Was | Wo | +|---|---| +| Session-Dauer (aktuell 2h) | `lib/admin-auth.ts` → `SESSION_DURATION` | +| Action-Token Gültigkeit (7 Tage) | `lib/admin-auth.ts` → `ACTION_TOKEN_DURATION` | +| Rate-Limit Schwellenwert (5 Versuche) | `lib/rate-limit.ts` → `MAX_ATTEMPTS` | +| Account-Lock Dauer (15 Min) | `lib/rate-limit.ts` → `LOCK_DURATION_MS` | +| Inaktivitäts-Timeout (2h) | `components/admin/SessionTimeoutProvider.tsx` → `TIMEOUT_MS` | +| Warn-Zeitpunkt (15 Min vor Ablauf) | `components/admin/SessionTimeoutProvider.tsx` → `WARN_BEFORE_MS` | +| Audit-Log Aufbewahrung (90 Tage) | `lib/audit-log.ts` → `deleteOldAuditLogs()` | + +--- + +## Integrations-Prompt + +Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`. + +``` +Ich integriere das Admin-Auth-Modul (HMAC-Session, Rate-Limit, Token-Blacklist, Audit-Log) in mein Next.js/Supabase-Projekt. + +PROJEKT-KONTEXT: +- Erster Admin-User: Email [ADMIN_EMAIL], Passwort [ADMIN_PASSWORT] +- Admin-Bereich URL-Prefix: /admin +- Supabase: bereits eingerichtet, lib/supabase.ts vorhanden + +BEREITS KOPIERTE DATEIEN (aus modules/02-admin-auth/files/): +- lib/admin-auth.ts, lib/rate-limit.ts, lib/token-blacklist.ts, lib/audit-log.ts +- app/api/admin/login/route.ts +- app/api/admin/anfragen-action/route.ts +- app/admin/login/page.tsx +- app/admin/audit-logs/page.tsx +- components/admin/SessionTimeoutProvider.tsx + +AUFGABEN – führe sie der Reihe nach aus: + +1. SUPABASE-MIGRATIONEN: Führe diese SQLs nacheinander im Supabase SQL-Editor aus: + + -- Aus migrations/MIGRATIONS_AUDIT_LOGS.sql: + CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, ip_addr text NOT NULL, user_agent text NOT NULL, + success boolean NOT NULL, reason text, timestamp timestamptz DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_audit_logs_email ON admin_audit_logs(email); + CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON admin_audit_logs(timestamp DESC); + ALTER TABLE admin_audit_logs ENABLE ROW LEVEL SECURITY; + CREATE POLICY "Admin Logs lesen" ON admin_audit_logs FOR SELECT USING (true); + + -- Aus migrations/MIGRATIONS_TOKEN_BLACKLIST.sql: + CREATE TABLE admin_session_blacklist ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + admin_id uuid NOT NULL, token_signature text NOT NULL UNIQUE, + revoked_at timestamptz DEFAULT now(), reason text NOT NULL, notes text + ); + CREATE INDEX idx_admin_session_blacklist_sig ON admin_session_blacklist(token_signature); + CREATE TABLE action_token_blacklist ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + anfrage_id uuid NOT NULL, token_signature text NOT NULL UNIQUE, + action_type text NOT NULL, used_at timestamptz DEFAULT now(), + used_by_ip text, notes text + ); + CREATE INDEX idx_action_token_blacklist_sig ON action_token_blacklist(token_signature); + ALTER TABLE admin_session_blacklist ENABLE ROW LEVEL SECURITY; + ALTER TABLE action_token_blacklist ENABLE ROW LEVEL SECURITY; + +2. ADMIN-USER-TABELLE: Prüfe ob eine Tabelle für Admin-Benutzer existiert. Falls nicht, erstelle: + CREATE TABLE benutzer ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, password_hash text NOT NULL, + name text, active boolean DEFAULT true, created_at timestamptz DEFAULT now() + ); + +3. ERSTEN ADMIN-USER ANLEGEN: Generiere einen bcrypt-Hash für [ADMIN_PASSWORT] (cost 12) + und füge diesen direkt per SQL ein: + INSERT INTO benutzer (email, password_hash, name) VALUES ('[ADMIN_EMAIL]', '[HASH]', 'Admin'); + +4. SUPABASE-TYPEN: Lies lib/supabase.ts und ergänze in Database.public.Tables: + benutzer: { Row: { id: string; email: string; password_hash: string; active: boolean; name: string | null } } + admin_audit_logs: { Row: { id: string; email: string; ip_addr: string; user_agent: string; success: boolean; reason: string | null; timestamp: string } } + admin_session_blacklist: { Row: { id: string; admin_id: string; token_signature: string; revoked_at: string; reason: string } } + action_token_blacklist: { Row: { id: string; anfrage_id: string; token_signature: string; action_type: string; used_at: string } } + +5. ENV-VARIABLEN: Ergänze .env.local um: + NEXTAUTH_SECRET=[MIN_32_ZEICHEN_ZUFAELLIGER_STRING] + +6. ADMIN-LAYOUT absichern: Lies app/admin/layout.tsx (oder erstelle es). + Importiere und wrappe mit : + import { SessionTimeoutProvider } from "@/components/admin/SessionTimeoutProvider"; + export default function AdminLayout({ children }) { + return {children}; + } + +7. API-ROUTES schützen: Zeige mir alle Dateien unter app/api/admin/ (außer login/). + Füge in jede Route am Anfang der Handler-Funktion ein: + import { requireAdmin } from "@/lib/admin-auth"; + const admin = await requireAdmin(req); + if (!admin.ok) return admin.response; + +8. TEST: Starte Dev-Server, öffne /admin/login, logge dich mit [ADMIN_EMAIL] ein. + Prüfe: Weiterleitung zu /admin nach Login, /admin/audit-logs zeigt Login-Eintrag. + +Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind. +``` diff --git a/modules/02-admin-auth/files/app/admin/audit-logs/page.tsx b/modules/02-admin-auth/files/app/admin/audit-logs/page.tsx new file mode 100644 index 0000000..7106527 --- /dev/null +++ b/modules/02-admin-auth/files/app/admin/audit-logs/page.tsx @@ -0,0 +1,204 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { verifySessionToken } from "@/lib/admin-auth"; +import { getAuditLogs } from "@/lib/audit-log"; +import { cookies } from "next/headers"; + +interface AuditLogEntry { + id: string; + email: string; + ip_addr: string; + user_agent: string; + success: boolean; + timestamp: string; + reason?: string; +} + +async function AuditLogsContent() { + // ──── Auth Check ──── + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + + if (!token) { + return ( +
+

Unauthorized

+ + Zurück zum Login + +
+ ); + } + + const session = await verifySessionToken(token); + if (!session) { + return ( +
+

Session abgelaufen

+ + Erneut anmelden + +
+ ); + } + + // ──── Audit-Logs abrufen (letzte 100) ──── + const logs = await getAuditLogs({ limit: 100, offset: 0 }); + + // ──── Statistiken ──── + const successCount = logs.filter((l) => l.success).length; + const failedCount = logs.filter((l) => !l.success).length; + const uniqueEmails = new Set(logs.map((l) => l.email)).size; + const uniqueIps = new Set(logs.map((l) => l.ip_addr)).size; + + // ──── Fehlerrate in der letzten Stunde ──── + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const recentLogs = logs.filter((l) => l.timestamp > oneHourAgo); + const recentFailed = recentLogs.filter((l) => !l.success).length; + + return ( +
+ {/* Header */} +
+
+
+

Audit-Logs

+

Login-Aktivitäten und Sicherheitsereignisse

+
+ + + Zurück + +
+
+ + {/* Statistiken */} +
+
+
Erfolgreich (Gesamt)
+
{successCount}
+
+
+
Fehlgeschlagen (Gesamt)
+
{failedCount}
+
+
+
Fehlgeschlagen (1h)
+
= 5 ? "text-red-600" : "text-green-600"}`}> + {recentFailed} +
+
+
+
Eindeutige IPs
+
{uniqueIps}
+
+
+ + {/* Warnung bei verdächtiger Aktivität */} + {recentFailed >= 5 && ( +
+
+
+

⚠️ Verdächtige Aktivität erkannt

+

+ {recentFailed} fehlgeschlagene Login-Versuche in der letzten Stunde +

+
+
+
+ )} + + {/* Logs Tabelle */} +
+
+ + + + + + + + + + + + {logs.length === 0 ? ( + + + + ) : ( + logs.map((log) => ( + + + + + + + + )) + )} + +
ZeitstempelE-MailIP-AdresseStatusGrund
+ Keine Audit-Logs vorhanden +
+ {new Date(log.timestamp).toLocaleString("de-DE")} + {log.email}{log.ip_addr} + + {log.success ? "✓ Erfolgreich" : "✗ Fehlgeschlagen"} + + + {log.reason ? formatReason(log.reason) : "-"} +
+
+
+ + {/* User-Agent Info */} +
+

+ Hinweis: User-Agent und detaillierte Geräte-Informationen sind in der Datenbank + gespeichert. Für erweiterte Analysen siehe die Supabase-Tabelle{" "} + + admin_audit_logs + +

+
+
+ ); +} + +function formatReason(reason: string): string { + const reasons: Record = { + invalid_password: "Falsches Passwort", + user_not_found_or_inactive: "User nicht gefunden / inaktiv", + missing_credentials: "Fehlende Anmeldedaten", + invalid_token: "Ungültiger Token", + token_expired: "Token abgelaufen", + }; + return reasons[reason] || reason; +} + +export default function AuditLogsPage() { + return ( +
+ +

Lädt Audit-Logs...

+
+ } + > + + +
+ ); +} diff --git a/modules/02-admin-auth/files/app/admin/login/page.tsx b/modules/02-admin-auth/files/app/admin/login/page.tsx new file mode 100644 index 0000000..b7b06c8 --- /dev/null +++ b/modules/02-admin-auth/files/app/admin/login/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +/** + * Validiert Redirect-URL: nur interne Routes erlauben + * Verhindert Open Redirect zu externen Seiten + */ +function isInternalUrl(url: string): boolean { + if (!url.startsWith("/")) return false; + // Nur Admin- und Kunden-Routes erlauben + return url.startsWith("/admin/") || url.startsWith("/kunden/"); +} + +function AdminLoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const rawFrom = searchParams.get("from"); + // ✅ Validierung: nur interne URLs erlauben, Fallback auf /admin/anfragen + const from = rawFrom && isInternalUrl(rawFrom) ? rawFrom : "/admin/anfragen"; + const sessionExpired = searchParams.get("session_expired") === "true"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState( + sessionExpired ? "Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an." : "" + ); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + if (res.ok) { + router.push(from); + } else { + setError("Ungültige Zugangsdaten"); + } + setLoading(false); + } + + return ( +
+
+
+

+ Admin · Mietpark Hahn +

+

Bitte anmelden

+
+ +
+
+ + setEmail(e.target.value)} + autoComplete="email" + placeholder="admin@beispiel.de" + className="mt-1 rounded-md" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + className="mt-1 rounded-md" + required + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +} + +export default function AdminLoginPage() { + return ( + + + + ); +} diff --git a/modules/02-admin-auth/files/app/api/admin/anfragen-action/route.ts b/modules/02-admin-auth/files/app/api/admin/anfragen-action/route.ts new file mode 100644 index 0000000..365f3c1 --- /dev/null +++ b/modules/02-admin-auth/files/app/api/admin/anfragen-action/route.ts @@ -0,0 +1,165 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { verifyActionToken } from "@/lib/admin-auth"; +import { markActionTokenUsed } from "@/lib/token-blacklist"; +import { sendeKundenStatusEmail, getKalenderTage } from "@/lib/mailer"; + +// Verfügbarkeit aktualisieren wenn Status sich ändert +async function aktualisiereVerfuegbarkeit( + db: ReturnType, + anfrageId: string, + neuerStatus: string +) { + if (neuerStatus === "bestaetigt") { + await db + .from("verfuegbarkeit") + .update({ status: "belegt" }) + .eq("anfrage_id", anfrageId); + } else if (neuerStatus === "abgelehnt") { + await db + .from("verfuegbarkeit") + .delete() + .eq("anfrage_id", anfrageId); + } +} + +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get("token"); + + if (!token) { + return NextResponse.json( + { error: "Token erforderlich" }, + { status: 400 } + ); + } + + // Token validieren + const actionToken = await verifyActionToken(token); + + if (!actionToken) { + return NextResponse.json( + { error: "Token ungültig oder abgelaufen" }, + { status: 400 } + ); + } + + const { anfrageId, status } = actionToken; + const appUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de"; + const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown"; + + // ✅ Token als verwendet markieren (One-Time-Use) + const [, tokenSig] = token.split("."); + await markActionTokenUsed(tokenSig, anfrageId, status, ipAddr); + + try { + const db = createServiceClient(); + + // Aktuellen Status laden (vor dem Update) für Audit Log + const { data: currentData } = await db + .from("anfragen") + .select("status") + .eq("id", anfrageId) + .single(); + + const alterStatus = currentData?.status || null; + + // Status aktualisieren + const { error } = await db + .from("anfragen") + .update({ status }) + .eq("id", anfrageId); + + if (error) { + console.error(`[Action] Fehler beim Update von Anfrage ${anfrageId}:`, error); + return NextResponse.json( + { error: "Statusaktualisierung fehlgeschlagen" }, + { status: 500 } + ); + } + + // Status-Änderung ins Audit Log schreiben + if (alterStatus !== status) { + const { error: auditError } = await db + .from("anfrage_status_audit") + .insert({ + anfrage_id: anfrageId, + status_von: alterStatus, + status_zu: status, + bearbeitet_von: "action-link", + notizen: null, + }); + if (auditError) { + console.error(`[Action] Fehler beim Schreiben des Status-Audit-Logs für Anfrage ${anfrageId}:`, auditError); + } + } + + // Verfügbarkeit aktualisieren + await aktualisiereVerfuegbarkeit(db, anfrageId, status); + + // Anfrage-Daten + Positionen für E-Mail laden + const { data: anfrage } = await db + .from("anfragen") + .select("firma, email, notizen") + .eq("id", anfrageId) + .single(); + + if (anfrage?.email) { + let positionen: any[] = []; + + // Positionen laden + const { data: posData, error: posError } = await db + .from("anfragen_positionen") + .select("*") + .eq("anfrage_id", anfrageId) + .order("mietbeginn"); + + if (posError) { + console.error( + `[Action] Fehler beim Laden von Positionen für Anfrage ${anfrageId}:`, + posError + ); + } + + // Positionen formatieren für Email-Template + positionen = (posData ?? []).map((p: any) => ({ + maschineName: p.maschine_name || "Unbekannte Maschine", + mietbeginn: p.mietbeginn, + mietende: p.mietende, + gesamtTage: p.gesamt_tage || getKalenderTage(p.mietbeginn, p.mietende), + lieferung: p.lieferung || false, + lieferadresse: p.lieferadresse || "", + anmerkung: p.anmerkung || "", + tagessatz: p.tagessatz, + preisStufe: null, + zubehoer: [], + })); + + console.log( + `[Action] Anfrage ${anfrageId}: ${positionen.length} Positionen geladen` + ); + + // Kunden-Status-Email versenden + sendeKundenStatusEmail({ + anfrageId, + firma: anfrage.firma, + email: anfrage.email, + neuerStatus: status as "bestaetigt" | "abgelehnt" | "abgeschlossen", + notizen: anfrage.notizen || undefined, + positionen: positionen.length > 0 ? positionen : undefined, + }).catch((err) => + console.error("[Action] Fehler beim Versand der Status-Email:", err) + ); + } + + // Redirect zum Admin-Panel mit Success-Indicator + return NextResponse.redirect( + `${appUrl}/admin/anfragen/${anfrageId}?action=done` + ); + } catch (err) { + console.error("[Action] Unerwarteter Fehler:", err); + return NextResponse.json( + { error: "Ein Fehler ist aufgetreten" }, + { status: 500 } + ); + } +} diff --git a/modules/02-admin-auth/files/app/api/admin/login/route.ts b/modules/02-admin-auth/files/app/api/admin/login/route.ts new file mode 100644 index 0000000..43ede23 --- /dev/null +++ b/modules/02-admin-auth/files/app/api/admin/login/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { createServiceClient } from "@/lib/supabase"; +import { createSessionToken, verifySessionToken } from "@/lib/admin-auth"; +import { logLoginAttempt, getFailedLoginCount, sendSecurityAlert } from "@/lib/audit-log"; +import { checkRateLimit, resetRateLimit } from "@/lib/rate-limit"; +import { revokeSessionToken } from "@/lib/token-blacklist"; + +export async function POST(req: NextRequest) { + const { email, password } = await req.json(); + const ipAddr = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "unknown"; + const userAgent = req.headers.get("user-agent") || "unknown"; + + // ──── Validierung ──── + if (!email || !password) { + await logLoginAttempt("", ipAddr, false, userAgent, "missing_credentials"); + return NextResponse.json({ error: "E-Mail und Passwort erforderlich" }, { status: 400 }); + } + + // ──── Rate-Limiting ──── + const rateLimitKey = `login:${email.toLowerCase()}:${ipAddr}`; + const { allowed, delayMs, locked } = checkRateLimit(rateLimitKey); + + if (locked) { + await logLoginAttempt(email, ipAddr, false, userAgent, "rate_limit_locked"); + const res = NextResponse.json( + { error: "Zu viele Anmeldeversuche. Bitte später versuchen." }, + { status: 429 } + ); + res.headers.set("Retry-After", String(Math.ceil(delayMs / 1000))); + return res; + } + + if (!allowed && delayMs > 0) { + // Künstliche Verzögerung um Timing-Attacken zu verhindern + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + // ──── Datenbankabfrage ──── + const db = createServiceClient(); + const { data: admin } = await db + .from("admin_users") + .select("id, email, name, password_hash, aktiv") + .eq("email", email.toLowerCase().trim()) + .single(); + + // ──── Admin nicht gefunden oder inaktiv ──── + if (!admin || !admin.aktiv) { + await logLoginAttempt(email, ipAddr, false, userAgent, "user_not_found_or_inactive"); + + // Prüfe auf verdächtige Aktivität + const failedCount = await getFailedLoginCount(email, "email", 60); + if (failedCount >= 10) { + await sendSecurityAlert( + "⚠️ Viele fehlgeschlagene Login-Versuche", + `Email: ${email}\nIPs: Siehe Audit-Logs\nVersuche in der letzten Stunde: ${failedCount}` + ); + } + + return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 }); + } + + // ──── Passwort validieren ──── + const valid = await bcrypt.compare(password, admin.password_hash); + if (!valid) { + await logLoginAttempt(email, ipAddr, false, userAgent, "invalid_password"); + + // Prüfe auf Brute-Force-Aktivität + const failedCount = await getFailedLoginCount(email, "email", 60); + if (failedCount >= 10) { + await sendSecurityAlert( + "⚠️ Viele fehlgeschlagene Login-Versuche (falsches Passwort)", + `Email: ${email}\nIPs: ${ipAddr}\nVersuche in der letzten Stunde: ${failedCount}` + ); + } + + return NextResponse.json({ error: "Ungültige Zugangsdaten" }, { status: 401 }); + } + + // ──── Login erfolgreich ──── + await logLoginAttempt(email, ipAddr, true, userAgent); + resetRateLimit(rateLimitKey); // Rate-Limit zurücksetzen + + const token = await createSessionToken({ id: admin.id, email: admin.email, name: admin.name }); + + const res = NextResponse.json({ success: true }); + res.cookies.set("admin_session", token, { + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 2, // ✅ Geändert: 2 Stunden statt 7 Tage + path: "/", + secure: process.env.NODE_ENV === "production", + }); + return res; +} + +export async function DELETE(req: NextRequest) { + const token = req.cookies.get("admin_session")?.value; + + if (token) { + try { + // ✅ Verify token to get admin_id, then add signature to blacklist + const session = await verifySessionToken(token); + if (session) { + const [, sig] = token.split("."); + await revokeSessionToken(sig, session.id, "logout"); + } + } catch (error) { + console.error("Error revoking session token:", error); + // Continue with logout even if revocation fails + } + } + + const res = NextResponse.json({ success: true }); + res.cookies.delete("admin_session"); + return res; +} diff --git a/modules/02-admin-auth/files/components/admin/SessionTimeoutProvider.tsx b/modules/02-admin-auth/files/components/admin/SessionTimeoutProvider.tsx new file mode 100644 index 0000000..1524435 --- /dev/null +++ b/modules/02-admin-auth/files/components/admin/SessionTimeoutProvider.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +const INACTIVITY_TIMEOUT_MS = 2 * 60 * 60 * 1000; // 2 Stunden +const WARNING_BEFORE_LOGOUT_MS = 15 * 60 * 1000; // 15 Minuten vor Logout warnen + +export function SessionTimeoutProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const timeoutRef = useRef(null); + const warningTimeoutRef = useRef(null); + const warningShownRef = useRef(false); + const lastActivityRef = useRef(Date.now()); + + function resetTimeout() { + // Alte Timer löschen + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + + lastActivityRef.current = Date.now(); + warningShownRef.current = false; + + // Warnung nach (TIMEOUT - 15min) + warningTimeoutRef.current = setTimeout(() => { + if (!warningShownRef.current) { + warningShownRef.current = true; + alert( + "⚠️ Ihre Session läuft in 15 Minuten ab. " + + "Bitte klicken Sie auf eine Taste oder bewegen Sie die Maus, um weiterzuarbeiten." + ); + } + }, INACTIVITY_TIMEOUT_MS - WARNING_BEFORE_LOGOUT_MS); + + // Logout nach TIMEOUT + timeoutRef.current = setTimeout(async () => { + // Session auf dem Server beenden + await fetch("/api/admin/login", { method: "DELETE" }); + // Redirect zur Login-Seite mit Hinweis + router.push("/admin/login?session_expired=true"); + }, INACTIVITY_TIMEOUT_MS); + } + + useEffect(() => { + const events = ["mousedown", "keydown", "scroll", "touchstart"]; + + function handleActivity() { + resetTimeout(); + } + + // Event-Listener registrieren + events.forEach((event) => window.addEventListener(event, handleActivity)); + + // Initial timeout setzen + resetTimeout(); + + // Cleanup + return () => { + events.forEach((event) => window.removeEventListener(event, handleActivity)); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + }; + }, [router]); + + return <>{children}; +} diff --git a/modules/02-admin-auth/files/lib/admin-auth.ts b/modules/02-admin-auth/files/lib/admin-auth.ts new file mode 100644 index 0000000..38e5bed --- /dev/null +++ b/modules/02-admin-auth/files/lib/admin-auth.ts @@ -0,0 +1,174 @@ +// Web Crypto API – kompatibel mit Edge Runtime + Node.js +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { isSessionTokenRevoked, isActionTokenUsed } from "./token-blacklist"; + +const encoder = new TextEncoder(); + +interface AdminSession { + id: string; + email: string; + name: string; + exp: number; +} + +interface ActionToken { + anfrageId: string; + status: "bestaetigt" | "abgelehnt" | "abgeschlossen"; + exp: number; +} + +function toBase64Url(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function fromBase64Url(str: string): ArrayBuffer { + const b64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + return bytes.buffer as ArrayBuffer; +} + +async function getKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); +} + +export async function createSessionToken( + session: Omit +): Promise { + const secret = process.env.ADMIN_SESSION_TOKEN!; + // ✅ Token expiration: 2 Stunden (matches cookie maxAge in login route) + const payload: AdminSession = { + ...session, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 2, + }; + const data = toBase64Url(encoder.encode(JSON.stringify(payload))); + const key = await getKey(secret); + const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data))); + return `${data}.${sig}`; +} + +/** + * Auth-Wrapper für Admin-API-Routes. + * + * Prüft den admin_session-Cookie, verifiziert das HMAC-Token und gibt die + * Session zurück ODER eine fertige 401-Response (wenn nicht authentifiziert). + * + * Verwendung am Anfang einer Admin-API-Route: + * const check = await requireAdmin(); + * if (check instanceof NextResponse) return check; + * const session = check; // AdminSession + */ +export async function requireAdmin(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + + if (!token) { + return NextResponse.json( + { error: "Nicht authentifiziert" }, + { status: 401 } + ); + } + + const session = await verifySessionToken(token); + if (!session) { + return NextResponse.json( + { error: "Session ungültig" }, + { status: 401 } + ); + } + + return session; +} + +export async function verifySessionToken(token: string): Promise { + try { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const [data, sig] = token.split("."); + if (!data || !sig) return null; + + // ✅ Prüfe ob Token revoked wurde + if (await isSessionTokenRevoked(sig)) { + return null; + } + + const key = await getKey(secret); + const valid = await crypto.subtle.verify( + "HMAC", + key, + fromBase64Url(sig), + encoder.encode(data) + ); + if (!valid) return null; + + const session: AdminSession = JSON.parse( + new TextDecoder().decode(new Uint8Array(fromBase64Url(data))) + ); + if (session.exp < Math.floor(Date.now() / 1000)) return null; + + return session; + } catch { + return null; + } +} + +// ──── Action-Tokens für Email-Links (Anfrage bestätigen/ablehnen/abschließen) ──── +export async function createActionToken( + anfrageId: string, + status: "bestaetigt" | "abgelehnt" | "abgeschlossen", + ablaufTage = 7 +): Promise { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const payload: ActionToken = { + anfrageId, + status, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * ablaufTage, + }; + const data = toBase64Url(encoder.encode(JSON.stringify(payload))); + const key = await getKey(secret); + const sig = toBase64Url(await crypto.subtle.sign("HMAC", key, encoder.encode(data))); + return `${data}.${sig}`; +} + +export async function verifyActionToken( + token: string +): Promise<{ anfrageId: string; status: string } | null> { + try { + const secret = process.env.ADMIN_SESSION_TOKEN!; + const [data, sig] = token.split("."); + if (!data || !sig) return null; + + // ✅ Prüfe ob Token bereits verwendet wurde (One-Time-Use) + if (await isActionTokenUsed(sig)) { + return null; + } + + const key = await getKey(secret); + const valid = await crypto.subtle.verify( + "HMAC", + key, + fromBase64Url(sig), + encoder.encode(data) + ); + if (!valid) return null; + + const actionToken: ActionToken = JSON.parse( + new TextDecoder().decode(new Uint8Array(fromBase64Url(data))) + ); + if (actionToken.exp < Math.floor(Date.now() / 1000)) return null; + + return { + anfrageId: actionToken.anfrageId, + status: actionToken.status, + }; + } catch { + return null; + } +} diff --git a/modules/02-admin-auth/files/lib/audit-log.ts b/modules/02-admin-auth/files/lib/audit-log.ts new file mode 100644 index 0000000..65a7eda --- /dev/null +++ b/modules/02-admin-auth/files/lib/audit-log.ts @@ -0,0 +1,154 @@ +import { createServiceClient } from "@/lib/supabase"; + +export interface AuditLogEntry { + id: string; + email: string; + ip_addr: string; + user_agent: string; + success: boolean; + timestamp: string; + reason?: string; // z.B. "invalid_password", "user_not_found", "account_inactive" +} + +/** + * Protokolliert einen Login-Versuch (erfolgreich oder fehlgeschlagen) + */ +export async function logLoginAttempt( + email: string, + ipAddr: string, + success: boolean, + userAgent: string, + reason?: string +): Promise { + try { + const db = createServiceClient(); + await db.from("admin_audit_logs").insert({ + email: email.toLowerCase().trim(), + ip_addr: ipAddr, + user_agent: userAgent, + success, + reason, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("Fehler beim Audit-Logging:", error); + // Nicht werfen – Login sollte nicht scheitern wenn Logging scheitert + } +} + +/** + * Prüft auf verdächtige Aktivitäten (zu viele fehlgeschlagene Versuche) + * Gibt die Anzahl fehlgeschlagener Versuche in der letzten Stunde zurück + */ +export async function getFailedLoginCount( + emailOrIp: string, + type: "email" | "ip" = "email", + timeWindowMinutes = 60 +): Promise { + try { + const db = createServiceClient(); + const since = new Date(Date.now() - timeWindowMinutes * 60 * 1000).toISOString(); + + const column = type === "email" ? "email" : "ip_addr"; + const { count } = await db + .from("admin_audit_logs") + .select("id", { count: "exact" }) + .eq(column, emailOrIp) + .eq("success", false) + .gt("timestamp", since); + + return count || 0; + } catch (error) { + console.error("Fehler beim Abrufen fehlgeschlagener Logins:", error); + return 0; + } +} + +/** + * Holt die Audit-Log-Einträge mit optionalen Filtern + */ +export async function getAuditLogs(options?: { + email?: string; + ipAddr?: string; + successOnly?: boolean; + limit?: number; + offset?: number; +}): Promise { + try { + const db = createServiceClient(); + const limit = options?.limit ?? 100; + const offset = options?.offset ?? 0; + + let query = db + .from("admin_audit_logs") + .select("*") + .order("timestamp", { ascending: false }) + .range(offset, offset + limit - 1); + + if (options?.email) { + query = query.eq("email", options.email.toLowerCase().trim()); + } + + if (options?.ipAddr) { + query = query.eq("ip_addr", options.ipAddr); + } + + if (options?.successOnly !== undefined) { + query = query.eq("success", options.successOnly); + } + + const { data, error } = await query; + + if (error) { + console.error("Fehler beim Abrufen von Audit-Logs:", error); + return []; + } + + return data || []; + } catch (error) { + console.error("Fehler beim Abrufen von Audit-Logs:", error); + return []; + } +} + +/** + * Löscht alte Audit-Logs (älter als X Tage) + * Sollte als Cron-Job regelmäßig ausgeführt werden + */ +export async function deleteOldAuditLogs(daysToKeep = 90): Promise { + try { + const db = createServiceClient(); + const cutoffDate = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString(); + + await db + .from("admin_audit_logs") + .delete() + .lt("timestamp", cutoffDate); + + console.log(`Audit-Logs älter als ${daysToKeep} Tage gelöscht.`); + } catch (error) { + console.error("Fehler beim Löschen alter Audit-Logs:", error); + } +} + +/** + * Sendet eine Alert-Email bei verdächtiger Aktivität + * TODO: Implementieren wenn Email-System erweitert ist + */ +export async function sendSecurityAlert( + subject: string, + message: string, + adminEmail: string = process.env.SMTP_TO || "" +): Promise { + if (!adminEmail) { + console.warn("SMTP_TO nicht konfiguriert – keine Alert-Email gesendet"); + return; + } + + try { + // TODO: Nodemailer/Email-Integration wenn nötig + console.log(`⚠️ SECURITY ALERT: ${subject}\n${message}`); + } catch (error) { + console.error("Fehler beim Senden der Security-Alert:", error); + } +} diff --git a/modules/02-admin-auth/files/lib/rate-limit.ts b/modules/02-admin-auth/files/lib/rate-limit.ts new file mode 100644 index 0000000..cc7170e --- /dev/null +++ b/modules/02-admin-auth/files/lib/rate-limit.ts @@ -0,0 +1,107 @@ +/** + * In-Memory Rate-Limiting für Brute-Force-Schutz + * Speichert Versuche pro Identifier (IP:Email Kombination) + */ + +interface RateLimitEntry { + count: number; + lastAttempt: number; + locked: boolean; + lockedUntil?: number; +} + +const attempts = new Map(); + +// Cleanup: Alte Einträge alle 10 Minuten löschen (Memory-Leak vermeiden) +const CLEANUP_INTERVAL = 10 * 60 * 1000; +const RESET_WINDOW = 15 * 60 * 1000; // 15 Minuten Fenster +const MAX_ATTEMPTS = 5; // Max 5 Versuche pro Fenster +const LOCK_THRESHOLD = 10; // Account sperren nach 10 Versuchen +const LOCK_DURATION = 15 * 60 * 1000; // 15 Minuten Sperrung + +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of attempts.entries()) { + if (now - entry.lastAttempt > RESET_WINDOW) { + attempts.delete(key); + } + } +}, CLEANUP_INTERVAL); + +/** + * Prüfe ob Request innerhalb von Rate-Limit liegt + * @param identifier - Eindeutige ID (z.B. "login:admin@example.com:192.168.1.1") + * @returns { allowed, delayMs, locked } + */ +export function checkRateLimit(identifier: string) { + const now = Date.now(); + let entry = attempts.get(identifier); + + // Neuer Eintrag + if (!entry) { + attempts.set(identifier, { + count: 1, + lastAttempt: now, + locked: false, + }); + return { allowed: true, delayMs: 0, locked: false }; + } + + // Fenster abgelaufen? → Zurücksetzen + if (now - entry.lastAttempt > RESET_WINDOW) { + attempts.set(identifier, { + count: 1, + lastAttempt: now, + locked: false, + }); + return { allowed: true, delayMs: 0, locked: false }; + } + + // Account gesperrt? + if (entry.locked && entry.lockedUntil) { + if (now < entry.lockedUntil) { + const remainingMs = entry.lockedUntil - now; + return { allowed: false, delayMs: remainingMs, locked: true }; + } else { + // Sperrung abgelaufen + entry.locked = false; + entry.count = 1; + entry.lastAttempt = now; + return { allowed: true, delayMs: 0, locked: false }; + } + } + + // Versuch inkrementieren + entry.count++; + entry.lastAttempt = now; + + // Exponential Backoff: 1s, 2s, 4s, 8s, 16s, 30s max + const delayMs = Math.min(Math.pow(2, entry.count - 2) * 1000, 30000); + + // Nach 10 Versuchen sperren + if (entry.count >= LOCK_THRESHOLD) { + entry.locked = true; + entry.lockedUntil = now + LOCK_DURATION; + return { allowed: false, delayMs: LOCK_DURATION, locked: true }; + } + + // Vor dem 5. Versuch noch erlaubt, danach mit Delay + const allowed = entry.count <= MAX_ATTEMPTS; + + return { allowed, delayMs: allowed ? 0 : delayMs, locked: false }; +} + +/** + * Manuelles Zurücksetzen (z.B. nach erfolgreichem Login) + */ +export function resetRateLimit(identifier: string) { + attempts.delete(identifier); +} + +/** + * Hole Versuchszahl für einen Identifier (für Monitoring) + */ +export function getAttemptCount(identifier: string): number { + const entry = attempts.get(identifier); + return entry?.count ?? 0; +} diff --git a/modules/02-admin-auth/files/lib/token-blacklist.ts b/modules/02-admin-auth/files/lib/token-blacklist.ts new file mode 100644 index 0000000..c0072d0 --- /dev/null +++ b/modules/02-admin-auth/files/lib/token-blacklist.ts @@ -0,0 +1,115 @@ +/** + * Token-Revocation & Blacklist Management + * Verhindert Wiederverwendung von: + * 1. Session-Tokens nach Logout + * 2. Action-Tokens nach einmaliger Verwendung + */ + +import { createServiceClient } from "./supabase"; + +/** + * Revoke a session token by adding its signature to the blacklist + */ +export async function revokeSessionToken( + tokenSignature: string, + adminId: string, + reason: "logout" | "password_changed" | "suspicious_activity" = "logout", + notes?: string +): Promise { + try { + const db = createServiceClient(); + const { error } = await db.from("admin_session_blacklist").insert({ + admin_id: adminId, + token_signature: tokenSignature, + reason, + notes, + }); + + if (error) { + console.error("Failed to revoke session token:", error); + return false; + } + return true; + } catch (error) { + console.error("Error revoking session token:", error); + return false; + } +} + +/** + * Check if a session token signature is revoked + */ +export async function isSessionTokenRevoked(tokenSignature: string): Promise { + try { + const db = createServiceClient(); + const { data, error } = await db + .from("admin_session_blacklist") + .select("id", { count: "exact" }) + .eq("token_signature", tokenSignature) + .single(); + + if (error && error.code !== "PGRST116") { + // PGRST116 = no rows found, which is expected + console.error("Error checking token revocation:", error); + return false; + } + + return data?.id !== undefined && data?.id !== null; + } catch (error) { + console.error("Error checking token revocation:", error); + return false; + } +} + +/** + * Mark an action token as used (one-time use) + */ +export async function markActionTokenUsed( + tokenSignature: string, + anfrageId: string, + actionType: string, + ipAddr?: string +): Promise { + try { + const db = createServiceClient(); + const { error } = await db.from("action_token_blacklist").insert({ + token_signature: tokenSignature, + anfrage_id: anfrageId, + action_type: actionType, + used_by_ip: ipAddr, + }); + + if (error) { + console.error("Failed to mark action token as used:", error); + return false; + } + return true; + } catch (error) { + console.error("Error marking action token as used:", error); + return false; + } +} + +/** + * Check if an action token has already been used + */ +export async function isActionTokenUsed(tokenSignature: string): Promise { + try { + const db = createServiceClient(); + const { data, error } = await db + .from("action_token_blacklist") + .select("id", { count: "exact" }) + .eq("token_signature", tokenSignature) + .single(); + + if (error && error.code !== "PGRST116") { + console.error("Error checking action token usage:", error); + return false; + } + + return data?.id !== undefined && data?.id !== null; + } catch (error) { + console.error("Error checking action token usage:", error); + return false; + } +} diff --git a/modules/02-admin-auth/migrations/MIGRATIONS_AUDIT_LOGS.sql b/modules/02-admin-auth/migrations/MIGRATIONS_AUDIT_LOGS.sql new file mode 100644 index 0000000..c3d96ec --- /dev/null +++ b/modules/02-admin-auth/migrations/MIGRATIONS_AUDIT_LOGS.sql @@ -0,0 +1,31 @@ +-- Migration: Erstelle admin_audit_logs Tabelle für Login-Sicherheit +-- Datum: 2026-04-17 +-- Zweck: Protokollierung aller Admin-Login-Versuche (erfolgreich und fehlgeschlagen) + +-- Tabelle erstellen +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, + ip_addr text NOT NULL, + user_agent text NOT NULL, + success boolean NOT NULL, + reason text, -- z.B. "invalid_password", "user_not_found_or_inactive", "missing_credentials" + timestamp timestamptz DEFAULT now() +); + +-- Indexes für Performance +CREATE INDEX IF NOT EXISTS idx_audit_logs_email ON admin_audit_logs(email); +CREATE INDEX IF NOT EXISTS idx_audit_logs_ip ON admin_audit_logs(ip_addr); +CREATE INDEX IF NOT EXISTS idx_audit_logs_success ON admin_audit_logs(success); +CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON admin_audit_logs(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_email_timestamp ON admin_audit_logs(email, timestamp DESC); + +-- RLS (Row Level Security) - nur Admins können Logs anschauen +ALTER TABLE admin_audit_logs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Admin können ihre eigenen Logs sehen" ON admin_audit_logs + FOR SELECT + USING (true); -- TODO: Später auf Admin-Session prüfen + +-- Automatische Cleanup: Alte Logs nach 90 Tagen löschen (optional) +-- Dies kann auch manuell als Cron-Job eingerichtet werden via supabase/functions diff --git a/modules/02-admin-auth/migrations/MIGRATIONS_TOKEN_BLACKLIST.sql b/modules/02-admin-auth/migrations/MIGRATIONS_TOKEN_BLACKLIST.sql new file mode 100644 index 0000000..1f484b7 --- /dev/null +++ b/modules/02-admin-auth/migrations/MIGRATIONS_TOKEN_BLACKLIST.sql @@ -0,0 +1,66 @@ +-- Migration: Admin Session Token Blacklist +-- Erlaubt es, Session-Tokens vor Ablauf ungültig zu machen + +CREATE TABLE admin_session_blacklist ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + admin_id uuid NOT NULL, + token_signature text NOT NULL UNIQUE, -- Base64-kodierte Signatur + revoked_at timestamptz DEFAULT now(), + reason text NOT NULL, -- "logout", "password_changed", "suspicious_activity" + notes text +); + +CREATE INDEX idx_admin_session_blacklist_sig ON admin_session_blacklist(token_signature); +CREATE INDEX idx_admin_session_blacklist_admin ON admin_session_blacklist(admin_id); +CREATE INDEX idx_admin_session_blacklist_revoked ON admin_session_blacklist(revoked_at DESC); + +-- Cleanup: Alte Einträge nach 7 Tagen (nach Token-Ablauf) löschen +CREATE OR REPLACE FUNCTION cleanup_old_blacklist_tokens() RETURNS void AS $$ +BEGIN + DELETE FROM admin_session_blacklist + WHERE revoked_at < now() - INTERVAL '7 days'; +END; +$$ LANGUAGE plpgsql; + +-- Trigger: Auto-Cleanup einmal täglich (optional) +-- HINWEIS: In Supabase muss dies manuell via Cron-Funktion aufgerufen werden + +--- + +-- Migration: Action Token Blacklist +-- Verhindert mehrfache Verwendung von Email-Action-Links (Status-Updates) + +CREATE TABLE action_token_blacklist ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + anfrage_id uuid NOT NULL, + token_signature text NOT NULL UNIQUE, -- Base64-kodierte Signatur + action_type text NOT NULL, -- "bestaetigt", "abgelehnt", "abgeschlossen" + used_at timestamptz DEFAULT now(), + used_by_ip text, + notes text +); + +CREATE INDEX idx_action_token_blacklist_sig ON action_token_blacklist(token_signature); +CREATE INDEX idx_action_token_blacklist_anfrage ON action_token_blacklist(anfrage_id); +CREATE INDEX idx_action_token_blacklist_used ON action_token_blacklist(used_at DESC); + +-- Cleanup: Alte Einträge nach 14 Tagen löschen (nach Token-Ablauf) +CREATE OR REPLACE FUNCTION cleanup_old_action_tokens() RETURNS void AS $$ +BEGIN + DELETE FROM action_token_blacklist + WHERE used_at < now() - INTERVAL '14 days'; +END; +$$ LANGUAGE plpgsql; + +--- + +-- RLS Policies +ALTER TABLE admin_session_blacklist ENABLE ROW LEVEL SECURITY; +ALTER TABLE action_token_blacklist ENABLE ROW LEVEL SECURITY; + +-- Nur Service-Role kann schreiben +CREATE POLICY "Service Role can manage session blacklist" ON admin_session_blacklist + FOR ALL USING (true) WITH CHECK (false); + +CREATE POLICY "Service Role can manage action token blacklist" ON action_token_blacklist + FOR ALL USING (true) WITH CHECK (false); diff --git a/modules/03-analytics/TEMPLATE.md b/modules/03-analytics/TEMPLATE.md new file mode 100644 index 0000000..e3ceba5 --- /dev/null +++ b/modules/03-analytics/TEMPLATE.md @@ -0,0 +1,181 @@ +# Modul: Analytics (Seitenaufrufe + Phone-Click-Tracking) + +> DSGVO-konformes Tracking ohne externe Dienste: Seitenaufrufe mit Verweildauer, Browser/OS/Gerät-Erkennung, anonymisierte IP (letztes IPv4-Oktett → 0). Zusätzlich automatisches Tracking aller `tel:`-Link-Klicks nach Quelle/Element. Admin-Dashboard mit KPI-Cards, Zeitreihen und Top-Seiten. + +--- + +## Enthaltene Dateien + +| Ziel im neuen Projekt | Inhalt | +|---|---| +| `lib/analytics.ts` | Bot-Filter, IP-Anonymisierung, Device/Browser/OS-Parser | +| `components/analytics/PageTracker.tsx` | Client-Komponente: Seitenaufruf + Verweildauer + Phone-Click-Tracking | +| `app/api/analytics/track/route.ts` | POST: Seitenaufruf speichern (öffentlich, kein Auth) | +| `app/api/analytics/track-phone-click/route.ts` | POST: Telefon-Click speichern (öffentlich) | +| `app/api/admin/analytics/phone-calls/route.ts` | GET: Aggregierte Phone-Click-Daten (requireAdmin) | +| `app/admin/analytics/page.tsx` | Admin-Dashboard mit Tabs: Seitenaufrufe + Phone-Calls | +| `migrations/MIGRATIONS_PAGE_VIEWS.sql` | Tabelle `page_views` | +| `migrations/MIGRATIONS_PHONE_CLICKS.sql` | Tabelle `phone_clicks` | + +--- + +## Voraussetzungen + +Keine zusätzlichen npm-Pakete. Benötigt: +- `lib/supabase.ts` (Service Client) +- `lib/admin-auth.ts` (für `requireAdmin` im Admin-Endpunkt, aus Modul 02) + +--- + +## Umgebungsvariablen + +Keine zusätzlichen. Nutzt bestehende Supabase-Variablen. + +--- + +## Datenbank-Migrationen (Supabase) + +``` +migrations/MIGRATIONS_PAGE_VIEWS.sql → Tabelle page_views +migrations/MIGRATIONS_PHONE_CLICKS.sql → Tabelle phone_clicks +``` + +Tabellen-Übersicht: +``` +page_views: path, timestamp, ip_anon, device_type, browser, os, referrer, session_id, duration_ms, is_bot +phone_clicks: phone_number, source_page, source_element, session_id, ip_anonymized, device_type, browser, os, timestamp +``` + +Supabase-Typen (`lib/supabase.ts`) ergänzen: +```ts +page_views: { Row: { id: string; path: string; timestamp: string; ip_anon: string | null; device_type: string | null; browser: string | null; os: string | null; referrer: string | null; session_id: string; duration_ms: number | null; is_bot: boolean } } +phone_clicks: { Row: { id: number; phone_number: string; source_page: string; source_element: string; session_id: string | null; ip_anonymized: string | null; device_type: string | null; browser: string | null; os: string | null; timestamp: string } } +``` + +--- + +## Einbindung Schritt für Schritt + +### 1. Dateien kopieren + +### 2. PageTracker in Root-Layout einbinden (`app/layout.tsx`) +```tsx +import { PageTracker } from "@/components/analytics/PageTracker"; + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + ); +} +``` + +### 3. Tel-Links mit `data-source-element` versehen +Der PageTracker hört automatisch auf Klicks von ``. Damit die Quelle erfasst wird, muss das Attribut gesetzt sein: +```tsx +Anrufen +``` + +Empfohlene Element-Namen (Konvention: lowercase, kebab-case): +- `header`, `footer`, `hero`, `cta-banner`, `kontakt-form`, `sidebar` + +### 4. Admin-Dashboard verlinken +```tsx +// In Admin-Navigation +Analytics +``` + +### 5. Admin-Routen aus Tracking ausschließen +`PageTracker.tsx` filtert bereits `/admin/*` und `/api/*` – keine Anpassung nötig. + +--- + +## Anpassungspunkte + +| Was | Wo | +|---|---| +| Ausgeschlossene Pfade | `components/analytics/PageTracker.tsx` → `EXCLUDED_PATHS` | +| Datenaufbewahrung (13 Monate) | Supabase Cron: `DELETE FROM page_views WHERE timestamp < now() - interval '13 months'` | +| Bot-Filter-Pattern | `lib/analytics.ts` → `BOT_PATTERNS` Array | +| Dashboard-Zeiträume | `app/admin/analytics/page.tsx` → Filter-Buttons | +| Geo-Lookup aktivieren | `app/api/analytics/track-phone-click/route.ts` → `GEO_LOOKUP_ENABLED` env var | + +--- + +## Integrations-Prompt + +Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`. + +``` +Ich integriere das Analytics-Modul (DSGVO-konforme Seitenaufrufe + Phone-Click-Tracking) in mein Next.js/Supabase-Projekt. + +PROJEKT-KONTEXT: +- Admin-Auth-Modul (02) ist bereits integriert (requireAdmin verfügbar) +- lib/supabase.ts mit Service Client vorhanden +- Admin-Bereich unter /admin + +BEREITS KOPIERTE DATEIEN (aus modules/03-analytics/files/): +- lib/analytics.ts +- components/analytics/PageTracker.tsx +- app/api/analytics/track/route.ts +- app/api/analytics/track-phone-click/route.ts +- app/api/admin/analytics/phone-calls/route.ts +- app/admin/analytics/page.tsx + +AUFGABEN – führe sie der Reihe nach aus: + +1. SUPABASE-MIGRATIONEN: Führe diese SQLs im Supabase SQL-Editor aus: + + -- Aus migrations/MIGRATIONS_PAGE_VIEWS.sql: + CREATE TABLE IF NOT EXISTS page_views ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + path text NOT NULL, timestamp timestamptz NOT NULL DEFAULT now(), + ip_anon text, device_type text CHECK (device_type IN ('desktop','tablet','mobile')), + browser text, os text, referrer text, + session_id text NOT NULL, duration_ms int, is_bot boolean NOT NULL DEFAULT false + ); + CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_pv_path ON page_views (path); + CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id); + ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; + CREATE POLICY "Service-Role Vollzugriff" ON page_views USING (true) WITH CHECK (true); + + -- Aus migrations/MIGRATIONS_PHONE_CLICKS.sql: + CREATE TABLE IF NOT EXISTS phone_clicks ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + phone_number TEXT NOT NULL, source_page TEXT NOT NULL, + source_element TEXT NOT NULL, session_id TEXT, ip_anonymized TEXT, + device_type TEXT, browser TEXT, os TEXT, timestamp TIMESTAMPTZ DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS idx_phone_clicks_timestamp ON phone_clicks(timestamp DESC); + ALTER TABLE phone_clicks DISABLE ROW LEVEL SECURITY; + +2. SUPABASE-TYPEN: Lies lib/supabase.ts und ergänze in Database.public.Tables: + page_views: { Row: { id: string; path: string; timestamp: string; ip_anon: string | null; device_type: string | null; browser: string | null; os: string | null; referrer: string | null; session_id: string; duration_ms: number | null; is_bot: boolean } } + phone_clicks: { Row: { id: number; phone_number: string; source_page: string; source_element: string; session_id: string | null; ip_anonymized: string | null; device_type: string | null; browser: string | null; os: string | null; timestamp: string } } + +3. PAGE-TRACKER einbinden: Lies app/layout.tsx. + Füge den Import und die Komponente im hinzu (vor {children}): + import { PageTracker } from "@/components/analytics/PageTracker"; + // Im JSX: + + +4. TEL-LINKS mit Tracking versehen: Suche im gesamten Projekt nach href="tel: + Füge bei jedem gefundenen Link das Attribut data-source-element="[ELEMENT_NAME]" hinzu. + Konvention für [ELEMENT_NAME]: header, footer, hero, cta-banner, kontakt-form (lowercase, kebab-case) + +5. ADMIN-NAV ergänzen: Lies die Admin-Navigations-Datei (meist components/admin/AdminNav.tsx). + Füge einen Link zu /admin/analytics hinzu. + +6. TEST: + a) Dev-Server starten, /kontakt oder eine andere Seite aufrufen + b) Supabase → Table page_views → neuer Eintrag sollte erscheinen + c) Auf eine Telefonnummer klicken → Supabase → Table phone_clicks → neuer Eintrag + d) /admin/analytics aufrufen → KPI-Cards sollten Daten zeigen + +Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind. +``` diff --git a/modules/03-analytics/files/app/admin/analytics/page.tsx b/modules/03-analytics/files/app/admin/analytics/page.tsx new file mode 100644 index 0000000..a24f6fe --- /dev/null +++ b/modules/03-analytics/files/app/admin/analytics/page.tsx @@ -0,0 +1,494 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { verifySessionToken } from "@/lib/admin-auth"; +import { createServiceClient } from "@/lib/supabase"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; +import { AnalyticsTabs } from "@/components/admin/AnalyticsTabs"; + +export const metadata: Metadata = { title: "Web-Analytics" }; +export const dynamic = "force-dynamic"; + +interface PageView { + id: string; + path: string; + timestamp: string; + device_type: string | null; + browser: string | null; + os: string | null; + session_id: string; + duration_ms: number | null; +} + +interface FilterOption { + label: string; + tage: number; + beschreibung: string; +} + +const FILTER_OPTIONS: FilterOption[] = [ + { label: "Heute", tage: 1, beschreibung: "letzter Tag" }, + { label: "Woche", tage: 7, beschreibung: "letzte 7 Tage" }, + { label: "Monat", tage: 30, beschreibung: "letzte 30 Tage" }, + { label: "3 Monate", tage: 90, beschreibung: "letzte 90 Tage" }, + { label: "Jahr", tage: 365, beschreibung: "letztes Jahr" }, +]; + +// Hilfsfunktionen (aus statistik/page.tsx übernommen) +function fmt(n: number, nachkomma = 0) { + return n.toLocaleString("de-DE", { + minimumFractionDigits: nachkomma, + maximumFractionDigits: nachkomma, + }); +} + +function balkenBreite(wert: number, max: number): string { + if (max === 0) return "0%"; + return `${Math.max(4, Math.round((wert / max) * 100))}%`; +} + +function fmtDuration(ms: number | null): string { + if (!ms) return "–"; + if (ms < 60_000) return `${Math.round(ms / 1000)}s`; + return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +} + +// Hilfsfunktion: Deutsches Datumsformat (DD.MM.YYYY) +function fmtDateDE(isoDate: string): string { + const [year, month, day] = isoDate.split("-"); + return `${day}.${month}.${year}`; +} + +async function AnalyticsContent({ + vonISO, + bisISO, + tage = null +}: { + vonISO: string; + bisISO: string; + tage?: number | null; +}) { + // ──── Auth Check ──────────────────────────────────────────────────────── + const cookieStore = await cookies(); + const token = cookieStore.get("admin_session")?.value; + + if (!token) { + redirect("/admin/login"); + } + + const session = await verifySessionToken(token); + if (!session) { + redirect("/admin/login"); + } + + // ──── Daten abrufen (dynamischer Zeitraum, ohne Bots) ────────────────────────── + const db = createServiceClient(); + const heute = new Date().toISOString().slice(0, 10); + + // Konvertiere ISO-Dates zu vollständigen Timestamps für korrekte DB-Filterung + const vonTimestamp = vonISO + "T00:00:00Z"; + const bisTimestamp = bisISO + "T23:59:59.999Z"; + + const { data: allViews, error } = await db + .from("page_views") + .select("id, path, timestamp, device_type, browser, os, session_id, duration_ms") + .eq("is_bot", false) + .gte("timestamp", vonTimestamp) + .lte("timestamp", bisTimestamp) + .order("timestamp", { ascending: false }); + + if (error) { + console.error("❌ Analytics Query Error:", error); + } + + const views: PageView[] = allViews ?? []; + + // ──── KPIs ────────────────────────────────────────────────────────────── + const heuteViews = views.filter((v) => v.timestamp.startsWith(heute)); + const uniqueSessionsHeute = new Set(heuteViews.map((v) => v.session_id)).size; + const mitDauer = views.filter((v) => v.duration_ms != null && v.duration_ms > 0); + const avgDuration = + mitDauer.length > 0 ? Math.round(mitDauer.reduce((s, v) => s + v.duration_ms!, 0) / mitDauer.length) : 0; + const mobileCount = views.filter((v) => v.device_type === "mobile").length; + const mobileRate = views.length > 0 ? Math.round((mobileCount / views.length) * 100) : 0; + + // ──── Tagesstatistik (letzte 30 Tage) ──────────────────────────────────── + const tagesMap: Record = {}; + for (const v of views) { + const tag = v.timestamp.slice(0, 10); + tagesMap[tag] = (tagesMap[tag] ?? 0) + 1; + } + const tagesStats = Object.entries(tagesMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); + const maxTagesWert = Math.max(0, ...tagesStats.map((s) => s.count)); + + // ──── Top-Seiten ──────────────────────────────────────────────────────── + const pathMap: Record = {}; + for (const v of views) { + if (!pathMap[v.path]) pathMap[v.path] = { count: 0, durSum: 0, durCount: 0 }; + pathMap[v.path].count++; + if (v.duration_ms) { + pathMap[v.path].durSum += v.duration_ms; + pathMap[v.path].durCount++; + } + } + const topSeiten = Object.entries(pathMap) + .map(([path, d]) => ({ + path, + count: d.count, + avgDuration: d.durCount > 0 ? Math.round(d.durSum / d.durCount) : null, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + const maxPathCount = Math.max(0, ...topSeiten.map((s) => s.count)); + + // ──── Browser-Verteilung ──────────────────────────────────────────────── + const browserMap: Record = {}; + for (const v of views) browserMap[v.browser ?? "Other"] = (browserMap[v.browser ?? "Other"] ?? 0) + 1; + const sortedBrowsers = Object.entries(browserMap) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + // ──── Gerät-Verteilung ────────────────────────────────────────────────── + const deviceMap: Record = {}; + for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1; + + // ──── OS-Verteilung ───────────────────────────────────────────────────── + const osMap: Record = {}; + for (const v of views) osMap[v.os ?? "Other"] = (osMap[v.os ?? "Other"] ?? 0) + 1; + const sortedOs = Object.entries(osMap) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + // ──── Filter-Button Label bestimmen ────────────────────────────────── + let filterDesc: string; + if (tage !== null) { + // Schnellauswahl + filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`; + } else { + // Freier Zeitraum (deutsches Datumsformat) + filterDesc = `${fmtDateDE(vonISO)} – ${fmtDateDE(bisISO)}`; + } + + // Debug: Wenn Fehler oder keine Daten + if (error) { + return ( +
+
+

Web-Analytics

+
+
+

❌ Fehler beim Laden der Daten:

+

{error.message}

+

+ Prüfe in Supabase, ob die page_views Tabelle existiert und RLS korrekt konfiguriert ist. +

+
+
+ ); + } + + const overviewContent = ( +
+ {/* Header */} +
+
+
+

Web-Analytics

+

{filterDesc} · Bots gefiltert · IPs anonymisiert

+
+ + + Zurück + +
+ + {/* Filter Buttons & Custom Date Range */} +
+ {/* Schnellauswahl-Buttons */} +
+ {FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} +
+ + {/* Datumsauswahl-Formular */} +
+
+ + +
+
+ + +
+ +
+
+
+ + {/* KPI-Cards */} +
+
+
Seitenaufrufe heute
+
{heuteViews.length}
+
+
+
Unique Sessions heute
+
{uniqueSessionsHeute}
+
+
+
Ø Verweildauer
+
{fmtDuration(avgDuration)}
+
+
+
Mobile-Anteil
+
{mobileRate}%
+
+
+ + {/* Tagesdiagramm */} +
+

Seitenaufrufe ({filterDesc})

+
+ {tagesStats.length === 0 ? ( +

Keine Daten vorhanden

+ ) : ( + tagesStats.map(({ date, count }) => ( +
+
{date}
+
+
+
+
{count}
+
+ )) + )} +
+
+ + {/* Top-Seiten */} +
+
+

Top-Seiten

+
+
+ + + + + + + + + + {topSeiten.length === 0 ? ( + + + + ) : ( + topSeiten.map(({ path, count, avgDuration }) => ( + + + + + + )) + )} + +
SeiteAufrufeØ Verweildauer
+ Keine Daten vorhanden +
{path} +
+
+
+
+ {count} +
+
{fmtDuration(avgDuration)}
+
+
+ + {/* Browser & OS Verteilung */} +
+ {/* Browser */} +
+

Browser

+
+ {sortedBrowsers.length === 0 ? ( +

Keine Daten vorhanden

+ ) : ( + sortedBrowsers.map(([browser, count]) => ( +
+
{browser}
+
+
s[1]))), + }} + /> +
+
{count}
+
+ )) + )} +
+
+ + {/* OS */} +
+

Betriebssystem

+
+ {sortedOs.length === 0 ? ( +

Keine Daten vorhanden

+ ) : ( + sortedOs.map(([os, count]) => ( +
+
{os}
+
+
s[1]))), + }} + /> +
+
{count}
+
+ )) + )} +
+
+
+ + {/* Gerät-Verteilung */} +
+

Geräte-Verteilung

+
+ {Object.entries(deviceMap).map(([device, count]) => ( +
+
{count}
+
+ {device === "desktop" ? "Desktop" : device === "tablet" ? "Tablet" : "Mobile"} +
+
+ {views.length > 0 ? `${Math.round((count / views.length) * 100)}%` : "0%"} +
+
+ ))} +
+
+ + {/* DSGVO-Hinweis */} +
+

+ 🔒 Datenschutz: IP-Adressen werden anonymisiert (letztes Oktett = 0). Es werden keine + Cookies für Analytics gesetzt. Session-IDs sind Tab-gebunden und werden nicht persistent gespeichert. + Weitere Details in der{" "} + + Datenschutzerklärung + + . +

+
+
+ ); + + return ; +} + +export default async function AnalyticsPage({ searchParams }: { searchParams: Promise> }) { + const params = await searchParams; + const von = String(params.von || ""); + const bis = String(params.bis || ""); + const tageParam = String(params.tage || ""); + + const heute = new Date().toISOString().slice(0, 10); + let vonISO: string; + let bisISO: string; + let tage: number | null; + + // Hilfsfunktion: Datum um X Tage zurück (ohne Zeitzone-Probleme) + function datumMinusTage(isoDate: string, tage: number): string { + const [year, month, day] = isoDate.split("-").map(Number); + const date = new Date(year, month - 1, day); + date.setDate(date.getDate() - tage); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + + // Berechne den Zeitraum basierend auf Parametern + if (von && bis) { + // Freier Datumsbereich + vonISO = von; + bisISO = bis; + tage = null; // null bedeutet: freier Bereich, nicht Schnellauswahl + } else if (tageParam) { + // Schnellauswahl: von heute rückwärts + tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30)); + bisISO = heute; + vonISO = datumMinusTage(heute, tage); + } else { + // Standard: 30 Tage von heute rückwärts + tage = 30; + bisISO = heute; + vonISO = datumMinusTage(heute, 30); + } + + return ( +
+ +

Lädt Analytics-Daten...

+
+ } + > + + +
+ ); +} diff --git a/modules/03-analytics/files/app/api/admin/analytics/phone-calls/route.ts b/modules/03-analytics/files/app/api/admin/analytics/phone-calls/route.ts new file mode 100644 index 0000000..627c3cf --- /dev/null +++ b/modules/03-analytics/files/app/api/admin/analytics/phone-calls/route.ts @@ -0,0 +1,240 @@ +import { NextResponse, NextRequest } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { requireAdmin } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +type DateRange = "today" | "7days" | "30days"; + +function getDateRange(range: DateRange): { start: string; end: string } { + const end = new Date(); + const start = new Date(); + + switch (range) { + case "today": + start.setHours(0, 0, 0, 0); + break; + case "7days": + start.setDate(start.getDate() - 7); + break; + case "30days": + start.setDate(start.getDate() - 30); + break; + } + + return { + start: start.toISOString(), + end: end.toISOString(), + }; +} + +export async function GET(request: NextRequest) { + // Admin Check + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const searchParams = request.nextUrl.searchParams; + const range = (searchParams.get("range") || "today") as DateRange; + const { start, end } = getDateRange(range); + + try { + const db = createServiceClient(); + + // ─── 1. KPIs: Calls today, trend, unique numbers ──────────────────────── + + // Calls today + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const todayISO = todayStart.toISOString(); + + const { count: todayCount } = await db + .from("phone_clicks") + .select("*", { count: "exact" }) + .gte("timestamp", todayISO) + .lte("timestamp", new Date().toISOString()); + + const callsToday = todayCount || 0; + + // Calls yesterday for trend + const yesterdayStart = new Date(todayStart); + yesterdayStart.setDate(yesterdayStart.getDate() - 1); + const yesterdayEnd = new Date(yesterdayStart); + yesterdayEnd.setDate(yesterdayEnd.getDate() + 1); + const yesterdayISO = yesterdayStart.toISOString(); + const yesterdayEndISO = yesterdayEnd.toISOString(); + + const { count: yesterdayCount } = await db + .from("phone_clicks") + .select("*", { count: "exact" }) + .gte("timestamp", yesterdayISO) + .lt("timestamp", yesterdayEndISO); + + const callsYesterday = yesterdayCount || 0; + const callsTodayTrend = + callsYesterday > 0 + ? Math.round(((callsToday - callsYesterday) / callsYesterday) * 100) + : callsToday > 0 + ? 100 + : 0; + + // Unique Numbers + const { data: uniqueData } = await db + .from("phone_clicks") + .select("phone_number") + .gte("timestamp", start) + .lte("timestamp", end); + + const uniqueNumbers = new Set( + (uniqueData || []).map((d) => d.phone_number) + ).size; + + // Top Number + const numberCounts: Record = {}; + (uniqueData || []).forEach((item) => { + numberCounts[item.phone_number] = + (numberCounts[item.phone_number] || 0) + 1; + }); + + const topNumberEntries = Object.entries(numberCounts).sort( + ([, a], [, b]) => b - a + ); + const [topNumber, topNumberCount] = topNumberEntries[0] || [null, 0]; + + // Top Source Element + const { data: elementData } = await db + .from("phone_clicks") + .select("source_element") + .gte("timestamp", start) + .lte("timestamp", end); + + const elementCounts: Record = {}; + (elementData || []).forEach((item) => { + elementCounts[item.source_element] = + (elementCounts[item.source_element] || 0) + 1; + }); + + const totalClicks = elementData?.length || 0; + const elementEntries = Object.entries(elementCounts).sort( + ([, a], [, b]) => b - a + ); + const [topSourceElement, topSourceElementCount] = elementEntries[0] || [ + null, + 0, + ]; + const topSourceElementPercent = + totalClicks > 0 + ? Math.round((topSourceElementCount / totalClicks) * 100) + : 0; + + // ─── 2. Phone Numbers Table ───────────────────────────────────────────── + const { data: tableData } = await db + .from("phone_clicks") + .select("phone_number, source_page, source_element, country") + .gte("timestamp", start) + .lte("timestamp", end); + + const phoneNumbersMap: Record< + string, + { + phone_number: string; + click_count: number; + trend_percent: number; + top_source_page: string; + top_source_element: string; + top_country: string; + } + > = {}; + + (tableData || []).forEach((item) => { + if (!phoneNumbersMap[item.phone_number]) { + phoneNumbersMap[item.phone_number] = { + phone_number: item.phone_number, + click_count: 0, + trend_percent: 0, + top_source_page: "", + top_source_element: "", + top_country: item.country || "—", + }; + } + phoneNumbersMap[item.phone_number].click_count++; + }); + + const phoneNumbers = Object.values(phoneNumbersMap) + .sort((a, b) => b.click_count - a.click_count); + + // ─── 3. Element Chart ─────────────────────────────────────────────────── + const elements = Object.entries(elementCounts) + .map(([element, count]) => ({ + source_element: element, + count, + percent: totalClicks > 0 ? Math.round((count / totalClicks) * 100) : 0, + })) + .sort((a, b) => b.count - a.count); + + // ─── 4. Timeseries Chart ──────────────────────────────────────────────── + const { data: timeseriesRaw } = await db + .from("phone_clicks") + .select("timestamp") + .gte("timestamp", start) + .lte("timestamp", end) + .order("timestamp", { ascending: true }); + + const timeseriesMap: Record = {}; + (timeseriesRaw || []).forEach((item) => { + const date = item.timestamp.split("T")[0]; + timeseriesMap[date] = (timeseriesMap[date] || 0) + 1; + }); + + const timeseries = Object.entries(timeseriesMap).map(([date, count]) => ({ + date, + count, + })); + + // ─── 5. Geo Chart ─────────────────────────────────────────────────────── + const { data: geoRaw } = await db + .from("phone_clicks") + .select("country") + .gte("timestamp", start) + .lte("timestamp", end) + .not("country", "is", null); + + const geoCounts: Record = {}; + (geoRaw || []).forEach((item) => { + if (item.country) { + geoCounts[item.country] = (geoCounts[item.country] || 0) + 1; + } + }); + + const geoTotal = geoRaw?.length || 0; + const geo = Object.entries(geoCounts) + .map(([country, count]) => ({ + country, + count, + percent: geoTotal > 0 ? Math.round((count / geoTotal) * 100) : 0, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + return NextResponse.json({ + kpis: { + callsToday, + callsTodayTrend, + uniqueNumbers, + topNumber, + topNumberCount, + topSourceElement, + topSourceElementPercent, + }, + phoneNumbers, + elements, + timeseries, + geo, + }); + } catch (error) { + console.error("Phone calls analytics error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/modules/03-analytics/files/app/api/analytics/track-phone-click/route.ts b/modules/03-analytics/files/app/api/analytics/track-phone-click/route.ts new file mode 100644 index 0000000..fc2f6b4 --- /dev/null +++ b/modules/03-analytics/files/app/api/analytics/track-phone-click/route.ts @@ -0,0 +1,64 @@ +import { createServiceClient } from "@/lib/supabase"; +import { isBot, anonymizeIp, parseDevice, parseBrowser, parseOs } from "@/lib/analytics"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { phone_number, source_page, source_element, session_id } = body; + + // Validierung + if (!phone_number || !source_page || !source_element) { + return Response.json( + { error: "Missing required fields: phone_number, source_page, source_element" }, + { status: 400 } + ); + } + + // IP + User-Agent extrahieren + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0].trim() || + request.headers.get("x-real-ip") || + "0.0.0.0"; + + const ua = request.headers.get("user-agent") || ""; + + // Bot-Filter (silent skip) + if (isBot(ua)) { + return Response.json({ ok: true }); + } + + // Parsing + const device = parseDevice(ua); + const browser = parseBrowser(ua); + const os = parseOs(ua); + const ipAnon = anonymizeIp(ip); + + // DB Insert + const supabase = createServiceClient(); + const { error } = await supabase.from("phone_clicks").insert({ + phone_number, + source_page, + source_element, + session_id: session_id || null, + ip_anonymized: ipAnon, + country: null, + region: null, + city: null, + device_type: device, + browser, + os, + user_agent: ua, + is_bot: false, + }); + + if (error) { + console.error("Phone click tracking error:", error); + return Response.json({ ok: true }); // Silent fail + } + + return Response.json({ ok: true }); + } catch (error) { + console.error("Phone click tracking exception:", error); + return Response.json({ ok: true }); // Silent fail + } +} diff --git a/modules/03-analytics/files/app/api/analytics/track/route.ts b/modules/03-analytics/files/app/api/analytics/track/route.ts new file mode 100644 index 0000000..df102d4 --- /dev/null +++ b/modules/03-analytics/files/app/api/analytics/track/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { anonymizeIp, isBot, parseDevice, parseBrowser, parseOs } from "@/lib/analytics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +interface TrackBody { + path: string; + session_id: string; + referrer?: string; + view_id?: string; // Für duration_ms-Updates + duration_ms?: number; // Nur bei pagehide-Updates +} + +/** + * POST /api/analytics/track + * + * Insert-Modus: { path, session_id, referrer? } + * → DB INSERT, view_id zurückgeben + * + * Update-Modus: { view_id, duration_ms } + * → DB UPDATE duration_ms + * + * Bot-Anfragen: Keine DB-Eintrag, aber 200 OK zurückgeben + */ +export async function POST(req: NextRequest) { + try { + const body: TrackBody = await req.json(); + + // Minimale Validierung + if (!body.path || !body.session_id) { + return NextResponse.json({ ok: false }, { status: 400 }); + } + + const ua = req.headers.get("user-agent") ?? ""; + + // ──── Bot-Filter: Keine DB-Einträge für Bots ──────────────────────────── + if (isBot(ua)) { + return NextResponse.json({ ok: true, bot: true }); + } + + const db = createServiceClient(); + + // ──── Update-Modus: Verweildauer setzen ──────────────────────────────── + if (body.view_id && body.duration_ms !== undefined) { + await db + .from("page_views") + .update({ duration_ms: body.duration_ms }) + .eq("id", body.view_id) + .is("duration_ms", null); // Idempotent: nur updaten wenn noch nicht gesetzt + return NextResponse.json({ ok: true }); + } + + // ──── Insert-Modus: Neuen Seitenaufruf anlegen ───────────────────────── + const rawIp = + req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? + req.headers.get("x-real-ip") ?? + "0.0.0.0"; + + const { data, error } = await db + .from("page_views") + .insert({ + path: body.path, + session_id: body.session_id, + referrer: body.referrer ?? null, + ip_anon: anonymizeIp(rawIp), + device_type: parseDevice(ua), + browser: parseBrowser(ua), + os: parseOs(ua), + is_bot: false, + }) + .select("id") + .single(); + + if (error) { + console.error("[analytics/track] DB error:", error); + return NextResponse.json({ ok: false }, { status: 500 }); + } + + // view_id zurückgeben für späteren duration_ms-Update + return NextResponse.json({ ok: true, view_id: data.id }); + } catch (err) { + console.error("[analytics/track] Error:", err); + return NextResponse.json({ ok: false }, { status: 500 }); + } +} diff --git a/modules/03-analytics/files/components/analytics/PageTracker.tsx b/modules/03-analytics/files/components/analytics/PageTracker.tsx new file mode 100644 index 0000000..f30cb55 --- /dev/null +++ b/modules/03-analytics/files/components/analytics/PageTracker.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname } from "next/navigation"; + +// Admin-Routen und API-Routen tracken wir nicht +const EXCLUDED_PREFIXES = ["/admin", "/api", "/_next"]; + +/** + * PageTracker – Client-seitige Komponente für Web-Analytics + * + * Wird in app/layout.tsx eingebunden (global, einmalig) + * - Erfasst Seitenaufrufe + * - Misst Verweildauer via pagehide-Event + * - Verwendet sessionStorage für Session-ID (nicht persistent) + * - Sendet Daten an /api/analytics/track + */ +export function PageTracker() { + const pathname = usePathname(); + const viewIdRef = useRef(null); + const startTimeRef = useRef(Date.now()); + + /** + * Gibt oder erstellt eine Session-ID aus sessionStorage + * Diese ID ist Tab-gebunden (nicht persistent über Reload) + */ + function getSessionId(): string { + let sid = sessionStorage.getItem("_mpv_sid"); + if (!sid) { + // UUID v4 via Crypto API (kein externe Library) + sid = crypto.randomUUID(); + sessionStorage.setItem("_mpv_sid", sid); + } + return sid; + } + + useEffect(() => { + // Skip ausgeschlossene Routen + if (EXCLUDED_PREFIXES.some((p) => pathname.startsWith(p))) return; + + startTimeRef.current = Date.now(); + viewIdRef.current = null; + + // ──── Seitenaufruf tracken ──────────────────────────────────────────── + fetch("/api/analytics/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: pathname, + session_id: getSessionId(), + referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined, + }), + }) + .then((r) => r.json()) + .then((d) => { + viewIdRef.current = d.view_id ?? null; + }) + .catch(() => { + // Tracking-Fehler nie nach oben propagieren + }); + + // ──── Verweildauer beim Verlassen der Seite senden ──────────────────── + function sendDuration() { + if (!viewIdRef.current) return; + const ms = Date.now() - startTimeRef.current; + + // sendBeacon ist zuverlässiger als fetch bei pagehide (safari, mobile) + // Blob mit application/json damit req.json() im Handler funktioniert + const blob = new Blob( + [ + JSON.stringify({ + path: pathname, + session_id: getSessionId(), + view_id: viewIdRef.current, + duration_ms: ms, + }), + ], + { type: "application/json" } + ); + navigator.sendBeacon("/api/analytics/track", blob); + } + + // ──── Phone-Click Tracking ──────────────────────────────────────── + function trackPhoneClick(event: Event) { + const target = event.target as HTMLElement; + const link = target.closest('a[href^="tel:"]'); + if (!link) return; + + const phoneHref = link.getAttribute("href"); + const phoneNumber = phoneHref?.replace("tel:", "").trim(); + + if (!phoneNumber) return; + + const sourceElement = + link.getAttribute("data-source-element") || + link.closest("[data-source-element]")?.getAttribute("data-source-element") || + "unknown"; + + fetch("/api/analytics/track-phone-click", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + phone_number: phoneNumber, + source_page: pathname, + source_element: sourceElement, + session_id: getSessionId(), + }), + }).catch(() => { + // Fehler ignorieren + }); + } + + // pagehide: zuverlässiger als beforeunload (Safari, Mobile) + window.addEventListener("pagehide", sendDuration); + + // Phone-Click Tracking + document.addEventListener("click", trackPhoneClick); + + return () => { + document.removeEventListener("click", trackPhoneClick); + window.removeEventListener("pagehide", sendDuration); + // Auch beim Route-Wechsel (SPA) die Verweildauer senden + sendDuration(); + }; + }, [pathname]); + + return null; // Kein Markup +} diff --git a/modules/03-analytics/files/lib/analytics.ts b/modules/03-analytics/files/lib/analytics.ts new file mode 100644 index 0000000..0ca6b10 --- /dev/null +++ b/modules/03-analytics/files/lib/analytics.ts @@ -0,0 +1,72 @@ +/** + * Analytics-Utilities für Page-View-Tracking + * Verwendbar in Middleware und API Routes (keine Next.js-Abhängigkeiten) + */ + +// ──── Bot-Filter ──────────────────────────────────────────────────────────── +// Regex für Crawler/Bots (nicht tracken) +const BOT_PATTERNS = + /bot|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|twitterbot|linkedinbot|whatsapp|telegram|pinterest|slack|discordbot|applebot|bingpreview|google-read-aloud|ia_archiver|mj12bot|ahrefs|semrush|dotbot|rogerbot|screaming\.?frog/i; + +export function isBot(ua: string): boolean { + return BOT_PATTERNS.test(ua); +} + +// ──── IP-Anonymisierung ──────────────────────────────────────────────────── +// DSGVO: Keine Rückverfolgung einzelner Personen +// IPv4: letztes Oktett auf 0 (192.168.1.123 → "192.168.1.0") +// IPv6: Auf /48 kürzen +export function anonymizeIp(ip: string): string { + // IPv4-mapped IPv6 (::ffff:1.2.3.4) + const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+)\.\d+$/i); + if (mapped) return `${mapped[1]}.0`; + + // IPv4 + const v4 = ip.match(/^(\d+\.\d+\.\d+)\.\d+$/); + if (v4) return `${v4[1]}.0`; + + // IPv6: erste 3 Gruppen behalten, Rest nullen + const v6parts = ip.split(":"); + if (v6parts.length >= 3) return `${v6parts.slice(0, 3).join(":")}::`; + + return "0.0.0.0"; +} + +// ──── User-Agent-Parsing ──────────────────────────────────────────────────── +export type DeviceType = "mobile" | "tablet" | "desktop"; +export type BrowserName = "Chrome" | "Firefox" | "Safari" | "Edge" | "Opera" | "Other"; +export type OsName = "Windows" | "macOS" | "iOS" | "Android" | "Linux" | "Other"; + +/** + * Erkennt ob der User ein Mobile-, Tablet- oder Desktop-Gerät nutzt + */ +export function parseDevice(ua: string): DeviceType { + if (/tablet|ipad|playbook|silk/i.test(ua)) return "tablet"; + if (/mobile|android.*mobile|iphone|ipod|blackberry|windows phone/i.test(ua)) return "mobile"; + return "desktop"; +} + +/** + * Erkennt den Browser + * Reihenfolge wichtig: Edge vor Chrome (Edge enthält auch "Chrome/" im UA) + */ +export function parseBrowser(ua: string): BrowserName { + if (/edg\//i.test(ua)) return "Edge"; + if (/opr\//i.test(ua)) return "Opera"; + if (/firefox\//i.test(ua)) return "Firefox"; + if (/chrome\//i.test(ua)) return "Chrome"; + if (/safari\//i.test(ua)) return "Safari"; + return "Other"; +} + +/** + * Erkennt das Betriebssystem + */ +export function parseOs(ua: string): OsName { + if (/windows/i.test(ua)) return "Windows"; + if (/iphone|ipad|ipod/i.test(ua)) return "iOS"; + if (/android/i.test(ua)) return "Android"; + if (/mac os x|macintosh/i.test(ua)) return "macOS"; + if (/linux/i.test(ua)) return "Linux"; + return "Other"; +} diff --git a/modules/03-analytics/migrations/MIGRATIONS_PAGE_VIEWS.sql b/modules/03-analytics/migrations/MIGRATIONS_PAGE_VIEWS.sql new file mode 100644 index 0000000..564a657 --- /dev/null +++ b/modules/03-analytics/migrations/MIGRATIONS_PAGE_VIEWS.sql @@ -0,0 +1,36 @@ +-- Migration: Erstelle page_views Tabelle für Web-Analytics +-- Datum: 2026-04-17 +-- Zweck: Protokollierung von Seitenaufrufen, Verweildauer, Browser, Gerät, anonymisierte IP + +CREATE TABLE IF NOT EXISTS page_views ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + path text NOT NULL, + timestamp timestamptz NOT NULL DEFAULT now(), + ip_anon text, -- IPv4: x.x.x.0 (DSGVO-anonymisiert) + device_type text CHECK (device_type IN ('desktop','tablet','mobile')), + browser text, -- Chrome/Firefox/Safari/Edge/Opera/Other + os text, -- Windows/macOS/iOS/Android/Linux/Other + referrer text, + session_id text NOT NULL, -- UUID aus sessionStorage + duration_ms int, -- Verweildauer in Millisekunden + is_bot boolean NOT NULL DEFAULT false +); + +-- Indizes für häufige Analytics-Abfragen +CREATE INDEX IF NOT EXISTS idx_pv_timestamp ON page_views (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_pv_path ON page_views (path); +CREATE INDEX IF NOT EXISTS idx_pv_session ON page_views (session_id); +CREATE INDEX IF NOT EXISTS idx_pv_is_bot ON page_views (is_bot) WHERE is_bot = false; +CREATE INDEX IF NOT EXISTS idx_pv_path_date ON page_views (path, timestamp DESC) WHERE is_bot = false; + +-- RLS: Nur Service-Role kann schreiben, Reads später für Admin +ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Service-Role Vollzugriff" ON page_views + USING (true) + WITH CHECK (true); + +-- Automatisches Cleanup nach 13 Monaten (DSGVO-Datensparsamkeit) +-- Kann via Supabase scheduled function oder pg_cron ausgelöst werden +-- HINWEIS: Diesen Query regelmäßig per Cron Job aufrufen: +-- DELETE FROM page_views WHERE timestamp < now() - interval '13 months'; diff --git a/modules/03-analytics/migrations/MIGRATIONS_PHONE_CLICKS.sql b/modules/03-analytics/migrations/MIGRATIONS_PHONE_CLICKS.sql new file mode 100644 index 0000000..457a366 --- /dev/null +++ b/modules/03-analytics/migrations/MIGRATIONS_PHONE_CLICKS.sql @@ -0,0 +1,29 @@ +-- docs/MIGRATIONS_PHONE_CLICKS.sql +-- Phone-Click Tracking Table + +CREATE TABLE IF NOT EXISTS phone_clicks ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + phone_number TEXT NOT NULL, + source_page TEXT NOT NULL, + source_element TEXT NOT NULL, + session_id TEXT, + ip_anonymized TEXT, + country TEXT, + region TEXT, + city TEXT, + device_type TEXT, + browser TEXT, + os TEXT, + user_agent TEXT, + is_bot BOOLEAN DEFAULT false, + timestamp TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_phone_clicks_timestamp ON phone_clicks(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_phone_clicks_phone_number ON phone_clicks(phone_number); +CREATE INDEX IF NOT EXISTS idx_phone_clicks_source_page ON phone_clicks(source_page); +CREATE INDEX IF NOT EXISTS idx_phone_clicks_source_element ON phone_clicks(source_element); +CREATE INDEX IF NOT EXISTS idx_phone_clicks_country ON phone_clicks(country); + +-- RLS (Row Level Security) deaktivieren für Admin-Zugriff +ALTER TABLE phone_clicks DISABLE ROW LEVEL SECURITY; diff --git a/modules/06-kunden-portal/TEMPLATE.md b/modules/06-kunden-portal/TEMPLATE.md new file mode 100644 index 0000000..415eb39 --- /dev/null +++ b/modules/06-kunden-portal/TEMPLATE.md @@ -0,0 +1,239 @@ +# Modul: Kunden-Portal (Supabase Auth + Dashboard) + +> Vollständiges Kundenkonto-System auf Basis von Supabase Auth: Registrierung mit Email-Bestätigung (signierter Confirmation-Link), Login mit Magic Link oder Passwort, Kundendashboard mit Anfragen-Übersicht (Status, Positionen, Admin-Notizen), Profilverwaltung, Logout. + +--- + +## Enthaltene Dateien + +| Ziel im neuen Projekt | Inhalt | +|---|---| +| `app/kunden/login/page.tsx` | Login-Seite (Supabase Auth, Redirect nach Login) | +| `app/kunden/registrieren/page.tsx` | Registrierungs-Seite (mit API-Aufruf) | +| `app/kunden/dashboard/page.tsx` | Kundendashboard (Anfragen, Status-Badges, Positionen) | +| `app/auth/callback/page.tsx` | OAuth/Magic-Link Callback Handler | +| `app/api/kunden/anfragen/route.ts` | GET: Anfragen des eingeloggten Kunden | +| `app/api/kunden/registrieren/route.ts` | POST: Registrierung via Supabase Admin API + Email | + +--- + +## Voraussetzungen + +```bash +npm install @supabase/supabase-js @supabase/ssr +``` + +Benötigt außerdem: +- `lib/supabase.ts` mit Browser-Client und Service-Client +- `lib/mailer.ts` (Modul 01) – für Registrierungs-Bestätigungs-Email (optional, Supabase sendet selbst) +- Modul 04 (Reservation System) – wenn Kunden ihre Anfragen sehen sollen + +--- + +## Umgebungsvariablen (`.env.local`) + +```env +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +SUPABASE_SERVICE_ROLE_KEY=eyJ... # Für Registrierung via Admin API +APP_URL=https://example.com # Basis-URL für Confirmation-Links +``` + +--- + +## Datenbank-Setup (Supabase) + +### 1. Supabase Auth aktivieren +Supabase Dashboard → Authentication → Settings: +- Email-Bestätigung aktivieren +- Redirect URL eintragen: `https://example.com/auth/callback` + +### 2. Optional: Kunden-Profil-Tabelle +```sql +-- Verknüpft auth.users mit zusätzlichen Kundendaten +CREATE TABLE kunden_profile ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + firma text, + telefon text, + erstellt_am timestamptz DEFAULT now() +); + +-- Trigger: Profil automatisch beim User-Create anlegen +CREATE OR REPLACE FUNCTION handle_new_user() +RETURNS trigger AS $$ +BEGIN + INSERT INTO public.kunden_profile (id) + VALUES (NEW.id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE PROCEDURE handle_new_user(); +``` + +### 3. Row Level Security für Anfragen +```sql +-- Kunden sehen nur ihre eigenen Anfragen +ALTER TABLE anfragen ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Kunden sehen eigene Anfragen" + ON anfragen FOR SELECT + USING (auth.uid() = kunde_id); +``` + +--- + +## Einbindung Schritt für Schritt + +### 1. Dateien kopieren + +### 2. Supabase Middleware einrichten (`middleware.ts` im Projekt-Root) +```ts +import { createServerClient } from "@supabase/ssr"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest) { + let response = NextResponse.next({ request }); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { cookies: { /* ... */ } } + ); + await supabase.auth.getUser(); // Session refreshen + return response; +} + +export const config = { matcher: ["/kunden/:path*"] }; +``` + +### 3. Supabase Browser-Client in `lib/supabase.ts` +```ts +import { createBrowserClient } from "@supabase/ssr"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} +``` + +### 4. Dashboard an eigene Datenstruktur anpassen +`app/kunden/dashboard/page.tsx` erwartet: +- `anfragen` Tabelle mit `status`, `firma`, `erstellt_am` +- `anfragen_positionen` mit `maschine_name`, `mietbeginn`, `mietende`, `gesamt_preis` +- RLS-Policy: Kunden sehen nur `anfragen WHERE kunde_id = auth.uid()` + +### 5. Navigation ergänzen +```tsx +Mein Konto +Meine Anfragen +``` + +--- + +## Anpassungspunkte + +| Was | Wo | +|---|---| +| Registrierungs-Email | `lib/mailer.ts` → `sendeRegistrierungsBestaetigung()` | +| Passwort-Mindestlänge (8 Zeichen) | `app/api/kunden/registrieren/route.ts` → Zod-Schema | +| Dashboard-Felder | `app/kunden/dashboard/page.tsx` | +| Auth-Provider (Magic Link, OAuth) | Supabase Dashboard → Auth → Providers | +| Session-Cookie-Dauer | Supabase Dashboard → Auth → Settings → JWT Expiry | +| Kunden-Profil-Felder | `kunden_profile` Tabelle + Profilseite | + +--- + +## Integrations-Prompt + +Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`. + +``` +Ich integriere das Kunden-Portal (Supabase Auth, Registrierung, Kundendashboard) in mein Next.js/Supabase-Projekt. + +PROJEKT-KONTEXT: +- Modul 01 (Email) ist bereits integriert (mailer.ts verfügbar) +- Modul 04 (Reservierungssystem) ist bereits integriert (anfragen-Tabelle existiert) +- Kundenbereich URL-Prefix: /kunden +- App-URL: [https://beispiel.de] + +BEREITS KOPIERTE DATEIEN (aus modules/06-kunden-portal/files/): +- app/kunden/login/page.tsx +- app/kunden/registrieren/page.tsx +- app/kunden/dashboard/page.tsx +- app/auth/callback/page.tsx +- app/api/kunden/anfragen/route.ts +- app/api/kunden/registrieren/route.ts + +AUFGABEN – führe sie der Reihe nach aus: + +1. SUPABASE AUTH KONFIGURIEREN: + Gehe zu Supabase Dashboard → Authentication → URL Configuration. + Trage ein: + - Site URL: [https://beispiel.de] + - Redirect URLs: [https://beispiel.de]/auth/callback + +2. SUPABASE-PAKETE installieren (falls noch nicht vorhanden): + npm install @supabase/supabase-js @supabase/ssr + +3. SUPABASE CLIENTS in lib/supabase.ts ergänzen: + Lies lib/supabase.ts. Füge hinzu (falls Browser-Client fehlt): + import { createBrowserClient } from "@supabase/ssr"; + export function createBrowserSupabaseClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + } + +4. MIDDLEWARE erstellen: Erstelle middleware.ts im Projekt-Root: + import { createServerClient } from "@supabase/ssr"; + import { NextResponse, type NextRequest } from "next/server"; + export async function middleware(request: NextRequest) { + let response = NextResponse.next({ request }); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { cookies: { + getAll() { return request.cookies.getAll(); }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options)); + }, + }} + ); + await supabase.auth.getUser(); + return response; + } + export const config = { matcher: ["/kunden/:path*", "/auth/:path*"] }; + +5. RLS-POLICY für Anfragen: Führe im Supabase SQL-Editor aus: + ALTER TABLE anfragen ENABLE ROW LEVEL SECURITY; + CREATE POLICY "Kunden sehen eigene Anfragen" + ON anfragen FOR SELECT USING (auth.uid() = kunde_id); + +6. REGISTRIERUNGS-EMAIL anpassen: Lies lib/mailer.ts. + Passe die Funktion sendeRegistrierungsBestaetigung() an (oder erstelle sie falls nicht vorhanden): + - Ersetze "Mietpark Hahn" durch "[PROJEKTNAME]" + - Passe den Betreff an: "Bitte bestätigen Sie Ihre E-Mail – [PROJEKTNAME]" + +7. DASHBOARD anpassen: Lies app/kunden/dashboard/page.tsx. + - Ersetze "Mietanfrage/Maschine" durch die eigene Bezeichnung + - Passe angezeigte Felder aus anfragen_positionen an + +8. NAVIGATION ergänzen: Füge in den öffentlichen Header hinzu: + Mein Konto + Nach Login: Meine Anfragen + +9. TEST: + a) /kunden/registrieren → Registrierung abschließen + b) Bestätigungs-Email öffnen, Link klicken → /auth/callback → /kunden/dashboard + c) /kunden/dashboard → Anfragen des Kunden erscheinen + d) /kunden/login → Logout → Redirect zu /kunden/login + +Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind. +``` diff --git a/modules/06-kunden-portal/files/app/api/kunden/anfragen/route.ts b/modules/06-kunden-portal/files/app/api/kunden/anfragen/route.ts new file mode 100644 index 0000000..5ad1a4e --- /dev/null +++ b/modules/06-kunden-portal/files/app/api/kunden/anfragen/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; +import { createServiceClient } from "@/lib/supabase"; + +// Validiert den Bearer-Token und gibt die E-Mail-Adresse zurück +async function getKundeEmail(authHeader: string | null): Promise { + if (!authHeader?.startsWith("Bearer ")) return null; + const token = authHeader.slice(7); + + const anonClient = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); + const { data: { user }, error } = await anonClient.auth.getUser(token); + if (error || !user?.email) return null; + return user.email; +} + +export async function GET(req: NextRequest) { + const email = await getKundeEmail(req.headers.get("authorization")); + if (!email) { + return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 }); + } + + const db = createServiceClient(); + + // Anfragen nach E-Mail + zugehörige Positionen laden + const { data: anfragen, error } = await db + .from("anfragen") + .select(` + id, + created_at, + status, + firma, + telefon, + email, + notizen, + anfragen_positionen ( + id, + maschine_name, + mietbeginn, + mietende, + gesamt_tage, + lieferung, + tagessatz + ) + `) + .eq("email", email) + .order("created_at", { ascending: false }); + + if (error) { + return NextResponse.json({ error: "Datenbankfehler" }, { status: 500 }); + } + + return NextResponse.json({ anfragen: anfragen ?? [] }); +} diff --git a/modules/06-kunden-portal/files/app/api/kunden/registrieren/route.ts b/modules/06-kunden-portal/files/app/api/kunden/registrieren/route.ts new file mode 100644 index 0000000..3be6496 --- /dev/null +++ b/modules/06-kunden-portal/files/app/api/kunden/registrieren/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { createServiceClient } from "@/lib/supabase"; +import { sendeRegistrierungsBestaetigung } from "@/lib/mailer"; + +const Schema = z.object({ + email: z.string().email(), + password: z.string().min(8), + firma: z.string().optional(), +}); + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => null); + const result = Schema.safeParse(body); + if (!result.success) { + return NextResponse.json({ error: "Ungültige Eingabe" }, { status: 422 }); + } + + const { email, password, firma } = result.data; + const appUrl = process.env.APP_URL ?? "https://www.mietparkhahn.de"; + const db = createServiceClient(); + + // Bestätigungslink über Supabase Admin API generieren + // → Erstellt den User + gibt einen signierten Bestätigungslink zurück + const { data: linkData, error } = await db.auth.admin.generateLink({ + type: "signup", + email, + password, + options: { + data: { firma: firma ?? "" }, + redirectTo: `${appUrl}/auth/callback`, + }, + }); + + if (error) { + // User existiert bereits + if (error.message.includes("already registered") || error.message.includes("already been registered")) { + return NextResponse.json({ error: "already_registered" }, { status: 409 }); + } + console.error("[Registrierung] Fehler:", error.message); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const bestaetigungsLink = linkData.properties?.action_link; + if (!bestaetigungsLink) { + return NextResponse.json({ error: "Kein Bestätigungslink erhalten" }, { status: 500 }); + } + + // Bestätigungs-E-Mail über eigenes IONOS-SMTP versenden + await sendeRegistrierungsBestaetigung({ email, firma, bestaetigungsLink }); + + return NextResponse.json({ success: true }); +} diff --git a/modules/06-kunden-portal/files/app/auth/callback/page.tsx b/modules/06-kunden-portal/files/app/auth/callback/page.tsx new file mode 100644 index 0000000..8d161a3 --- /dev/null +++ b/modules/06-kunden-portal/files/app/auth/callback/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { CheckCircle2, XCircle } from "lucide-react"; +import Link from "next/link"; +import { supabase } from "@/lib/supabase"; + +export default function AuthCallbackPage() { + const router = useRouter(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + // Supabase JS verarbeitet automatisch den ?code= oder #access_token= aus der URL + // und etabliert die Session (detectSessionInUrl ist standardmäßig aktiviert). + // Wir warten kurz und prüfen dann die Session. + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { + if (event === "SIGNED_IN" && session) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } else if (event === "TOKEN_REFRESHED" && session) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } + }); + + // Fallback: Session direkt prüfen (falls onAuthStateChange zu spät feuert) + supabase.auth.getSession().then(({ data: { session } }) => { + if (session) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } else { + // Noch kurz warten, Supabase braucht manchmal einen Moment + setTimeout(() => { + supabase.auth.getSession().then(({ data: { session: s } }) => { + if (s) { + setStatus("success"); + setTimeout(() => router.replace("/kunden/dashboard"), 1500); + } else { + setStatus("error"); + } + }); + }, 2000); + } + }); + + return () => subscription.unsubscribe(); + }, [router]); + + if (status === "loading") { + return ( +
+
+
+

E-Mail-Adresse wird bestätigt…

+
+
+ ); + } + + if (status === "success") { + return ( +
+
+
+ +
+

E-Mail bestätigt!

+

+ Sie werden automatisch weitergeleitet… +

+
+
+ ); + } + + return ( +
+
+
+ +
+

Bestätigung fehlgeschlagen

+

+ Der Bestätigungslink ist abgelaufen oder ungültig. Bitte registrieren Sie sich erneut. +

+ + Zur Registrierung + +
+
+ ); +} diff --git a/modules/06-kunden-portal/files/app/kunden/dashboard/page.tsx b/modules/06-kunden-portal/files/app/kunden/dashboard/page.tsx new file mode 100644 index 0000000..1739460 --- /dev/null +++ b/modules/06-kunden-portal/files/app/kunden/dashboard/page.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { LogOut, Package, Clock, CheckCircle2, XCircle, AlertCircle, UserCog } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { supabase } from "@/lib/supabase"; +import type { User } from "@supabase/supabase-js"; + +const STATUS_MAP: Record = { + offen: { label: "Offen", icon: Clock, color: "text-amber-700 bg-amber-50 border-amber-200" }, + bestaetigt: { label: "Bestätigt", icon: CheckCircle2, color: "text-green-700 bg-green-50 border-green-200" }, + abgelehnt: { label: "Abgelehnt", icon: XCircle, color: "text-red-700 bg-red-50 border-red-200" }, + abgeschlossen: { label: "Abgeschlossen", icon: CheckCircle2, color: "text-slate-600 bg-slate-50 border-slate-200" }, +}; + +interface Anfrage { + id: string; + created_at: string; + status: string; + firma: string; + telefon: string; + email: string; + notizen: string; + anfragen_positionen: { + id: string; + maschine_name: string; + mietbeginn: string; + mietende: string; + gesamt_tage: number; + lieferung: boolean; + tagessatz: number | null; + }[]; +} + +function formatDatum(iso: string) { + return new Date(iso).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +export default function KundenDashboardPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [anfragen, setAnfragen] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function init() { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + router.push("/kunden/login"); + return; + } + setUser(session.user); + + // Anfragen laden + const res = await fetch("/api/kunden/anfragen", { + headers: { Authorization: `Bearer ${session.access_token}` }, + }); + if (res.ok) { + const json = await res.json(); + setAnfragen(json.anfragen ?? []); + } + setLoading(false); + } + init(); + }, [router]); + + async function handleLogout() { + await supabase.auth.signOut(); + router.push("/kunden/login"); + } + + if (loading) { + return ( +
+

Wird geladen…

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Mein Bereich

+

{user?.email}

+
+
+ + + Profil + + +
+
+ + + + {/* Anfragen */} +
+
+

Meine Anfragen

+ + + Neue Anfrage + +
+ + {anfragen.length === 0 ? ( +
+ +

Noch keine Anfragen vorhanden

+

+ Stöbern Sie im Mietpark und stellen Sie Ihre erste Anfrage. +

+ + Zum Mietpark + +
+ ) : ( +
+ {anfragen.map((anfrage) => { + const st = STATUS_MAP[anfrage.status] ?? STATUS_MAP.offen; + const Icon = st.icon; + return ( +
+ {/* Anfrage-Header */} +
+
+ + + {st.label} + + + {formatDatum(anfrage.created_at)} + +
+ + #{anfrage.id.slice(0, 8)} + +
+ + {/* Positionen */} +
+ {anfrage.anfragen_positionen.map((pos) => ( +
+
+

{pos.maschine_name}

+

+ {formatDatum(pos.mietbeginn)} – {formatDatum(pos.mietende)} · {pos.gesamt_tage} Tag{pos.gesamt_tage !== 1 ? "e" : ""} + {pos.lieferung && " · Lieferung"} +

+
+ + {pos.tagessatz != null + ? `${(pos.tagessatz * pos.gesamt_tage).toLocaleString("de-DE")} €` + : "Auf Anfrage"} + +
+ ))} +
+ + {/* Notiz vom Verleih */} + {anfrage.notizen && ( +
+
+ +

{anfrage.notizen}

+
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/modules/06-kunden-portal/files/app/kunden/login/page.tsx b/modules/06-kunden-portal/files/app/kunden/login/page.tsx new file mode 100644 index 0000000..d9248c6 --- /dev/null +++ b/modules/06-kunden-portal/files/app/kunden/login/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { LogIn } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { supabase } from "@/lib/supabase"; +import { config } from "@/lib/config"; + +export default function KundenLoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [passwort, setPasswort] = useState(""); + const [fehler, setFehler] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + setFehler(""); + setLoading(true); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password: passwort, + }); + + if (error) { + if (error.message.toLowerCase().includes("email not confirmed")) { + setFehler("Bitte bestätigen Sie zuerst Ihre E-Mail-Adresse. Schauen Sie in Ihren Posteingang."); + } else { + setFehler("E-Mail oder Passwort ungültig."); + } + } else { + router.push("/kunden/dashboard"); + } + setLoading(false); + } + + return ( +
+
+ {/* Header */} +
+
+ +
+

Kunden-Login

+

+ Melden Sie sich an, um Ihre Mietanfragen einzusehen. +

+
+ +
+
+
+ + setEmail(e.target.value)} + placeholder="ihre@email.de" + required + className="mt-1 rounded-md" + autoComplete="email" + /> +
+
+ + setPasswort(e.target.value)} + placeholder="••••••••" + required + className="mt-1 rounded-md" + autoComplete="current-password" + /> +
+ + {fehler && ( +

+ {fehler} +

+ )} + + +
+ +
+

+ Noch kein Konto?{" "} + + Jetzt registrieren + +

+
+
+ +

+ Fragen?{" "} + + {config.company.phone} + +

+
+
+ ); +} diff --git a/modules/06-kunden-portal/files/app/kunden/registrieren/page.tsx b/modules/06-kunden-portal/files/app/kunden/registrieren/page.tsx new file mode 100644 index 0000000..d869fda --- /dev/null +++ b/modules/06-kunden-portal/files/app/kunden/registrieren/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { UserPlus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { config } from "@/lib/config"; + +export default function KundenRegistrierenPage() { + const [email, setEmail] = useState(""); + const [passwort, setPasswort] = useState(""); + const [passwortWdh, setPasswortWdh] = useState(""); + const [firma, setFirma] = useState(""); + const [fehler, setFehler] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + async function handleRegistrieren(e: React.FormEvent) { + e.preventDefault(); + setFehler(""); + + if (passwort.length < 8) { + setFehler("Das Passwort muss mindestens 8 Zeichen lang sein."); + return; + } + if (passwort !== passwortWdh) { + setFehler("Die Passwörter stimmen nicht überein."); + return; + } + + setLoading(true); + + const res = await fetch("/api/kunden/registrieren", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: passwort, firma }), + }); + const json = await res.json(); + + if (!res.ok) { + if (json.error === "already_registered") { + setFehler("Diese E-Mail ist bereits registriert. Bitte direkt anmelden."); + } else { + setFehler(`Registrierung fehlgeschlagen: ${json.error}`); + } + } else { + setSuccess(true); + } + setLoading(false); + } + + if (success) { + return ( +
+
+
+ +
+

Registrierung erfolgreich!

+

+ Bitte bestätigen Sie Ihre E-Mail-Adresse. Nach der Bestätigung können Sie sich anmelden. +

+ + Zur Anmeldung + +
+
+ ); + } + + return ( +
+
+
+
+ +
+

Konto erstellen

+

+ Registrieren Sie sich, um Ihre Mietanfragen zu verwalten. +

+
+ +
+
+
+ + setFirma(e.target.value)} + placeholder="Muster GmbH oder Max Muster" + className="mt-1 rounded-md" + autoComplete="organization" + /> +
+
+ + setEmail(e.target.value)} + placeholder="ihre@email.de" + required + className="mt-1 rounded-md" + autoComplete="email" + /> +
+
+ + setPasswort(e.target.value)} + placeholder="••••••••" + required + className="mt-1 rounded-md" + autoComplete="new-password" + /> +
+
+ + setPasswortWdh(e.target.value)} + placeholder="••••••••" + required + className="mt-1 rounded-md" + autoComplete="new-password" + /> +
+ + {fehler && ( +

+ {fehler} +

+ )} + + +
+ +
+

+ Bereits registriert?{" "} + + Jetzt anmelden + +

+
+
+ +

+ Fragen?{" "} + + {config.company.phone} + +

+
+
+ ); +} diff --git a/modules/07-kpi-dashboard/TEMPLATE.md b/modules/07-kpi-dashboard/TEMPLATE.md new file mode 100644 index 0000000..78b90eb --- /dev/null +++ b/modules/07-kpi-dashboard/TEMPLATE.md @@ -0,0 +1,169 @@ +# Modul: KPI-Dashboard (Admin-Statistik) + +> Server-seitig gerendertes Admin-Statistik-Dashboard: 4 KPI-Cards (Anfragen, bestätigte Mieten, Miettage, Umsatz), Umsatz-Balkendiagramm (letzte 6 Monate, CSS-basiert), Maschinenauslastungs-Tabelle (Top-Ressourcen), integrierter Gantt-Kalender (±3 Monate). Vollständig ohne externe Chart-Bibliothek. + +--- + +## Enthaltene Dateien + +| Ziel im neuen Projekt | Inhalt | +|---|---| +| `app/admin/statistik/page.tsx` | Server Component: KPI-Cards + Umsatzdiagramm + Gantt | +| `app/admin/statistik/GanttKalender.tsx` | Wiederverwendbare Gantt-Komponente (auch in Modul 04) | +| `app/api/admin/statistik/route.ts` | GET: Aggregierte KPI-Daten (requireAdmin) | + +--- + +## Voraussetzungen + +Keine zusätzlichen npm-Pakete. Benötigt: +- `lib/supabase.ts` (Service Client) +- `lib/admin-auth.ts` (Modul 02: `requireAdmin`) +- Modul 04 (Reservation System) – Tabellen `anfragen`, `anfragen_positionen` + +--- + +## Umgebungsvariablen + +Keine zusätzlichen. + +--- + +## Datenbank + +Liest aus bestehenden Tabellen (kein Schema-Change nötig): +- `anfragen` – Anzahl, Status-Verteilung, Zeiträume +- `anfragen_positionen` – Tagessätze, Miettage, Umsatz +- `anfrage_status_audit` – optional für Audit-Timeline + +--- + +## Einbindung Schritt für Schritt + +### 1. Dateien kopieren + +### 2. Admin-Navigation ergänzen +```tsx +Statistik +``` + +### 3. Statistik-API anpassen (`app/api/admin/statistik/route.ts`) +Die API aggregiert aktuell folgende Felder: +```ts +{ + kpis: { + anfragen_gesamt: number, + bestaetigt: number, + umsatz_gesamt: number, + miettage_gesamt: number + }, + monatsUmsatz: Array<{ monat: string, umsatz: number, anzahl: number }>, // 6 Monate + maschinenAuslastung: Array<{ name: string, tage: number, umsatz: number }>, + planDaten: PlanPosition[] // für GanttKalender +} +``` + +Abfrage anpassen wenn Tabellen-/Feldnamen abweichen. + +### 4. Seite an eigenes Design anpassen (`app/admin/statistik/page.tsx`) +- KPI-Cards: Farben, Icons, Bezeichnungen +- Diagramm: Anzahl Monate, Skala +- Auslastungs-Tabelle: Spalten + +### 5. GanttKalender-Props +```tsx + +``` + +`PlanPosition` Interface: +```ts +interface PlanPosition { + id: string; + anfrage_id: string; + maschine_name: string; + maschine_kategorie: string; + mietbeginn: string; // "YYYY-MM-DD" + mietende: string; // "YYYY-MM-DD" + gesamt_tage: number; + tagessatz: number | null; + anfrage_status: string; // "offen"|"bestaetigt"|"abgelehnt"|"abgeschlossen" + firma: string | null; +} +``` + +--- + +## Anpassungspunkte + +| Was | Wo | +|---|---| +| KPI-Bezeichnungen | `app/admin/statistik/page.tsx` → KPI-Card Labels | +| Umsatz-Formel (Tagessatz × Tage) | `app/api/admin/statistik/route.ts` → `umsatz_calc` | +| Nur bestätigte Anfragen zählen | `app/api/admin/statistik/route.ts` → `status = 'bestaetigt'` Filter | +| Diagramm Zeitraum (6 Monate) | `app/api/admin/statistik/route.ts` → `INTERVAL '6 months'` | +| Gantt Zeitraum | `app/admin/statistik/page.tsx` → `startISO` + `tageGesamt` | +| Status-Farben im Gantt | `app/admin/statistik/GanttKalender.tsx` → `STATUS_COLORS` | + +--- + +## Integrations-Prompt + +Kopiere diesen Prompt in eine neue KI-Konversation, nachdem du die `files/` in dein Projekt kopiert hast. Ersetze alle `[PLATZHALTER]`. + +``` +Ich integriere das KPI-Dashboard (Statistik-Übersicht mit Gantt) in mein Next.js/Supabase-Projekt. + +PROJEKT-KONTEXT: +- Modul 02 (Admin-Auth) ist bereits integriert (requireAdmin verfügbar) +- Modul 04 (Reservierungssystem) ist bereits integriert (Tabellen: anfragen, anfragen_positionen, anfrage_status_audit) +- Ressourcen-Bezeichnung: [MASCHINEN/ARTIKEL/...] +- Währung: [EUR / CHF / ...] + +BEREITS KOPIERTE DATEIEN (aus modules/07-kpi-dashboard/files/): +- app/admin/statistik/page.tsx +- app/admin/statistik/GanttKalender.tsx +- app/api/admin/statistik/route.ts + +AUFGABEN – führe sie der Reihe nach aus: + +1. API ANPASSEN: Lies app/api/admin/statistik/route.ts vollständig. + Prüfe ob alle referenzierten Tabellen-/Feldnamen mit den tatsächlichen übereinstimmen: + - Tabelle "anfragen" → Status-Felder, Datum-Felder + - Tabelle "anfragen_positionen" → Preis-Felder (tagessatz, gesamt_preis) + Passe ggf. Feldnamen in den SQL-Abfragen an. + +2. KPI-TEXTE ANPASSEN: Lies app/admin/statistik/page.tsx. + Passe folgende Labels an die eigene Domäne an: + - "Mietanfragen" → "[ANFRAGEN_BEZEICHNUNG]" + - "Bestätigte Mieten" → "[BESTÄTIGTE_BEZEICHNUNG]" + - "Miettage" → "[ZEITEINHEIT_BEZEICHNUNG]" + - "Umsatz" → "Umsatz ([WÄHRUNG])" + Ersetze alle Vorkommen von "Mietpark Hahn" und "Maschine/Gerät" durch die eigenen Begriffe. + +3. GANTT ANPASSEN: Lies app/admin/statistik/GanttKalender.tsx. + Falls Modul 04 bereits mit angepassten Ressourcen-Bezeichnungen eingebunden wurde, + prüfe ob die PlanPosition-Felder noch stimmen (maschine_name, maschine_kategorie). + Passe den Tooltip-Text an (aktuell: "Firma – Maschine – X Tage"). + +4. ADMIN-NAV ergänzen: Füge einen Link zu /admin/statistik hinzu. + +5. GANTT ZEITRAUM konfigurieren: Lies app/admin/statistik/page.tsx. + Passe startISO und tageGesamt an die gewünschte Standardansicht an: + - Kurzfristiger Betrieb (< 1 Monat): tageGesamt=60 + - Mittelfristiger Betrieb: tageGesamt=180 (Standard) + - Langzeitplanung: tageGesamt=365 + +6. TEST: + a) Dev-Server starten, /admin/statistik aufrufen + b) KPI-Cards zeigen Zahlen aus anfragen-Tabelle + c) Umsatzdiagramm zeigt letzte 6 Monate + d) Gantt-Kalender zeigt bestätigte Anfragen als Balken + e) Balken anklicken → Weiterleitung zu /admin/anfragen/[id] + +Lies jede Datei vor dem Bearbeiten. Melde wenn alle Schritte abgeschlossen sind. +``` diff --git a/modules/07-kpi-dashboard/files/app/admin/statistik/GanttKalender.tsx b/modules/07-kpi-dashboard/files/app/admin/statistik/GanttKalender.tsx new file mode 100644 index 0000000..5fca70e --- /dev/null +++ b/modules/07-kpi-dashboard/files/app/admin/statistik/GanttKalender.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useRef } from "react"; +import { useRouter } from "next/navigation"; +import { Lock } from "lucide-react"; + +interface PlanPosition { + id: string; + anfrage_id: string; + maschine_name: string; + maschine_kategorie: string; + mietbeginn: string; + mietende: string; + gesamt_tage: number; + tagessatz: number | null; + anfrage_status: string; + firma: string; +} + +/** Minimaler Sperrung-Typ fuer den Gantt. maschine_name wird benoetigt, + * weil der Gantt Zeilen anhand des Namens gruppiert. */ +export interface SperrungGantt { + sperr_id: string; + maschine_name: string; + von: string; + bis: string; + grund: string; + notiz?: string; +} + +interface Props { + planDaten: PlanPosition[]; + heuteISO: string; + startISO?: string; // Optional: Startdatum (default: heuteISO) + tageGesamt?: number; // Optional: Sichtbare Tage (default: 95) + sperrungen?: SperrungGantt[]; +} + +const STATUS_FARBE: Record = { + bestaetigt: "bg-green-500 border-green-600", + offen: "bg-amber-400 border-amber-500", + abgeschlossen: "bg-slate-400 border-slate-500", + abgelehnt: "bg-red-300 border-red-400", +}; + +const STATUS_LABEL: Record = { + bestaetigt: "Bestätigt", + offen: "Offen", + abgeschlossen: "Abgeschlossen", + abgelehnt: "Abgelehnt", +}; + +// Sperrungen werden in einem neutralen Dunkelgrau mit diagonaler Schraffur +// dargestellt, damit sie sich visuell klar von Anfrage-Balken abheben. +const SPERRUNG_STYLE = + "bg-slate-700 border-slate-800 text-white " + + "bg-[repeating-linear-gradient(45deg,rgba(255,255,255,0.08)_0_6px,transparent_6px_12px)]"; + +function addDays(iso: string, days: number): string { + const d = new Date(iso + "T00:00:00"); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +} + +function daysBetween(a: string, b: string): number { + const da = new Date(a + "T00:00:00"); + const db = new Date(b + "T00:00:00"); + return Math.round((db.getTime() - da.getTime()) / 86400000); +} + +function formatDate(iso: string): string { + return new Date(iso + "T00:00:00").toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + }); +} + +function fmt(n: number) { + return n.toLocaleString("de-DE", { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} + +const TAGE_GESAMT = 95; // ca. 3 Monate + etwas Puffer +const TAG_BREITE = 28; // px pro Tag + +export function GanttKalender({ + planDaten, + heuteISO, + startISO, + tageGesamt, + sperrungen = [], +}: Props) { + const scrollRef = useRef(null); + const router = useRouter(); + + // Defaults setzen + const kalenderStart = startISO ?? heuteISO; + const TAGE_GESAMT = tageGesamt ?? 95; + const kalenderEnde = addDays(kalenderStart, TAGE_GESAMT - 1); + + // Nur relevante Status anzeigen (nicht abgelehnt) + const sichtbar = planDaten.filter((p) => p.anfrage_status !== "abgelehnt"); + + // Sperrungen auf den sichtbaren Zeitraum eingrenzen + const sperrungenSichtbar = sperrungen.filter( + (s) => s.bis >= kalenderStart && s.von <= kalenderEnde + ); + + // Eindeutige Maschinen: zusammenfuehren aus Anfragen + Sperrungen, damit + // Sperrungen fuer reine "Leer-Maschinen" ebenfalls sichtbar sind. + const maschinenSet = new Set(); + for (const p of sichtbar) maschinenSet.add(p.maschine_name); + for (const s of sperrungenSichtbar) maschinenSet.add(s.maschine_name); + const maschinenOhneDouble = Array.from(maschinenSet).sort(); + + // Kalender-Tage generieren (ab kalenderStart statt immer ab heute) + const tage: string[] = []; + for (let i = 0; i < TAGE_GESAMT; i++) { + tage.push(addDays(kalenderStart, i)); + } + + // Monats-Separatoren + const monate: { label: string; start: number; breite: number }[] = []; + let currentMonat = ""; + let startIdx = 0; + tage.forEach((d, i) => { + const m = d.slice(0, 7); + if (m !== currentMonat) { + if (currentMonat) { + monate.push({ + label: new Date(currentMonat + "-01").toLocaleDateString("de-DE", { + month: "long", + year: "numeric", + }), + start: startIdx * TAG_BREITE, + breite: (i - startIdx) * TAG_BREITE, + }); + } + currentMonat = m; + startIdx = i; + } + }); + if (currentMonat) { + monate.push({ + label: new Date(currentMonat + "-01").toLocaleDateString("de-DE", { + month: "long", + year: "numeric", + }), + start: startIdx * TAG_BREITE, + breite: (tage.length - startIdx) * TAG_BREITE, + }); + } + + const gesamtBreite = TAGE_GESAMT * TAG_BREITE; + + if (maschinenOhneDouble.length === 0) { + return ( +
+ Keine geplanten Vermietungen oder Sperrungen im gewählten Zeitraum. +
+ ); + } + + return ( +
+ {/* Legende */} +
+ {Object.entries(STATUS_LABEL) + .filter(([k]) => k !== "abgelehnt") + .map(([k, v]) => ( +
+ + {v} +
+ ))} +
+ + Gesperrt +
+
+ + {/* Scrollbarer Gantt */} +
+
+ {/* Monats-Header */} +
+
+ Maschine +
+
+ {monate.map((m) => ( +
+ {m.label} +
+ ))} +
+
+ + {/* Tages-Header */} +
+
+
+ {tage.map((d, i) => { + const wochentag = new Date(d + "T00:00:00").getDay(); + const istHeute = d === heuteISO; + const istSo = wochentag === 0; + const istMo = wochentag === 1; + const tag = d.slice(8); + return ( +
+ {tag} +
+ ); + })} +
+
+ + {/* Maschinen-Zeilen */} + {maschinenOhneDouble.map((maschine, rowIdx) => { + const positionen = sichtbar.filter((p) => p.maschine_name === maschine); + const rowBg = rowIdx % 2 === 0 ? "bg-white" : "bg-slate-50/60"; + + return ( +
+ {/* Maschinenname */} +
+ {maschine} +
+ + {/* Balkenfläche */} +
+ {/* Heute-Linie */} +
+ + {/* Wochenenden */} + {tage.map((d, i) => { + const wt = new Date(d + "T00:00:00").getDay(); + if (wt !== 0) return null; + return ( +
+ ); + })} + + {/* Sperrungs-Balken (unter den Anfragen gerendert, damit + Anfragen bei einer unerwarteten Ueberlappung oben liegen. + Durch die Collision-Pruefung in /api/admin/sperrungen + sollte das in der Praxis aber nicht vorkommen.) */} + {sperrungenSichtbar + .filter((s) => s.maschine_name === maschine) + .map((s) => { + const startOff = daysBetween(kalenderStart, s.von); + const endOff = daysBetween(kalenderStart, s.bis); + const left = Math.max(0, startOff) * TAG_BREITE; + const right = Math.min(TAGE_GESAMT, endOff + 1) * TAG_BREITE; + const width = right - left; + if (width <= 0) return null; + + return ( +
+ + + {s.grund} + + {/* Tooltip bei Hover */} +
+

+ Sperrung +

+

{s.grund}

+

+ {formatDate(s.von)} – {formatDate(s.bis)} +

+ {s.notiz && ( +

+ {s.notiz} +

+ )} +
+
+ ); + })} + + {/* Vermietungs-Balken */} + {positionen.map((p) => { + const startOff = daysBetween(kalenderStart, p.mietbeginn); + const endOff = daysBetween(kalenderStart, p.mietende); + const left = Math.max(0, startOff) * TAG_BREITE; + const right = Math.min(TAGE_GESAMT, endOff + 1) * TAG_BREITE; + const width = right - left; + if (width <= 0) return null; + + const umsatz = p.tagessatz != null + ? p.tagessatz * p.gesamt_tage + : null; + + return ( +
router.push(`/admin/anfragen/${p.anfrage_id}`)} + > + {p.firma} + {/* Tooltip bei Hover */} +
+

{p.firma}

+

{STATUS_LABEL[p.anfrage_status]}

+

{formatDate(p.mietbeginn)} – {formatDate(p.mietende)}

+

{p.gesamt_tage} Miettage

+ {umsatz != null && ( +

{fmt(umsatz)} € netto

+ )} +

Klicken zum Öffnen →

+
+
+ ); + })} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/modules/07-kpi-dashboard/files/app/admin/statistik/page.tsx b/modules/07-kpi-dashboard/files/app/admin/statistik/page.tsx new file mode 100644 index 0000000..0b9cbac --- /dev/null +++ b/modules/07-kpi-dashboard/files/app/admin/statistik/page.tsx @@ -0,0 +1,343 @@ +import { createServiceClient } from "@/lib/supabase"; +import { AdminNav } from "@/components/admin/AdminNav"; +import { GanttKalender } from "./GanttKalender"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Statistik & Planung" }; +export const dynamic = "force-dynamic"; + +function fmt(n: number, nachkomma = 0) { + return n.toLocaleString("de-DE", { + minimumFractionDigits: nachkomma, + maximumFractionDigits: nachkomma, + }); +} + +// Maximale Balkenbreite in % relativ zum Top-Wert +function balkenBreite(wert: number, max: number): string { + if (max === 0) return "0%"; + return `${Math.max(4, Math.round((wert / max) * 100))}%`; +} + +export default async function StatistikPage() { + const db = createServiceClient(); + + const heute = new Date(); + heute.setHours(0, 0, 0, 0); + const heuteISO = heute.toISOString().slice(0, 10); + + const vor12Monaten = new Date(heute); + vor12Monaten.setMonth(vor12Monaten.getMonth() - 12); + const vor12ISO = vor12Monaten.toISOString().slice(0, 10); + + const in3Monaten = new Date(heute); + in3Monaten.setMonth(in3Monaten.getMonth() + 3); + in3Monaten.setDate(in3Monaten.getDate() + 1); + const in3ISO = in3Monaten.toISOString().slice(0, 10); + + // ── DB-Abfragen ────────────────────────────────────────────────────────── + const { data: anfragen } = await db + .from("anfragen") + .select("id, status, created_at, firma"); + + const { data: allePositionen } = await db + .from("anfragen_positionen") + .select( + "id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz" + ) + .gte("mietbeginn", vor12ISO) + .order("mietbeginn"); + + const { data: planRoh } = await db + .from("anfragen_positionen") + .select( + "id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz" + ) + .lte("mietbeginn", in3ISO) + .gte("mietende", heuteISO) + .order("mietbeginn"); + + const anfrageMap = new Map( + (anfragen ?? []).map((a) => [a.id, a]) + ); + + // Status-Sets (nur abgeschlossene Mietvorgänge zählen, die zuvor bestätigt waren) + // Lade Audit Log um zu verifizieren: bestätigt → abgeschlossenen Workflow + const { data: auditLogs } = await db + .from("anfrage_status_audit") + .select("anfrage_id, status_zu"); + + // Map: anfrage_id → hat bestätigt Status durchlaufen? + const hatBestaetigt = new Map(); + (auditLogs ?? []).forEach((log: any) => { + if (log.status_zu === "bestaetigt") { + hatBestaetigt.set(log.anfrage_id, true); + } + }); + + const bestaetigtIds = new Set( + (anfragen ?? []) + .filter((a) => a.status === "abgeschlossen" && hatBestaetigt.has(a.id)) + .map((a) => a.id) + ); + + // ── KPIs ──────────────────────────────────────────────────────────────── + const kpiPos = (allePositionen ?? []).filter((p) => bestaetigtIds.has(p.anfrage_id)); + const umsatzGesamt = kpiPos.reduce( + (s, p) => s + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0), + 0 + ); + const mietTageGesamt = kpiPos.reduce((s, p) => s + (p.gesamt_tage ?? 0), 0); + const offenAnzahl = (anfragen ?? []).filter((a) => a.status === "offen").length; + + // ── Auslastung pro Maschine ────────────────────────────────────────────── + const maschinenMap: Record< + string, + { name: string; kategorie: string; tage: number; umsatz: number; einsaetze: number } + > = {}; + + for (const p of allePositionen ?? []) { + if (!bestaetigtIds.has(p.anfrage_id)) continue; + const key = p.maschine_name; + if (!maschinenMap[key]) { + maschinenMap[key] = { + name: p.maschine_name, + kategorie: p.maschine_kategorie ?? "", + tage: 0, + umsatz: 0, + einsaetze: 0, + }; + } + maschinenMap[key].tage += p.gesamt_tage ?? 0; + maschinenMap[key].umsatz += + p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0; + maschinenMap[key].einsaetze += 1; + } + + const maschinenStats = Object.values(maschinenMap).sort((a, b) => b.tage - a.tage); + const maxTage = maschinenStats[0]?.tage ?? 1; + + // ── Monatliche Entwicklung (letzte 6 Monate) ──────────────────────────── + const monatsStats: { + monat: string; + label: string; + einsaetze: number; + tage: number; + umsatz: number; + }[] = []; + + for (let i = 5; i >= 0; i--) { + const d = new Date(heute); + d.setMonth(d.getMonth() - i); + const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" }); + + const mPos = (allePositionen ?? []).filter( + (p) => bestaetigtIds.has(p.anfrage_id) && p.mietbeginn?.startsWith(monat) + ); + + monatsStats.push({ + monat, + label, + einsaetze: mPos.length, + tage: mPos.reduce((s, p) => s + (p.gesamt_tage ?? 0), 0), + umsatz: mPos.reduce( + (s, p) => s + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0), + 0 + ), + }); + } + const maxUmsatz = Math.max(...monatsStats.map((m) => m.umsatz), 1); + + // ── Planungs-Daten anreichern ───────────────────────────────────────── + const planDaten = (planRoh ?? []).map((p) => { + const a = anfrageMap.get(p.anfrage_id); + return { + ...p, + anfrage_status: a?.status ?? "offen", + firma: a?.firma ?? "–", + }; + }); + + return ( +
+ + +
+
+

+ Statistik & Planung +

+

+ Letzte 12 Monate · Bestätigte Vermietungen +

+
+ + {/* ── KPI-Cards ─────────────────────────────────────────────────── */} +
+ {[ + { + label: "Anfragen gesamt", + wert: fmt(anfragen?.length ?? 0), + sub: `${offenAnzahl} offen`, + farbe: "border-l-slate-400", + }, + { + label: "Bestätigte Mieten", + wert: fmt(bestaetigtIds.size), + sub: "letzte 12 Monate", + farbe: "border-l-green-500", + }, + { + label: "Vermietete Tage", + wert: fmt(mietTageGesamt), + sub: `Ø ${fmt(mietTageGesamt / Math.max(bestaetigtIds.size, 1), 1)} Tage/Miete`, + farbe: "border-l-[#f7d334]", + }, + { + label: "Netto-Umsatz", + wert: `${fmt(umsatzGesamt)} €`, + sub: `Ø ${fmt(umsatzGesamt / Math.max(6, 1))} €/Monat`, + farbe: "border-l-blue-500", + }, + ].map((k) => ( +
+

+ {k.label} +

+

{k.wert}

+

{k.sub}

+
+ ))} +
+ + {/* ── Monatsüberblick (Balkendiagramm) ─────────────────────────── */} +
+

+ Umsatz letzte 6 Monate (netto) +

+
+ {monatsStats.map((m) => ( +
+ + {m.umsatz > 0 ? `${fmt(m.umsatz)} €` : "–"} + +
+
0 ? 8 : 0, Math.round((m.umsatz / maxUmsatz) * 80))}px`, + }} + /> +
+ {m.label} + {m.einsaetze} Eins. +
+ ))} +
+
+ + {/* ── Gantt-Kalender ───────────────────────────────────────────── */} +
+
+

+ Vermietungsplanung – aktuelle & nächste 3 Monate +

+ + {planDaten.filter((p) => p.anfrage_status !== "abgelehnt").length} Positionen + +
+ +
+ + {/* ── Auslastungstabelle pro Maschine ─────────────────────────── */} +
+

+ Auslastung pro Maschine + letzte 12 Monate +

+ + {maschinenStats.length === 0 ? ( +

+ Noch keine bestätigten Vermietungen vorhanden. +

+ ) : ( +
+ + + + + + + + + + + + + {maschinenStats.map((m, i) => ( + + + + + + + + + ))} + + + + + + + + + +
MaschineKategorieEinsätzeMiettage + Auslastung + Umsatz netto
{m.name} + {m.kategorie} + + {m.einsaetze}× + + {fmt(m.tage)} + +
+
+
+
+ + {Math.round((m.tage / maxTage) * 100)}% + +
+
+ {m.umsatz > 0 ? `${fmt(m.umsatz)} €` : "–"} +
+ Gesamt + + {fmt(maschinenStats.reduce((s, m) => s + m.einsaetze, 0))}× + + {fmt(mietTageGesamt)} + + + {fmt(umsatzGesamt)} € +
+
+ )} +
+
+
+ ); +} diff --git a/modules/07-kpi-dashboard/files/app/api/admin/statistik/route.ts b/modules/07-kpi-dashboard/files/app/api/admin/statistik/route.ts new file mode 100644 index 0000000..435df0f --- /dev/null +++ b/modules/07-kpi-dashboard/files/app/api/admin/statistik/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase"; +import { requireAdmin } from "@/lib/admin-auth"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const check = await requireAdmin(); + if (check instanceof NextResponse) return check; + + const db = createServiceClient(); + + const heute = new Date(); + heute.setHours(0, 0, 0, 0); + const heuteISO = heute.toISOString().slice(0, 10); + + // Fenster: 12 Monate zurück für Statistiken + const vor12Monaten = new Date(heute); + vor12Monaten.setMonth(vor12Monaten.getMonth() - 12); + const vor12ISO = vor12Monaten.toISOString().slice(0, 10); + + // Fenster: 3 Monate voraus für Planungsübersicht + const in3Monaten = new Date(heute); + in3Monaten.setMonth(in3Monaten.getMonth() + 3); + in3Monaten.setDate(in3Monaten.getDate() + 1); + const in3ISO = in3Monaten.toISOString().slice(0, 10); + + // ── Alle Anfragen (Status + Datum) ───────────────────────────────────── + const { data: anfragen } = await db + .from("anfragen") + .select("id, status, created_at, firma, telefon, email") + .order("created_at", { ascending: false }); + + // ── Alle Positionen (Statistik: letzte 12 Monate) ────────────────────── + const { data: allePositionen } = await db + .from("anfragen_positionen") + .select( + "id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz, lieferung" + ) + .gte("mietbeginn", vor12ISO) + .order("mietbeginn"); + + // ── Planungs-Positionen (heute bis +3 Monate) ────────────────────────── + const { data: planPositionen } = await db + .from("anfragen_positionen") + .select( + "id, anfrage_id, maschine_name, maschine_kategorie, mietbeginn, mietende, gesamt_tage, tagessatz" + ) + .lte("mietbeginn", in3ISO) + .gte("mietende", heuteISO) + .order("mietbeginn"); + + const anfrageMap = new Map( + (anfragen ?? []).map((a) => [a.id, a]) + ); + + // ── KPI-Berechnung (nur abgeschlossene Mietvorgänge zählen, die zuvor bestätigt waren) ── + // Lade Audit Log um zu verifizieren: bestätigt → abgeschlossenen Workflow + const { data: auditLogs } = await db + .from("anfrage_status_audit") + .select("anfrage_id, status_zu"); + + // Map: anfrage_id → hat bestätigt Status durchlaufen? + const hatBestaetigt = new Map(); + (auditLogs ?? []).forEach((log: any) => { + if (log.status_zu === "bestaetigt") { + hatBestaetigt.set(log.anfrage_id, true); + } + }); + + const bestaetigtIds = new Set( + (anfragen ?? []) + .filter((a) => a.status === "abgeschlossen" && hatBestaetigt.has(a.id)) + .map((a) => a.id) + ); + + const kpiPositionen = (allePositionen ?? []).filter((p) => + bestaetigtIds.has(p.anfrage_id) + ); + + const umsatzGesamt = kpiPositionen.reduce( + (sum, p) => sum + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0), + 0 + ); + const mietTageGesamt = kpiPositionen.reduce( + (sum, p) => sum + (p.gesamt_tage ?? 0), + 0 + ); + + // ── Auslastung pro Maschine ───────────────────────────────────────────── + const maschinenMap: Record< + string, + { name: string; kategorie: string; tage: number; umsatz: number; anfragen: number } + > = {}; + + for (const p of allePositionen ?? []) { + if (!bestaetigtIds.has(p.anfrage_id)) continue; + const key = p.maschine_name; + if (!maschinenMap[key]) { + maschinenMap[key] = { + name: p.maschine_name, + kategorie: p.maschine_kategorie ?? "", + tage: 0, + umsatz: 0, + anfragen: 0, + }; + } + maschinenMap[key].tage += p.gesamt_tage ?? 0; + maschinenMap[key].umsatz += + p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0; + maschinenMap[key].anfragen += 1; + } + + const maschinenStats = Object.values(maschinenMap).sort( + (a, b) => b.tage - a.tage + ); + + // ── Monatliche Entwicklung (letzte 6 Monate) ─────────────────────────── + const monatsStats: { + monat: string; + label: string; + anfragen: number; + tage: number; + umsatz: number; + }[] = []; + + for (let i = 5; i >= 0; i--) { + const d = new Date(heute); + d.setMonth(d.getMonth() - i); + const monat = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + const label = d.toLocaleDateString("de-DE", { month: "short", year: "2-digit" }); + + const mPositionen = (allePositionen ?? []).filter( + (p) => + bestaetigtIds.has(p.anfrage_id) && + p.mietbeginn?.startsWith(monat) + ); + + monatsStats.push({ + monat, + label, + anfragen: mPositionen.length, + tage: mPositionen.reduce((s, p) => s + (p.gesamt_tage ?? 0), 0), + umsatz: mPositionen.reduce( + (s, p) => + s + (p.tagessatz != null ? p.tagessatz * p.gesamt_tage : 0), + 0 + ), + }); + } + + // ── Planungs-Daten mit Anfragestatus anreichern ───────────────────────── + const planDaten = (planPositionen ?? []).map((p) => { + const anfrage = anfrageMap.get(p.anfrage_id); + return { + ...p, + anfrage_status: anfrage?.status ?? "offen", + firma: anfrage?.firma ?? "", + }; + }); + + return NextResponse.json({ + kpi: { + anfragenGesamt: anfragen?.length ?? 0, + bestaetigtGesamt: bestaetigtIds.size, + umsatzGesamt, + mietTageGesamt, + }, + maschinenStats, + monatsStats, + planDaten, + heuteISO, + in3ISO, + }); +} diff --git a/package-lock.json b/package-lock.json index 81f26c8..e2f8c7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "mbo-tech-it", "version": "0.1.0", "dependencies": { + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.104.1", + "bcryptjs": "^3.0.3", + "lucide-react": "^1.11.0", "next": "^15.2.0", "next-themes": "^0.4.6", "nodemailer": "^8.0.6", @@ -15,6 +19,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^22", "@types/nodemailer": "^8.0.0", "@types/react": "^19", @@ -725,6 +730,104 @@ "node": ">= 8" } }, + "node_modules/@supabase/auth-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.104.1.tgz", + "integrity": "sha512-pqFnDKekq1isqlqnzqzyJ3mzmho+o+FjfVTqhKY3PFlwj2anx3OPznO1kbo1ZEwD8zg1r4EAFf/7pplLyX0ocQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.104.1.tgz", + "integrity": "sha512-JjAH4JN9rZzxh4plQnILPrQZXAG6ccoRS6z9hQAGmXpRSwJA+7CWbsDV2R82I8MROlGDsjqj1Ot/cWpTfdf6xg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.104.1.tgz", + "integrity": "sha512-RqlLpvgXsjcc27fLyHNGm3zN0KDWXbkdTdaFtaEdX83RsTEqH7BAmshH7zoUMml5lL04naUeRjS3B81O6jZcJw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.104.1.tgz", + "integrity": "sha512-dVJHhFB2ErBd0/2qE9G8CedCrGoAtBfL9Q4zbSMXO7b1Cpld916ljSiX21mURUqijPf1WoPQG4Bp/averUzk/g==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz", + "integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.102.1" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.104.1.tgz", + "integrity": "sha512-2bQaLbkRshctkUVuqamwYZDEd+0cGSc9DY9sjh92DcA5hu1F/1AP8p6gxGr76sgdK9Ngi0rh+2Kdh+uC4hcnGA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.104.1.tgz", + "integrity": "sha512-E0H/CtVmaGjiAy+ieZ5ZB/1EqxXcGdaFaAc23AE5zaYfz6NtCNDcmaEdoGPYMPFH5pE6drGG6e3ljPmkFoGVxQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.104.1", + "@supabase/functions-js": "2.104.1", + "@supabase/postgrest-js": "2.104.1", + "@supabase/realtime-js": "2.104.1", + "@supabase/storage-js": "2.104.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -734,11 +837,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -774,6 +883,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -852,6 +970,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -996,6 +1123,19 @@ "node": ">= 6" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1175,6 +1315,15 @@ "node": ">= 0.4" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1267,6 +1416,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lucide-react": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", + "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2072,7 +2230,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -2112,6 +2269,27 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 2d771b4..4959b05 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ "lint": "next lint" }, "dependencies": { + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.104.1", + "bcryptjs": "^3.0.3", + "lucide-react": "^1.11.0", "next": "^15.2.0", "next-themes": "^0.4.6", "nodemailer": "^8.0.6", @@ -16,6 +20,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^22", "@types/nodemailer": "^8.0.0", "@types/react": "^19",