From ee48a938249b42a3613e29ed38cc07ad4837f847 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 7 May 2026 13:04:02 +0200 Subject: [PATCH] feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure --- .dockerignore | 18 + .env.example | 68 + .eslintignore | 7 + .eslintrc.json | 3 + .gitignore | 18 + .npmrc | 1 + Dockerfile | 33 + Makefile | 37 + app/(admin)/admin/backup/page.tsx | 7 + app/(admin)/admin/branding/page.tsx | 7 + app/(admin)/admin/einstellungen/page.tsx | 7 + app/(admin)/admin/email-templates/page.tsx | 7 + app/(admin)/admin/instant-meeting/page.tsx | 7 + app/(admin)/admin/kalender/page.tsx | 7 + app/(admin)/admin/layout.tsx | 26 + app/(admin)/admin/page.tsx | 5 + app/(admin)/admin/rechtliches/page.tsx | 7 + app/(admin)/admin/termine/page.tsx | 7 + app/(admin)/admin/uebersicht/page.tsx | 156 + app/(admin)/loading.tsx | 26 + app/(auth)/anmelden/login-form.tsx | 69 + app/(auth)/anmelden/page.tsx | 52 + app/(public)/buchen/[slug]/page.tsx | 28 + app/(public)/buchen/page.tsx | 24 + app/(public)/datenschutz/page.tsx | 5 + app/(public)/impressum/page.tsx | 5 + app/(public)/stornieren/page.tsx | 38 + app/api/admin/backup/route.ts | 286 + app/api/admin/einstellungen/route.ts | 38 + .../admin/einstellungen/test-smtp/route.ts | 67 + app/api/admin/instant-meeting/route.ts | 105 + app/api/admin/kalender/[id]/route.ts | 174 + app/api/admin/kalender/[id]/sync/route.ts | 112 + app/api/admin/kalender/route.ts | 180 + .../admin/kalender/test-connection/route.ts | 43 + app/api/admin/letzte-buchungen/route.ts | 218 + app/api/admin/termine/route.ts | 297 + app/api/auth/[...nextauth]/route.ts | 8 + app/api/cron/sync/route.ts | 32 + app/api/public/buchen/route.ts | 35 + app/api/public/mitarbeiter/route.ts | 75 + app/api/public/slots-monat/route.ts | 180 + app/api/public/slots/route.ts | 69 + app/api/public/stornieren/route.ts | 47 + app/api/public/umbuchen/route.ts | 38 + app/globals.css | 198 + app/layout.tsx | 62 + app/page.tsx | 5 + app/providers.tsx | 24 + components/admin/admin-nav.tsx | 142 + components/admin/appointments-panel.tsx | 309 + components/admin/backup-panel.tsx | 312 + components/admin/branding-panel.tsx | 231 + components/admin/calendar-person-panel.tsx | 1005 +++ components/admin/email-templates-panel.tsx | 1668 ++++ components/admin/footer-settings-panel.tsx | 225 + components/admin/header-settings-panel.tsx | 170 + components/admin/instant-meeting-panel.tsx | 243 + components/admin/latest-bookings-panel.tsx | 253 + .../admin/legal-pages-settings-panel.tsx | 170 + components/admin/settings-panel.tsx | 720 ++ components/booking/cancel-form.tsx | 143 + components/booking/embed-mode.tsx | 24 + components/booking/public-booking-flow.tsx | 1652 ++++ components/booking/shared-legal-page.tsx | 55 + components/layout/accent-color.tsx | 11 + components/layout/animated-page.tsx | 73 + components/layout/legal-content-card.tsx | 38 + components/layout/public-footer.tsx | 58 + components/layout/session-provider.tsx | 8 + components/layout/theme-provider.tsx | 34 + components/ui/button.tsx | 41 + components/ui/card.tsx | 26 + components/ui/confirm-dialog.tsx | 61 + components/ui/input.tsx | 20 + components/ui/label.tsx | 15 + components/ui/skeleton.tsx | 5 + components/ui/textarea.tsx | 22 + deploy.sh | 444 ++ design.md | 121 + docker-compose.direct.yml | 50 + docker-compose.proxy.yml | 65 + instrumentation.ts | 7 + lib/all-timezones.ts | 431 ++ lib/api.ts | 21 + lib/auth/options.ts | 87 + lib/auth/session.ts | 18 + lib/constants.ts | 165 + lib/crypto.ts | 48 + lib/date.ts | 129 + lib/email/mailer.ts | 815 ++ lib/email/shortcodes.ts | 21 + lib/email/style-presets.ts | 182 + lib/email/style-renderer.ts | 353 + lib/email/template-engine.ts | 29 + lib/prisma.ts | 16 + lib/public-booking-config.ts | 55 + lib/public-url.ts | 31 + lib/rate-limit.ts | 170 + lib/security/config-guard.ts | 169 + lib/security/request.ts | 118 + lib/services/appointments.ts | 559 ++ lib/services/availability.ts | 329 + lib/services/caldav-sync-logs.ts | 95 + lib/services/caldav.ts | 702 ++ lib/services/cron.ts | 24 + lib/services/delivery-issues.ts | 89 + lib/services/instant-meeting.ts | 380 + lib/services/meeting-links.ts | 62 + lib/services/person-calendar-resources.ts | 365 + lib/services/reminders.ts | 191 + lib/services/retry.ts | 65 + lib/settings.ts | 109 + lib/ui-appearance.ts | 813 ++ lib/utils.ts | 27 + lib/validators/admin.ts | 54 + lib/validators/public.ts | 52 + lib/weekday-availability.ts | 160 + next-env.d.ts | 6 + next.config.mjs | 14 + package-lock.json | 6883 +++++++++++++++++ package.json | 59 + postcss.config.js | 6 + prisma/migrations/000001_init/migration.sql | 212 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 169 + prisma/seed.ts | 64 + scripts/export-backup.sh | 118 + scripts/run-sync.ts | 28 + scripts/test-timezone-dst.ts | 48 + tailwind.config.ts | 65 + tsconfig.json | 24 + types/next-auth.d.ts | 27 + 133 files changed, 26049 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 app/(admin)/admin/backup/page.tsx create mode 100644 app/(admin)/admin/branding/page.tsx create mode 100644 app/(admin)/admin/einstellungen/page.tsx create mode 100644 app/(admin)/admin/email-templates/page.tsx create mode 100644 app/(admin)/admin/instant-meeting/page.tsx create mode 100644 app/(admin)/admin/kalender/page.tsx create mode 100644 app/(admin)/admin/layout.tsx create mode 100644 app/(admin)/admin/page.tsx create mode 100644 app/(admin)/admin/rechtliches/page.tsx create mode 100644 app/(admin)/admin/termine/page.tsx create mode 100644 app/(admin)/admin/uebersicht/page.tsx create mode 100644 app/(admin)/loading.tsx create mode 100644 app/(auth)/anmelden/login-form.tsx create mode 100644 app/(auth)/anmelden/page.tsx create mode 100644 app/(public)/buchen/[slug]/page.tsx create mode 100644 app/(public)/buchen/page.tsx create mode 100644 app/(public)/datenschutz/page.tsx create mode 100644 app/(public)/impressum/page.tsx create mode 100644 app/(public)/stornieren/page.tsx create mode 100644 app/api/admin/backup/route.ts create mode 100644 app/api/admin/einstellungen/route.ts create mode 100644 app/api/admin/einstellungen/test-smtp/route.ts create mode 100644 app/api/admin/instant-meeting/route.ts create mode 100644 app/api/admin/kalender/[id]/route.ts create mode 100644 app/api/admin/kalender/[id]/sync/route.ts create mode 100644 app/api/admin/kalender/route.ts create mode 100644 app/api/admin/kalender/test-connection/route.ts create mode 100644 app/api/admin/letzte-buchungen/route.ts create mode 100644 app/api/admin/termine/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/cron/sync/route.ts create mode 100644 app/api/public/buchen/route.ts create mode 100644 app/api/public/mitarbeiter/route.ts create mode 100644 app/api/public/slots-monat/route.ts create mode 100644 app/api/public/slots/route.ts create mode 100644 app/api/public/stornieren/route.ts create mode 100644 app/api/public/umbuchen/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/providers.tsx create mode 100644 components/admin/admin-nav.tsx create mode 100644 components/admin/appointments-panel.tsx create mode 100644 components/admin/backup-panel.tsx create mode 100644 components/admin/branding-panel.tsx create mode 100644 components/admin/calendar-person-panel.tsx create mode 100644 components/admin/email-templates-panel.tsx create mode 100644 components/admin/footer-settings-panel.tsx create mode 100644 components/admin/header-settings-panel.tsx create mode 100644 components/admin/instant-meeting-panel.tsx create mode 100644 components/admin/latest-bookings-panel.tsx create mode 100644 components/admin/legal-pages-settings-panel.tsx create mode 100644 components/admin/settings-panel.tsx create mode 100644 components/booking/cancel-form.tsx create mode 100644 components/booking/embed-mode.tsx create mode 100644 components/booking/public-booking-flow.tsx create mode 100644 components/booking/shared-legal-page.tsx create mode 100644 components/layout/accent-color.tsx create mode 100644 components/layout/animated-page.tsx create mode 100644 components/layout/legal-content-card.tsx create mode 100644 components/layout/public-footer.tsx create mode 100644 components/layout/session-provider.tsx create mode 100644 components/layout/theme-provider.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/confirm-dialog.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/textarea.tsx create mode 100755 deploy.sh create mode 100644 design.md create mode 100644 docker-compose.direct.yml create mode 100644 docker-compose.proxy.yml create mode 100644 instrumentation.ts create mode 100644 lib/all-timezones.ts create mode 100644 lib/api.ts create mode 100644 lib/auth/options.ts create mode 100644 lib/auth/session.ts create mode 100644 lib/constants.ts create mode 100644 lib/crypto.ts create mode 100644 lib/date.ts create mode 100644 lib/email/mailer.ts create mode 100644 lib/email/shortcodes.ts create mode 100644 lib/email/style-presets.ts create mode 100644 lib/email/style-renderer.ts create mode 100644 lib/email/template-engine.ts create mode 100644 lib/prisma.ts create mode 100644 lib/public-booking-config.ts create mode 100644 lib/public-url.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/security/config-guard.ts create mode 100644 lib/security/request.ts create mode 100644 lib/services/appointments.ts create mode 100644 lib/services/availability.ts create mode 100644 lib/services/caldav-sync-logs.ts create mode 100644 lib/services/caldav.ts create mode 100644 lib/services/cron.ts create mode 100644 lib/services/delivery-issues.ts create mode 100644 lib/services/instant-meeting.ts create mode 100644 lib/services/meeting-links.ts create mode 100644 lib/services/person-calendar-resources.ts create mode 100644 lib/services/reminders.ts create mode 100644 lib/services/retry.ts create mode 100644 lib/settings.ts create mode 100644 lib/ui-appearance.ts create mode 100644 lib/utils.ts create mode 100644 lib/validators/admin.ts create mode 100644 lib/validators/public.ts create mode 100644 lib/weekday-availability.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/migrations/000001_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100755 scripts/export-backup.sh create mode 100644 scripts/run-sync.ts create mode 100644 scripts/test-timezone-dst.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/next-auth.d.ts 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" /> +
+
+ +