diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..19234e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.gitignore +.next +node_modules +coverage +volumes +.env +.env.* +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +tsconfig.tsbuildinfo +*.sqlite +*.db +prisma/dev.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9fa82e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,68 @@ +# App +NODE_ENV=development +STACK_NAME=calbook +DEPLOYMENT_MODE=direct +PUBLIC_URL=http://localhost:3000 +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=CHANGE_ME_WITH_A_LONG_RANDOM_SECRET_MIN_32_CHARS +CRON_SECRET=CHANGE_ME_WITH_A_RANDOM_CRON_SECRET_MIN_24_CHARS +TRUST_PROXY_HEADERS=false +# Legacy-Fallback (optional, wird genutzt falls PUBLIC_URL fehlt) +APP_BASE_URL=http://localhost:3000 + +# Datenbank +POSTGRES_DB=calbook +POSTGRES_USER=calbook +POSTGRES_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD +DATABASE_URL=postgresql://calbook:CHANGE_ME_STRONG_DATABASE_PASSWORD@db:5432/calbook?schema=public + +# Lokalisierung +DEFAULT_TIMEZONE=Europe/Berlin + +# Admin Seed +ADMIN_NAME=CalBook Admin +ADMIN_EMAIL=admin@calbook.local +ADMIN_PASSWORD=CHANGE_ME_STRONG_ADMIN_PASSWORD_MIN_12 + +# Verschlüsselung (32 Zeichen empfohlen) +CALDAV_ENCRYPTION_KEY=CHANGE_ME_WITH_A_LONG_RANDOM_KEY_MIN_32_CHARS + +# SMTP +SMTP_HOST=mailhog +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASS= +SMTP_FROM_NAME=CalBook +SMTP_FROM=no-reply@calbook.local + +# Buchungsregeln +DEFAULT_DURATION_MINUTES=60 +DEFAULT_BUFFER_MINUTES=10 +DEFAULT_BOOKING_LEAD_HOURS=2 +DEFAULT_BOOKING_WINDOW_DAYS=60 +DEFAULT_BOOKING_ALLOWED_WEEKDAYS=0,1,2,3,4 +DEFAULT_BOOKING_DAY_START_TIME=09:00 +DEFAULT_BOOKING_DAY_END_TIME=17:00 +DEFAULT_CANCEL_HOURS=24 + +# Performance (optional) +SETTINGS_CACHE_TTL_MS=30000 +SLOTS_DAY_CACHE_TTL_MS=6000 +SLOTS_MONTH_CACHE_TTL_MS=12000 +SLOTS_MONTH_CONCURRENCY=4 + +# Jitsi (optional) +JITSI_MEETING_MODE=public +JITSI_BASE_URL=https://meet.jit.si +JITSI_ROOM_PREFIX=calbook +JITSI_ROOM_SALT=CHANGE_ME_WITH_A_RANDOM_SALT + +# Optional: Traefik +ENABLE_TRAEFIK=false +TRAEFIK_HOST=calbook.local +TRAEFIK_TLS=true +TRAEFIK_ENTRYPOINTS=websecure +TRAEFIK_CERTRESOLVER=tls_resolver +TRAEFIK_ROUTER_NAME=calbook +TRAEFIK_SERVICE_NAME=calbook +TRAEFIK_DOCKER_NETWORK=proxy diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..bba8f94 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +.next +node_modules +volumes +coverage +*.config.js +next-env.d.ts +*.tsbuildinfo diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9898fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.next +node_modules +.env +.env.* +!.env.example +coverage +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +*.log +postgres_data +volumes/ +prisma/dev.db +tsconfig.tsbuildinfo +*.sqlite +*.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..824977d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:22-bookworm-slim AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +RUN apt-get update -y && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* + +FROM base AS deps +COPY package.json package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm npm ci + +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run prisma:generate && npm run build + +FROM base AS prod-deps +COPY package.json package-lock.json* ./ +COPY prisma ./prisma +RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm run prisma:generate + +FROM prod-deps AS tools +ENV NODE_ENV=production +COPY lib ./lib +COPY prisma ./prisma + +FROM base AS runner +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1eb90e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +SHELL := /bin/bash + +DEPLOYMENT_MODE := $(shell grep -E '^DEPLOYMENT_MODE=' .env 2>/dev/null | cut -d= -f2 | tr -d '"' || true) +ifeq ($(DEPLOYMENT_MODE),) + DEPLOYMENT_MODE := direct +endif + +ifeq ($(DEPLOYMENT_MODE),proxy) + COMPOSE_FILE := docker-compose.proxy.yml +else + COMPOSE_FILE := docker-compose.direct.yml +endif + +COMPOSE := docker compose -f $(COMPOSE_FILE) + +.PHONY: deploy setup dev prod logs restart + +deploy: + ./deploy.sh + +setup: deploy + +dev: + $(COMPOSE) up --build + +prod: + $(COMPOSE) up -d --build + $(COMPOSE) build calbook-tools + $(COMPOSE) run --rm calbook-tools npm run prisma:generate + $(COMPOSE) run --rm calbook-tools npm run prisma:migrate || $(COMPOSE) run --rm calbook-tools npm run prisma:push + $(COMPOSE) run --rm calbook-tools npm run db:seed + +logs: + $(COMPOSE) logs -f calbook-app db + +restart: + $(COMPOSE) up -d --build calbook-app diff --git a/app/(admin)/admin/backup/page.tsx b/app/(admin)/admin/backup/page.tsx new file mode 100644 index 0000000..c32ac64 --- /dev/null +++ b/app/(admin)/admin/backup/page.tsx @@ -0,0 +1,7 @@ +import { BackupPanel } from "@/components/admin/backup-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminBackupPage() { + return ; +} diff --git a/app/(admin)/admin/branding/page.tsx b/app/(admin)/admin/branding/page.tsx new file mode 100644 index 0000000..e6bdf90 --- /dev/null +++ b/app/(admin)/admin/branding/page.tsx @@ -0,0 +1,7 @@ +import { BrandingPanel } from "@/components/admin/branding-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminBrandingPage() { + return ; +} diff --git a/app/(admin)/admin/einstellungen/page.tsx b/app/(admin)/admin/einstellungen/page.tsx new file mode 100644 index 0000000..e43092a --- /dev/null +++ b/app/(admin)/admin/einstellungen/page.tsx @@ -0,0 +1,7 @@ +import { SettingsPanel } from "@/components/admin/settings-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminSettingsPage() { + return ; +} diff --git a/app/(admin)/admin/email-templates/page.tsx b/app/(admin)/admin/email-templates/page.tsx new file mode 100644 index 0000000..e638852 --- /dev/null +++ b/app/(admin)/admin/email-templates/page.tsx @@ -0,0 +1,7 @@ +import { EmailTemplatesPanel } from "@/components/admin/email-templates-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminEmailTemplatesPage() { + return ; +} diff --git a/app/(admin)/admin/instant-meeting/page.tsx b/app/(admin)/admin/instant-meeting/page.tsx new file mode 100644 index 0000000..b57605b --- /dev/null +++ b/app/(admin)/admin/instant-meeting/page.tsx @@ -0,0 +1,7 @@ +import { InstantMeetingPanel } from "@/components/admin/instant-meeting-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminInstantMeetingPage() { + return ; +} diff --git a/app/(admin)/admin/kalender/page.tsx b/app/(admin)/admin/kalender/page.tsx new file mode 100644 index 0000000..69d57b5 --- /dev/null +++ b/app/(admin)/admin/kalender/page.tsx @@ -0,0 +1,7 @@ +import { CalendarPersonPanel } from "@/components/admin/calendar-person-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminCalendarsPage() { + return ; +} diff --git a/app/(admin)/admin/layout.tsx b/app/(admin)/admin/layout.tsx new file mode 100644 index 0000000..cc00559 --- /dev/null +++ b/app/(admin)/admin/layout.tsx @@ -0,0 +1,26 @@ +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/lib/auth/options"; +import { AdminLayoutClientShell } from "@/components/admin/admin-nav"; +import { SETTING_KEYS } from "@/lib/constants"; +import { getSetting } from "@/lib/settings"; +import { AccentColorScript } from "@/components/layout/accent-color"; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await getServerSession(authOptions); + if (!session?.user) redirect("/anmelden"); + if (session.user.role !== "ADMIN") redirect("/anmelden"); + + let accentColor = "#4f46e5"; + try { + const color = await getSetting(SETTING_KEYS.BRANDING_ACCENT_COLOR); + if (color && /^#[0-9a-fA-F]{6}$/.test(color)) accentColor = color; + } catch { /* default */ } + + return ( + <> + + {children} + + ); +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..e424e47 --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminPage() { + redirect("/admin/uebersicht"); +} diff --git a/app/(admin)/admin/rechtliches/page.tsx b/app/(admin)/admin/rechtliches/page.tsx new file mode 100644 index 0000000..a6d06e9 --- /dev/null +++ b/app/(admin)/admin/rechtliches/page.tsx @@ -0,0 +1,7 @@ +import { LegalPagesSettingsPanel } from "@/components/admin/legal-pages-settings-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminLegalPagesPage() { + return ; +} diff --git a/app/(admin)/admin/termine/page.tsx b/app/(admin)/admin/termine/page.tsx new file mode 100644 index 0000000..b831a86 --- /dev/null +++ b/app/(admin)/admin/termine/page.tsx @@ -0,0 +1,7 @@ +import { AppointmentsPanel } from "@/components/admin/appointments-panel"; + +export const dynamic = "force-dynamic"; + +export default function AdminAppointmentsPage() { + return ; +} diff --git a/app/(admin)/admin/uebersicht/page.tsx b/app/(admin)/admin/uebersicht/page.tsx new file mode 100644 index 0000000..59502ed --- /dev/null +++ b/app/(admin)/admin/uebersicht/page.tsx @@ -0,0 +1,156 @@ +import Link from "next/link"; +import { endOfMonth, startOfMonth, startOfDay, endOfDay, subWeeks, startOfWeek, addDays, format as fnsFormat } from "date-fns"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { BarChart3, Calendar, CheckCircle2, Clock, Users, AlertTriangle, ArrowRight } from "lucide-react"; +import { prisma } from "@/lib/prisma"; +import { LatestBookingsPanel } from "@/components/admin/latest-bookings-panel"; + +export const dynamic = "force-dynamic"; + +export default async function AdminOverviewPage() { + const now = new Date(); + const eightWeeksAgo = subWeeks(now, 7); + + const [ + activeResources, + totalResources, + upcomingAppointments, + monthTotal, + monthCancelled, + monthNoShow, + openDeliveryIssues, + todayAppointments, + weeklyAppointments + ] = await Promise.all([ + prisma.calendarConn.count({ where: { user: { role: "STAFF", isActive: true } } }), + prisma.calendarConn.count({ where: { user: { role: "STAFF" } } }), + prisma.appointment.count({ where: { status: "CONFIRMED", startAt: { gte: now } } }), + prisma.appointment.count({ where: { status: "CONFIRMED", startAt: { gte: startOfMonth(now), lte: endOfMonth(now) } } }), + prisma.appointment.count({ where: { status: "CANCELLED", startAt: { gte: startOfMonth(now), lte: endOfMonth(now) } } }), + prisma.appointment.count({ where: { status: "CONFIRMED", noShowAt: { not: null }, startAt: { gte: startOfMonth(now), lte: endOfMonth(now) } } }), + prisma.deliveryIssue.count({ where: { resolvedAt: null } }), + prisma.appointment.findMany({ + where: { status: "CONFIRMED", startAt: { gte: startOfDay(now), lte: endOfDay(now) } }, + include: { staff: { select: { name: true } } }, + orderBy: { startAt: "asc" } + }), + prisma.appointment.findMany({ + where: { status: "CONFIRMED", startAt: { gte: eightWeeksAgo } }, + select: { startAt: true } + }) + ]); + + // Build weekly chart data + const weeks: { label: string; count: number }[] = []; + for (let i = 7; i >= 0; i--) { + const weekStart = startOfWeek(subWeeks(now, i), { weekStartsOn: 1 }); + const weekEnd = addDays(weekStart, 6); + const count = weeklyAppointments.filter((a) => a.startAt >= weekStart && a.startAt <= weekEnd).length; + weeks.push({ label: format(weekStart, "dd.MM.", { locale: de }), count }); + } + const maxCount = Math.max(1, ...weeks.map((w) => w.count)); + + return ( +
+
+

Dashboard

+

+ {format(now, "EEEE, d. MMMM yyyy", { locale: de })} +

+
+ + {/* Stats */} +
+ {[ + { icon: Calendar, iconBg: "bg-indigo-50", iconColor: "text-indigo-600", value: upcomingAppointments, label: "Offene Buchungen" }, + { icon: Users, iconBg: "bg-emerald-50", iconColor: "text-emerald-600", value: <>{activeResources}/{totalResources}, label: "Aktive Kalender" }, + { icon: AlertTriangle, iconBg: "bg-amber-50", iconColor: "text-amber-600", value: openDeliveryIssues, label: "Zustellfehler" }, + { icon: CheckCircle2, iconBg: "bg-slate-900", iconColor: "text-white", value: Aktiv, label: "System läuft" } + ].map((stat, i) => ( +
+
+
+ +
+
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ + {/* Chart + Today's appointments side by side */} +
+ {/* Weekly chart */} +
+
+ +

Buchungen

+ letzte 8 Wochen +
+
+
+ {weeks.map((week, i) => ( +
+ {week.count} +
+ {week.label} +
+ ))} +
+
+
+ + {/* Today's appointments */} +
+
+
+ +

Heutige Termine

+ {todayAppointments.length} +
+ + Alle + +
+ {todayAppointments.length === 0 ? ( +
+ + Keine Termine für heute. +
+ ) : ( +
+ {todayAppointments.map((a) => ( +
+ {format(new Date(a.startAt), "HH:mm")} +
+

{a.customerFirstName} {a.customerLastName}

+

{a.customerEmail}

+
+
+ ))} +
+ )} +
+
+ + {/* Latest bookings */} +
+ +
+ +
+ + Kalender verwalten + + + Termine anzeigen + +
+
+ ); +} diff --git a/app/(admin)/loading.tsx b/app/(admin)/loading.tsx new file mode 100644 index 0000000..90062bc --- /dev/null +++ b/app/(admin)/loading.tsx @@ -0,0 +1,26 @@ +export default function AdminLoading() { + return ( +
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/app/(auth)/anmelden/login-form.tsx b/app/(auth)/anmelden/login-form.tsx new file mode 100644 index 0000000..e3f28ab --- /dev/null +++ b/app/(auth)/anmelden/login-form.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +const schema = z.object({ + email: z.string().email("Bitte gültige E-Mail eingeben"), + password: z.string().min(8, "Mindestens 8 Zeichen") +}); + +type FormValues = z.infer; + +export function LoginForm() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema) + }); + + const onSubmit = handleSubmit(async (values) => { + setLoading(true); + try { + const result = await signIn("credentials", { + email: values.email, + password: values.password, + redirect: false + }); + if (!result || result.error) { toast.error("Anmeldung fehlgeschlagen"); return; } + toast.success("Erfolgreich angemeldet"); + router.push("/admin/uebersicht"); + router.refresh(); + } finally { setLoading(false); } + }); + + return ( + + Admin-Anmeldung + +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + + {errors.password &&

{errors.password.message}

} +
+ +
+
+
+ ); +} diff --git a/app/(auth)/anmelden/page.tsx b/app/(auth)/anmelden/page.tsx new file mode 100644 index 0000000..af596b9 --- /dev/null +++ b/app/(auth)/anmelden/page.tsx @@ -0,0 +1,52 @@ +import { PublicFooter } from "@/components/layout/public-footer"; +import { SETTING_KEYS } from "@/lib/constants"; +import { getSettings } from "@/lib/settings"; +import { LoginForm } from "./login-form"; + +export const dynamic = "force-dynamic"; + +export default async function LoginPage() { + const settings = await getSettings([ + SETTING_KEYS.COMPANY_NAME, + SETTING_KEYS.FRONTEND_HEADER_LOGO_URL, + SETTING_KEYS.FOOTER_PRIVACY_LABEL, + SETTING_KEYS.FOOTER_PRIVACY_URL, + SETTING_KEYS.FOOTER_IMPRINT_LABEL, + SETTING_KEYS.FOOTER_IMPRINT_URL, + SETTING_KEYS.FOOTER_COPYRIGHT_TEXT + ]).catch(() => ({} as Record)); + + const companyName = settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook"; + const logoUrl = settings[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] ?? ""; + + return ( +
+
+
+
+ {logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {companyName} + ) : ( +
+ + + +
+ )} +

{companyName}

+
+ +
+
+ +
+ ); +} diff --git a/app/(public)/buchen/[slug]/page.tsx b/app/(public)/buchen/[slug]/page.tsx new file mode 100644 index 0000000..5a3eb9b --- /dev/null +++ b/app/(public)/buchen/[slug]/page.tsx @@ -0,0 +1,28 @@ +import { PublicBookingFlow } from "@/components/booking/public-booking-flow"; +import { EmbedMode } from "@/components/booking/embed-mode"; +import { getPublicBookingInitialConfig } from "@/lib/public-booking-config"; + +export default async function StaffBookingPage({ + params, + searchParams +}: { + params: Promise<{ slug: string }>; + searchParams: Promise<{ embed?: string; rescheduleToken?: string }>; +}) { + const { slug } = await params; + const sp = await searchParams; + const embedded = sp.embed === "true"; + const initialConfig = await getPublicBookingInitialConfig(); + + return ( + <> + + + + ); +} diff --git a/app/(public)/buchen/page.tsx b/app/(public)/buchen/page.tsx new file mode 100644 index 0000000..b50d246 --- /dev/null +++ b/app/(public)/buchen/page.tsx @@ -0,0 +1,24 @@ +import { PublicBookingFlow } from "@/components/booking/public-booking-flow"; +import { EmbedMode } from "@/components/booking/embed-mode"; +import { getPublicBookingInitialConfig } from "@/lib/public-booking-config"; + +export default async function PublicBookingPage({ + searchParams +}: { + searchParams: Promise<{ embed?: string; rescheduleToken?: string }>; +}) { + const params = await searchParams; + const embedded = params.embed === "true"; + const initialConfig = await getPublicBookingInitialConfig(); + + return ( + <> + + + + ); +} diff --git a/app/(public)/datenschutz/page.tsx b/app/(public)/datenschutz/page.tsx new file mode 100644 index 0000000..235f2b1 --- /dev/null +++ b/app/(public)/datenschutz/page.tsx @@ -0,0 +1,5 @@ +import { SharedLegalPage } from "@/components/booking/shared-legal-page"; + +export default function DatenschutzPage() { + return ; +} diff --git a/app/(public)/impressum/page.tsx b/app/(public)/impressum/page.tsx new file mode 100644 index 0000000..a54fab6 --- /dev/null +++ b/app/(public)/impressum/page.tsx @@ -0,0 +1,5 @@ +import { SharedLegalPage } from "@/components/booking/shared-legal-page"; + +export default function ImpressumPage() { + return ; +} diff --git a/app/(public)/stornieren/page.tsx b/app/(public)/stornieren/page.tsx new file mode 100644 index 0000000..863e050 --- /dev/null +++ b/app/(public)/stornieren/page.tsx @@ -0,0 +1,38 @@ +import { CancelForm } from "@/components/booking/cancel-form"; +import { SETTING_KEYS } from "@/lib/constants"; +import { getSettings } from "@/lib/settings"; + +export const dynamic = "force-dynamic"; + +export default async function CancelPage({ + searchParams +}: { + searchParams: Promise<{ token?: string }>; +}) { + const params = await searchParams; + const settings = await getSettings([ + SETTING_KEYS.COMPANY_NAME, + SETTING_KEYS.FOOTER_PRIVACY_LABEL, + SETTING_KEYS.FOOTER_PRIVACY_URL, + SETTING_KEYS.FOOTER_IMPRINT_LABEL, + SETTING_KEYS.FOOTER_IMPRINT_URL, + SETTING_KEYS.FOOTER_COPYRIGHT_TEXT + ]).catch( + () => ({} as Record) + ); + const companyName = settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook"; + + return ( + + ); +} diff --git a/app/api/admin/backup/route.ts b/app/api/admin/backup/route.ts new file mode 100644 index 0000000..4a1894c --- /dev/null +++ b/app/api/admin/backup/route.ts @@ -0,0 +1,286 @@ +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/auth/session"; +import { prisma } from "@/lib/prisma"; +import { reEncryptWithNewKey } from "@/lib/crypto"; + +const SKIP_SETTING_KEYS = new Set(["PUBLIC_URL", "NEXTAUTH_URL", "APP_BASE_URL"]); + +export async function GET() { + try { + await requireAdmin(); + + const caldavKey = process.env.CALDAV_ENCRYPTION_KEY ?? ""; + + const [ + settings, + users, + calendarConns, + appointments, + busyBlocks, + deliveryIssues, + syncRuns + ] = await Promise.all([ + prisma.setting.findMany(), + prisma.user.findMany({ select: { id: true, name: true, email: true, hashedPassword: true, role: true, slug: true, bio: true, avatarUrl: true, timezone: true, isActive: true, createdAt: true, updatedAt: true } }), + prisma.calendarConn.findMany({ select: { id: true, userId: true, name: true, bookingAllowedWeekdays: true, bookingDayStartTime: true, bookingDayEndTime: true, bookingDayRangesJson: true, url: true, username: true, notificationEmail: true, encryptedPassword: true, color: true, syncEnabled: true, lastSyncedAt: true, createdAt: true, updatedAt: true } }), + prisma.appointment.findMany(), + prisma.busyBlock.findMany(), + prisma.deliveryIssue.findMany(), + prisma.calendarSyncRun.findMany({ include: { entries: true } }) + ]); + + const backup = { + version: 1, + exportedAt: new Date().toISOString(), + caldavEncryptionKey: caldavKey || undefined, + settings: settings.filter((s) => !SKIP_SETTING_KEYS.has(s.key)).map((s) => ({ key: s.key, value: s.value })), + users, + calendarConns, + appointments, + busyBlocks, + deliveryIssues, + syncRuns + }; + + const json = JSON.stringify(backup, null, 2); + + return new NextResponse(json, { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="calbook-backup-${new Date().toISOString().slice(0, 10)}.json"` + } + }); + } catch (error) { + if (error instanceof Error) { + if (error.message === "UNAUTHORIZED") return NextResponse.json({ message: "Nicht autorisiert" }, { status: 401 }); + if (error.message === "FORBIDDEN") return NextResponse.json({ message: "Keine Admin-Rechte" }, { status: 403 }); + } + return NextResponse.json({ message: "Backup konnte nicht erstellt werden" }, { status: 500 }); + } +} + +export async function POST(req: Request) { + try { + await requireAdmin(); + + const body = await req.json(); + if (!body || typeof body !== "object" || !body.version) { + return NextResponse.json({ message: "Ungültiges Backup-Format" }, { status: 400 }); + } + + const importedAt = new Date().toISOString(); + const steps: Array<{ + label: string; + status: "ok" | "error" | "skipped"; + detail: string; + }> = []; + const userIdMap = new Map(); + const backupCaldavKey: string = + typeof body.caldavEncryptionKey === "string" && body.caldavEncryptionKey.length >= 32 + ? body.caldavEncryptionKey + : ""; + + async function addStep(label: string, fn: () => Promise) { + try { + const detail = await fn(); + steps.push({ label, status: "ok", detail }); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + steps.push({ label, status: "error", detail: msg }); + } + } + + await addStep("Einstellungen", async () => { + if (!Array.isArray(body.settings)) return "Keine Settings im Backup"; + const filtered = body.settings.filter((s: { key: string }) => !SKIP_SETTING_KEYS.has(s.key)); + await prisma.$transaction( + filtered.map((s: { key: string; value: string }) => + prisma.setting.upsert({ + where: { key: s.key }, + create: { key: s.key, value: s.value }, + update: { value: s.value } + }) + ) + ); + return `${filtered.length} Settings wiederhergestellt`; + }); + + await addStep("Benutzer", async () => { + if (!Array.isArray(body.users)) return "Keine Benutzer im Backup"; + const existingEmails = await prisma.user.findMany({ select: { email: true, id: true } }); + const emailToExistingId = new Map(existingEmails.map((u) => [u.email, u.id])); + let created = 0; + let updated = 0; + for (const u of body.users) { + const hasPw = typeof u.hashedPassword === "string" && u.hashedPassword.length > 0; + const existingId = emailToExistingId.get(u.email); + if (existingId) { + const updateData: Record = { + name: u.name, role: u.role, slug: u.slug, + bio: u.bio ?? null, avatarUrl: u.avatarUrl ?? null, + timezone: u.timezone ?? "Europe/Berlin", isActive: u.isActive ?? true + }; + if (hasPw) updateData.hashedPassword = u.hashedPassword; + await prisma.user.update({ where: { email: u.email }, data: updateData }); + userIdMap.set(u.id, existingId); + updated++; + } else { + const createdUser = await prisma.user.create({ + data: { + name: u.name, email: u.email, hashedPassword: hasPw ? u.hashedPassword : "", + role: u.role ?? "STAFF", + slug: u.slug ?? u.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + bio: u.bio ?? null, avatarUrl: u.avatarUrl ?? null, + timezone: u.timezone ?? "Europe/Berlin", isActive: u.isActive ?? true + } + }); + userIdMap.set(u.id, createdUser.id); + created++; + } + } + return `${created} erstellt, ${updated} aktualisiert`; + }); + + await addStep("Kalender-Verbindungen", async () => { + if (!Array.isArray(body.calendarConns)) return "Keine Kalender im Backup"; + let created = 0; + let updated = 0; + let reEncrypted = 0; + for (const cc of body.calendarConns) { + const resolvedUserId = userIdMap.get(cc.userId) ?? cc.userId; + const resolvedPassword = backupCaldavKey && cc.encryptedPassword + ? reEncryptWithNewKey(String(cc.encryptedPassword), backupCaldavKey) + : (cc.encryptedPassword ?? ""); + if (backupCaldavKey && cc.encryptedPassword && resolvedPassword !== cc.encryptedPassword) reEncrypted++; + const exists = await prisma.calendarConn.findUnique({ where: { id: cc.id } }); + if (exists) { + await prisma.calendarConn.update({ + where: { id: cc.id }, + data: { + name: cc.name, bookingAllowedWeekdays: cc.bookingAllowedWeekdays ?? "0,1,2,3,4", + bookingDayStartTime: cc.bookingDayStartTime ?? "09:00", + bookingDayEndTime: cc.bookingDayEndTime ?? "17:00", + bookingDayRangesJson: cc.bookingDayRangesJson ?? null, + url: cc.url, username: cc.username, + notificationEmail: cc.notificationEmail ?? null, + encryptedPassword: resolvedPassword, + color: cc.color ?? null, syncEnabled: cc.syncEnabled ?? true + } + }); + updated++; + } else { + await prisma.calendarConn.create({ + data: { + id: cc.id, userId: resolvedUserId, name: cc.name, + bookingAllowedWeekdays: cc.bookingAllowedWeekdays ?? "0,1,2,3,4", + bookingDayStartTime: cc.bookingDayStartTime ?? "09:00", + bookingDayEndTime: cc.bookingDayEndTime ?? "17:00", + bookingDayRangesJson: cc.bookingDayRangesJson ?? null, + url: cc.url, username: cc.username, + notificationEmail: cc.notificationEmail ?? null, + encryptedPassword: resolvedPassword, + color: cc.color ?? null, syncEnabled: cc.syncEnabled ?? true + } + }); + created++; + } + } + const reEncNote = backupCaldavKey && reEncrypted > 0 ? `, ${reEncrypted} Passwörter neu verschlüsselt` : ""; + return `${created} erstellt, ${updated} aktualisiert${reEncNote}`; + }); + + await addStep("Termine", async () => { + if (!Array.isArray(body.appointments)) return "Keine Termine im Backup"; + let created = 0; + let updated = 0; + for (const a of body.appointments) { + const resolvedStaffId = userIdMap.get(a.staffId) ?? a.staffId; + const exists = await prisma.appointment.findUnique({ where: { id: a.id } }); + if (exists) { + await prisma.appointment.update({ + where: { id: a.id }, + data: { + bookingGroupId: a.bookingGroupId ?? null, staffId: resolvedStaffId, + customerFirstName: a.customerFirstName, customerLastName: a.customerLastName, + customerEmail: a.customerEmail, customerPhone: a.customerPhone ?? null, + notes: a.notes ?? null, startAt: new Date(a.startAt), endAt: new Date(a.endAt), + durationMinutes: a.durationMinutes ?? 60, status: a.status ?? "CONFIRMED", + cancellationToken: a.cancellationToken, calendarEventUid: a.calendarEventUid ?? null, + cancelledAt: a.cancelledAt ? new Date(a.cancelledAt) : null, + noShowAt: a.noShowAt ? new Date(a.noShowAt) : null, + reminder24hSentAt: a.reminder24hSentAt ? new Date(a.reminder24hSentAt) : null, + reminder2hSentAt: a.reminder2hSentAt ? new Date(a.reminder2hSentAt) : null + } + }); + updated++; + } else { + await prisma.appointment.create({ + data: { + id: a.id, bookingGroupId: a.bookingGroupId ?? null, staffId: resolvedStaffId, + customerFirstName: a.customerFirstName, customerLastName: a.customerLastName, + customerEmail: a.customerEmail, customerPhone: a.customerPhone ?? null, + notes: a.notes ?? null, startAt: new Date(a.startAt), endAt: new Date(a.endAt), + durationMinutes: a.durationMinutes ?? 60, status: a.status ?? "CONFIRMED", + cancellationToken: a.cancellationToken, calendarEventUid: a.calendarEventUid ?? null, + cancelledAt: a.cancelledAt ? new Date(a.cancelledAt) : null, + noShowAt: a.noShowAt ? new Date(a.noShowAt) : null, + reminder24hSentAt: a.reminder24hSentAt ? new Date(a.reminder24hSentAt) : null, + reminder2hSentAt: a.reminder2hSentAt ? new Date(a.reminder2hSentAt) : null + } + }); + created++; + } + } + return `${created} erstellt, ${updated} aktualisiert`; + }); + + await addStep("Zustellfehler", async () => { + if (!Array.isArray(body.deliveryIssues)) return "Keine Zustellfehler im Backup"; + let created = 0; + let updated = 0; + for (const di of body.deliveryIssues) { + const exists = await prisma.deliveryIssue.findUnique({ where: { id: di.id } }); + if (exists) { + await prisma.deliveryIssue.update({ + where: { id: di.id }, + data: { + channel: di.channel, operation: di.operation, target: di.target, + lastError: di.lastError, attemptCount: di.attemptCount ?? 1, + firstSeenAt: new Date(di.firstSeenAt), + lastSeenAt: di.lastSeenAt ? new Date(di.lastSeenAt) : undefined, + resolvedAt: di.resolvedAt ? new Date(di.resolvedAt) : null + } + }); + updated++; + } else { + await prisma.deliveryIssue.create({ + data: { + id: di.id, channel: di.channel, operation: di.operation, target: di.target, + lastError: di.lastError, attemptCount: di.attemptCount ?? 1, + firstSeenAt: new Date(di.firstSeenAt), + lastSeenAt: di.lastSeenAt ? new Date(di.lastSeenAt) : new Date(di.firstSeenAt), + resolvedAt: di.resolvedAt ? new Date(di.resolvedAt) : null + } + }); + created++; + } + } + return `${created} erstellt, ${updated} aktualisiert`; + }); + + const hasErrors = steps.some((s) => s.status === "error"); + + return NextResponse.json({ + message: hasErrors ? "Import mit Fehlern abgeschlossen" : "Import erfolgreich", + importedAt, + steps + }); + } catch (error) { + if (error instanceof Error) { + if (error.message === "UNAUTHORIZED") return NextResponse.json({ message: "Nicht autorisiert" }, { status: 401 }); + if (error.message === "FORBIDDEN") return NextResponse.json({ message: "Keine Admin-Rechte" }, { status: 403 }); + } + return NextResponse.json({ message: "Import fehlgeschlagen" }, { status: 500 }); + } +} diff --git a/app/api/admin/einstellungen/route.ts b/app/api/admin/einstellungen/route.ts new file mode 100644 index 0000000..0808494 --- /dev/null +++ b/app/api/admin/einstellungen/route.ts @@ -0,0 +1,38 @@ +export const dynamic = "force-dynamic"; + +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { getSettings, setSettings } from "@/lib/settings"; +import { settingsSchema } from "@/lib/validators/admin"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +export async function GET() { + try { + await requireAdmin(); + const settings = await getSettings(); + return ok({ settings }); + } catch (error) { + return handleAuthError(error); + } +} + +export async function PATCH(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const bodyResult = await readJsonBody(req, { maxBytes: 512 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = settingsSchema.safeParse(bodyResult.data); + + if (!parsed.success) { + return fail("Ungültige Einstellungen", 400, parsed.error.flatten()); + } + + await setSettings(parsed.data.values); + return ok({ message: "Einstellungen gespeichert" }); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/admin/einstellungen/test-smtp/route.ts b/app/api/admin/einstellungen/test-smtp/route.ts new file mode 100644 index 0000000..565bc9f --- /dev/null +++ b/app/api/admin/einstellungen/test-smtp/route.ts @@ -0,0 +1,67 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { getSettings } from "@/lib/settings"; +import { SETTING_KEYS } from "@/lib/constants"; +import { sendSmtpTestEmail } from "@/lib/email/mailer"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +const bodySchema = z.object({ + to: z.string().email("Bitte gültige Empfänger-E-Mail angeben"), + smtp: z + .object({ + host: z.string().trim().optional().default(""), + port: z + .string() + .trim() + .regex(/^\d+$/, "Bitte einen numerischen SMTP-Port eingeben") + .optional() + .default("587"), + user: z.string().trim().optional().default(""), + pass: z.string().optional().default(""), + fromName: z.string().trim().optional().default("CalBook"), + from: z + .string() + .trim() + .refine( + (value) => value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), + "Bitte eine gültige Absender-E-Mail eingeben" + ) + .optional() + .default("") + }) + .optional() +}); + +export async function POST(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const bodyResult = await readJsonBody(req, { maxBytes: 16 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = bodySchema.safeParse(bodyResult.data); + + if (!parsed.success) { + return fail("Ungültige Eingabe", 400, parsed.error.flatten()); + } + + const settings = await getSettings([SETTING_KEYS.COMPANY_NAME]); + const result = await sendSmtpTestEmail({ + to: parsed.data.to, + companyName: settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook", + smtp: parsed.data.smtp + }); + + if (!result.ok) { + return fail(result.message ?? "SMTP-Test fehlgeschlagen", 400); + } + + return ok({ message: "SMTP-Testmail erfolgreich versendet" }); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/admin/instant-meeting/route.ts b/app/api/admin/instant-meeting/route.ts new file mode 100644 index 0000000..6e5bb7b --- /dev/null +++ b/app/api/admin/instant-meeting/route.ts @@ -0,0 +1,105 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { getSettings } from "@/lib/settings"; +import { SETTING_KEYS } from "@/lib/constants"; +import { randomToken } from "@/lib/utils"; +import { createMeetingUrlWithConfig } from "@/lib/services/meeting-links"; +import { + getInstantMeetingBootstrap, + resolveInstantMeetingSelection, + updateInstantMeetingEmailCache +} from "@/lib/services/instant-meeting"; +import { sendInstantMeetingEmails } from "@/lib/email/mailer"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +const sendSchema = z.object({ + personId: z.string().min(1), + additionalEmails: z.array(z.string().email()).max(100).default([]), + customMessage: z.string().max(4000).optional(), + subjectOverride: z.string().max(200).optional() +}); + +export async function GET() { + try { + await requireAdmin(); + const bootstrap = await getInstantMeetingBootstrap(); + return ok(bootstrap); + } catch (error) { + return handleAuthError(error); + } +} + +export async function POST(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + const session = await requireAdmin(); + const bodyResult = await readJsonBody(req, { maxBytes: 64 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = sendSchema.safeParse(bodyResult.data); + + if (!parsed.success) { + return fail("Ungültige Instant-Meeting Daten", 400, parsed.error.flatten()); + } + + const selection = await resolveInstantMeetingSelection({ + scopeType: "person", + scopeId: parsed.data.personId, + additionalEmails: parsed.data.additionalEmails + }); + + if (!selection.ok) { + return fail(selection.message, 400); + } + + const settings = await getSettings([ + SETTING_KEYS.COMPANY_NAME, + SETTING_KEYS.JITSI_MEETING_MODE, + SETTING_KEYS.JITSI_BASE_URL, + SETTING_KEYS.JITSI_ROOM_PREFIX + ]); + + const meetingUrl = createMeetingUrlWithConfig(randomToken(24), { + mode: settings[SETTING_KEYS.JITSI_MEETING_MODE], + baseUrl: settings[SETTING_KEYS.JITSI_BASE_URL], + roomPrefix: settings[SETTING_KEYS.JITSI_ROOM_PREFIX] + }); + + const companyName = (settings[SETTING_KEYS.COMPANY_NAME] || "CalBook").trim() || "CalBook"; + const sendResult = await sendInstantMeetingEmails({ + recipients: selection.recipients, + meetingUrl, + scopeLabel: selection.scopeLabel, + initiatorName: session.user.name?.trim() || "Admin", + companyName, + customMessage: parsed.data.customMessage, + subjectOverride: parsed.data.subjectOverride + }); + + if (!sendResult.ok) { + return fail(sendResult.message, 400); + } + + await updateInstantMeetingEmailCache(selection.recipients); + + return ok({ + message: "Instant Meeting erfolgreich versendet.", + meetingUrl, + sentCount: sendResult.sentCount, + recipients: selection.recipients, + scopeLabel: selection.scopeLabel + }); + } catch (error) { + if (error instanceof Error && (error.message === "UNAUTHORIZED" || error.message === "FORBIDDEN")) { + return handleAuthError(error); + } + + const message = + error instanceof Error ? error.message : "Instant Meeting konnte nicht versendet werden."; + return fail(message, 500); + } +} diff --git a/app/api/admin/kalender/[id]/route.ts b/app/api/admin/kalender/[id]/route.ts new file mode 100644 index 0000000..6029328 --- /dev/null +++ b/app/api/admin/kalender/[id]/route.ts @@ -0,0 +1,174 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { + deletePersonCalendarResource, + updatePersonCalendarResource +} from "@/lib/services/person-calendar-resources"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; +import { + deriveLegacyAvailability, + hasAtLeastOneEnabledDay, + normalizeWeekdayAvailability, + serializeWeekdayAvailability +} from "@/lib/weekday-availability"; + +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +const dayRangeSchema = z.object({ + enabled: z.boolean(), + start: z.string().regex(TIME_RE), + end: z.string().regex(TIME_RE) +}); + +const weekdayRangesSchema = z + .object({ + "0": dayRangeSchema, + "1": dayRangeSchema, + "2": dayRangeSchema, + "3": dayRangeSchema, + "4": dayRangeSchema, + "5": dayRangeSchema, + "6": dayRangeSchema + }) + .superRefine((ranges, ctx) => { + for (const day of ["0", "1", "2", "3", "4", "5", "6"] as const) { + const value = ranges[day]; + if (value.enabled && value.start >= value.end) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Ungültiges Zeitfenster", + path: [day, "end"] + }); + } + } + }); + +const updateSchema = z + .object({ + resourceName: z.string().trim().min(2).max(120).optional(), + resourceBio: z.string().trim().max(500).optional(), + isActive: z.boolean().optional(), + calendarName: z.string().trim().min(2).max(120).optional(), + bookingDayRanges: weekdayRangesSchema.optional(), + bookingAllowedWeekdays: z + .string() + .regex(/^([0-6](,[0-6])*)$/) + .optional(), + bookingDayStartTime: z + .string() + .regex(TIME_RE) + .optional(), + bookingDayEndTime: z + .string() + .regex(TIME_RE) + .optional(), + url: z.string().url().max(1024).optional(), + username: z.string().trim().min(1).max(160).optional(), + notificationEmail: z.string().trim().email().max(320).optional(), + password: z.string().min(1).max(2000).optional(), + color: z.string().trim().max(64).optional(), + syncEnabled: z.boolean().optional() + }) + .superRefine((data, ctx) => { + if (data.bookingDayRanges && !Object.values(data.bookingDayRanges).some((day) => day.enabled)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Mindestens ein Wochentag muss aktiv sein.", + path: ["bookingDayRanges", "0", "enabled"] + }); + } + + if ( + data.bookingDayStartTime && + data.bookingDayEndTime && + data.bookingDayStartTime >= data.bookingDayEndTime + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Ungültiges Zeitfenster", + path: ["bookingDayEndTime"] + }); + } + }); + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const { id } = await params; + const bodyResult = await readJsonBody(req, { maxBytes: 64 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = updateSchema.safeParse(bodyResult.data); + + if (!parsed.success) { + return fail("Ungültige Kalenderdaten", 400, parsed.error.flatten()); + } + + const updatePayload: Parameters[1] = { + resourceName: parsed.data.resourceName, + resourceBio: parsed.data.resourceBio, + isActive: parsed.data.isActive, + calendarName: parsed.data.calendarName, + bookingAllowedWeekdays: parsed.data.bookingAllowedWeekdays, + bookingDayStartTime: parsed.data.bookingDayStartTime, + bookingDayEndTime: parsed.data.bookingDayEndTime, + url: parsed.data.url, + username: parsed.data.username, + notificationEmail: parsed.data.notificationEmail, + password: parsed.data.password, + color: parsed.data.color, + syncEnabled: parsed.data.syncEnabled + }; + + if (parsed.data.bookingDayRanges) { + const normalizedRanges = normalizeWeekdayAvailability(parsed.data.bookingDayRanges); + if (!hasAtLeastOneEnabledDay(normalizedRanges)) { + return fail("Mindestens ein aktiver Wochentag mit gültiger Uhrzeit ist erforderlich.", 400); + } + const legacy = deriveLegacyAvailability(normalizedRanges); + updatePayload.bookingAllowedWeekdays = legacy.bookingAllowedWeekdays; + updatePayload.bookingDayStartTime = legacy.bookingDayStartTime; + updatePayload.bookingDayEndTime = legacy.bookingDayEndTime; + updatePayload.bookingDayRangesJson = serializeWeekdayAvailability(normalizedRanges); + } + + const resource = await updatePersonCalendarResource(id, updatePayload); + if (!resource) { + return fail("Personen-Kalender nicht gefunden", 404); + } + + return ok({ resource }); + } catch (error) { + return handleAuthError(error); + } +} + +export async function DELETE( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const { id } = await params; + const deleted = await deletePersonCalendarResource(id); + + if (!deleted) { + return fail("Personen-Kalender nicht gefunden", 404); + } + + return ok({ message: "Personen-Kalender entfernt" }); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/admin/kalender/[id]/sync/route.ts b/app/api/admin/kalender/[id]/sync/route.ts new file mode 100644 index 0000000..025ae28 --- /dev/null +++ b/app/api/admin/kalender/[id]/sync/route.ts @@ -0,0 +1,112 @@ +export const dynamic = "force-dynamic"; + +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; +import { syncCalendarConnectionWithLogger } from "@/lib/services/caldav"; +import { + appendCalendarSyncLog, + finishCalendarSyncRun, + getCalendarSyncRunWithLogs, + startCalendarSyncRun +} from "@/lib/services/caldav-sync-logs"; +import { validateMutationRequestOrigin } from "@/lib/security/request"; + +async function getConnection(id: string) { + return prisma.calendarConn.findFirst({ + where: { + id, + user: { + role: "STAFF" + } + }, + select: { id: true } + }); +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireAdmin(); + const { id } = await params; + + const connection = await getConnection(id); + if (!connection) { + return fail("Personen-Kalender nicht gefunden", 404); + } + + const { searchParams } = new URL(req.url); + const runId = searchParams.get("runId")?.trim() || undefined; + const run = await getCalendarSyncRunWithLogs({ + calendarConnId: connection.id, + runId + }); + + return ok({ run }); + } catch (error) { + return handleAuthError(error); + } +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const { id } = await params; + + const connection = await getConnection(id); + + if (!connection) { + return fail("Personen-Kalender nicht gefunden", 404); + } + + const run = await startCalendarSyncRun(connection.id); + void (async () => { + try { + await appendCalendarSyncLog(run.id, "INFO", "Sync-Job wurde gestartet."); + const result = await syncCalendarConnectionWithLogger( + connection.id, + async (level, message) => { + await appendCalendarSyncLog(run.id, level, message); + } + ); + + if (result.ok) { + await finishCalendarSyncRun( + run.id, + "SUCCESS", + `Synchronisiert: ${result.count ?? 0} Termin(e).` + ); + return; + } + + await finishCalendarSyncRun( + run.id, + "FAILED", + result.message ?? "Synchronisierung fehlgeschlagen" + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + await appendCalendarSyncLog(run.id, "ERROR", message); + await finishCalendarSyncRun(run.id, "FAILED", message); + } + })(); + + return ok( + { + message: "Kalender-Synchronisierung gestartet", + runId: run.id + }, + 202 + ); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/admin/kalender/route.ts b/app/api/admin/kalender/route.ts new file mode 100644 index 0000000..6fc3b82 --- /dev/null +++ b/app/api/admin/kalender/route.ts @@ -0,0 +1,180 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { + createPersonCalendarResource, + listPersonCalendarResources +} from "@/lib/services/person-calendar-resources"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; +import { + createWeekdayAvailabilityFromLegacy, + deriveLegacyAvailability, + hasAtLeastOneEnabledDay, + normalizeWeekdayAvailability, + serializeWeekdayAvailability +} from "@/lib/weekday-availability"; + +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +const dayRangeSchema = z.object({ + enabled: z.boolean(), + start: z.string().regex(TIME_RE), + end: z.string().regex(TIME_RE) +}); + +const weekdayRangesSchema = z + .object({ + "0": dayRangeSchema, + "1": dayRangeSchema, + "2": dayRangeSchema, + "3": dayRangeSchema, + "4": dayRangeSchema, + "5": dayRangeSchema, + "6": dayRangeSchema + }) + .superRefine((ranges, ctx) => { + for (const day of ["0", "1", "2", "3", "4", "5", "6"] as const) { + const value = ranges[day]; + if (value.enabled && value.start >= value.end) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Ungültiges Zeitfenster", + path: [day, "end"] + }); + } + } + + if (!Object.values(ranges).some((value) => value.enabled)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Mindestens ein Wochentag muss aktiv sein.", + path: ["0", "enabled"] + }); + } + }); + +const createSchema = z + .object({ + resourceName: z.string().trim().min(2).max(120), + resourceBio: z.string().trim().max(500).optional(), + isActive: z.boolean().default(true), + calendarName: z.string().trim().min(2).max(120), + bookingDayRanges: weekdayRangesSchema.optional(), + bookingAllowedWeekdays: z + .string() + .regex(/^([0-6](,[0-6])*)$/) + .optional(), + bookingDayStartTime: z.string().regex(TIME_RE).optional(), + bookingDayEndTime: z.string().regex(TIME_RE).optional(), + url: z.string().url(), + username: z.string().trim().min(1).max(160), + notificationEmail: z.string().trim().email().max(320), + password: z.string().min(1).max(2000), + color: z.string().trim().max(64).optional(), + syncEnabled: z.boolean().default(true) + }) + .superRefine((data, ctx) => { + if (data.bookingDayRanges) { + return; + } + + if (!data.bookingAllowedWeekdays) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Fehlende Wochentage", + path: ["bookingAllowedWeekdays"] + }); + } + + if (!data.bookingDayStartTime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Fehlende Startzeit", + path: ["bookingDayStartTime"] + }); + } + + if (!data.bookingDayEndTime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Fehlende Endzeit", + path: ["bookingDayEndTime"] + }); + } + + if ( + data.bookingDayStartTime && + data.bookingDayEndTime && + data.bookingDayStartTime >= data.bookingDayEndTime + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Ungültiges Zeitfenster", + path: ["bookingDayEndTime"] + }); + } + }); + +export async function GET() { + try { + await requireAdmin(); + const data = await listPersonCalendarResources(); + return ok(data); + } catch (error) { + return handleAuthError(error); + } +} + +export async function POST(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const bodyResult = await readJsonBody(req, { maxBytes: 64 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = createSchema.safeParse(bodyResult.data); + + if (!parsed.success) { + return fail("Ungültige Kalenderdaten", 400, parsed.error.flatten()); + } + + const normalizedRanges = normalizeWeekdayAvailability( + parsed.data.bookingDayRanges + ? parsed.data.bookingDayRanges + : createWeekdayAvailabilityFromLegacy( + parsed.data.bookingAllowedWeekdays ?? "0,1,2,3,4", + parsed.data.bookingDayStartTime ?? "09:00", + parsed.data.bookingDayEndTime ?? "17:00" + ) + ); + + if (!hasAtLeastOneEnabledDay(normalizedRanges)) { + return fail("Mindestens ein aktiver Wochentag mit gültiger Uhrzeit ist erforderlich.", 400); + } + + const legacy = deriveLegacyAvailability(normalizedRanges); + + const resource = await createPersonCalendarResource({ + resourceName: parsed.data.resourceName, + resourceBio: parsed.data.resourceBio, + isActive: parsed.data.isActive, + calendarName: parsed.data.calendarName, + bookingAllowedWeekdays: legacy.bookingAllowedWeekdays, + bookingDayStartTime: legacy.bookingDayStartTime, + bookingDayEndTime: legacy.bookingDayEndTime, + bookingDayRangesJson: serializeWeekdayAvailability(normalizedRanges), + url: parsed.data.url, + username: parsed.data.username, + notificationEmail: parsed.data.notificationEmail, + password: parsed.data.password, + color: parsed.data.color, + syncEnabled: parsed.data.syncEnabled + }); + return ok({ resource }, 201); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/admin/kalender/test-connection/route.ts b/app/api/admin/kalender/test-connection/route.ts new file mode 100644 index 0000000..c9b42e3 --- /dev/null +++ b/app/api/admin/kalender/test-connection/route.ts @@ -0,0 +1,43 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { fail, handleAuthError, ok } from "@/lib/api"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; +import { testCaldavConnection } from "@/lib/services/caldav"; + +const testConnectionSchema = z.object({ + url: z.string().trim().url("Bitte eine gültige CalDAV-URL eingeben"), + username: z.string().trim().min(1, "Benutzername ist erforderlich").max(160), + password: z.string().min(1, "Passwort ist erforderlich").max(2000) +}); + +export async function POST(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const bodyResult = await readJsonBody(req, { maxBytes: 16 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + + const parsed = testConnectionSchema.safeParse(bodyResult.data); + if (!parsed.success) { + return fail("Ungültige Verbindungsdaten", 400, parsed.error.flatten()); + } + + const result = await testCaldavConnection(parsed.data); + return ok({ + message: `${result.calendarCount} Kalender gefunden`, + ...result + }); + } catch (error) { + if (error instanceof Error) { + const authResponse = handleAuthError(error); + if (authResponse.status !== 500) return authResponse; + return fail(error.message || "CalDAV-Verbindung fehlgeschlagen", 502); + } + + return fail("CalDAV-Verbindung fehlgeschlagen", 502); + } +} diff --git a/app/api/admin/letzte-buchungen/route.ts b/app/api/admin/letzte-buchungen/route.ts new file mode 100644 index 0000000..1886638 --- /dev/null +++ b/app/api/admin/letzte-buchungen/route.ts @@ -0,0 +1,218 @@ +export const dynamic = "force-dynamic"; + +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { fail, handleAuthError, ok } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; +import { getSetting, setSettings } from "@/lib/settings"; +import { SETTING_KEYS } from "@/lib/constants"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +const sortSchema = z.enum([ + "date_desc", + "date_asc", + "customer_asc", + "customer_desc", + "person_asc", + "person_desc" +]); + +const actionSchema = z.object({ + id: z.string().min(1), + action: z.enum(["archive", "delete"]) +}); + +type GroupedBooking = { + key: string; + id: string; + customerFirstName: string; + customerLastName: string; + customerEmail: string; + startAt: Date; + staffNames: string[]; + staffCount: number; +}; + +function parseArchivedKeys(raw: string) { + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0); + } catch { + return []; + } +} + +function bookingKey(row: { id: string; bookingGroupId: string | null }) { + return row.bookingGroupId ?? row.id; +} + +function sortBookings(items: GroupedBooking[], sort: z.infer) { + const sorted = [...items]; + + sorted.sort((a, b) => { + if (sort === "date_desc") { + return b.startAt.getTime() - a.startAt.getTime(); + } + + if (sort === "date_asc") { + return a.startAt.getTime() - b.startAt.getTime(); + } + + if (sort === "customer_asc") { + return `${a.customerLastName} ${a.customerFirstName}`.localeCompare( + `${b.customerLastName} ${b.customerFirstName}` + ); + } + + if (sort === "customer_desc") { + return `${b.customerLastName} ${b.customerFirstName}`.localeCompare( + `${a.customerLastName} ${a.customerFirstName}` + ); + } + + if (sort === "person_asc") { + return (a.staffNames[0] ?? "").localeCompare(b.staffNames[0] ?? ""); + } + + return (b.staffNames[0] ?? "").localeCompare(a.staffNames[0] ?? ""); + }); + + return sorted; +} + +export async function GET(req: Request) { + try { + await requireAdmin(); + + const url = new URL(req.url); + const parsedSort = sortSchema.safeParse(url.searchParams.get("sort") ?? "date_desc"); + if (!parsedSort.success) { + return fail("Ungültige Sortierung", 400, parsedSort.error.flatten()); + } + + const archivedRaw = await getSetting(SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS); + const archivedSet = new Set(parseArchivedKeys(archivedRaw)); + + const rows = await prisma.appointment.findMany({ + where: { + status: "CONFIRMED" + }, + include: { + staff: { + select: { + name: true + } + } + }, + orderBy: { + startAt: "desc" + }, + take: 300 + }); + + const grouped = new Map(); + for (const row of rows) { + const key = bookingKey(row); + if (archivedSet.has(key)) continue; + + const existing = grouped.get(key); + if (!existing) { + grouped.set(key, { + key, + id: row.id, + customerFirstName: row.customerFirstName, + customerLastName: row.customerLastName, + customerEmail: row.customerEmail, + startAt: row.startAt, + staffNames: [row.staff.name], + staffCount: 1 + }); + continue; + } + + if (!existing.staffNames.includes(row.staff.name)) { + existing.staffNames.push(row.staff.name); + existing.staffCount = existing.staffNames.length; + } + } + + const sorted = sortBookings(Array.from(grouped.values()), parsedSort.data).slice(0, 50); + + return ok({ + bookings: sorted + }); + } catch (error) { + return handleAuthError(error); + } +} + +export async function PATCH(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + + const bodyResult = await readJsonBody(req, { maxBytes: 16 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = actionSchema.safeParse(bodyResult.data); + if (!parsed.success) { + return fail("Ungültige Aktion", 400, parsed.error.flatten()); + } + + const target = await prisma.appointment.findUnique({ + where: { + id: parsed.data.id + }, + select: { + id: true, + bookingGroupId: true + } + }); + + if (!target) { + return fail("Buchung nicht gefunden", 404); + } + + const key = bookingKey(target); + + if (parsed.data.action === "archive") { + const archivedRaw = await getSetting(SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS); + const archived = new Set(parseArchivedKeys(archivedRaw)); + archived.add(key); + + await setSettings({ + [SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS]: JSON.stringify(Array.from(archived)) + }); + + return ok({ message: "Buchung archiviert." }); + } + + if (target.bookingGroupId) { + await prisma.appointment.deleteMany({ + where: { + bookingGroupId: target.bookingGroupId + } + }); + } else { + await prisma.appointment.delete({ + where: { + id: target.id + } + }); + } + + const archivedRaw = await getSetting(SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS); + const archived = parseArchivedKeys(archivedRaw).filter((entry) => entry !== key); + await setSettings({ + [SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS]: JSON.stringify(archived) + }); + + return ok({ message: "Buchung gelöscht." }); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/admin/termine/route.ts b/app/api/admin/termine/route.ts new file mode 100644 index 0000000..850e3c8 --- /dev/null +++ b/app/api/admin/termine/route.ts @@ -0,0 +1,297 @@ +export const dynamic = "force-dynamic"; + +import { parseISO } from "date-fns"; +import { z } from "zod"; +import { requireAdmin } from "@/lib/auth/session"; +import { handleAuthError, fail, ok } from "@/lib/api"; +import { prisma } from "@/lib/prisma"; +import { appointmentsFilterSchema } from "@/lib/validators/admin"; +import { sendCancellationEmails } from "@/lib/email/mailer"; +import { getSetting } from "@/lib/settings"; +import { SETTING_KEYS } from "@/lib/constants"; +import { deleteEventInCaldav } from "@/lib/services/caldav"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +const patchSchema = z + .object({ + id: z.string().min(1).max(128), + status: z.enum(["CONFIRMED", "CANCELLED"]).optional(), + noShow: z.boolean().optional() + }) + .refine((data) => Boolean(data.status) || typeof data.noShow === "boolean", { + message: "status oder noShow ist erforderlich", + path: ["status"] + }); + +function resolveNotificationEmail(staff: { + email: string; + calendars: Array<{ notificationEmail: string | null }>; +}) { + const direct = staff.calendars + .map((entry) => entry.notificationEmail?.trim() ?? "") + .find((value) => value.length > 0); + return direct ?? staff.email; +} + +async function deleteCalendarEventsForAppointments( + appointments: Array<{ + id: string; + staffId: string; + calendarEventUid: string | null; + startAt: Date; + endAt: Date; + }> +) { + const deletedAppointmentIds = ( + await Promise.all( + appointments.map(async (appointment) => { + if (!appointment.calendarEventUid) return null; + + const deleted = await deleteEventInCaldav(appointment.staffId, { + eventUid: appointment.calendarEventUid, + startAt: appointment.startAt, + endAt: appointment.endAt + }); + + return deleted ? appointment.id : null; + }) + ) + ).filter((id): id is string => Boolean(id)); + + if (deletedAppointmentIds.length > 0) { + await prisma.appointment.updateMany({ + where: { + id: { + in: deletedAppointmentIds + } + }, + data: { + calendarEventUid: null + } + }); + } +} + +export async function GET(req: Request) { + try { + await requireAdmin(); + const url = new URL(req.url); + + const parsed = appointmentsFilterSchema.safeParse({ + mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + noShow: url.searchParams.get("noShow") ?? undefined, + q: url.searchParams.get("q") ?? undefined, + von: url.searchParams.get("von") ?? undefined, + bis: url.searchParams.get("bis") ?? undefined + }); + + if (!parsed.success) { + return fail("Ungültige Filter", 400, parsed.error.flatten()); + } + + const { mitarbeiterId, status, noShow, q, von, bis } = parsed.data; + + const termine = await prisma.appointment.findMany({ + where: { + ...(mitarbeiterId ? { staffId: mitarbeiterId } : {}), + ...(status ? { status } : {}), + ...(noShow === "true" + ? { noShowAt: { not: null } } + : noShow === "false" + ? { noShowAt: null } + : {}), + ...(q + ? { + OR: [ + { customerFirstName: { contains: q, mode: "insensitive" } }, + { customerLastName: { contains: q, mode: "insensitive" } }, + { customerEmail: { contains: q, mode: "insensitive" } } + ] + } + : {}), + ...(von || bis + ? { + startAt: { + ...(von ? { gte: parseISO(von) } : {}), + ...(bis ? { lte: parseISO(bis) } : {}) + } + } + : {}) + }, + include: { + staff: { + select: { + id: true, + name: true, + email: true, + slug: true + } + } + }, + orderBy: { startAt: "asc" } + }); + + return ok({ termine }); + } catch (error) { + return handleAuthError(error); + } +} + +export async function PATCH(req: Request) { + try { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + await requireAdmin(); + const bodyResult = await readJsonBody(req, { maxBytes: 32 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsedBody = patchSchema.safeParse(bodyResult.data); + if (!parsedBody.success) { + return fail("Ungültige Eingaben", 400, parsedBody.error.flatten()); + } + + const { id, status, noShow } = parsedBody.data; + + const appointment = await prisma.appointment.findUnique({ + where: { id }, + include: { staff: true } + }); + + if (!appointment) return fail("Termin nicht gefunden", 404); + + if (typeof noShow === "boolean") { + const targetAppointments = await prisma.appointment.findMany({ + where: appointment.bookingGroupId + ? { + bookingGroupId: appointment.bookingGroupId, + status: "CONFIRMED" + } + : { + id: appointment.id, + status: "CONFIRMED" + }, + select: { + id: true + } + }); + + if (targetAppointments.length === 0) { + return fail("Kein bestätigter Termin für No-Show-Markierung gefunden", 409); + } + + await prisma.appointment.updateMany({ + where: { + id: { + in: targetAppointments.map((item) => item.id) + } + }, + data: { + noShowAt: noShow ? new Date() : null + } + }); + + const refreshed = await prisma.appointment.findUnique({ + where: { id: appointment.id }, + include: { + staff: { + select: { + id: true, + name: true, + email: true, + slug: true + } + } + } + }); + + return ok({ + termin: refreshed, + betroffen: targetAppointments.length + }); + } + + const targetAppointments = await prisma.appointment.findMany({ + where: appointment.bookingGroupId + ? { + bookingGroupId: appointment.bookingGroupId, + status: "CONFIRMED" + } + : { + id: appointment.id, + status: "CONFIRMED" + }, + include: { + staff: { + select: { + name: true, + email: true, + calendars: { + select: { + notificationEmail: true + }, + orderBy: { + createdAt: "asc" + } + } + } + } + } + }); + + if (targetAppointments.length === 0) { + return fail("Termin ist bereits storniert", 409); + } + + await prisma.appointment.updateMany({ + where: { + id: { + in: targetAppointments.map((item) => item.id) + } + }, + data: { + status, + cancelledAt: status === "CANCELLED" ? new Date() : null, + ...(status === "CANCELLED" ? { noShowAt: null } : {}) + } + }); + + const updated = targetAppointments[0]!; + + if (status === "CANCELLED") { + await deleteCalendarEventsForAppointments( + targetAppointments.map((item) => ({ + id: item.id, + staffId: item.staffId, + calendarEventUid: item.calendarEventUid, + startAt: item.startAt, + endAt: item.endAt + })) + ); + + const companyName = await getSetting(SETTING_KEYS.COMPANY_NAME); + try { + await sendCancellationEmails({ + customerEmail: updated.customerEmail, + customerName: `${updated.customerFirstName} ${updated.customerLastName}`, + staffList: targetAppointments.map((item) => ({ + name: item.staff.name, + email: resolveNotificationEmail(item.staff) + })), + date: updated.startAt, + companyName + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[calbook] sendCancellationEmails(Admin) fehlgeschlagen", error); + } + } + + return ok({ + termin: updated, + betroffen: targetAppointments.length + }); + } catch (error) { + return handleAuthError(error); + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..ab71944 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,8 @@ +export const dynamic = "force-dynamic"; + +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth/options"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/cron/sync/route.ts b/app/api/cron/sync/route.ts new file mode 100644 index 0000000..0c688ff --- /dev/null +++ b/app/api/cron/sync/route.ts @@ -0,0 +1,32 @@ +export const dynamic = "force-dynamic"; + +import crypto from "crypto"; +import { fail, ok } from "@/lib/api"; +import { syncAllEnabledCalendars } from "@/lib/services/caldav"; +import { runAppointmentReminders } from "@/lib/services/reminders"; + +function safeSecretMatch(expected: string, provided?: string | null) { + if (!provided) return false; + const expectedBytes = Buffer.from(expected, "utf8"); + const providedBytes = Buffer.from(provided, "utf8"); + if (expectedBytes.length !== providedBytes.length) return false; + return crypto.timingSafeEqual(expectedBytes, providedBytes); +} + +export async function POST(req: Request) { + const secret = process.env.CRON_SECRET; + if (!secret) { + return fail("CRON_SECRET ist nicht konfiguriert", 503); + } + + const provided = req.headers.get("x-cron-secret"); + if (!safeSecretMatch(secret, provided)) { + return fail("Nicht erlaubt", 403); + } + + const [results, reminders] = await Promise.all([ + syncAllEnabledCalendars(), + runAppointmentReminders() + ]); + return ok({ results, reminders }); +} diff --git a/app/api/public/buchen/route.ts b/app/api/public/buchen/route.ts new file mode 100644 index 0000000..f098a20 --- /dev/null +++ b/app/api/public/buchen/route.ts @@ -0,0 +1,35 @@ +export const dynamic = "force-dynamic"; + +import { createAppointment } from "@/lib/services/appointments"; +import { fail, ok } from "@/lib/api"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +export async function POST(req: Request) { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + const limit = enforceRateLimit({ + req, + scope: "public-book", + limit: 20, + windowMs: 60_000 + }); + + if (!limit.ok) { + return fail("Zu viele Buchungsversuche. Bitte kurz warten.", 429, { + retryAfterSeconds: limit.retryAfterSeconds + }); + } + + const bodyResult = await readJsonBody(req, { maxBytes: 32 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + + const result = await createAppointment(bodyResult.data); + + if (!result.ok) { + return fail(result.message ?? "Buchung fehlgeschlagen", result.status ?? 400, "errors" in result ? result.errors : undefined); + } + + return ok(result.data, result.status); +} diff --git a/app/api/public/mitarbeiter/route.ts b/app/api/public/mitarbeiter/route.ts new file mode 100644 index 0000000..920a22a --- /dev/null +++ b/app/api/public/mitarbeiter/route.ts @@ -0,0 +1,75 @@ +export const dynamic = "force-dynamic"; + +import { prisma } from "@/lib/prisma"; +import { fail, ok } from "@/lib/api"; +import { getSettings } from "@/lib/settings"; +import { SETTING_KEYS } from "@/lib/constants"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { DEFAULT_TIMEZONE } from "@/lib/date"; + +export async function GET(req: Request) { + const limit = enforceRateLimit({ + req, + scope: "public-staff", + limit: 120, + windowMs: 60_000 + }); + + if (!limit.ok) { + return fail("Zu viele Anfragen. Bitte kurz warten.", 429, { + retryAfterSeconds: limit.retryAfterSeconds + }); + } + + const [mitarbeiter, settings] = await Promise.all([ + prisma.user.findMany({ + where: { + isActive: true, + role: "STAFF", + calendars: { some: {} } + }, + orderBy: { + name: "asc" + }, + select: { + id: true, + name: true, + slug: true, + bio: true, + avatarUrl: true, + timezone: true + } + }), + getSettings([ + SETTING_KEYS.COMPANY_NAME, + SETTING_KEYS.BOOKING_NOTICE_TEXT, + SETTING_KEYS.DEFAULT_DURATION_MINUTES, + SETTING_KEYS.FRONTEND_HEADER_TEXT, + SETTING_KEYS.FRONTEND_HEADER_LOGO_URL, + SETTING_KEYS.FOOTER_PRIVACY_LABEL, + SETTING_KEYS.FOOTER_PRIVACY_URL, + SETTING_KEYS.FOOTER_IMPRINT_LABEL, + SETTING_KEYS.FOOTER_IMPRINT_URL, + SETTING_KEYS.FOOTER_COPYRIGHT_TEXT + ]) + ]); + + return ok({ + mitarbeiter, + config: { + companyName: settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook", + bookingNoticeText: settings[SETTING_KEYS.BOOKING_NOTICE_TEXT] ?? "", + defaultDurationMinutes: Number(settings[SETTING_KEYS.DEFAULT_DURATION_MINUTES] ?? "60"), + headerText: settings[SETTING_KEYS.FRONTEND_HEADER_TEXT] ?? "Gespräch", + headerLogoUrl: settings[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] ?? "", + footerPrivacyLabel: settings[SETTING_KEYS.FOOTER_PRIVACY_LABEL] ?? "Datenschutz", + footerPrivacyUrl: settings[SETTING_KEYS.FOOTER_PRIVACY_URL] ?? "/datenschutz", + footerImprintLabel: settings[SETTING_KEYS.FOOTER_IMPRINT_LABEL] ?? "Impressum", + footerImprintUrl: settings[SETTING_KEYS.FOOTER_IMPRINT_URL] ?? "/impressum", + footerCopyrightText: + settings[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] ?? "© {{year}} {{companyName}}", + defaultTimezone: DEFAULT_TIMEZONE, + personCount: mitarbeiter.length + } + }); +} diff --git a/app/api/public/slots-monat/route.ts b/app/api/public/slots-monat/route.ts new file mode 100644 index 0000000..bb24ac3 --- /dev/null +++ b/app/api/public/slots-monat/route.ts @@ -0,0 +1,180 @@ +export const dynamic = "force-dynamic"; + +import { eachDayOfInterval } from "date-fns"; +import { prisma } from "@/lib/prisma"; +import { + calculateSlotsForDisplayDate, + loadSlotConfig +} from "@/lib/services/availability"; +import { monthSlotsQuerySchema } from "@/lib/validators/public"; +import { fail, ok } from "@/lib/api"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { resolveTimeZone } from "@/lib/date"; + +function getMonthDays(monat: string) { + const [yearRaw, monthRaw] = monat.split("-"); + const year = Number(yearRaw); + const month = Number(monthRaw); + + // Use UTC noon to avoid any timezone/DST rollover to adjacent dates. + const start = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + const end = new Date(Date.UTC(year, month, 0, 12, 0, 0)); + + return eachDayOfInterval({ start, end }).map((day) => day.toISOString().slice(0, 10)); +} + +function countSlotsForDay( + results: Array<{ slots: string[] }>, + requireAll: boolean +) { + if (results.length === 0) return 0; + + if (requireAll) { + const first = results[0]; + if (!first) return 0; + + const intersection = new Set(first.slots); + for (const item of results.slice(1)) { + const next = new Set(item.slots); + for (const value of Array.from(intersection)) { + if (!next.has(value)) { + intersection.delete(value); + } + } + } + return intersection.size; + } + + const unique = new Set(); + for (const item of results) { + for (const slot of item.slots) { + unique.add(slot); + } + } + return unique.size; +} + +type MonthAvailabilityCacheEntry = { + availability: Record; + expiresAt: number; +}; + +declare global { + // eslint-disable-next-line no-var + var calbookMonthAvailabilityCache: Map | undefined; +} + +const monthAvailabilityCache = + global.calbookMonthAvailabilityCache ?? new Map(); +if (!global.calbookMonthAvailabilityCache) { + global.calbookMonthAvailabilityCache = monthAvailabilityCache; +} + +const MONTH_CACHE_TTL_MS = Number(process.env.SLOTS_MONTH_CACHE_TTL_MS ?? "12000"); +const DAYS_CONCURRENCY = Math.max(1, Number(process.env.SLOTS_MONTH_CONCURRENCY ?? "4")); + +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T) => Promise +) { + const results: R[] = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (true) { + const current = nextIndex; + nextIndex += 1; + if (current >= items.length) return; + results[current] = await mapper(items[current] as T); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + return results; +} + +export async function GET(req: Request) { + const limit = enforceRateLimit({ + req, + scope: "public-slots-month", + limit: 60, + windowMs: 60_000 + }); + + if (!limit.ok) { + return fail("Zu viele Anfragen. Bitte kurz warten.", 429, { + retryAfterSeconds: limit.retryAfterSeconds + }); + } + + const url = new URL(req.url); + const parsed = monthSlotsQuerySchema.safeParse({ + mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined, + monat: url.searchParams.get("monat"), + timezone: url.searchParams.get("timezone") ?? undefined, + requireAll: url.searchParams.get("requireAll") ?? undefined + }); + + if (!parsed.success) { + return fail("Ungültige Parameter", 400, parsed.error.flatten()); + } + + const timezone = resolveTimeZone(parsed.data.timezone); + const cacheKey = [ + parsed.data.monat, + parsed.data.mitarbeiterId ?? "all", + timezone, + parsed.data.requireAll ? "all-required" : "any" + ].join("|"); + const cached = monthAvailabilityCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return ok({ availability: cached.availability }); + } + + const dayKeys = getMonthDays(parsed.data.monat); + const slotConfig = await loadSlotConfig(); + + const availability: Record = {}; + const sharedStaffIds = + parsed.data.mitarbeiterId === undefined + ? ( + await prisma.user.findMany({ + where: { + isActive: true, + role: "STAFF", + calendars: { some: {} } + }, + select: { id: true } + }) + ).map((staff) => staff.id) + : undefined; + + const pairs = await mapWithConcurrency(dayKeys, DAYS_CONCURRENCY, async (dayKey) => { + const dayResults = await calculateSlotsForDisplayDate( + parsed.data.mitarbeiterId, + dayKey, + { + displayTimezone: timezone, + staffIds: sharedStaffIds, + config: slotConfig + } + ); + const count = countSlotsForDay(dayResults, Boolean(parsed.data.requireAll)); + return { + dayKey, + count + }; + }); + + for (const pair of pairs) { + availability[pair.dayKey] = pair.count; + } + + monthAvailabilityCache.set(cacheKey, { + availability, + expiresAt: Date.now() + MONTH_CACHE_TTL_MS + }); + + return ok({ availability }); +} diff --git a/app/api/public/slots/route.ts b/app/api/public/slots/route.ts new file mode 100644 index 0000000..b527816 --- /dev/null +++ b/app/api/public/slots/route.ts @@ -0,0 +1,69 @@ +export const dynamic = "force-dynamic"; + +import { calculateSlotsForDisplayDate } from "@/lib/services/availability"; +import { slotsQuerySchema } from "@/lib/validators/public"; +import { fail, ok } from "@/lib/api"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { resolveTimeZone } from "@/lib/date"; + +type DaySlotsCacheEntry = { + slots: Awaited>; + expiresAt: number; +}; + +declare global { + // eslint-disable-next-line no-var + var calbookDaySlotsCache: Map | undefined; +} + +const daySlotsCache = global.calbookDaySlotsCache ?? new Map(); +if (!global.calbookDaySlotsCache) { + global.calbookDaySlotsCache = daySlotsCache; +} + +const DAY_SLOTS_CACHE_TTL_MS = Number(process.env.SLOTS_DAY_CACHE_TTL_MS ?? "6000"); + +export async function GET(req: Request) { + const limit = enforceRateLimit({ + req, + scope: "public-slots", + limit: 120, + windowMs: 60_000 + }); + + if (!limit.ok) { + return fail("Zu viele Anfragen. Bitte kurz warten.", 429, { + retryAfterSeconds: limit.retryAfterSeconds + }); + } + + const url = new URL(req.url); + const parsed = slotsQuerySchema.safeParse({ + mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined, + datum: url.searchParams.get("datum"), + timezone: url.searchParams.get("timezone") ?? undefined + }); + + if (!parsed.success) { + return fail("Ungültige Parameter", 400, parsed.error.flatten()); + } + + const timezone = resolveTimeZone(parsed.data.timezone); + const cacheKey = `${parsed.data.mitarbeiterId ?? "all"}|${parsed.data.datum}|${timezone}`; + const cached = daySlotsCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return ok({ slots: cached.slots }); + } + + const results = await calculateSlotsForDisplayDate( + parsed.data.mitarbeiterId, + parsed.data.datum, + { displayTimezone: timezone } + ); + daySlotsCache.set(cacheKey, { + slots: results, + expiresAt: Date.now() + DAY_SLOTS_CACHE_TTL_MS + }); + + return ok({ slots: results }); +} diff --git a/app/api/public/stornieren/route.ts b/app/api/public/stornieren/route.ts new file mode 100644 index 0000000..6b92425 --- /dev/null +++ b/app/api/public/stornieren/route.ts @@ -0,0 +1,47 @@ +export const dynamic = "force-dynamic"; + +import { cancelAppointmentByToken } from "@/lib/services/appointments"; +import { cancelSchema } from "@/lib/validators/public"; +import { fail, ok } from "@/lib/api"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request"; + +export async function GET() { + return fail("Bitte verwende POST für die Stornierung.", 405); +} + +export async function POST(req: Request) { + const originError = validateMutationRequestOrigin(req); + if (originError) return originError; + + const bodyResult = await readJsonBody(req, { maxBytes: 8 * 1024 }); + if (!bodyResult.ok) return bodyResult.response; + const parsed = cancelSchema.safeParse({ + token: (bodyResult.data as { token?: string }).token + }); + + const limit = enforceRateLimit({ + req, + scope: "public-cancel", + limit: 30, + windowMs: 60_000, + ...(parsed.success ? { keySuffix: parsed.data.token.slice(0, 12) } : {}) + }); + + if (!limit.ok) { + return fail("Zu viele Anfragen. Bitte kurz warten.", 429, { + retryAfterSeconds: limit.retryAfterSeconds + }); + } + + if (!parsed.success) { + return fail("Token fehlt oder ist ungültig", 400, parsed.error.flatten()); + } + + const result = await cancelAppointmentByToken(parsed.data.token); + if (!result.ok) { + return fail(result.message ?? "Stornierung fehlgeschlagen", result.status ?? 400); + } + + return ok({ message: "Termin erfolgreich storniert" }); +} diff --git a/app/api/public/umbuchen/route.ts b/app/api/public/umbuchen/route.ts new file mode 100644 index 0000000..ff5a300 --- /dev/null +++ b/app/api/public/umbuchen/route.ts @@ -0,0 +1,38 @@ +export const dynamic = "force-dynamic"; + +import { fail, ok } from "@/lib/api"; +import { enforceRateLimit } from "@/lib/rate-limit"; +import { cancelSchema } from "@/lib/validators/public"; +import { getRescheduleInfo } from "@/lib/services/appointments"; +import { resolveTimeZone } from "@/lib/date"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const parsed = cancelSchema.safeParse({ token: url.searchParams.get("token") }); + const timezone = resolveTimeZone(url.searchParams.get("timezone")); + + const limit = enforceRateLimit({ + req, + scope: "public-reschedule-info", + limit: 40, + windowMs: 60_000, + ...(parsed.success ? { keySuffix: parsed.data.token.slice(0, 12) } : {}) + }); + + if (!limit.ok) { + return fail("Zu viele Anfragen. Bitte kurz warten.", 429, { + retryAfterSeconds: limit.retryAfterSeconds + }); + } + + if (!parsed.success) { + return fail("Token fehlt oder ist ungültig", 400, parsed.error.flatten()); + } + + const result = await getRescheduleInfo(parsed.data.token, timezone); + if (!result.ok) { + return fail(result.message, result.status); + } + + return ok(result.data); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..451b5ca --- /dev/null +++ b/app/globals.css @@ -0,0 +1,198 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --theme-light-background: 230 33% 99%; + --theme-light-foreground: 229 40% 15%; + --theme-light-card: 0 0% 100%; + --theme-light-card-foreground: 229 40% 15%; + --theme-light-popover: 0 0% 100%; + --theme-light-popover-foreground: 229 40% 15%; + --theme-light-primary: 248 85% 60%; + --theme-light-primary-foreground: 0 0% 100%; + --theme-light-secondary: 230 30% 96%; + --theme-light-secondary-foreground: 229 40% 20%; + --theme-light-muted: 228 25% 94%; + --theme-light-muted-foreground: 227 12% 40%; + --theme-light-accent: 248 80% 96%; + --theme-light-accent-foreground: 248 55% 35%; + --theme-light-destructive: 0 84% 60%; + --theme-light-destructive-foreground: 0 0% 100%; + --theme-light-border: 230 20% 88%; + --theme-light-input: 230 20% 88%; + --theme-light-ring: 248 85% 60%; + --theme-light-glow-1: 248 85% 60%; + --theme-light-glow-2: 234 80% 72%; + --theme-light-bg-image: + radial-gradient(circle at 15% -20%, hsl(248 85% 60% / 0.16) 0%, transparent 40%), + radial-gradient(circle at 85% -15%, hsl(234 80% 72% / 0.12) 0%, transparent 35%), + linear-gradient(180deg, hsl(230 33% 99%) 0%, hsl(228 29% 97%) 100%); + --theme-light-surface-shadow: 0 12px 34px rgba(30, 41, 59, 0.08); + + --theme-dark-background: 224 28% 10%; + --theme-dark-foreground: 220 17% 97%; + --theme-dark-card: 223 25% 14%; + --theme-dark-card-foreground: 220 17% 97%; + --theme-dark-popover: 223 25% 14%; + --theme-dark-popover-foreground: 220 17% 97%; + --theme-dark-primary: 247 78% 69%; + --theme-dark-primary-foreground: 230 35% 11%; + --theme-dark-secondary: 225 18% 18%; + --theme-dark-secondary-foreground: 220 17% 95%; + --theme-dark-muted: 225 12% 20%; + --theme-dark-muted-foreground: 225 10% 70%; + --theme-dark-accent: 245 45% 25%; + --theme-dark-accent-foreground: 220 17% 97%; + --theme-dark-destructive: 0 62% 48%; + --theme-dark-destructive-foreground: 220 17% 95%; + --theme-dark-border: 225 15% 24%; + --theme-dark-input: 225 15% 24%; + --theme-dark-ring: 247 78% 69%; + --theme-dark-glow-1: 247 78% 69%; + --theme-dark-glow-2: 265 78% 66%; + --theme-dark-bg-image: + radial-gradient(circle at 20% -10%, hsl(247 78% 69% / 0.22) 0%, transparent 42%), + radial-gradient(circle at 80% -20%, hsl(265 78% 66% / 0.2) 0%, transparent 36%), + linear-gradient(180deg, hsl(224 28% 10%) 0%, hsl(228 22% 8%) 100%); + --theme-dark-surface-shadow: 0 22px 44px rgba(2, 6, 23, 0.42); + + --background: var(--theme-light-background); + --foreground: var(--theme-light-foreground); + --card: var(--theme-light-card); + --card-foreground: var(--theme-light-card-foreground); + --popover: var(--theme-light-popover); + --popover-foreground: var(--theme-light-popover-foreground); + --primary: var(--theme-light-primary); + --primary-foreground: var(--theme-light-primary-foreground); + --secondary: var(--theme-light-secondary); + --secondary-foreground: var(--theme-light-secondary-foreground); + --muted: var(--theme-light-muted); + --muted-foreground: var(--theme-light-muted-foreground); + --accent: var(--theme-light-accent); + --accent-foreground: var(--theme-light-accent-foreground); + --destructive: var(--theme-light-destructive); + --destructive-foreground: var(--theme-light-destructive-foreground); + --border: var(--theme-light-border); + --input: var(--theme-light-input); + --ring: var(--theme-light-ring); + + --bg-glow-1: var(--theme-light-glow-1); + --bg-glow-2: var(--theme-light-glow-2); + --bg-image: var(--theme-light-bg-image); + --surface-shadow: var(--theme-light-surface-shadow); + + --font-body: var(--font-inter), "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-heading: var(--font-inter), "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --radius: 1.05rem; +} + +.dark { + --background: var(--theme-dark-background, 224 28% 10%); + --foreground: var(--theme-dark-foreground, 220 17% 97%); + --card: var(--theme-dark-card, 223 25% 14%); + --card-foreground: var(--theme-dark-card-foreground, 220 17% 97%); + --popover: var(--theme-dark-popover, 223 25% 14%); + --popover-foreground: var(--theme-dark-popover-foreground, 220 17% 97%); + --primary: var(--theme-dark-primary, 247 78% 69%); + --primary-foreground: var(--theme-dark-primary-foreground, 230 35% 11%); + --secondary: var(--theme-dark-secondary, 225 18% 18%); + --secondary-foreground: var(--theme-dark-secondary-foreground, 220 17% 95%); + --muted: var(--theme-dark-muted, 225 12% 20%); + --muted-foreground: var(--theme-dark-muted-foreground, 225 10% 70%); + --accent: var(--theme-dark-accent, 245 45% 25%); + --accent-foreground: var(--theme-dark-accent-foreground, 220 17% 97%); + --destructive: var(--theme-dark-destructive, 0 62% 48%); + --destructive-foreground: var(--theme-dark-destructive-foreground, 220 17% 95%); + --border: var(--theme-dark-border, 225 15% 24%); + --input: var(--theme-dark-input, 225 15% 24%); + --ring: var(--theme-dark-ring, 247 78% 69%); + + --bg-glow-1: var(--theme-dark-glow-1, 247 78% 69%); + --bg-glow-2: var(--theme-dark-glow-2, 265 78% 66%); + --bg-image: var(--theme-dark-bg-image); + --surface-shadow: var(--theme-dark-surface-shadow); +} + +* { + @apply border-border; +} + +html, +body { + min-height: 100%; +} + +body { + @apply text-foreground antialiased; + font-family: var(--font-body); + background-color: #f8fafc; + position: relative; +} + +body::before, +body::after { + display: none; +} + +body.calbook-embed::before, +body.calbook-embed::after { + display: none; +} + +main { + position: relative; + z-index: 1; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-heading); + letter-spacing: -0.01em; +} + +::selection { + background: hsl(var(--foreground) / 0.2); +} + +a { + color: inherit; +} + +.tap-target { + min-height: 48px; + min-width: 48px; +} + +.bento-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 24px; + padding: 24px; +} + +select { + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, hsl(var(--muted-foreground)) 50%), + linear-gradient(135deg, hsl(var(--muted-foreground)) 50%, transparent 50%); + background-position: + calc(100% - 18px) calc(50% - 3px), + calc(100% - 13px) calc(50% - 3px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +input[type="checkbox"] { + accent-color: hsl(var(--foreground)); +} + +@layer utilities { + .rounded-2xl { + border-radius: calc(var(--radius) + 0.15rem); + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d143cf0 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import { Manrope, Sora } from "next/font/google"; +import "./globals.css"; +import { Providers } from "@/app/providers"; +import { buildUiAppearanceStyle } from "@/lib/ui-appearance"; +import { SETTING_KEYS } from "@/lib/constants"; +import { getSetting } from "@/lib/settings"; +import { AccentColorScript } from "@/components/layout/accent-color"; + +const manrope = Manrope({ + subsets: ["latin"], + variable: "--font-manrope" +}); + +const sora = Sora({ + subsets: ["latin"], + variable: "--font-sora" +}); + +export const metadata: Metadata = { + title: "CalBook", + description: "Moderne, mobile-first Terminbuchung auf Deutsch" +}; + +export const dynamic = "force-dynamic"; + +export default async function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + const selectedThemeId = "theme:monochrome-ink-glass"; + const appearanceStyle = buildUiAppearanceStyle({ + themeId: selectedThemeId, + bodyFontId: "font:manrope", + headingFontId: "font:sora" + }); + + let accentColor = "#4f46e5"; + try { + const color = await getSetting(SETTING_KEYS.BRANDING_ACCENT_COLOR); + if (color && /^#[0-9a-fA-F]{6}$/.test(color)) accentColor = color; + } catch { /* use default */ } + + const combinedStyle = { ...(appearanceStyle as React.CSSProperties), "--accent": accentColor }; + + return ( + + + + +
{children}
+
+ + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..fc384b4 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/buchen"); +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..fdd2e84 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { ThemeProvider } from "@/components/layout/theme-provider"; +import type { ThemeModeSetting } from "@/components/layout/theme-provider"; +import { AuthSessionProvider } from "@/components/layout/session-provider"; +import { Toaster } from "sonner"; +import { type ReactNode } from "react"; + +export function Providers({ + children, + themeMode = "light" +}: { + children: ReactNode; + themeMode?: ThemeModeSetting; +}) { + return ( + + + {children} + + + + ); +} diff --git a/components/admin/admin-nav.tsx b/components/admin/admin-nav.tsx new file mode 100644 index 0000000..d2616af --- /dev/null +++ b/components/admin/admin-nav.tsx @@ -0,0 +1,142 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + CalendarDays, + Database, + FileText, + Globe, + LayoutDashboard, + Mail, + Megaphone, + Menu, + Palette, + Settings, + Shield, + Users, + X +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { signOut } from "next-auth/react"; +import { AnimatedPage } from "@/components/layout/animated-page"; + +const NAV_ITEMS = [ + { href: "/admin/uebersicht", label: "Dashboard", icon: LayoutDashboard }, + { href: "/admin/termine", label: "Termine", icon: CalendarDays }, + { href: "/admin/kalender", label: "Kalender", icon: Users }, + { href: "/admin/email-templates", label: "E-Mails", icon: Mail }, + { href: "/admin/branding", label: "Branding", icon: Palette }, + { href: "/admin/rechtliches", label: "Rechtliches", icon: Shield }, + { href: "/admin/instant-meeting", label: "Instant Meeting", icon: Megaphone }, + { href: "/admin/backup", label: "Backup", icon: Database }, + { href: "/admin/einstellungen", label: "Einstellungen", icon: Settings } +]; + +export function AdminNav() { + const pathname = usePathname(); + + return ( + + ); +} + +export function AdminLayoutClientShell({ children }: { children: React.ReactNode }) { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const pathname = usePathname(); + + return ( +
+ + +
+ + +
+ + + +
+

admin.

+ + + {NAV_ITEMS.find((item) => pathname === item.href || pathname.startsWith(`${item.href}/`))?.label ?? "Admin"} + +
+ + {mobileMenuOpen && ( +
+
setMobileMenuOpen(false)} /> +
+
+ setMobileMenuOpen(false)}> +
+ + + +
+

admin.

+ +
+ +
+ setMobileMenuOpen(false)} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold text-slate-500 hover:text-slate-900 hover:bg-slate-50"> + Buchung + + +
+
+
+ )} + +
+
+ {children} +
+
+
+ ); +} diff --git a/components/admin/appointments-panel.tsx b/components/admin/appointments-panel.tsx new file mode 100644 index 0000000..3d7df41 --- /dev/null +++ b/components/admin/appointments-panel.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isToday, isBefore, startOfDay } from "date-fns"; +import { de } from "date-fns/locale"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { toast } from "sonner"; +import { Calendar, CheckSquare, ChevronLeft, ChevronRight, EyeOff, Search, XCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type Appointment = { + id: string; + bookingGroupId: string | null; + customerFirstName: string; + customerLastName: string; + customerEmail: string; + customerPhone: string | null; + notes: string | null; + startAt: string; + endAt: string; + status: "CONFIRMED" | "CANCELLED"; + noShowAt: string | null; + staff: { id: string; name: string; email: string; slug: string }; +}; + +type GroupedAppointment = { + key: string; + id: string; + status: "CONFIRMED" | "CANCELLED"; + noShowAt: string | null; + customerFirstName: string; + customerLastName: string; + customerEmail: string; + customerPhone: string | null; + notes: string | null; + startAt: string; + endAt: string; + staffNames: string[]; + staffCount: number; +}; + +const STATUS_TABS = [ + { value: "" as const, label: "Alle" }, + { value: "CONFIRMED" as const, label: "Bestätigt" }, + { value: "CANCELLED" as const, label: "Storniert" } +]; + +export function AppointmentsPanel() { + const [appointments, setAppointments] = useState([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState<"" | "CONFIRMED" | "CANCELLED">(""); + const [noShowFilter, setNoShowFilter] = useState(false); + const [search, setSearch] = useState(""); + const [miniMonth, setMiniMonth] = useState(new Date()); + const [cancelConfirm, setCancelConfirm] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkConfirm, setBulkConfirm] = useState<"" | "cancel" | "noshow" | null>(null); + const [busy, setBusy] = useState(false); + + async function loadAppointments() { + setLoading(true); + try { + const params = new URLSearchParams(); + if (statusFilter) params.set("status", statusFilter); + if (noShowFilter) params.set("noShow", "true"); + if (search.trim()) params.set("q", search.trim()); + const res = await fetch(`/api/admin/termine?${params.toString()}`, { cache: "no-store" }); + const data = await res.json(); + setAppointments(data.termine ?? []); + setSelectedIds(new Set()); + } catch { toast.error("Termine konnten nicht geladen werden."); } + finally { setLoading(false); } + } + + useEffect(() => { void loadAppointments(); }, [statusFilter, noShowFilter]); // eslint-disable-line react-hooks/exhaustive-deps + + const groupedAppointments = useMemo(() => { + const groups = new Map(); + for (const a of appointments) { + const key = a.bookingGroupId ?? a.id; + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + key, id: a.id, status: a.status, noShowAt: a.noShowAt, + customerFirstName: a.customerFirstName, customerLastName: a.customerLastName, + customerEmail: a.customerEmail, customerPhone: a.customerPhone, notes: a.notes, + startAt: a.startAt, endAt: a.endAt, staffNames: [a.staff.name], staffCount: 1 + }); + } else { + if (!existing.staffNames.includes(a.staff.name)) existing.staffNames.push(a.staff.name); + existing.staffCount = existing.staffNames.length; + if (!existing.noShowAt && a.noShowAt) existing.noShowAt = a.noShowAt; + } + } + return Array.from(groups.values()).sort((a, b) => a.startAt.localeCompare(b.startAt)); + }, [appointments]); + + const dayCounts = useMemo(() => { + const map = new Map(); + for (const a of appointments) map.set(format(new Date(a.startAt), "yyyy-MM-dd"), (map.get(format(new Date(a.startAt), "yyyy-MM-dd")) ?? 0) + 1); + return map; + }, [appointments]); + + const selectableAppointments = useMemo(() => + groupedAppointments.filter((a) => a.status === "CONFIRMED"), + [groupedAppointments] + ); + + async function cancelAppointment(id: string) { + const res = await fetch("/api/admin/termine", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status: "CANCELLED" }) }); + if (!res.ok) { toast.error("Termin konnte nicht storniert werden"); setCancelConfirm(null); return; } + toast.success("Termin storniert"); + setCancelConfirm(null); + await loadAppointments(); + } + + async function toggleNoShow(id: string, noShow: boolean) { + const res = await fetch("/api/admin/termine", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, noShow }) }); + if (!res.ok) { toast.error(noShow ? "No-Show konnte nicht gesetzt werden" : "No-Show konnte nicht entfernt werden"); return; } + toast.success(noShow ? "No-Show markiert" : "No-Show entfernt"); + await loadAppointments(); + } + + function toggleSelect(id: string) { + setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + } + + function selectAll() { + setSelectedIds(new Set(selectableAppointments.map((a) => a.key))); + } + + function deselectAll() { + setSelectedIds(new Set()); + } + + async function bulkAction(action: "cancel" | "noshow") { + setBusy(true); + const ids = [...selectedIds]; + let ok = 0; + let fail = 0; + for (const id of ids) { + try { + const body = action === "cancel" ? { id, status: "CANCELLED" } : { id, noShow: true }; + const res = await fetch("/api/admin/termine", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + if (res.ok) ok++; else fail++; + } catch { fail++; } + } + toast.success(`${ok} ${action === "cancel" ? "storniert" : "als No-Show markiert"}${fail > 0 ? `, ${fail} fehlgeschlagen` : ""}`); + setSelectedIds(new Set()); + setBulkConfirm(null); + setBusy(false); + await loadAppointments(); + } + + const monthStart = startOfMonth(miniMonth); + const monthEnd = endOfMonth(miniMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + const today = new Date(); + + return ( +
+
+

Termine

+

{groupedAppointments.length} Termine gefunden

+
+ +
+
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {selectableAppointments.length > 0 && ( + + )} +
+
+ + setSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && loadAppointments()} placeholder="Name oder E-Mail..." className="h-10 pl-9 w-56" /> +
+ +
+ +
+
+
+ +

{format(miniMonth, "MMMM yyyy", { locale: de })}

+ +
+
{[ "Mo","Di","Mi","Do","Fr","Sa","So" ].map((d) =>
{d}
)}
+
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const count = dayCounts.get(key) ?? 0; + const isPast = isBefore(day, startOfDay(today)); + return ( +
+ {format(day, "d")} + {count > 0 && {count}} +
+ ); + })} +
+
+ +
+ {loading ? ( +
+ ) : groupedAppointments.length === 0 ? ( +
+ +

Keine Termine gefunden

+

Neue Buchungen erscheinen hier automatisch.

+
+ ) : ( +
+ {groupedAppointments.map((a) => { + const isSelected = selectedIds.has(a.key); + const isSelectable = a.status === "CONFIRMED"; + return ( +
+
+ {isSelectable && ( + + )} +
+
+
+

{a.customerFirstName} {a.customerLastName}

+

{a.customerEmail}

+
+ + {a.status === "CANCELLED" ? "Storniert" : a.noShowAt ? "No-Show" : "Bestätigt"} + +
+
+

{format(new Date(a.startAt), "dd.MM.yyyy HH:mm", { locale: de })} – {format(new Date(a.endAt), "HH:mm", { locale: de })}

+

Personen: {a.staffNames.join(", ")}

+ {a.customerPhone &&

Tel: {a.customerPhone}

} + {a.notes &&

Notiz: {a.notes}

} +
+ {a.status === "CONFIRMED" && !isSelected && ( +
+ + {a.noShowAt ? ( + + ) : new Date(a.startAt) < new Date() ? ( + + ) : null} +
+ )} +
+
+
+ ); + })} +
+ )} +
+
+ + {/* Bulk action bar */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} ausgewählt + + + +
+ )} + + { if (cancelConfirm) void cancelAppointment(cancelConfirm); }} onCancel={() => setCancelConfirm(null)} /> + + { if (bulkConfirm) void bulkAction(bulkConfirm); }} onCancel={() => setBulkConfirm(null)} /> +
+ ); +} diff --git a/components/admin/backup-panel.tsx b/components/admin/backup-panel.tsx new file mode 100644 index 0000000..a602e30 --- /dev/null +++ b/components/admin/backup-panel.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useRef, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + Download, + HardDrive, + Loader2, + Upload, + XCircle +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type ImportStep = { + label: string; + status: "ok" | "error" | "skipped"; + detail: string; +}; + +type ImportResult = { + message: string; + importedAt: string; + steps: ImportStep[]; +}; + +export function BackupPanel() { + const [downloading, setDownloading] = useState(false); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [preview, setPreview] = useState | null>(null); + const fileInputRef = useRef(null); + + async function handleDownload() { + setDownloading(true); + try { + const res = await fetch("/api/admin/backup"); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast.error(data?.message ?? "Backup konnte nicht erstellt werden."); + return; + } + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `calbook-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast.success("Backup heruntergeladen."); + } catch { + toast.error("Backup konnte nicht erstellt werden."); + } finally { + setDownloading(false); + } + } + + function handleFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setSelectedFile(file); + setImportResult(null); + + const reader = new FileReader(); + reader.onload = () => { + try { + const data = JSON.parse(reader.result as string); + const counts: Record = {}; + if (Array.isArray(data.settings)) counts["Settings"] = data.settings.length; + if (Array.isArray(data.users)) counts["Benutzer"] = data.users.length; + if (Array.isArray(data.calendarConns)) counts["Kalender"] = data.calendarConns.length; + if (Array.isArray(data.appointments)) counts["Termine"] = data.appointments.length; + if (Array.isArray(data.busyBlocks)) counts["Sync-Daten"] = data.busyBlocks.length; + if (Array.isArray(data.deliveryIssues)) counts["Zustellfehler"] = data.deliveryIssues.length; + if (Array.isArray(data.syncRuns)) counts["Sync-Logs"] = data.syncRuns.length; + setPreview(counts); + } catch { + setPreview(null); + toast.error("Ungültige Backup-Datei."); + } + }; + reader.readAsText(file); + } + + async function handleImport() { + if (!selectedFile) return; + + setImporting(true); + setImportResult(null); + + try { + const text = await selectedFile.text(); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + toast.error("Ungültige Backup-Datei."); + setImporting(false); + return; + } + + const res = await fetch("/api/admin/backup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data) + }); + + const result = await res.json(); + if (!res.ok) { + toast.error(result?.message ?? "Import fehlgeschlagen."); + return; + } + + setImportResult(result); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + toast.success("Import abgeschlossen."); + } catch { + toast.error("Import fehlgeschlagen."); + } finally { + setImporting(false); + } + } + + return ( +
+
+

Backup

+

Daten exportieren und wiederherstellen

+
+ + {/* Export */} +
+
+ +

Export

+
+
+

+ Lädt alle Daten (Settings, Benutzer, Kalender, Termine) als JSON-Datei herunter. + Benutzer-Passwörter werden als bcrypt-Hashes gesichert – nach einem Import sind alle Logins wieder funktionsfähig. +

+ +
+
+ + {/* Import */} +
+
+ +

Import

+
+
+

+ Wähle eine zuvor exportierte Backup-Datei (.json) aus, um die Daten wiederherzustellen. + Bestehende Einträge werden aktualisiert, neue hinzugefügt. Keine Daten werden gelöscht. +

+ + {/* File picker */} +
+ + +
+ + {/* Preview */} + {preview && ( +
+

+ Vorschau – {Object.values(preview).reduce((a, b) => a + b, 0)} Einträge gefunden +

+
+ {Object.entries(preview).map(([label, count]) => ( +
+ {label} + {count} +
+ ))} +
+
+ )} + + + + {/* Result */} + {importing && ( +
+ + Import läuft... +
+ )} + + {importResult && ( +
+
+
+ {importResult.message.includes("Fehler") ? ( + + ) : ( + + )} +

{importResult.message}

+
+
+ +
+ {importResult.steps.map((step, i) => ( +
+
+ {step.status === "ok" ? ( + + ) : step.status === "error" ? ( + + ) : ( +
+ )} +
+
+

+ {step.label} +

+

+ {step.detail} +

+
+ + {step.status === "ok" ? "OK" : step.status === "error" ? "FEHLER" : "–"} + +
+ ))} +
+
+ )} + + {importResult === null && !importing && selectedFile && !preview && ( +
+ Datei konnte nicht als Backup erkannt werden. +
+ )} +
+
+
+ ); +} diff --git a/components/admin/branding-panel.tsx b/components/admin/branding-panel.tsx new file mode 100644 index 0000000..143ea49 --- /dev/null +++ b/components/admin/branding-panel.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PublicFooter } from "@/components/layout/public-footer"; +import { SETTING_KEYS } from "@/lib/constants"; +import { toast } from "sonner"; +import { Eye, Save } from "lucide-react"; + +function isValidFooterUrl(value: string) { + if (!value) return true; + if (value.startsWith("/")) return true; + return /^https?:\/\//i.test(value); +} + +const brandingSchema = z.object({ + frontend_header_text: z.string().trim().min(1, "Bitte einen Header-Text eingeben").max(80), + frontend_header_logo_url: z.string().trim().refine((v) => v === "" || /^https?:\/\//i.test(v), "Gültige URL mit http:// oder https://"), + footer_privacy_label: z.string().trim().max(60), + footer_privacy_url: z.string().trim().refine(isValidFooterUrl, "URL muss mit /, http:// oder https:// beginnen"), + footer_imprint_label: z.string().trim().max(60), + footer_imprint_url: z.string().trim().refine(isValidFooterUrl, "URL muss mit /, http:// oder https:// beginnen"), + footer_copyright_text: z.string().trim().min(1, "Bitte Copyright-Text eingeben").max(180), + branding_accent_color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/, "Hex-Farbe, z.B. #4f46e5") +}); + +type BrandingFormValues = z.infer; + +export function BrandingPanel() { + const [loading, setLoading] = useState(true); + const [companyName, setCompanyName] = useState("CalBook"); + + const form = useForm({ + resolver: zodResolver(brandingSchema), + defaultValues: { + frontend_header_text: "Gespräch", + frontend_header_logo_url: "", + footer_privacy_label: "Datenschutz", + footer_privacy_url: "/datenschutz", + footer_imprint_label: "Impressum", + footer_imprint_url: "/impressum", + footer_copyright_text: "© {{year}} {{companyName}}", + branding_accent_color: "#4f46e5" + } + }); + + const headerText = form.watch("frontend_header_text"); + const headerLogoUrl = form.watch("frontend_header_logo_url"); + const accentColor = form.watch("branding_accent_color"); + const privacyLabel = form.watch("footer_privacy_label"); + const privacyUrl = form.watch("footer_privacy_url"); + const imprintLabel = form.watch("footer_imprint_label"); + const imprintUrl = form.watch("footer_imprint_url"); + const copyrightText = form.watch("footer_copyright_text"); + + useEffect(() => { + async function load() { + setLoading(true); + try { + const res = await fetch("/api/admin/einstellungen", { cache: "no-store" }); + const data = await res.json(); + const s = (data.settings ?? {}) as Record; + form.reset({ + frontend_header_text: s[SETTING_KEYS.FRONTEND_HEADER_TEXT] ?? "Gespräch", + frontend_header_logo_url: s[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] ?? "", + footer_privacy_label: s[SETTING_KEYS.FOOTER_PRIVACY_LABEL] ?? "Datenschutz", + footer_privacy_url: s[SETTING_KEYS.FOOTER_PRIVACY_URL] ?? "/datenschutz", + footer_imprint_label: s[SETTING_KEYS.FOOTER_IMPRINT_LABEL] ?? "Impressum", + footer_imprint_url: s[SETTING_KEYS.FOOTER_IMPRINT_URL] ?? "/impressum", + footer_copyright_text: s[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] ?? "© {{year}} {{companyName}}", + branding_accent_color: s[SETTING_KEYS.BRANDING_ACCENT_COLOR] ?? "#4f46e5" + }); + setCompanyName(s[SETTING_KEYS.COMPANY_NAME] ?? "CalBook"); + } catch { toast.error("Branding-Einstellungen konnten nicht geladen werden."); } + finally { setLoading(false); } + } + void load(); + }, [form]); + + const onSubmit = form.handleSubmit(async (values) => { + const res = await fetch("/api/admin/einstellungen", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + values: { + [SETTING_KEYS.FRONTEND_HEADER_TEXT]: values.frontend_header_text.trim(), + [SETTING_KEYS.FRONTEND_HEADER_LOGO_URL]: values.frontend_header_logo_url.trim(), + [SETTING_KEYS.FOOTER_PRIVACY_LABEL]: values.footer_privacy_label.trim(), + [SETTING_KEYS.FOOTER_PRIVACY_URL]: values.footer_privacy_url.trim(), + [SETTING_KEYS.FOOTER_IMPRINT_LABEL]: values.footer_imprint_label.trim(), + [SETTING_KEYS.FOOTER_IMPRINT_URL]: values.footer_imprint_url.trim(), + [SETTING_KEYS.FOOTER_COPYRIGHT_TEXT]: values.footer_copyright_text.trim(), + [SETTING_KEYS.BRANDING_ACCENT_COLOR]: values.branding_accent_color.trim() + } + }) + }); + if (!res.ok) { toast.error("Branding-Einstellungen konnten nicht gespeichert werden."); return; } + toast.success("Branding gespeichert."); + }, () => { toast.error("Bitte prüfe die Eingaben."); }); + + if (loading) return null; + + return ( +
+
+
+

Branding

+

Header, Footer und Logo

+
+
+ +
+ {/* Header */} +
+
+ +

Frontend-Header

+
+
+
+ + + {form.formState.errors.frontend_header_text &&

{form.formState.errors.frontend_header_text.message}

} +
+
+ + +

Leer = Standard-Icon.

+
+
+
+ +
+ { + form.setValue("branding_accent_color", e.target.value); + document.documentElement.style.setProperty("--accent", e.target.value); + }} + className="h-10 w-10 rounded-lg border border-slate-200 cursor-pointer" + /> + { + form.setValue("branding_accent_color", e.target.value); + document.documentElement.style.setProperty("--accent", e.target.value); + }} + /> +
+

Schritt-Nummern, Logo-Icon, Diagramme.

+
+
+
+

Vorschau

+
+ {headerLogoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Logo + ) : ( +
+ )} +

{headerText || "Gespräch"}

+
+
+
+
+ + {/* Footer */} +
+
+ +

Frontend-Footer

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +

{"Platzhalter: {{year}}, {{companyName}}"}

+
+
+

Vorschau Footer

+
+ +
+
+
+
+ +
+ +
+ +
+ ); +} diff --git a/components/admin/calendar-person-panel.tsx b/components/admin/calendar-person-panel.tsx new file mode 100644 index 0000000..a8d0e45 --- /dev/null +++ b/components/admin/calendar-person-panel.tsx @@ -0,0 +1,1005 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "sonner"; +import { + Calendar, + CalendarCheck, + CalendarX, + CheckCircle2, + ChevronDown, + ChevronRight, + Globe, + Link, + Loader2, + MoreHorizontal, + Pencil, + Plus, + Power, + PowerOff, + RefreshCw, + Save, + Settings, + Terminal, + Trash2, + UserPlus, + X +} from "lucide-react"; +import { + createDefaultWeekdayAvailability, + createWeekdayAvailabilityFromLegacy, + hasAtLeastOneEnabledDay, + isValidTimeValue, + parseWeekdayAvailabilityJson, + type WeekdayAvailability, + type WeekdayKey +} from "@/lib/weekday-availability"; +import { cn } from "@/lib/utils"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; + +type PersonCalendarResource = { + id: string; + personId: string; + personName: string; + personBio: string | null; + isActive: boolean; + upcomingAppointments: number; + calendarName: string; + bookingAllowedWeekdays: string; + bookingDayStartTime: string; + bookingDayEndTime: string; + bookingDayRangesJson: string | null; + url: string; + username: string; + notificationEmail: string | null; + color: string | null; + syncEnabled: boolean; + lastSyncedAt: string | null; + syncError: string | null; + createdAt: string; +}; + +type ResourceResponse = { + resources: PersonCalendarResource[]; + meta: { employeeCount: number }; +}; + +type SyncRunStatus = "RUNNING" | "SUCCESS" | "FAILED"; + +type SyncLogEntry = { + id: string; + level: string; + message: string; + createdAt: string; +}; + +type SyncRun = { + id: string; + calendarConnId: string; + status: SyncRunStatus; + message: string | null; + startedAt: string; + finishedAt: string | null; + entries: SyncLogEntry[]; +}; + +type SyncRunResponse = { + run: SyncRun | null; +}; + +type CalendarDraft = { + resourceName: string; + resourceBio: string; + calendarName: string; + calendarUrl: string; + calendarUsername: string; + notificationEmail: string; + calendarPassword: string; + syncEnabled: boolean; + resourceActive: boolean; + availability: WeekdayAvailability; +}; + +type ConnectionTestState = { + status: "idle" | "loading" | "success" | "error"; + message: string; + calendars: Array<{ name: string; url: string }>; +}; + +const WEEKDAY_OPTIONS = [ + { value: "0", label: "Mo" }, + { value: "1", label: "Di" }, + { value: "2", label: "Mi" }, + { value: "3", label: "Do" }, + { value: "4", label: "Fr" }, + { value: "5", label: "Sa" }, + { value: "6", label: "So" } +] as const; + +const SETUP_STEPS = ["Person", "Verbindung", "Verfügbarkeit", "Prüfen"] as const; + +function createEmptyDraft(): CalendarDraft { + return { + resourceName: "", + resourceBio: "", + calendarName: "", + calendarUrl: "", + calendarUsername: "", + notificationEmail: "", + calendarPassword: "", + syncEnabled: true, + resourceActive: true, + availability: createDefaultWeekdayAvailability() + }; +} + +function emptyConnectionTest(): ConnectionTestState { + return { status: "idle", message: "Noch nicht getestet.", calendars: [] }; +} + +function getAvailabilityFromResource(resource: PersonCalendarResource): WeekdayAvailability { + const fallback = createWeekdayAvailabilityFromLegacy( + resource.bookingAllowedWeekdays, + resource.bookingDayStartTime, + resource.bookingDayEndTime + ); + return parseWeekdayAvailabilityJson(resource.bookingDayRangesJson, fallback); +} + +function updateDayState( + availability: WeekdayAvailability, + day: WeekdayKey, + patch: Partial +) { + return { ...availability, [day]: { ...availability[day], ...patch } }; +} + +function hasInvalidEnabledRanges(availability: WeekdayAvailability) { + return WEEKDAY_OPTIONS.some((option) => { + const day = availability[option.value]; + return day.enabled && (!isValidTimeValue(day.start) || !isValidTimeValue(day.end) || day.start >= day.end); + }); +} + +function formatAvailabilitySummary(availability: WeekdayAvailability) { + const parts = WEEKDAY_OPTIONS.filter((option) => availability[option.value].enabled).map( + (option) => { + const range = availability[option.value]; + return `${option.label} ${range.start}-${range.end}`; + } + ); + return parts.length > 0 ? parts.join(" · ") : "Keine aktiven Tage"; +} + +function runStatusLabel(status: SyncRunStatus) { + if (status === "RUNNING") return "Läuft"; + if (status === "SUCCESS") return "Erfolgreich"; + return "Fehlgeschlagen"; +} + +function runStatusClass(status: SyncRunStatus) { + if (status === "RUNNING") return "bg-amber-100 text-amber-800"; + if (status === "SUCCESS") return "bg-emerald-100 text-emerald-800"; + return "bg-red-100 text-red-800"; +} + +function logLevelClass(level: string) { + const normalized = level.toUpperCase(); + if (normalized === "ERROR") return "text-red-300"; + if (normalized === "WARN") return "text-amber-300"; + return "text-slate-200"; +} + +function draftFromResource(resource: PersonCalendarResource): CalendarDraft { + return { + resourceName: resource.personName, + resourceBio: resource.personBio ?? "", + calendarName: resource.calendarName, + calendarUrl: resource.url, + calendarUsername: resource.username, + notificationEmail: resource.notificationEmail ?? "", + calendarPassword: "", + syncEnabled: resource.syncEnabled, + resourceActive: resource.isActive, + availability: getAvailabilityFromResource(resource) + }; +} + +function AvailabilityEditor({ + value, + onChange +}: { + value: WeekdayAvailability; + onChange: (next: WeekdayAvailability) => void; +}) { + return ( +
+ {WEEKDAY_OPTIONS.map((option) => { + const day = value[option.value]; + return ( +
+ + onChange(updateDayState(value, option.value, { start: e.target.value }))} + className="h-9 text-xs" + /> + onChange(updateDayState(value, option.value, { end: e.target.value }))} + className="h-9 text-xs" + /> +
+ ); + })} +
+ ); +} + +function ConnectionTestBox({ state }: { state: ConnectionTestState }) { + return ( +
+
+ {state.status === "loading" && } + {state.status === "success" && } + {state.status === "error" && } +

{state.status === "loading" ? "Verbindung wird getestet ..." : state.message}

+
+ {state.calendars.length > 0 && ( +
    + {state.calendars.map((c) => ( +
  • + {c.name}{c.url ? ` — ${c.url}` : ""} +
  • + ))} +
+ )} +
+ ); +} + +function validateDraft(draft: CalendarDraft, options: { passwordRequired: boolean }) { + if (!draft.resourceName.trim()) { toast.error("Bitte einen Personennamen eintragen."); return false; } + if (!draft.calendarUrl.trim() || !draft.calendarUsername.trim()) { toast.error("Bitte CalDAV-URL und Benutzername eintragen."); return false; } + if (options.passwordRequired && !draft.calendarPassword.trim()) { toast.error("Bitte das CalDAV-Passwort eintragen."); return false; } + if (!draft.notificationEmail.trim()) { toast.error("Bitte eine Benachrichtigungs-E-Mail eintragen."); return false; } + if (!hasAtLeastOneEnabledDay(draft.availability)) { toast.error("Bitte mindestens einen verfügbaren Wochentag aktivieren."); return false; } + if (hasInvalidEnabledRanges(draft.availability)) { toast.error("Bitte pro aktivem Tag ein gültiges Zeitfenster setzen (Von < Bis)."); return false; } + return true; +} + +export function CalendarPersonPanel() { + const [resources, setResources] = useState([]); + const [employeeCount, setEmployeeCount] = useState(0); + const [loading, setLoading] = useState(true); + + const [wizardOpen, setWizardOpen] = useState(false); + const [createDraft, setCreateDraft] = useState(createEmptyDraft); + const [createStep, setCreateStep] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [connectionTest, setConnectionTest] = useState(emptyConnectionTest); + + const [expandedResource, setExpandedResource] = useState(null); + const [editingResource, setEditingResource] = useState(null); + const [editDraft, setEditDraft] = useState(null); + const [editSaving, setEditSaving] = useState(false); + const [editConnectionTest, setEditConnectionTest] = useState(emptyConnectionTest); + + const [syncRunByResource, setSyncRunByResource] = useState>({}); + const [logOpenByResource, setLogOpenByResource] = useState>({}); + const [activeRunIdByResource, setActiveRunIdByResource] = useState>({}); + const [syncingByResource, setSyncingByResource] = useState>({}); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + async function loadResources() { + setLoading(true); + try { + const res = await fetch("/api/admin/kalender", { cache: "no-store" }); + const data = (await res.json()) as ResourceResponse & { message?: string }; + if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnten nicht geladen werden."); return; } + setResources(data.resources ?? []); + setEmployeeCount(data.meta?.employeeCount ?? 0); + } catch { toast.error("Personen-Kalender konnten nicht geladen werden."); } + finally { setLoading(false); } + } + + useEffect(() => { void loadResources(); }, []); + + useEffect(() => { + const activeEntries = Object.entries(activeRunIdByResource); + if (activeEntries.length === 0) return; + const timer = window.setInterval(() => { + for (const [resourceId, runId] of activeEntries) { + void fetchSyncRun(resourceId, runId, true); + } + }, 1000); + return () => window.clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeRunIdByResource]); + + function updateCreateDraft(patch: Partial) { + setCreateDraft((prev) => ({ ...prev, ...patch })); + if ("calendarUrl" in patch || "calendarUsername" in patch || "calendarPassword" in patch) { + setConnectionTest(emptyConnectionTest()); + } + } + + function updateEditDraft(patch: Partial) { + setEditDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + if ("calendarUrl" in patch || "calendarUsername" in patch || "calendarPassword" in patch) { + setEditConnectionTest(emptyConnectionTest()); + } + } + + async function fetchSyncRun(resourceId: string, runId?: string, silent = false) { + try { + const query = runId ? `?runId=${encodeURIComponent(runId)}` : ""; + const res = await fetch(`/api/admin/kalender/${resourceId}/sync${query}`, { cache: "no-store" }); + const data = (await res.json()) as SyncRunResponse & { message?: string }; + if (!res.ok) { if (!silent) toast.error(data?.message ?? "Sync-Log konnte nicht geladen werden."); return; } + const run = data.run; + setSyncRunByResource((prev) => ({ ...prev, [resourceId]: run })); + if (run?.status === "RUNNING") { + setActiveRunIdByResource((prev) => ({ ...prev, [resourceId]: run.id })); + return; + } + setActiveRunIdByResource((prev) => { const next = { ...prev }; delete next[resourceId]; return next; }); + if (run) await loadResources(); + } catch { if (!silent) toast.error("Sync-Log konnte nicht geladen werden."); } + } + + async function testConnectionForDraft(draft: CalendarDraft, setState: (s: ConnectionTestState) => void) { + if (!draft.calendarUrl.trim() || !draft.calendarUsername.trim() || !draft.calendarPassword.trim()) { + toast.error("Bitte URL, Benutzername und Passwort für den Verbindungstest eintragen."); + return; + } + setState({ status: "loading", message: "Verbindung wird getestet ...", calendars: [] }); + try { + const res = await fetch("/api/admin/kalender/test-connection", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: draft.calendarUrl.trim(), username: draft.calendarUsername.trim(), password: draft.calendarPassword }) + }); + const data = (await res.json()) as { message?: string; calendarCount?: number; calendars?: Array<{ name: string; url: string }> }; + if (!res.ok) { setState({ status: "error", message: data?.message ?? "CalDAV-Verbindung fehlgeschlagen.", calendars: [] }); return; } + setState({ status: "success", message: data?.message ?? "Verbindung erfolgreich getestet.", calendars: data.calendars ?? [] }); + } catch { setState({ status: "error", message: "CalDAV-Verbindung fehlgeschlagen.", calendars: [] }); } + } + + function goToNextCreateStep() { + if (createStep === 0) { if (!createDraft.resourceName.trim() || !createDraft.notificationEmail.trim()) { toast.error("Bitte Personenname und Benachrichtigungs-E-Mail eintragen."); return; } } + if (createStep === 1) { if (!createDraft.calendarUrl.trim() || !createDraft.calendarUsername.trim() || !createDraft.calendarPassword.trim()) { toast.error("Bitte CalDAV-URL, Benutzername und Passwort eintragen."); return; } } + if (createStep === 2) { if (!hasAtLeastOneEnabledDay(createDraft.availability) || hasInvalidEnabledRanges(createDraft.availability)) { toast.error("Bitte gültige Verfügbarkeiten eintragen."); return; } } + setCreateStep((prev) => Math.min(prev + 1, SETUP_STEPS.length - 1)); + } + + async function createResource() { + if (!validateDraft(createDraft, { passwordRequired: true })) return; + setSubmitting(true); + try { + const res = await fetch("/api/admin/kalender", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + resourceName: createDraft.resourceName.trim(), + resourceBio: createDraft.resourceBio.trim() || undefined, + isActive: createDraft.resourceActive, + calendarName: createDraft.calendarName.trim() || createDraft.resourceName.trim(), + bookingDayRanges: createDraft.availability, + url: createDraft.calendarUrl.trim(), + username: createDraft.calendarUsername.trim(), + notificationEmail: createDraft.notificationEmail.trim().toLowerCase(), + password: createDraft.calendarPassword, + syncEnabled: createDraft.syncEnabled + }) + }); + const data = await res.json(); + if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnte nicht erstellt werden."); return; } + setCreateDraft(createEmptyDraft()); + setCreateStep(0); + setConnectionTest(emptyConnectionTest()); + setWizardOpen(false); + toast.success("Personen-Kalender angelegt"); + await loadResources(); + } finally { setSubmitting(false); } + } + + function startEdit(resource: PersonCalendarResource) { + setEditingResource(resource); + setEditDraft(draftFromResource(resource)); + setEditConnectionTest({ + status: "idle", + message: "Zum Testen gespeicherter Zugangsdaten bitte ein neues Passwort eintragen oder den Sync ausführen.", + calendars: [] + }); + } + + function cancelEdit() { + setEditingResource(null); + setEditDraft(null); + setEditConnectionTest(emptyConnectionTest()); + } + + async function saveEdit() { + if (!editingResource || !editDraft) return; + if (!validateDraft(editDraft, { passwordRequired: false })) return; + setEditSaving(true); + try { + const payload: Record = { + resourceName: editDraft.resourceName.trim(), + resourceBio: editDraft.resourceBio, + isActive: editDraft.resourceActive, + calendarName: editDraft.calendarName.trim() || editDraft.resourceName.trim(), + bookingDayRanges: editDraft.availability, + url: editDraft.calendarUrl.trim(), + username: editDraft.calendarUsername.trim(), + notificationEmail: editDraft.notificationEmail.trim().toLowerCase(), + syncEnabled: editDraft.syncEnabled + }; + if (editDraft.calendarPassword.trim()) payload.password = editDraft.calendarPassword; + const res = await fetch(`/api/admin/kalender/${editingResource.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnte nicht gespeichert werden."); return; } + toast.success("Personen-Kalender gespeichert"); + cancelEdit(); + await loadResources(); + } finally { setEditSaving(false); } + } + + async function syncResource(id: string) { + setSyncingByResource((prev) => ({ ...prev, [id]: true })); + try { + const res = await fetch(`/api/admin/kalender/${id}/sync`, { method: "POST" }); + const data = (await res.json()) as { message?: string; runId?: string }; + if (!res.ok || !data.runId) { toast.error(data?.message ?? "Sync fehlgeschlagen"); return; } + setLogOpenByResource((prev) => ({ ...prev, [id]: true })); + setActiveRunIdByResource((prev) => ({ ...prev, [id]: data.runId as string })); + toast.success("Synchronisierung gestartet"); + await fetchSyncRun(id, data.runId, true); + } finally { setSyncingByResource((prev) => ({ ...prev, [id]: false })); } + } + + async function toggleLog(resourceId: string) { + const isOpen = logOpenByResource[resourceId] === true; + if (isOpen) { setLogOpenByResource((prev) => ({ ...prev, [resourceId]: false })); return; } + setLogOpenByResource((prev) => ({ ...prev, [resourceId]: true })); + await fetchSyncRun(resourceId, activeRunIdByResource[resourceId], false); + } + + async function toggleResourceActive(resource: PersonCalendarResource) { + const res = await fetch(`/api/admin/kalender/${resource.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive: !resource.isActive }) + }); + const data = await res.json(); + if (!res.ok) { toast.error(data?.message ?? "Status konnte nicht geändert werden."); return; } + toast.success(resource.isActive ? "Person deaktiviert" : "Person aktiviert"); + await loadResources(); + } + + async function toggleSync(resource: PersonCalendarResource) { + const res = await fetch(`/api/admin/kalender/${resource.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ syncEnabled: !resource.syncEnabled }) + }); + const data = await res.json(); + if (!res.ok) { toast.error(data?.message ?? "Sync-Status konnte nicht geändert werden."); return; } + toast.success(resource.syncEnabled ? "Sync deaktiviert" : "Sync aktiviert"); + await loadResources(); + } + + async function deleteResource(resource: PersonCalendarResource) { + setDeleteConfirm(resource); + } + + async function execDelete() { + if (!deleteConfirm) return; + const res = await fetch(`/api/admin/kalender/${deleteConfirm.id}`, { method: "DELETE" }); + const data = await res.json(); + if (!res.ok) { toast.error(data?.message ?? "Personen-Kalender konnte nicht entfernt werden."); setDeleteConfirm(null); return; } + toast.success("Personen-Kalender entfernt"); + setDeleteConfirm(null); + await loadResources(); + } + + const activeResources = resources.filter((r) => r.isActive).length; + + return ( +
+ {/* Header */} +
+
+

Kalender-Personen

+

+ {activeResources} von {resources.length} {resources.length === 1 ? "Person" : "Personen"} aktiv ·{" "} + {employeeCount} buchbare {employeeCount === 1 ? "Kapazität" : "Kapazitäten"} +

+
+ {!wizardOpen && ( + + )} +
+ + {/* Setup Wizard (collapsible) */} + {wizardOpen && ( +
+
+
+ +

Neue Person anlegen

+
+ +
+ +
+ {/* Step indicators */} +
+ {SETUP_STEPS.map((step, index) => ( + + ))} +
+ + {/* Step 0: Person */} + {createStep === 0 && ( +
+
+ + updateCreateDraft({ resourceName: e.target.value })} placeholder="z. B. Jonas Keil" /> +
+
+ + updateCreateDraft({ calendarName: e.target.value })} placeholder="Leer = Personenname" /> +
+
+ + updateCreateDraft({ notificationEmail: e.target.value })} placeholder="name@beispiel.de" /> +
+
+ +