feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -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
|
||||
68
.env.example
Normal file
68
.env.example
Normal file
@@ -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
|
||||
7
.eslintignore
Normal file
7
.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.next
|
||||
node_modules
|
||||
volumes
|
||||
coverage
|
||||
*.config.js
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -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
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -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"]
|
||||
37
Makefile
Normal file
37
Makefile
Normal file
@@ -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
|
||||
7
app/(admin)/admin/backup/page.tsx
Normal file
7
app/(admin)/admin/backup/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BackupPanel } from "@/components/admin/backup-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminBackupPage() {
|
||||
return <BackupPanel />;
|
||||
}
|
||||
7
app/(admin)/admin/branding/page.tsx
Normal file
7
app/(admin)/admin/branding/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BrandingPanel } from "@/components/admin/branding-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminBrandingPage() {
|
||||
return <BrandingPanel />;
|
||||
}
|
||||
7
app/(admin)/admin/einstellungen/page.tsx
Normal file
7
app/(admin)/admin/einstellungen/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { SettingsPanel } from "@/components/admin/settings-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <SettingsPanel />;
|
||||
}
|
||||
7
app/(admin)/admin/email-templates/page.tsx
Normal file
7
app/(admin)/admin/email-templates/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { EmailTemplatesPanel } from "@/components/admin/email-templates-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminEmailTemplatesPage() {
|
||||
return <EmailTemplatesPanel />;
|
||||
}
|
||||
7
app/(admin)/admin/instant-meeting/page.tsx
Normal file
7
app/(admin)/admin/instant-meeting/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { InstantMeetingPanel } from "@/components/admin/instant-meeting-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminInstantMeetingPage() {
|
||||
return <InstantMeetingPanel />;
|
||||
}
|
||||
7
app/(admin)/admin/kalender/page.tsx
Normal file
7
app/(admin)/admin/kalender/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CalendarPersonPanel } from "@/components/admin/calendar-person-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminCalendarsPage() {
|
||||
return <CalendarPersonPanel />;
|
||||
}
|
||||
26
app/(admin)/admin/layout.tsx
Normal file
26
app/(admin)/admin/layout.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<AccentColorScript color={accentColor} />
|
||||
<AdminLayoutClientShell>{children}</AdminLayoutClientShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
app/(admin)/admin/page.tsx
Normal file
5
app/(admin)/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/uebersicht");
|
||||
}
|
||||
7
app/(admin)/admin/rechtliches/page.tsx
Normal file
7
app/(admin)/admin/rechtliches/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { LegalPagesSettingsPanel } from "@/components/admin/legal-pages-settings-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminLegalPagesPage() {
|
||||
return <LegalPagesSettingsPanel />;
|
||||
}
|
||||
7
app/(admin)/admin/termine/page.tsx
Normal file
7
app/(admin)/admin/termine/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AppointmentsPanel } from "@/components/admin/appointments-panel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminAppointmentsPage() {
|
||||
return <AppointmentsPanel />;
|
||||
}
|
||||
156
app/(admin)/admin/uebersicht/page.tsx
Normal file
156
app/(admin)/admin/uebersicht/page.tsx
Normal file
@@ -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 (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Dashboard</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">
|
||||
{format(now, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{[
|
||||
{ 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}<span className="text-lg text-slate-400">/{totalResources}</span></>, 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: <span className="text-emerald-600">Aktiv</span>, label: "System läuft" }
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className={`rounded-lg ${stat.iconBg} p-1.5`}>
|
||||
<stat.icon className={`h-4 w-4 ${stat.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-slate-900">{stat.value}</div>
|
||||
<div className="text-xs font-medium text-slate-500">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart + Today's appointments side by side */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_380px]">
|
||||
{/* Weekly chart */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<BarChart3 className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Buchungen</h2>
|
||||
<span className="text-xs font-medium text-slate-400">letzte 8 Wochen</span>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-end gap-2 h-40">
|
||||
{weeks.map((week, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] font-bold text-slate-500">{week.count}</span>
|
||||
<div
|
||||
className="w-full rounded-t-md transition-all"
|
||||
style={{ backgroundColor: "var(--accent)", height: `${Math.max(4, (week.count / maxCount) * 100)}%` }}
|
||||
/>
|
||||
<span className="text-[9px] font-medium text-slate-400 truncate w-full text-center">{week.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's appointments */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Heutige Termine</h2>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold text-slate-500">{todayAppointments.length}</span>
|
||||
</div>
|
||||
<Link href="/admin/termine" className="flex items-center gap-1 text-xs font-bold text-indigo-600 hover:text-indigo-700">
|
||||
Alle <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
{todayAppointments.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-400">
|
||||
<Clock className="mx-auto h-6 w-6 mb-2 opacity-30" />
|
||||
Keine Termine für heute.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 max-h-[300px] overflow-auto">
|
||||
{todayAppointments.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 px-5 py-3 text-sm">
|
||||
<span className="shrink-0 text-sm font-black text-slate-900 w-12">{format(new Date(a.startAt), "HH:mm")}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate">{a.customerFirstName} {a.customerLastName}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{a.customerEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest bookings */}
|
||||
<div>
|
||||
<LatestBookingsPanel monthTotal={monthTotal} monthCancelled={monthCancelled} monthNoShow={monthNoShow} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/admin/kalender" className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-bold text-white hover:bg-slate-800">
|
||||
<Users className="h-4 w-4" /> Kalender verwalten
|
||||
</Link>
|
||||
<Link href="/admin/termine" className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50">
|
||||
<Calendar className="h-4 w-4" /> Termine anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
app/(admin)/loading.tsx
Normal file
26
app/(admin)/loading.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-48 rounded-lg bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border border-slate-100 bg-white p-5">
|
||||
<div className="mb-3 h-8 w-8 rounded-lg bg-slate-100 animate-pulse" />
|
||||
<div className="h-8 w-16 rounded bg-slate-100 animate-pulse mb-1" />
|
||||
<div className="h-3 w-24 rounded bg-slate-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-6">
|
||||
<div className="h-5 w-40 rounded bg-slate-100 animate-pulse mb-4" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-slate-50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
app/(auth)/anmelden/login-form.tsx
Normal file
69
app/(auth)/anmelden/login-form.tsx
Normal file
@@ -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<typeof schema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
|
||||
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 (
|
||||
<Card className="w-full">
|
||||
<CardHeader><CardTitle>Admin-Anmeldung</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input id="email" type="email" {...register("email")} />
|
||||
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Input id="password" type="password" {...register("password")} />
|
||||
{errors.password && <p className="text-xs text-destructive">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{loading ? "Anmeldung..." : "Einloggen"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
52
app/(auth)/anmelden/page.tsx
Normal file
52
app/(auth)/anmelden/page.tsx
Normal file
@@ -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<string, string>));
|
||||
|
||||
const companyName = settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook";
|
||||
const logoUrl = settings[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] ?? "";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col font-sans">
|
||||
<main className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoUrl} alt={companyName} className="h-10 w-10 rounded-xl object-cover border border-slate-200 bg-white" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: "var(--accent)" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-xl font-black text-slate-900">{companyName}</h1>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</div>
|
||||
</main>
|
||||
<PublicFooter
|
||||
companyName={companyName}
|
||||
privacyLabel={settings[SETTING_KEYS.FOOTER_PRIVACY_LABEL] ?? "Datenschutz"}
|
||||
privacyHref={settings[SETTING_KEYS.FOOTER_PRIVACY_URL] ?? "/datenschutz"}
|
||||
imprintLabel={settings[SETTING_KEYS.FOOTER_IMPRINT_LABEL] ?? "Impressum"}
|
||||
imprintHref={settings[SETTING_KEYS.FOOTER_IMPRINT_URL] ?? "/impressum"}
|
||||
copyrightTemplate={settings[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] ?? "© {{year}} {{companyName}}"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
app/(public)/buchen/[slug]/page.tsx
Normal file
28
app/(public)/buchen/[slug]/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<EmbedMode enabled={embedded} />
|
||||
<PublicBookingFlow
|
||||
embedded={embedded}
|
||||
rescheduleToken={sp.rescheduleToken}
|
||||
initialConfig={initialConfig}
|
||||
preselectedStaffSlug={slug}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
app/(public)/buchen/page.tsx
Normal file
24
app/(public)/buchen/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<EmbedMode enabled={embedded} />
|
||||
<PublicBookingFlow
|
||||
embedded={embedded}
|
||||
rescheduleToken={params.rescheduleToken}
|
||||
initialConfig={initialConfig}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
app/(public)/datenschutz/page.tsx
Normal file
5
app/(public)/datenschutz/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SharedLegalPage } from "@/components/booking/shared-legal-page";
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
return <SharedLegalPage type="privacy" />;
|
||||
}
|
||||
5
app/(public)/impressum/page.tsx
Normal file
5
app/(public)/impressum/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SharedLegalPage } from "@/components/booking/shared-legal-page";
|
||||
|
||||
export default function ImpressumPage() {
|
||||
return <SharedLegalPage type="imprint" />;
|
||||
}
|
||||
38
app/(public)/stornieren/page.tsx
Normal file
38
app/(public)/stornieren/page.tsx
Normal file
@@ -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<string, string>)
|
||||
);
|
||||
const companyName = settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook";
|
||||
|
||||
return (
|
||||
<CancelForm
|
||||
initialToken={params.token ?? ""}
|
||||
companyName={companyName}
|
||||
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}}"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
286
app/api/admin/backup/route.ts
Normal file
286
app/api/admin/backup/route.ts
Normal file
@@ -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<string, string>();
|
||||
const backupCaldavKey: string =
|
||||
typeof body.caldavEncryptionKey === "string" && body.caldavEncryptionKey.length >= 32
|
||||
? body.caldavEncryptionKey
|
||||
: "";
|
||||
|
||||
async function addStep(label: string, fn: () => Promise<string>) {
|
||||
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<string, unknown> = {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
38
app/api/admin/einstellungen/route.ts
Normal file
38
app/api/admin/einstellungen/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
67
app/api/admin/einstellungen/test-smtp/route.ts
Normal file
67
app/api/admin/einstellungen/test-smtp/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
105
app/api/admin/instant-meeting/route.ts
Normal file
105
app/api/admin/instant-meeting/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
174
app/api/admin/kalender/[id]/route.ts
Normal file
174
app/api/admin/kalender/[id]/route.ts
Normal file
@@ -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<typeof updatePersonCalendarResource>[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);
|
||||
}
|
||||
}
|
||||
112
app/api/admin/kalender/[id]/sync/route.ts
Normal file
112
app/api/admin/kalender/[id]/sync/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
180
app/api/admin/kalender/route.ts
Normal file
180
app/api/admin/kalender/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
43
app/api/admin/kalender/test-connection/route.ts
Normal file
43
app/api/admin/kalender/test-connection/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
218
app/api/admin/letzte-buchungen/route.ts
Normal file
218
app/api/admin/letzte-buchungen/route.ts
Normal file
@@ -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<typeof sortSchema>) {
|
||||
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<string, GroupedBooking>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
297
app/api/admin/termine/route.ts
Normal file
297
app/api/admin/termine/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
8
app/api/auth/[...nextauth]/route.ts
Normal file
8
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -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 };
|
||||
32
app/api/cron/sync/route.ts
Normal file
32
app/api/cron/sync/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
35
app/api/public/buchen/route.ts
Normal file
35
app/api/public/buchen/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
75
app/api/public/mitarbeiter/route.ts
Normal file
75
app/api/public/mitarbeiter/route.ts
Normal file
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
180
app/api/public/slots-monat/route.ts
Normal file
180
app/api/public/slots-monat/route.ts
Normal file
@@ -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<string>();
|
||||
for (const item of results) {
|
||||
for (const slot of item.slots) {
|
||||
unique.add(slot);
|
||||
}
|
||||
}
|
||||
return unique.size;
|
||||
}
|
||||
|
||||
type MonthAvailabilityCacheEntry = {
|
||||
availability: Record<string, number>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var calbookMonthAvailabilityCache: Map<string, MonthAvailabilityCacheEntry> | undefined;
|
||||
}
|
||||
|
||||
const monthAvailabilityCache =
|
||||
global.calbookMonthAvailabilityCache ?? new Map<string, MonthAvailabilityCacheEntry>();
|
||||
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<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<R>
|
||||
) {
|
||||
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<string, number> = {};
|
||||
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 });
|
||||
}
|
||||
69
app/api/public/slots/route.ts
Normal file
69
app/api/public/slots/route.ts
Normal file
@@ -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<ReturnType<typeof calculateSlotsForDisplayDate>>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var calbookDaySlotsCache: Map<string, DaySlotsCacheEntry> | undefined;
|
||||
}
|
||||
|
||||
const daySlotsCache = global.calbookDaySlotsCache ?? new Map<string, DaySlotsCacheEntry>();
|
||||
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 });
|
||||
}
|
||||
47
app/api/public/stornieren/route.ts
Normal file
47
app/api/public/stornieren/route.ts
Normal file
@@ -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" });
|
||||
}
|
||||
38
app/api/public/umbuchen/route.ts
Normal file
38
app/api/public/umbuchen/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
198
app/globals.css
Normal file
198
app/globals.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
62
app/layout.tsx
Normal file
62
app/layout.tsx
Normal file
@@ -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 (
|
||||
<html
|
||||
lang="de"
|
||||
suppressHydrationWarning
|
||||
data-ui-theme={selectedThemeId}
|
||||
style={combinedStyle as React.CSSProperties}
|
||||
>
|
||||
<body className={`${manrope.variable} ${sora.variable} font-sans`}>
|
||||
<AccentColorScript color={accentColor} />
|
||||
<Providers themeMode="light">
|
||||
<main className="min-h-screen">{children}</main>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/buchen");
|
||||
}
|
||||
24
app/providers.tsx
Normal file
24
app/providers.tsx
Normal file
@@ -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 (
|
||||
<AuthSessionProvider>
|
||||
<ThemeProvider mode={themeMode}>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</ThemeProvider>
|
||||
</AuthSessionProvider>
|
||||
);
|
||||
}
|
||||
142
components/admin/admin-nav.tsx
Normal file
142
components/admin/admin-nav.tsx
Normal file
@@ -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 (
|
||||
<nav className="flex-1 py-4 px-3 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all",
|
||||
isActive
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4", isActive ? "text-indigo-600" : "text-slate-400")} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminLayoutClientShell({ children }: { children: React.ReactNode }) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 font-sans">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-slate-200 bg-white lg:flex">
|
||||
<Link href="/admin/uebersicht" className="h-16 flex items-center gap-2 px-6 border-b border-slate-100">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ backgroundColor: "var(--accent)" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-lg font-black text-slate-900 tracking-tight">admin<span className="text-indigo-600">.</span></h1>
|
||||
</Link>
|
||||
<AdminNav />
|
||||
<div className="p-3 border-t border-slate-100 space-y-2">
|
||||
<Link href="/buchen" 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 transition-all">
|
||||
<Globe className="h-4 w-4" /> Buchung
|
||||
</Link>
|
||||
<button onClick={() => signOut({ callbackUrl: "/anmelden" })} className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold text-slate-400 hover:text-red-600 hover:bg-red-50 transition-all">
|
||||
<FileText className="h-4 w-4" /> Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="lg:hidden sticky top-0 z-30 flex items-center gap-3 border-b border-slate-200 bg-white px-4 h-14">
|
||||
<button onClick={() => setMobileMenuOpen(!mobileMenuOpen)} className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100">
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
<Link href="/admin/uebersicht" className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center" style={{ backgroundColor: "var(--accent)" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-sm font-black text-slate-900">admin<span className="text-indigo-600">.</span></h1>
|
||||
</Link>
|
||||
<span className="text-xs font-bold text-slate-400 ml-auto">
|
||||
{NAV_ITEMS.find((item) => pathname === item.href || pathname.startsWith(`${item.href}/`))?.label ?? "Admin"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-20">
|
||||
<div className="absolute inset-0 bg-slate-950/40" onClick={() => setMobileMenuOpen(false)} />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-64 bg-white border-r border-slate-200 shadow-2xl animate-in slide-in-from-left duration-200">
|
||||
<div className="h-14 flex items-center border-b border-slate-100 px-4">
|
||||
<Link href="/admin/uebersicht" className="flex items-center gap-2" onClick={() => setMobileMenuOpen(false)}>
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center" style={{ backgroundColor: "var(--accent)" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-sm font-black text-slate-900">admin<span className="text-indigo-600">.</span></h1>
|
||||
</Link>
|
||||
</div>
|
||||
<AdminNav />
|
||||
<div className="p-3 border-t border-slate-100 space-y-2">
|
||||
<Link href="/buchen" onClick={() => 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">
|
||||
<Globe className="h-4 w-4" /> Buchung
|
||||
</Link>
|
||||
<button onClick={() => signOut({ callbackUrl: "/anmelden" })} className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold text-slate-400 hover:text-red-600 hover:bg-red-50">
|
||||
<FileText className="h-4 w-4" /> Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="min-h-screen lg:pl-60">
|
||||
<div className="w-full max-w-6xl mx-auto p-4 lg:p-8">
|
||||
<AnimatedPage key={pathname}>{children}</AnimatedPage>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
components/admin/appointments-panel.tsx
Normal file
309
components/admin/appointments-panel.tsx
Normal file
@@ -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<Appointment[]>([]);
|
||||
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<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<GroupedAppointment[]>(() => {
|
||||
const groups = new Map<string, GroupedAppointment>();
|
||||
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<string, number>();
|
||||
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 (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Termine</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">{groupedAppointments.length} Termine gefunden</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
<div className="flex rounded-xl border border-slate-200 bg-white p-0.5">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button key={tab.value} type="button" onClick={() => setStatusFilter(tab.value)}
|
||||
className={cn("rounded-lg px-3 py-1.5 text-xs font-bold transition-all", statusFilter === tab.value ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-700")}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => setNoShowFilter(!noShowFilter)}
|
||||
className={cn("rounded-xl border px-3 py-1.5 text-xs font-bold transition-all", noShowFilter ? "border-amber-300 bg-amber-50 text-amber-800" : "border-slate-200 bg-white text-slate-500 hover:border-slate-300")}>
|
||||
{noShowFilter ? "Nur No-Show" : <><EyeOff className="mr-1 inline h-3 w-3" />No-Show</>}
|
||||
</button>
|
||||
{selectableAppointments.length > 0 && (
|
||||
<button type="button" onClick={selectedIds.size > 0 ? deselectAll : selectAll}
|
||||
className="rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-500 hover:border-indigo-300 hover:text-indigo-600 transition">
|
||||
<CheckSquare className="mr-1 inline h-3 w-3" />
|
||||
{selectedIds.size > 0 ? `${selectedIds.size} abwählen` : "Alle auswählen"}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && loadAppointments()} placeholder="Name oder E-Mail..." className="h-10 pl-9 w-56" />
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadAppointments()}>Suchen</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[260px_1fr]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm p-4 h-fit lg:sticky lg:top-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button onClick={() => setMiniMonth((m) => new Date(m.getFullYear(), m.getMonth() - 1))} className="rounded-lg p-1 hover:bg-slate-100"><ChevronLeft className="h-4 w-4 text-slate-500" /></button>
|
||||
<p className="text-sm font-bold text-slate-900">{format(miniMonth, "MMMM yyyy", { locale: de })}</p>
|
||||
<button onClick={() => setMiniMonth((m) => new Date(m.getFullYear(), m.getMonth() + 1))} className="rounded-lg p-1 hover:bg-slate-100"><ChevronRight className="h-4 w-4 text-slate-500" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 text-center text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1">{[ "Mo","Di","Mi","Do","Fr","Sa","So" ].map((d) => <div key={d}>{d}</div>)}</div>
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{days.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const count = dayCounts.get(key) ?? 0;
|
||||
const isPast = isBefore(day, startOfDay(today));
|
||||
return (
|
||||
<div key={key} className={cn("aspect-square flex flex-col items-center justify-center rounded-lg text-xs", isToday(day) && "bg-indigo-50 ring-1 ring-indigo-200", isPast && !isToday(day) && "opacity-30")}>
|
||||
<span className={cn("font-medium", isToday(day) ? "text-indigo-700" : "text-slate-600")}>{format(day, "d")}</span>
|
||||
{count > 0 && <span className={cn("text-[9px] font-bold", isToday(day) ? "text-indigo-500" : "text-slate-400")}>{count}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="space-y-3"><Skeleton className="h-28" /><Skeleton className="h-28" /></div>
|
||||
) : groupedAppointments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Calendar className="mb-3 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm font-bold">Keine Termine gefunden</p>
|
||||
<p className="text-xs mt-1">Neue Buchungen erscheinen hier automatisch.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groupedAppointments.map((a) => {
|
||||
const isSelected = selectedIds.has(a.key);
|
||||
const isSelectable = a.status === "CONFIRMED";
|
||||
return (
|
||||
<div key={a.key}
|
||||
className={cn(
|
||||
"rounded-xl border bg-white p-4 transition",
|
||||
isSelected ? "border-indigo-400 ring-1 ring-indigo-100" : "border-slate-200 hover:border-slate-300"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isSelectable && (
|
||||
<label className="mt-0.5 shrink-0 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={isSelected} onChange={() => toggleSelect(a.key)} className="h-4 w-4 rounded border-slate-300" />
|
||||
</label>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-slate-900">{a.customerFirstName} {a.customerLastName}</p>
|
||||
<p className="text-xs text-slate-500">{a.customerEmail}</p>
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-black uppercase tracking-wider",
|
||||
a.status === "CANCELLED" ? "bg-slate-100 text-slate-500" : a.noShowAt ? "bg-amber-100 text-amber-800" : "bg-emerald-100 text-emerald-800")}>
|
||||
{a.status === "CANCELLED" ? "Storniert" : a.noShowAt ? "No-Show" : "Bestätigt"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-1 text-xs text-slate-500 mb-3">
|
||||
<p><span className="font-bold text-slate-700">{format(new Date(a.startAt), "dd.MM.yyyy HH:mm", { locale: de })} – {format(new Date(a.endAt), "HH:mm", { locale: de })}</span></p>
|
||||
<p>Personen: <span className="font-medium text-slate-700">{a.staffNames.join(", ")}</span></p>
|
||||
{a.customerPhone && <p>Tel: {a.customerPhone}</p>}
|
||||
{a.notes && <p className="italic">Notiz: {a.notes}</p>}
|
||||
</div>
|
||||
{a.status === "CONFIRMED" && !isSelected && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="destructive" onClick={() => setCancelConfirm(a.id)}><XCircle className="mr-1 h-3.5 w-3.5" /> Stornieren</Button>
|
||||
{a.noShowAt ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => void toggleNoShow(a.id, false)}>No-Show zurücknehmen</Button>
|
||||
) : new Date(a.startAt) < new Date() ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => void toggleNoShow(a.id, true)}>Als No-Show markieren</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-2xl border border-slate-300 bg-white px-5 py-3 shadow-xl">
|
||||
<span className="text-sm font-bold text-slate-900">{selectedIds.size} ausgewählt</span>
|
||||
<Button size="sm" variant="destructive" disabled={busy} onClick={() => setBulkConfirm("cancel")}>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" /> Stornieren
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setBulkConfirm("noshow")}>
|
||||
Als No-Show markieren
|
||||
</Button>
|
||||
<button onClick={deselectAll} className="rounded-lg p-1.5 text-slate-400 hover:text-slate-600"><XCircle className="h-4 w-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog open={cancelConfirm !== null} title="Termin stornieren" message="Der Termin wird storniert. Eine Stornierungs-Mail wird an den Kunden gesendet." confirmLabel="Stornieren" variant="danger"
|
||||
onConfirm={() => { if (cancelConfirm) void cancelAppointment(cancelConfirm); }} onCancel={() => setCancelConfirm(null)} />
|
||||
|
||||
<ConfirmDialog open={bulkConfirm !== null}
|
||||
title={bulkConfirm === "cancel" ? `${selectedIds.size} Termine stornieren` : `${selectedIds.size} Termine als No-Show markieren`}
|
||||
message={bulkConfirm === "cancel" ? "Alle ausgewählten bestätigten Termine werden storniert. Stornierungs-Mails werden versendet." : "Alle ausgewählten Termine werden als No-Show markiert."}
|
||||
confirmLabel={bulkConfirm === "cancel" ? "Alle stornieren" : "Alle markieren"} variant={bulkConfirm === "cancel" ? "danger" : "default"} loading={busy}
|
||||
onConfirm={() => { if (bulkConfirm) void bulkAction(bulkConfirm); }} onCancel={() => setBulkConfirm(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
components/admin/backup-panel.tsx
Normal file
312
components/admin/backup-panel.tsx
Normal file
@@ -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<ImportResult | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<Record<string, number> | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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<string, number> = {};
|
||||
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 (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Backup</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">Daten exportieren und wiederherstellen</p>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden mb-6">
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Export</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
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.
|
||||
</p>
|
||||
<Button type="button" onClick={() => void handleDownload()} disabled={downloading}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{downloading ? "Wird erstellt..." : "Backup herunterladen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Import</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* File picker */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
|
||||
selectedFile ? "border-indigo-300 bg-indigo-50" : "border-slate-200 hover:border-slate-300"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
id="backup-file-input"
|
||||
/>
|
||||
<label htmlFor="backup-file-input" className="cursor-pointer">
|
||||
<HardDrive className="mx-auto h-8 w-8 text-slate-300 mb-2" />
|
||||
{selectedFile ? (
|
||||
<p className="text-sm font-bold text-indigo-700">{selectedFile.name}</p>
|
||||
) : (
|
||||
<p className="text-sm font-bold text-slate-500">
|
||||
Klicke hier oder ziehe eine Backup-Datei
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-400 mt-1">.json</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="text-xs font-black uppercase tracking-widest text-slate-400 mb-2">
|
||||
Vorschau – {Object.values(preview).reduce((a, b) => a + b, 0)} Einträge gefunden
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{Object.entries(preview).map(([label, count]) => (
|
||||
<div key={label} className="flex items-center justify-between rounded-lg bg-white px-3 py-1.5 border border-slate-100">
|
||||
<span className="text-slate-600">{label}</span>
|
||||
<span className="font-bold text-slate-900">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={!selectedFile || importing}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{importing ? "Importiert..." : "Backup importieren"}
|
||||
</Button>
|
||||
|
||||
{/* Result */}
|
||||
{importing && (
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 flex items-center gap-3 text-sm text-blue-800">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Import läuft...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className="animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className={cn(
|
||||
"rounded-xl border p-4 mb-3",
|
||||
importResult.message.includes("Fehler") ? "border-amber-200 bg-amber-50" : "border-emerald-200 bg-emerald-50"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 mb-1",
|
||||
importResult.message.includes("Fehler") ? "text-amber-800" : "text-emerald-800"
|
||||
)}>
|
||||
{importResult.message.includes("Fehler") ? (
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
)}
|
||||
<p className="font-bold">{importResult.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{importResult.steps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-3 py-2 text-xs",
|
||||
step.status === "ok" && "border-emerald-100 bg-emerald-50/50",
|
||||
step.status === "error" && "border-red-100 bg-red-50/50",
|
||||
step.status === "skipped" && "border-slate-100 bg-slate-50/50"
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{step.status === "ok" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : step.status === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
"font-bold",
|
||||
step.status === "ok" && "text-emerald-900",
|
||||
step.status === "error" && "text-red-800",
|
||||
step.status === "skipped" && "text-slate-400"
|
||||
)}>
|
||||
{step.label}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"truncate",
|
||||
step.status === "ok" && "text-emerald-700",
|
||||
step.status === "error" && "text-red-600",
|
||||
step.status === "skipped" && "text-slate-400"
|
||||
)}>
|
||||
{step.detail}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"shrink-0 text-[10px] font-black uppercase tracking-wider rounded-full px-1.5 py-0.5",
|
||||
step.status === "ok" && "bg-emerald-100 text-emerald-700",
|
||||
step.status === "error" && "bg-red-100 text-red-700",
|
||||
step.status === "skipped" && "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{step.status === "ok" ? "OK" : step.status === "error" ? "FEHLER" : "–"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult === null && !importing && selectedFile && !preview && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3 flex items-center gap-2 text-sm text-red-800">
|
||||
<XCircle className="h-4 w-4" /> Datei konnte nicht als Backup erkannt werden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
components/admin/branding-panel.tsx
Normal file
231
components/admin/branding-panel.tsx
Normal file
@@ -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<typeof brandingSchema>;
|
||||
|
||||
export function BrandingPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [companyName, setCompanyName] = useState("CalBook");
|
||||
|
||||
const form = useForm<BrandingFormValues>({
|
||||
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<string, string>;
|
||||
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 (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Branding</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">Header, Footer und Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Frontend-Header</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="header-text">Header-Text</Label>
|
||||
<Input id="header-text" {...form.register("frontend_header_text")} placeholder="Gespräch" />
|
||||
{form.formState.errors.frontend_header_text && <p className="text-xs text-red-600">{form.formState.errors.frontend_header_text.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="header-logo">Logo-URL (optional)</Label>
|
||||
<Input id="header-logo" {...form.register("frontend_header_logo_url")} placeholder="https://example.com/logo.png" />
|
||||
<p className="text-xs text-slate-400">Leer = Standard-Icon.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="accent-color">Akzent-Farbe</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="accent-color-picker"
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<Input
|
||||
{...form.register("branding_accent_color")}
|
||||
className="font-mono"
|
||||
onChange={(e) => {
|
||||
form.setValue("branding_accent_color", e.target.value);
|
||||
document.documentElement.style.setProperty("--accent", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Schritt-Nummern, Logo-Icon, Diagramme.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-2 text-xs font-black uppercase tracking-widest text-slate-400">Vorschau</p>
|
||||
<div className="flex items-center gap-3 rounded-xl bg-white p-3">
|
||||
{headerLogoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={headerLogoUrl} alt="Logo" className="h-10 w-10 rounded-lg border border-slate-200 object-cover" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-lg flex items-center justify-center" style={{ backgroundColor: accentColor }} />
|
||||
)}
|
||||
<p className="text-xl font-bold text-slate-900">{headerText || "Gespräch"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Frontend-Footer</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="footer-privacy-label">Link-Text Datenschutz</Label>
|
||||
<Input id="footer-privacy-label" {...form.register("footer_privacy_label")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="footer-privacy-url">Link-Ziel Datenschutz</Label>
|
||||
<Input id="footer-privacy-url" {...form.register("footer_privacy_url")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="footer-imprint-label">Link-Text Impressum</Label>
|
||||
<Input id="footer-imprint-label" {...form.register("footer_imprint_label")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="footer-imprint-url">Link-Ziel Impressum</Label>
|
||||
<Input id="footer-imprint-url" {...form.register("footer_imprint_url")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="footer-copyright">Copyright-Text</Label>
|
||||
<Input id="footer-copyright" {...form.register("footer_copyright_text")} />
|
||||
<p className="text-xs text-slate-400">{"Platzhalter: {{year}}, {{companyName}}"}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-2 text-xs font-black uppercase tracking-widest text-slate-400">Vorschau Footer</p>
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4">
|
||||
<PublicFooter
|
||||
companyName={companyName}
|
||||
privacyLabel={privacyLabel}
|
||||
privacyHref={privacyUrl}
|
||||
imprintLabel={imprintLabel}
|
||||
imprintHref={imprintUrl}
|
||||
copyrightTemplate={copyrightText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="lg" disabled={form.formState.isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{form.formState.isSubmitting ? "Speichert..." : "Alles speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1005
components/admin/calendar-person-panel.tsx
Normal file
1005
components/admin/calendar-person-panel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1668
components/admin/email-templates-panel.tsx
Normal file
1668
components/admin/email-templates-panel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
225
components/admin/footer-settings-panel.tsx
Normal file
225
components/admin/footer-settings-panel.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PublicFooter } from "@/components/layout/public-footer";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function isValidFooterUrl(value: string) {
|
||||
if (!value) return true;
|
||||
if (value.startsWith("/")) return true;
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
const footerSchema = z.object({
|
||||
footer_privacy_label: z.string().trim().max(60, "Maximal 60 Zeichen"),
|
||||
footer_privacy_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
isValidFooterUrl,
|
||||
"URL muss mit /, http:// oder https:// beginnen"
|
||||
),
|
||||
footer_imprint_label: z.string().trim().max(60, "Maximal 60 Zeichen"),
|
||||
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, "Maximal 180 Zeichen")
|
||||
});
|
||||
|
||||
type FooterFormValues = z.infer<typeof footerSchema>;
|
||||
|
||||
type SettingsResponse = {
|
||||
settings?: Record<string, string>;
|
||||
};
|
||||
|
||||
export function FooterSettingsPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [companyName, setCompanyName] = useState("CalBook");
|
||||
|
||||
const form = useForm<FooterFormValues>({
|
||||
resolver: zodResolver(footerSchema),
|
||||
defaultValues: {
|
||||
footer_privacy_label: "Datenschutz",
|
||||
footer_privacy_url: "/datenschutz",
|
||||
footer_imprint_label: "Impressum",
|
||||
footer_imprint_url: "/impressum",
|
||||
footer_copyright_text: "© {{year}} {{companyName}}"
|
||||
}
|
||||
});
|
||||
|
||||
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 loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/einstellungen", {
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = (await response.json()) as SettingsResponse;
|
||||
const settings = data.settings ?? {};
|
||||
form.reset({
|
||||
footer_privacy_label:
|
||||
settings[SETTING_KEYS.FOOTER_PRIVACY_LABEL] ?? "Datenschutz",
|
||||
footer_privacy_url:
|
||||
settings[SETTING_KEYS.FOOTER_PRIVACY_URL] ?? "/datenschutz",
|
||||
footer_imprint_label:
|
||||
settings[SETTING_KEYS.FOOTER_IMPRINT_LABEL] ?? "Impressum",
|
||||
footer_imprint_url:
|
||||
settings[SETTING_KEYS.FOOTER_IMPRINT_URL] ?? "/impressum",
|
||||
footer_copyright_text:
|
||||
settings[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] ??
|
||||
"© {{year}} {{companyName}}"
|
||||
});
|
||||
setCompanyName(settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook");
|
||||
} catch {
|
||||
toast.error("Footer-Einstellungen konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadSettings();
|
||||
}, [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.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()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error("Footer-Einstellungen konnten nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Footer-Einstellungen gespeichert.");
|
||||
},
|
||||
() => {
|
||||
toast.error("Bitte prüfe die Eingaben.");
|
||||
}
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-64" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Frontend-Footer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label>Link-Text Datenschutz</Label>
|
||||
<Input {...form.register("footer_privacy_label")} placeholder="Datenschutz" />
|
||||
{form.formState.errors.footer_privacy_label ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.footer_privacy_label.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link-Ziel Datenschutz</Label>
|
||||
<Input {...form.register("footer_privacy_url")} placeholder="/datenschutz" />
|
||||
{form.formState.errors.footer_privacy_url ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.footer_privacy_url.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link-Text Impressum</Label>
|
||||
<Input {...form.register("footer_imprint_label")} placeholder="Impressum" />
|
||||
{form.formState.errors.footer_imprint_label ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.footer_imprint_label.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link-Ziel Impressum</Label>
|
||||
<Input {...form.register("footer_imprint_url")} placeholder="/impressum" />
|
||||
{form.formState.errors.footer_imprint_url ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.footer_imprint_url.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Copyright-Text</Label>
|
||||
<Input
|
||||
{...form.register("footer_copyright_text")}
|
||||
placeholder="© {{year}} {{companyName}}"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Platzhalter: <code>{"{{year}}"}</code>, <code>{"{{companyName}}"}</code>
|
||||
</p>
|
||||
{form.formState.errors.footer_copyright_text ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.footer_copyright_text.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" className="tap-target" disabled={form.formState.isSubmitting}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-slate-600">Live-Vorschau</p>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4">
|
||||
<PublicFooter
|
||||
companyName={companyName}
|
||||
privacyLabel={privacyLabel}
|
||||
privacyHref={privacyUrl}
|
||||
imprintLabel={imprintLabel}
|
||||
imprintHref={imprintUrl}
|
||||
copyrightTemplate={copyrightText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
170
components/admin/header-settings-panel.tsx
Normal file
170
components/admin/header-settings-panel.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const headerSchema = z.object({
|
||||
frontend_header_text: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Bitte einen Header-Text eingeben")
|
||||
.max(80, "Maximal 80 Zeichen"),
|
||||
frontend_header_logo_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value === "" || /^https?:\/\//i.test(value),
|
||||
"Bitte eine gültige URL mit http:// oder https:// eingeben"
|
||||
)
|
||||
});
|
||||
|
||||
type HeaderFormValues = z.infer<typeof headerSchema>;
|
||||
|
||||
type SettingsResponse = {
|
||||
settings?: Record<string, string>;
|
||||
};
|
||||
|
||||
export function HeaderSettingsPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const form = useForm<HeaderFormValues>({
|
||||
resolver: zodResolver(headerSchema),
|
||||
defaultValues: {
|
||||
frontend_header_text: "Gespräch",
|
||||
frontend_header_logo_url: ""
|
||||
}
|
||||
});
|
||||
|
||||
const headerText = form.watch("frontend_header_text");
|
||||
const headerLogoUrl = form.watch("frontend_header_logo_url");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/einstellungen", {
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = (await response.json()) as SettingsResponse;
|
||||
const settings = data.settings ?? {};
|
||||
form.reset({
|
||||
frontend_header_text: settings[SETTING_KEYS.FRONTEND_HEADER_TEXT] ?? "Gespräch",
|
||||
frontend_header_logo_url:
|
||||
settings[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] ?? ""
|
||||
});
|
||||
} catch {
|
||||
toast.error("Header-Einstellungen konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadSettings();
|
||||
}, [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()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error("Header-Einstellungen konnten nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Header-Einstellungen gespeichert.");
|
||||
},
|
||||
() => {
|
||||
toast.error("Bitte prüfe die Eingaben.");
|
||||
}
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-48" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Frontend-Header</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={onSubmit}>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Header-Text</Label>
|
||||
<Input {...form.register("frontend_header_text")} placeholder="Gespräch" />
|
||||
{form.formState.errors.frontend_header_text ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.frontend_header_text.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Logo-URL (optional)</Label>
|
||||
<Input
|
||||
{...form.register("frontend_header_logo_url")}
|
||||
placeholder="https://example.com/logo.png"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leer lassen, um das Standard-Icon zu verwenden.
|
||||
</p>
|
||||
{form.formState.errors.frontend_header_logo_url ? (
|
||||
<p className="text-xs text-red-600">
|
||||
{form.formState.errors.frontend_header_logo_url.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" className="tap-target" disabled={form.formState.isSubmitting}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-slate-600">Live-Vorschau</p>
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-white p-4">
|
||||
{headerLogoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={headerLogoUrl}
|
||||
alt="Logo Vorschau"
|
||||
className="h-10 w-10 rounded-xl border border-slate-200 object-cover bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-xl bg-indigo-600" />
|
||||
)}
|
||||
<p className="text-xl font-bold text-slate-900">
|
||||
{(headerText || "Gespräch").trim() || "Gespräch"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
243
components/admin/instant-meeting-panel.tsx
Normal file
243
components/admin/instant-meeting-panel.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CheckCircle2, Copy, Megaphone, Plus, Send, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type InstantMeetingPerson = {
|
||||
id: string;
|
||||
name: string;
|
||||
emailRecipients: string[];
|
||||
calendarIds: string[];
|
||||
};
|
||||
|
||||
type CacheEntry = { email: string; name: string; lastUsedAt: string };
|
||||
|
||||
type BootstrapResponse = {
|
||||
people: InstantMeetingPerson[];
|
||||
emailCache: CacheEntry[];
|
||||
defaultSubject: string;
|
||||
defaultTemplate: string;
|
||||
};
|
||||
|
||||
type MeetingResult = {
|
||||
sentCount: number;
|
||||
scopeLabel: string;
|
||||
meetingUrl: string;
|
||||
};
|
||||
|
||||
export function InstantMeetingPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const [people, setPeople] = useState<InstantMeetingPerson[]>([]);
|
||||
const [emailCache, setEmailCache] = useState<CacheEntry[]>([]);
|
||||
const [personScopeId, setPersonScopeId] = useState("all");
|
||||
const [subjectOverride, setSubjectOverride] = useState("");
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
const [cacheInput, setCacheInput] = useState("");
|
||||
const [additionalRecipients, setAdditionalRecipients] = useState<Array<{ email: string; name: string }>>([]);
|
||||
const [result, setResult] = useState<MeetingResult | null>(null);
|
||||
|
||||
const allRecipients = useMemo(() => additionalRecipients, [additionalRecipients]);
|
||||
|
||||
function addAdditionalEmail(raw: string, nameOverride?: string) {
|
||||
const normalized = (raw ?? "").trim();
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
toast.error("Bitte eine gültige E-Mail-Adresse eingeben.");
|
||||
return;
|
||||
}
|
||||
if (allRecipients.some((r) => r.email.toLowerCase() === normalized.toLowerCase())) {
|
||||
toast.error("Adresse bereits ausgewählt.");
|
||||
return;
|
||||
}
|
||||
const name = nameOverride?.trim() || normalized.split("@")[0] || "";
|
||||
setAdditionalRecipients((prev) => [...prev, { email: normalized, name }]);
|
||||
setCacheInput("");
|
||||
}
|
||||
|
||||
function removeAdditionalEmail(email: string) {
|
||||
setAdditionalRecipients((prev) => prev.filter((r) => r.email !== email));
|
||||
}
|
||||
|
||||
const bootstrap = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/instant-meeting", { cache: "no-store" });
|
||||
const data = (await res.json()) as BootstrapResponse & { message?: string };
|
||||
if (!res.ok) { toast.error(data?.message ?? "Konnte Daten nicht laden."); return; }
|
||||
setPeople(data.people ?? []);
|
||||
setEmailCache(data.emailCache ?? []);
|
||||
setSubjectOverride(data.defaultSubject ?? "");
|
||||
} catch { toast.error("Konnte Daten nicht laden."); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { void bootstrap(); }, []);
|
||||
|
||||
const onSendMeeting = async () => {
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/instant-meeting", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
personScopeId,
|
||||
subjectOverride: subjectOverride.trim() || undefined,
|
||||
customMessage: customMessage.trim() || undefined,
|
||||
additionalRecipients: additionalRecipients.map((r) => r.email)
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { toast.error(data?.message ?? "Versand fehlgeschlagen."); return; }
|
||||
setResult({
|
||||
sentCount: data.sentCount ?? 0,
|
||||
scopeLabel: data.scopeLabel ?? "",
|
||||
meetingUrl: data.meetingUrl ?? ""
|
||||
});
|
||||
setAdditionalRecipients([]);
|
||||
toast.success("Instant Meeting gesendet.");
|
||||
} catch { toast.error("Versand fehlgeschlagen."); }
|
||||
finally { setSending(false); }
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
const quickCache = emailCache.slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Instant Meeting</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">Spontanen Meeting-Link per E-Mail senden</p>
|
||||
</div>
|
||||
|
||||
{/* Config section */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
||||
<Megaphone className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Konfiguration</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="im-person">Person</Label>
|
||||
<select
|
||||
id="im-person"
|
||||
value={personScopeId}
|
||||
onChange={(e) => setPersonScopeId(e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-medium text-slate-900 transition-all focus:border-indigo-600 focus:outline-none focus:ring-1 focus:ring-indigo-600"
|
||||
>
|
||||
<option value="all">Alle / Beide Personen</option>
|
||||
{people.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="im-subject">Mail-Betreff</Label>
|
||||
<Input id="im-subject" value={subjectOverride} onChange={(e) => setSubjectOverride(e.target.value)} placeholder="Sofort-Meeting" />
|
||||
<p className="text-xs text-slate-400">{"Platzhalter: {{companyName}}, {{recipientName}}, {{meetingUrl}}"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="im-message">Zusatznachricht (optional)</Label>
|
||||
<Textarea id="im-message" rows={3} value={customMessage} onChange={(e) => setCustomMessage(e.target.value)} placeholder="Optionaler Zusatztext..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipients section */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="border-b border-slate-100 px-5 py-3 flex items-center gap-2">
|
||||
<Megaphone className="h-4 w-4 text-slate-400" />
|
||||
<h2 className="text-sm font-black text-slate-900">Empfänger</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="im-email">Zusätzliche Empfänger</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="im-email" list="im-cache" value={cacheInput} onChange={(e) => setCacheInput(e.target.value)} placeholder="name@example.com" />
|
||||
<Button type="button" variant="secondary" onClick={() => addAdditionalEmail(cacheInput)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<datalist id="im-cache">
|
||||
{emailCache.map((e) => <option key={e.email} value={e.email}>{e.name || e.email}</option>)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{quickCache.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-slate-400">Letzte Kontakte</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quickCache.map((entry) => (
|
||||
<button
|
||||
key={entry.email}
|
||||
type="button"
|
||||
onClick={() => addAdditionalEmail(entry.email, entry.name)}
|
||||
className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 hover:border-indigo-400 hover:text-indigo-600 transition"
|
||||
>
|
||||
{entry.name ? `${entry.name} · ${entry.email}` : entry.email}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{additionalRecipients.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-slate-400">Ausgewählt</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{additionalRecipients.map((entry) => (
|
||||
<span key={entry.email} className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 pl-3 pr-1.5 py-1 text-xs font-bold text-slate-600">
|
||||
{entry.name ? `${entry.name} · ${entry.email}` : entry.email}
|
||||
<button type="button" onClick={() => removeAdditionalEmail(entry.email)} className="rounded-full p-0.5 hover:bg-red-100 hover:text-red-600">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm flex items-center justify-between">
|
||||
<p><span className="font-black text-slate-900">{allRecipients.length}</span> <span className="text-slate-500">Empfänger gesamt</span></p>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={() => { void onSendMeeting(); }} disabled={sending || allRecipients.length === 0} className="w-full md:w-auto">
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{sending ? "Wird versendet..." : "Instant Meeting jetzt senden"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 shadow-sm overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-emerald-800">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<p className="font-bold">Versendet an {result.sentCount} Empfänger ({result.scopeLabel})</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-emerald-200 bg-white p-3">
|
||||
<p className="text-xs text-slate-500 mb-1">Meeting-Link</p>
|
||||
<p className="text-sm font-medium text-slate-900 break-all">{result.meetingUrl}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={async () => { await navigator.clipboard.writeText(result.meetingUrl); toast.success("Meeting-Link kopiert."); }}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Link kopieren
|
||||
</Button>
|
||||
<Button type="button" onClick={() => window.open(result.meetingUrl, "_blank", "noopener,noreferrer")}>
|
||||
Meeting öffnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
components/admin/latest-bookings-panel.tsx
Normal file
253
components/admin/latest-bookings-panel.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Archive, Calendar, Mail, Trash2, User } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
|
||||
type SortOption =
|
||||
| "date_desc"
|
||||
| "date_asc"
|
||||
| "customer_asc"
|
||||
| "customer_desc"
|
||||
| "person_asc"
|
||||
| "person_desc";
|
||||
|
||||
type BookingRow = {
|
||||
key: string;
|
||||
id: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
startAt: string;
|
||||
staffNames: string[];
|
||||
staffCount: number;
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortOption; label: string }> = [
|
||||
{ value: "date_desc", label: "Datum: Neueste zuerst" },
|
||||
{ value: "date_asc", label: "Datum: Älteste zuerst" },
|
||||
{ value: "customer_asc", label: "Kunde: A-Z" },
|
||||
{ value: "customer_desc", label: "Kunde: Z-A" },
|
||||
{ value: "person_asc", label: "Person: A-Z" },
|
||||
{ value: "person_desc", label: "Person: Z-A" }
|
||||
];
|
||||
|
||||
export function LatestBookingsPanel(props: {
|
||||
monthTotal: number;
|
||||
monthCancelled: number;
|
||||
monthNoShow: number;
|
||||
}) {
|
||||
const [sort, setSort] = useState<SortOption>("date_desc");
|
||||
const [rows, setRows] = useState<BookingRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
async function loadRows(nextSort: SortOption) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ sort: nextSort });
|
||||
const res = await fetch(`/api/admin/letzte-buchungen?${params.toString()}`, {
|
||||
cache: "no-store"
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(data?.message ?? "Buchungen konnten nicht geladen werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
setRows(data.bookings ?? []);
|
||||
} catch {
|
||||
toast.error("Buchungen konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(sort);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sort]);
|
||||
|
||||
async function execDelete(id: string) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch("/api/admin/letzte-buchungen", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "delete" })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(data?.message ?? "Aktion fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
toast.success("Buchung gelöscht.");
|
||||
await loadRows(sort);
|
||||
} catch {
|
||||
toast.error("Aktion fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(id: string, action: "archive" | "delete") {
|
||||
if (action === "delete") {
|
||||
setConfirmDelete(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch("/api/admin/letzte-buchungen", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(data?.message ?? "Aktion fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(action === "archive" ? "Buchung archiviert." : "Buchung gelöscht.");
|
||||
await loadRows(sort);
|
||||
} catch {
|
||||
toast.error("Aktion fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-[24px] overflow-hidden flex-1">
|
||||
<div className="p-6 border-b border-slate-200 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center">
|
||||
<h2 className="text-lg font-bold text-slate-900">Letzte Buchungen</h2>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-bold text-slate-500">
|
||||
<span>Monat gesamt: {props.monthTotal}</span>
|
||||
<span>•</span>
|
||||
<span>Stornos: {props.monthCancelled}</span>
|
||||
<span>•</span>
|
||||
<span>No-Show: {props.monthNoShow}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.target.value as SortOption)}
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700"
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50/50">
|
||||
<th className="px-6 py-4 font-bold text-slate-500 max-w-[220px]">Kunde</th>
|
||||
<th className="px-6 py-4 font-bold text-slate-500">Datum & Zeit</th>
|
||||
<th className="px-6 py-4 font-bold text-slate-500">Person(en)</th>
|
||||
<th className="px-6 py-4 font-bold text-slate-500 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
|
||||
Lade Buchungen...
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center">
|
||||
<p className="text-lg font-bold text-slate-900 mb-1">Keine Buchungen vorhanden</p>
|
||||
<p className="text-slate-500 font-medium">Deine ausstehenden Termine erscheinen hier.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr key={row.key} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-slate-400" />
|
||||
{row.customerFirstName} {row.customerLastName}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-500 flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-slate-400" />
|
||||
{row.customerEmail}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-slate-400" />
|
||||
{format(new Date(row.startAt), "dd.MM.yyyy HH:mm", { locale: de })}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-600 font-medium">
|
||||
{row.staffNames.join(", ")} ({row.staffCount})
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => {
|
||||
void runAction(row.id, "archive");
|
||||
}}
|
||||
>
|
||||
<Archive className="mr-1 h-3.5 w-3.5" />
|
||||
Archivieren
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={busyId === row.id}
|
||||
onClick={() => {
|
||||
void runAction(row.id, "delete");
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Buchung löschen"
|
||||
message="Diese Buchung wird dauerhaft gelöscht und kann nicht wiederhergestellt werden."
|
||||
confirmLabel="Löschen"
|
||||
variant="danger"
|
||||
loading={busyId !== null}
|
||||
onConfirm={() => {
|
||||
if (confirmDelete) void execDelete(confirmDelete);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
components/admin/legal-pages-settings-panel.tsx
Normal file
170
components/admin/legal-pages-settings-panel.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"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 { Textarea } from "@/components/ui/textarea";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, Save, Shield } from "lucide-react";
|
||||
import { cn, renderLegalTokens } from "@/lib/utils";
|
||||
|
||||
const legalSchema = z.object({
|
||||
privacy_page_title: z.string().trim().min(1, "Titel erforderlich").max(120),
|
||||
privacy_page_content: z.string().trim().min(1, "Inhalt erforderlich").max(12000),
|
||||
imprint_page_title: z.string().trim().min(1, "Titel erforderlich").max(120),
|
||||
imprint_page_content: z.string().trim().min(1, "Inhalt erforderlich").max(12000)
|
||||
});
|
||||
|
||||
type LegalFormValues = z.infer<typeof legalSchema>;
|
||||
|
||||
const TABS = [
|
||||
{ id: "privacy", label: "Datenschutz", icon: Shield },
|
||||
{ id: "imprint", label: "Impressum", icon: Shield }
|
||||
] as const;
|
||||
|
||||
export function LegalPagesSettingsPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [companyName, setCompanyName] = useState("CalBook");
|
||||
const [activeTab, setActiveTab] = useState<"privacy" | "imprint">("privacy");
|
||||
|
||||
const form = useForm<LegalFormValues>({
|
||||
resolver: zodResolver(legalSchema),
|
||||
defaultValues: {
|
||||
privacy_page_title: "Datenschutz",
|
||||
privacy_page_content: "",
|
||||
imprint_page_title: "Impressum",
|
||||
imprint_page_content: ""
|
||||
}
|
||||
});
|
||||
|
||||
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<string, string>;
|
||||
form.reset({
|
||||
privacy_page_title: s[SETTING_KEYS.PRIVACY_PAGE_TITLE] ?? "Datenschutz",
|
||||
privacy_page_content: s[SETTING_KEYS.PRIVACY_PAGE_CONTENT] ?? "",
|
||||
imprint_page_title: s[SETTING_KEYS.IMPRINT_PAGE_TITLE] ?? "Impressum",
|
||||
imprint_page_content: s[SETTING_KEYS.IMPRINT_PAGE_CONTENT] ?? ""
|
||||
});
|
||||
setCompanyName(s[SETTING_KEYS.COMPANY_NAME] ?? "CalBook");
|
||||
} catch { toast.error("Rechtliche Seiten 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.PRIVACY_PAGE_TITLE]: values.privacy_page_title.trim(),
|
||||
[SETTING_KEYS.PRIVACY_PAGE_CONTENT]: values.privacy_page_content.trim(),
|
||||
[SETTING_KEYS.IMPRINT_PAGE_TITLE]: values.imprint_page_title.trim(),
|
||||
[SETTING_KEYS.IMPRINT_PAGE_CONTENT]: values.imprint_page_content.trim()
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!res.ok) { toast.error("Seiten konnten nicht gespeichert werden."); return; }
|
||||
toast.success("Rechtliche Seiten gespeichert.");
|
||||
}, () => { toast.error("Bitte prüfe die Eingaben."); });
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Rechtliches</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">Datenschutz und Impressum</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
{/* Tabs */}
|
||||
<div className="flex rounded-t-2xl border border-b-0 border-slate-200 bg-slate-50/80">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-5 py-3.5 text-sm font-bold transition-all first:rounded-tl-2xl",
|
||||
activeTab === tab.id
|
||||
? "border-b-2 border-slate-900 bg-white text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-b-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
{activeTab === "privacy" && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="privacy-title">Titel</Label>
|
||||
<Input id="privacy-title" {...form.register("privacy_page_title")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="privacy-content">Inhalt</Label>
|
||||
<Textarea id="privacy-content" {...form.register("privacy_page_content")} rows={12} />
|
||||
<p className="text-xs text-slate-400">{"Platzhalter: {{companyName}}, {{year}}"}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-2 text-xs font-black uppercase tracking-widest text-slate-400 flex items-center gap-1.5">
|
||||
<Eye className="h-3.5 w-3.5" /> Vorschau
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-slate-900">{form.watch("privacy_page_title") || "Datenschutz"}</h3>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm text-slate-700 leading-relaxed">
|
||||
{renderLegalTokens(form.watch("privacy_page_content") || "", companyName)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "imprint" && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="imprint-title">Titel</Label>
|
||||
<Input id="imprint-title" {...form.register("imprint_page_title")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="imprint-content">Inhalt</Label>
|
||||
<Textarea id="imprint-content" {...form.register("imprint_page_content")} rows={12} />
|
||||
<p className="text-xs text-slate-400">{"Platzhalter: {{companyName}}, {{year}}"}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<p className="mb-2 text-xs font-black uppercase tracking-widest text-slate-400 flex items-center gap-1.5">
|
||||
<Eye className="h-3.5 w-3.5" /> Vorschau
|
||||
</p>
|
||||
<h3 className="text-lg font-bold text-slate-900">{form.watch("imprint_page_title") || "Impressum"}</h3>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm text-slate-700 leading-relaxed">
|
||||
{renderLegalTokens(form.watch("imprint_page_content") || "", companyName)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button type="submit" size="lg" disabled={form.formState.isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{form.formState.isSubmitting ? "Speichert..." : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
720
components/admin/settings-panel.tsx
Normal file
720
components/admin/settings-panel.tsx
Normal file
@@ -0,0 +1,720 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CalendarCog,
|
||||
CheckCircle2,
|
||||
Mail,
|
||||
Save,
|
||||
Video,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsMap = Record<string, string>;
|
||||
|
||||
type SmtpTestState = {
|
||||
status: "idle" | "loading" | "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
function extractAddressFromFromHeader(value: string | undefined) {
|
||||
if (!value) return "";
|
||||
const match = value.match(/<([^>]+)>/);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function extractNameFromFromHeader(value: string | undefined) {
|
||||
if (!value) return "";
|
||||
const match = value.match(/^([^<]+)<[^>]+>$/);
|
||||
if (!match?.[1]) return "";
|
||||
return match[1].trim().replace(/^"(.+)"$/, "$1");
|
||||
}
|
||||
|
||||
const JITSI_MODE_OPTIONS = [
|
||||
{ value: "public", label: "Öffentlich (meet.jit.si)" },
|
||||
{ value: "custom", label: "Eigene Jitsi-URL" }
|
||||
] as const;
|
||||
|
||||
const SMTP_SETUP_STEPS = ["Server", "Absender", "Prüfen"] as const;
|
||||
|
||||
function emptySmtpTestState(): SmtpTestState {
|
||||
return { status: "idle", message: "Noch nicht getestet." };
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: "general", label: "Allgemein", icon: <Building2 className="h-4 w-4" /> },
|
||||
{ id: "booking", label: "Buchungsregeln", icon: <CalendarCog className="h-4 w-4" /> },
|
||||
{ id: "jitsi", label: "Jitsi Meet", icon: <Video className="h-4 w-4" /> },
|
||||
{ id: "smtp", label: "SMTP", icon: <Mail className="h-4 w-4" /> }
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
function SmtpTestBox({ state }: { state: SmtpTestState }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-4 text-sm",
|
||||
state.status === "success" && "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
state.status === "error" && "border-red-200 bg-red-50 text-red-800",
|
||||
state.status === "loading" && "border-blue-200 bg-blue-50 text-blue-800",
|
||||
state.status === "idle" && "border-slate-200 bg-white text-slate-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{state.status === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : state.status === "error" ? (
|
||||
<Zap className="h-4 w-4 text-red-500" />
|
||||
) : state.status === "loading" ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-blue-400 border-t-transparent" />
|
||||
) : null}
|
||||
<p className="font-bold">
|
||||
{state.status === "success"
|
||||
? "SMTP-Test erfolgreich"
|
||||
: state.status === "error"
|
||||
? "SMTP-Test fehlgeschlagen"
|
||||
: state.status === "loading"
|
||||
? "SMTP-Test läuft"
|
||||
: "SMTP-Test"}
|
||||
</p>
|
||||
</div>
|
||||
<p>{state.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settingsSchema = z
|
||||
.object({
|
||||
company_name: z.string().min(1),
|
||||
contact_email: z.string().email(),
|
||||
default_duration_minutes: z.string().min(1),
|
||||
buffer_minutes: z.string().min(1),
|
||||
booking_lead_hours: z.string().min(1),
|
||||
booking_window_days: z.string().min(1),
|
||||
cancel_limit_hours: z.string().min(1),
|
||||
reminder_primary_hours: z.string().regex(/^\d+$/, "Bitte eine Zahl eingeben"),
|
||||
reminder_secondary_hours: z.string().regex(/^\d+$/, "Bitte eine Zahl eingeben"),
|
||||
jitsi_meeting_mode: z.enum(["public", "custom"]),
|
||||
jitsi_base_url: z.string().trim().url("Bitte eine gültige Jitsi-URL eingeben"),
|
||||
jitsi_room_prefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, "Bitte ein Präfix mit mindestens 2 Zeichen eingeben")
|
||||
.regex(/^[a-z0-9-]+$/, "Nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt"),
|
||||
booking_notice_text: z.string().min(1),
|
||||
smtp_host: z.string().optional().default(""),
|
||||
smtp_port: z.string().regex(/^\d+$/, "Bitte einen numerischen SMTP-Port eingeben"),
|
||||
smtp_user: z.string().optional().default(""),
|
||||
smtp_pass: z.string().optional().default(""),
|
||||
smtp_from_name: z.string().min(1),
|
||||
smtp_from: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
||||
"Bitte eine gültige Absender-E-Mail eingeben"
|
||||
)
|
||||
.default("")
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
const first = Number(values.reminder_primary_hours);
|
||||
const second = Number(values.reminder_secondary_hours);
|
||||
|
||||
if (!Number.isFinite(first) || first < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["reminder_primary_hours"],
|
||||
message: "Reminder 1 muss mindestens 1 Stunde sein."
|
||||
});
|
||||
}
|
||||
|
||||
if (!Number.isFinite(second) || second < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["reminder_secondary_hours"],
|
||||
message: "Reminder 2 muss mindestens 1 Stunde sein."
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isFinite(first) && Number.isFinite(second) && first <= second) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["reminder_primary_hours"],
|
||||
message: "Reminder 1 muss später liegen als Reminder 2 (z. B. 24 und 1)."
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||
|
||||
export function SettingsPanel() {
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useState<SettingsMap | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("general");
|
||||
const [smtpTestTo, setSmtpTestTo] = useState("");
|
||||
const [smtpTestLoading, setSmtpTestLoading] = useState(false);
|
||||
const [smtpStep, setSmtpStep] = useState(0);
|
||||
const [smtpTestState, setSmtpTestState] = useState<SmtpTestState>(() => emptySmtpTestState());
|
||||
|
||||
const settingsForm = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsSchema)
|
||||
});
|
||||
const jitsiMode = settingsForm.watch("jitsi_meeting_mode");
|
||||
const smtpHost = settingsForm.watch("smtp_host") ?? "";
|
||||
const smtpPort = settingsForm.watch("smtp_port") ?? "587";
|
||||
const smtpUser = settingsForm.watch("smtp_user") ?? "";
|
||||
const smtpPass = settingsForm.watch("smtp_pass") ?? "";
|
||||
const smtpFromName = settingsForm.watch("smtp_from_name") ?? "CalBook";
|
||||
const smtpFrom = settingsForm.watch("smtp_from") ?? "";
|
||||
const smtpFromPreview = `${smtpFromName || "CalBook"} <${
|
||||
smtpFrom || smtpUser || "no-reply@calbook.local"
|
||||
}>`;
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const settingsRes = await fetch("/api/admin/einstellungen", { cache: "no-store" });
|
||||
const settingsData = await settingsRes.json();
|
||||
setSettings(settingsData.settings ?? {});
|
||||
|
||||
setSmtpTestTo(
|
||||
settingsData.settings?.contact_email ??
|
||||
extractAddressFromFromHeader(settingsData.settings?.smtp_from) ??
|
||||
""
|
||||
);
|
||||
setSmtpStep(0);
|
||||
setSmtpTestState(emptySmtpTestState());
|
||||
|
||||
settingsForm.reset({
|
||||
company_name: settingsData.settings?.company_name ?? "CalBook",
|
||||
contact_email: settingsData.settings?.contact_email ?? "",
|
||||
default_duration_minutes: settingsData.settings?.default_duration_minutes ?? "60",
|
||||
buffer_minutes: settingsData.settings?.buffer_minutes ?? "10",
|
||||
booking_lead_hours: settingsData.settings?.booking_lead_hours ?? "2",
|
||||
booking_window_days: settingsData.settings?.booking_window_days ?? "60",
|
||||
cancel_limit_hours: settingsData.settings?.cancel_limit_hours ?? "24",
|
||||
reminder_primary_hours: settingsData.settings?.reminder_primary_hours ?? "24",
|
||||
reminder_secondary_hours: settingsData.settings?.reminder_secondary_hours ?? "1",
|
||||
jitsi_meeting_mode: settingsData.settings?.jitsi_meeting_mode ?? "public",
|
||||
jitsi_base_url: settingsData.settings?.jitsi_base_url || "https://meet.jit.si",
|
||||
jitsi_room_prefix: settingsData.settings?.jitsi_room_prefix || "calbook",
|
||||
booking_notice_text: settingsData.settings?.booking_notice_text ?? "",
|
||||
smtp_host: settingsData.settings?.smtp_host ?? "",
|
||||
smtp_port: settingsData.settings?.smtp_port ?? "587",
|
||||
smtp_user: settingsData.settings?.smtp_user ?? "",
|
||||
smtp_pass: settingsData.settings?.smtp_pass ?? "",
|
||||
smtp_from_name:
|
||||
settingsData.settings?.smtp_from_name ??
|
||||
extractNameFromFromHeader(settingsData.settings?.smtp_from) ??
|
||||
settingsData.settings?.company_name ??
|
||||
"CalBook",
|
||||
smtp_from: extractAddressFromFromHeader(settingsData.settings?.smtp_from)
|
||||
});
|
||||
} catch {
|
||||
toast.error("Einstellungen konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSmtpTestState((prev) => {
|
||||
if (prev.status === "idle" || prev.status === "loading") return prev;
|
||||
return { status: "idle", message: "SMTP-Daten wurden geändert. Bitte erneut testen." };
|
||||
});
|
||||
}, [smtpHost, smtpPort, smtpUser, smtpPass, smtpFromName, smtpFrom]);
|
||||
|
||||
const onSaveSettings = settingsForm.handleSubmit(
|
||||
async (values) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/einstellungen", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error("Einstellungen konnten nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Einstellungen gespeichert");
|
||||
await loadSettings();
|
||||
router.refresh();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
toast.error("Bitte prüfe die Eingaben in den Einstellungen.");
|
||||
}
|
||||
);
|
||||
|
||||
async function sendSmtpTest() {
|
||||
const values = settingsForm.getValues();
|
||||
const host = values.smtp_host?.trim() ?? "";
|
||||
const port = values.smtp_port?.trim() ?? "";
|
||||
|
||||
if (!host || !port) {
|
||||
toast.error("Bitte SMTP-Host und SMTP-Port eintragen.");
|
||||
setSmtpTestState({ status: "error", message: "SMTP-Host und Port fehlen." });
|
||||
return;
|
||||
}
|
||||
|
||||
const formIsValid = await settingsForm.trigger(["smtp_host", "smtp_port", "smtp_from_name", "smtp_from"]);
|
||||
if (!formIsValid) {
|
||||
toast.error("Bitte prüfe Absendername und Absender-E-Mail.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!smtpTestTo.trim()) {
|
||||
toast.error("Bitte eine Empfänger-E-Mail für den SMTP-Test angeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSmtpTestLoading(true);
|
||||
setSmtpTestState({
|
||||
status: "loading",
|
||||
message: "Testmail wird mit den aktuell eingetragenen SMTP-Daten versendet ..."
|
||||
});
|
||||
try {
|
||||
const res = await fetch("/api/admin/einstellungen/test-smtp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
to: smtpTestTo.trim(),
|
||||
smtp: {
|
||||
host,
|
||||
port,
|
||||
user: values.smtp_user?.trim() ?? "",
|
||||
pass: values.smtp_pass ?? "",
|
||||
fromName: values.smtp_from_name?.trim() ?? "CalBook",
|
||||
from: values.smtp_from?.trim() ?? ""
|
||||
}
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setSmtpTestState({ status: "error", message: data?.message ?? "SMTP-Test fehlgeschlagen." });
|
||||
toast.error(data?.message ?? "SMTP-Test fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
setSmtpTestState({
|
||||
status: "success",
|
||||
message: data?.message ?? "Testmail wurde erfolgreich versendet."
|
||||
});
|
||||
toast.success("SMTP-Testmail wurde versendet.");
|
||||
} catch {
|
||||
setSmtpTestState({ status: "error", message: "SMTP-Test fehlgeschlagen." });
|
||||
toast.error("SMTP-Test fehlgeschlagen.");
|
||||
} finally {
|
||||
setSmtpTestLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function goToNextSmtpStep() {
|
||||
if (smtpStep === 0) {
|
||||
const values = settingsForm.getValues();
|
||||
if (!values.smtp_host?.trim() || !values.smtp_port?.trim()) {
|
||||
toast.error("Bitte SMTP-Host und SMTP-Port eintragen.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (smtpStep === 1) {
|
||||
const formIsValid = await settingsForm.trigger(["smtp_from_name", "smtp_from"]);
|
||||
if (!formIsValid) {
|
||||
toast.error("Bitte prüfe die Absenderdaten.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSmtpStep((prev) => Math.min(prev + 1, SMTP_SETUP_STEPS.length - 1));
|
||||
}
|
||||
|
||||
if (loading || !settings) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<form onSubmit={onSaveSettings}>
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Einstellungen</h1>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">
|
||||
Firma, Buchungsregeln und Kommunikation konfigurieren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-0 flex rounded-t-2xl border border-b-0 border-slate-200 bg-slate-50/80">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-5 py-3.5 text-sm font-bold transition-all first:rounded-tl-2xl",
|
||||
activeTab === tab.id
|
||||
? "border-b-2 border-slate-900 bg-white text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="rounded-b-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
{/* Allgemein */}
|
||||
{activeTab === "general" && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
||||
Firmeninformationen
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="company_name">Firmenname</Label>
|
||||
<Input id="company_name" {...settingsForm.register("company_name")} />
|
||||
<p className="text-xs text-slate-400">Erscheint in E-Mails und auf der Buchungsseite.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact_email">Kontakt-E-Mail</Label>
|
||||
<Input id="contact_email" {...settingsForm.register("contact_email")} />
|
||||
<p className="text-xs text-slate-400">Wird für SMTP-Test und als Rückfall-Adresse verwendet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="booking_notice_text">Hinweistext Buchungsseite</Label>
|
||||
<Textarea id="booking_notice_text" {...settingsForm.register("booking_notice_text")} />
|
||||
<p className="text-xs text-slate-400">Optionaler Hinweis, der auf der öffentlichen Buchungsseite angezeigt wird.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buchungsregeln */}
|
||||
{activeTab === "booking" && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
||||
Termin-Parameter
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="default_duration_minutes">Standard-Dauer (Min.)</Label>
|
||||
<Input id="default_duration_minutes" {...settingsForm.register("default_duration_minutes")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="buffer_minutes">Puffer (Min.)</Label>
|
||||
<Input id="buffer_minutes" {...settingsForm.register("buffer_minutes")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="booking_lead_hours">Buchungsvorlauf (Std.)</Label>
|
||||
<Input id="booking_lead_hours" {...settingsForm.register("booking_lead_hours")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="booking_window_days">Buchungsfenster (Tage)</Label>
|
||||
<Input id="booking_window_days" {...settingsForm.register("booking_window_days")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cancel_limit_hours">Storno-Limit (Std.)</Label>
|
||||
<Input id="cancel_limit_hours" {...settingsForm.register("cancel_limit_hours")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
||||
Buchbare Wochentage und Uhrzeiten werden pro Personen-Kalender unter{" "}
|
||||
<strong>Kalender</strong> gepflegt.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
||||
Erinnerungen
|
||||
</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder_primary_hours">Erste Erinnerung (Std. vorher)</Label>
|
||||
<Input id="reminder_primary_hours" {...settingsForm.register("reminder_primary_hours")} />
|
||||
<p className="text-xs text-slate-400">Z. B. 24 für einen Tag vorher.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reminder_secondary_hours">Zweite Erinnerung (Std. vorher)</Label>
|
||||
<Input id="reminder_secondary_hours" {...settingsForm.register("reminder_secondary_hours")} />
|
||||
<p className="text-xs text-slate-400">Muss kleiner als die erste sein (z. B. 1).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 p-3 text-xs text-slate-500">
|
||||
Beide Erinnerungen gelten für Kunde und Kalenderbesitzer, aber nicht für Instant Meetings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jitsi Meet */}
|
||||
{activeTab === "jitsi" && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
||||
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400 mb-4">
|
||||
Videokonferenz
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="jitsi_meeting_mode">Jitsi-Modus</Label>
|
||||
<select
|
||||
id="jitsi_meeting_mode"
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm font-medium text-slate-900 transition-all focus:border-indigo-600 focus:outline-none focus:ring-1 focus:ring-indigo-600"
|
||||
{...settingsForm.register("jitsi_meeting_mode")}
|
||||
>
|
||||
{JITSI_MODE_OPTIONS.map((mode) => (
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-400">
|
||||
Öffentlich vermeidet Moderator-Login und ist für Kundentermine am einfachsten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="jitsi_base_url">Jitsi-Basis-URL</Label>
|
||||
<Input
|
||||
id="jitsi_base_url"
|
||||
{...settingsForm.register("jitsi_base_url")}
|
||||
placeholder="https://meet.jit.si"
|
||||
disabled={jitsiMode === "public"}
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Beispiel: <code>https://meet.jit.si</code> oder eigene Jitsi-Domain (nur bei „Eigene Jitsi-URL“).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="jitsi_room_prefix">Jitsi-Raum-Präfix</Label>
|
||||
<Input
|
||||
id="jitsi_room_prefix"
|
||||
{...settingsForm.register("jitsi_room_prefix")}
|
||||
placeholder="calbook"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Wird vor jede Raum-ID gesetzt, z. B. <code>calbook-abc123</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMTP */}
|
||||
{activeTab === "smtp" && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-200 space-y-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-black uppercase tracking-[0.22em] text-slate-400">
|
||||
SMTP-Assistent
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-500">
|
||||
Serverdaten eintragen, Absender prüfen und Testmail senden.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-xs text-slate-600">
|
||||
Absender: <span className="font-bold text-slate-900">{smtpFromPreview}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex gap-2">
|
||||
{SMTP_SETUP_STEPS.map((step, index) => (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
onClick={() => setSmtpStep(index)}
|
||||
className={cn(
|
||||
"flex-1 rounded-xl border px-4 py-3 text-left text-sm font-bold transition-all",
|
||||
smtpStep === index
|
||||
? "border-slate-900 bg-slate-900 text-white shadow-sm"
|
||||
: smtpStep > index
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300"
|
||||
)}
|
||||
>
|
||||
<span className="block text-[10px] opacity-70">Schritt {index + 1}</span>
|
||||
{smtpStep > index ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> {step}
|
||||
</span>
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
||||
{smtpStep === 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-host">SMTP-Host*</Label>
|
||||
<Input id="smtp-host" {...settingsForm.register("smtp_host")} placeholder="mail.example.com" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-port">SMTP-Port*</Label>
|
||||
<Input id="smtp-port" {...settingsForm.register("smtp_port")} placeholder="587" />
|
||||
<p className="text-xs text-slate-400">
|
||||
Port 465 = SSL, sonst STARTTLS falls angeboten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-user">SMTP-Benutzer</Label>
|
||||
<Input id="smtp-user" {...settingsForm.register("smtp_user")} autoComplete="username" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-pass">SMTP-Passwort</Label>
|
||||
<Input
|
||||
id="smtp-pass"
|
||||
type="password"
|
||||
{...settingsForm.register("smtp_pass")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-from-name">Absender-Name*</Label>
|
||||
<Input id="smtp-from-name" {...settingsForm.register("smtp_from_name")} placeholder="CalBook" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-from">Absender-E-Mail</Label>
|
||||
<Input id="smtp-from" {...settingsForm.register("smtp_from")} placeholder="no-reply@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm">
|
||||
<p className="font-bold text-slate-900 mb-1">Vorschau</p>
|
||||
<p className="break-all font-mono text-slate-700">{smtpFromPreview}</p>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
Viele SMTP-Server erzwingen als technische Absenderadresse den SMTP-Benutzer.
|
||||
Der sichtbare Name bleibt trotzdem steuerbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpStep === 2 && (
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_320px]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 text-sm">
|
||||
<p className="mb-2 font-bold text-slate-900">Zusammenfassung</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-slate-600">
|
||||
<p>Host:</p><p className="font-medium text-slate-900">{smtpHost || "-"}</p>
|
||||
<p>Port:</p><p className="font-medium text-slate-900">{smtpPort || "-"}</p>
|
||||
<p>Benutzer:</p><p className="font-medium text-slate-900">{smtpUser || "ohne Auth"}</p>
|
||||
</div>
|
||||
<p className="mt-2 break-all text-xs text-slate-500">
|
||||
Absender: <span className="font-medium text-slate-700">{smtpFromPreview}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp-test-to">SMTP-Testempfänger*</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
id="smtp-test-to"
|
||||
type="email"
|
||||
value={smtpTestTo}
|
||||
onChange={(e) => {
|
||||
setSmtpTestTo(e.target.value);
|
||||
setSmtpTestState(emptySmtpTestState());
|
||||
}}
|
||||
placeholder="test@example.com"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => void sendSmtpTest()}
|
||||
disabled={smtpTestLoading}
|
||||
>
|
||||
{smtpTestLoading ? "Test läuft..." : "Testmail senden"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Der Test nutzt die aktuellen Eingaben, auch wenn sie noch nicht gespeichert sind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SmtpTestBox state={smtpTestState} />
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
Nach erfolgreichem Test speichern, damit die SMTP-Daten aktiv für Buchungen,
|
||||
Erinnerungen und Instant Meetings genutzt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SMTP navigation */}
|
||||
<div className="mt-5 flex justify-between border-t border-slate-200 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setSmtpStep((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={smtpStep === 0}
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
{smtpStep < SMTP_SETUP_STEPS.length - 1 ? (
|
||||
<Button type="button" onClick={() => void goToNextSmtpStep()}>
|
||||
Weiter
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => void sendSmtpTest()}
|
||||
disabled={smtpTestLoading}
|
||||
>
|
||||
{smtpTestLoading ? "Test läuft..." : "Jetzt SMTP prüfen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom save button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button type="submit" size="lg" disabled={saving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? "Speichert..." : "Einstellungen speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
components/booking/cancel-form.tsx
Normal file
143
components/booking/cancel-form.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { PublicFooter } from "@/components/layout/public-footer";
|
||||
|
||||
export function CancelForm({
|
||||
initialToken = "",
|
||||
companyName = "CalBook",
|
||||
footerPrivacyLabel = "Datenschutz",
|
||||
footerPrivacyUrl = "/datenschutz",
|
||||
footerImprintLabel = "Impressum",
|
||||
footerImprintUrl = "/impressum",
|
||||
footerCopyrightText = "© {{year}} {{companyName}}"
|
||||
}: {
|
||||
initialToken?: string;
|
||||
companyName?: string;
|
||||
footerPrivacyLabel?: string;
|
||||
footerPrivacyUrl?: string;
|
||||
footerImprintLabel?: string;
|
||||
footerImprintUrl?: string;
|
||||
footerCopyrightText?: string;
|
||||
}) {
|
||||
const [token, setToken] = useState(initialToken);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
async function onCancel() {
|
||||
if (!token.trim()) {
|
||||
setStatus("error");
|
||||
setErrorMsg("Bitte Token aus der E-Mail eingeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/public/stornieren", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token.trim()
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setErrorMsg(data?.message ?? "Stornierung fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("success");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setErrorMsg("Netzwerkfehler bei der Stornierung.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col font-sans">
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-4">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: "var(--accent)" }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-lg font-black text-slate-900">{companyName}</h1>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md p-8 text-center ring-1 ring-slate-100">
|
||||
{status === "loading" ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4" />
|
||||
<h2 className="text-xl font-bold text-slate-900">Termin wird storniert...</h2>
|
||||
<p className="text-slate-500 mt-2 text-sm">
|
||||
Bitte warten, wir stornieren deinen Termin im System.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "success" ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<CheckCircle2 className="w-16 h-16 text-emerald-500 mb-4" />
|
||||
<h2 className="text-2xl font-black text-slate-900 tracking-tight">Erfolgreich storniert!</h2>
|
||||
<p className="text-slate-600 mt-3 text-sm leading-relaxed">
|
||||
Dein Termin wurde erfolgreich abgesagt und aus unserem Kalender entfernt.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "error" ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<XCircle className="w-16 h-16 text-red-500 mb-4" />
|
||||
<h2 className="text-2xl font-black text-slate-900 tracking-tight">Ein Fehler ist aufgetreten</h2>
|
||||
<p className="text-slate-600 mt-3 text-sm leading-relaxed">{errorMsg}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus("idle")}
|
||||
className="mt-6 h-10 px-5 rounded-xl border border-slate-200 bg-slate-50 text-sm font-bold text-slate-700 hover:bg-slate-100"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "idle" ? (
|
||||
<div className="space-y-4 text-left">
|
||||
<h2 className="text-2xl font-black text-slate-900 tracking-tight text-center">Termin stornieren</h2>
|
||||
<p className="text-slate-600 text-sm text-center">
|
||||
Bitte den Stornierungs-Token aus deiner E-Mail eingeben.
|
||||
</p>
|
||||
<input
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
placeholder="Token"
|
||||
className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:border-indigo-600 focus:ring-1 focus:ring-indigo-600 transition-all font-medium text-slate-900 placeholder:text-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCancel()}
|
||||
className="w-full h-11 bg-slate-900 text-white rounded-xl flex items-center justify-center font-bold hover:bg-slate-800 transition-all"
|
||||
>
|
||||
Jetzt stornieren
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<PublicFooter
|
||||
companyName={companyName}
|
||||
privacyLabel={footerPrivacyLabel}
|
||||
privacyHref={footerPrivacyUrl}
|
||||
imprintLabel={footerImprintLabel}
|
||||
imprintHref={footerImprintUrl}
|
||||
copyrightTemplate={footerCopyrightText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
components/booking/embed-mode.tsx
Normal file
24
components/booking/embed-mode.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function EmbedMode({ enabled }: { enabled: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const header = document.querySelector("body > div > header, body > div > div > header");
|
||||
const footer = document.querySelector("body > div > footer, body > div > div > footer");
|
||||
|
||||
if (header) header.classList.add("hidden");
|
||||
if (footer) footer.classList.add("hidden");
|
||||
document.body.classList.add("bg-white");
|
||||
|
||||
return () => {
|
||||
if (header) header.classList.remove("hidden");
|
||||
if (footer) footer.classList.remove("hidden");
|
||||
document.body.classList.remove("bg-white");
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return null;
|
||||
}
|
||||
1652
components/booking/public-booking-flow.tsx
Normal file
1652
components/booking/public-booking-flow.tsx
Normal file
File diff suppressed because it is too large
Load Diff
55
components/booking/shared-legal-page.tsx
Normal file
55
components/booking/shared-legal-page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { LegalContentCard } from "@/components/layout/legal-content-card";
|
||||
import { PublicFooter } from "@/components/layout/public-footer";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { renderLegalTokens } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function SharedLegalPage({
|
||||
type
|
||||
}: {
|
||||
type: "privacy" | "imprint";
|
||||
}) {
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.COMPANY_NAME,
|
||||
type === "privacy" ? SETTING_KEYS.PRIVACY_PAGE_TITLE : SETTING_KEYS.IMPRINT_PAGE_TITLE,
|
||||
type === "privacy" ? SETTING_KEYS.PRIVACY_PAGE_CONTENT : SETTING_KEYS.IMPRINT_PAGE_CONTENT,
|
||||
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<string, string>));
|
||||
|
||||
const companyName = settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook";
|
||||
const title =
|
||||
type === "privacy"
|
||||
? (settings[SETTING_KEYS.PRIVACY_PAGE_TITLE] ?? "Datenschutz")
|
||||
: (settings[SETTING_KEYS.IMPRINT_PAGE_TITLE] ?? "Impressum");
|
||||
const content = renderLegalTokens(
|
||||
type === "privacy"
|
||||
? (settings[SETTING_KEYS.PRIVACY_PAGE_CONTENT] ?? "")
|
||||
: (settings[SETTING_KEYS.IMPRINT_PAGE_CONTENT] ?? ""),
|
||||
companyName
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col font-sans">
|
||||
<main className="flex-1 mx-auto w-full max-w-3xl px-4 py-8 lg:py-12">
|
||||
<LegalContentCard title={title} content={content} />
|
||||
</main>
|
||||
<PublicFooter
|
||||
companyName={companyName}
|
||||
privacyLabel={settings[SETTING_KEYS.FOOTER_PRIVACY_LABEL] ?? "Datenschutz"}
|
||||
privacyHref={settings[SETTING_KEYS.FOOTER_PRIVACY_URL] ?? "/datenschutz"}
|
||||
imprintLabel={settings[SETTING_KEYS.FOOTER_IMPRINT_LABEL] ?? "Impressum"}
|
||||
imprintHref={settings[SETTING_KEYS.FOOTER_IMPRINT_URL] ?? "/impressum"}
|
||||
copyrightTemplate={
|
||||
settings[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] ??
|
||||
"© {{year}} {{companyName}}"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
components/layout/accent-color.tsx
Normal file
11
components/layout/accent-color.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AccentColorScript({ color }: { color: string }) {
|
||||
useEffect(() => {
|
||||
if (color) document.documentElement.style.setProperty("--accent", color);
|
||||
}, [color]);
|
||||
|
||||
return null;
|
||||
}
|
||||
73
components/layout/animated-page.tsx
Normal file
73
components/layout/animated-page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const pageVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
export function AnimatedPage({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={pageVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedCard({
|
||||
children,
|
||||
className,
|
||||
delay = 0
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimatedStagger({
|
||||
children,
|
||||
staggerDelay = 0.06
|
||||
}: {
|
||||
children: ReactNode;
|
||||
staggerDelay?: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: staggerDelay } }
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
38
components/layout/legal-content-card.tsx
Normal file
38
components/layout/legal-content-card.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type LegalContentCardProps = {
|
||||
title: string;
|
||||
content: string;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
};
|
||||
|
||||
export function LegalContentCard({
|
||||
title,
|
||||
content,
|
||||
backHref = "/buchen",
|
||||
backLabel = "Zurück zur Buchung"
|
||||
}: LegalContentCardProps) {
|
||||
return (
|
||||
<motion.article
|
||||
className="rounded-2xl border border-slate-200 bg-white p-6 lg:p-8 space-y-5"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{title}</h1>
|
||||
<div className="whitespace-pre-wrap text-sm text-slate-700 leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex text-sm font-medium text-indigo-600 hover:text-indigo-700"
|
||||
>
|
||||
{backLabel}
|
||||
</Link>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
58
components/layout/public-footer.tsx
Normal file
58
components/layout/public-footer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PublicFooterProps = {
|
||||
companyName?: string;
|
||||
privacyLabel?: string;
|
||||
privacyHref?: string;
|
||||
imprintLabel?: string;
|
||||
imprintHref?: string;
|
||||
copyrightTemplate?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PublicFooter({
|
||||
companyName = "CalBook",
|
||||
privacyLabel = "Datenschutz",
|
||||
privacyHref = "/datenschutz",
|
||||
imprintLabel = "Impressum",
|
||||
imprintHref = "/impressum",
|
||||
copyrightTemplate = "© {{year}} {{companyName}}",
|
||||
className
|
||||
}: PublicFooterProps) {
|
||||
const year = String(new Date().getFullYear());
|
||||
const copyrightText = (copyrightTemplate || "© {{year}} {{companyName}}")
|
||||
.replace(/\{\{\s*year\s*\}\}/gi, year)
|
||||
.replace(/\{\{\s*companyName\s*\}\}/gi, companyName)
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<footer className={cn("w-full border-t border-slate-200 bg-slate-50", className)}>
|
||||
<div className="w-full px-4 py-4 lg:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<nav className="flex h-5 items-center gap-4 text-xs leading-none text-slate-500">
|
||||
{privacyLabel && privacyHref ? (
|
||||
<Link
|
||||
href={privacyHref}
|
||||
className="inline-flex h-5 items-center whitespace-nowrap transition-colors hover:text-slate-700"
|
||||
>
|
||||
{privacyLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
{imprintLabel && imprintHref ? (
|
||||
<Link
|
||||
href={imprintHref}
|
||||
className="inline-flex h-5 items-center whitespace-nowrap transition-colors hover:text-slate-700"
|
||||
>
|
||||
{imprintLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
<p className="flex h-5 items-center whitespace-nowrap text-right text-xs leading-none text-slate-400">
|
||||
{copyrightText || `© ${year} ${companyName}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
8
components/layout/session-provider.tsx
Normal file
8
components/layout/session-provider.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export function AuthSessionProvider({ children }: { children: ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
34
components/layout/theme-provider.tsx
Normal file
34
components/layout/theme-provider.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export type ThemeModeSetting = "light" | "dark" | "auto";
|
||||
|
||||
function toNextTheme(mode: ThemeModeSetting): "light" | "dark" | "system" {
|
||||
if (mode === "light") return "light";
|
||||
if (mode === "dark") return "dark";
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
mode = "auto"
|
||||
}: {
|
||||
children: ReactNode;
|
||||
mode?: ThemeModeSetting;
|
||||
}) {
|
||||
const defaultTheme = toNextTheme(mode);
|
||||
const enableSystem = mode === "auto";
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme={defaultTheme}
|
||||
enableSystem={enableSystem}
|
||||
forcedTheme={mode === "auto" ? undefined : mode}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
41
components/ui/button.tsx
Normal file
41
components/ui/button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex tap-target items-center justify-center rounded-xl px-4 py-2 text-sm font-bold transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600/40 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-900 text-white hover:bg-slate-800 shadow-sm",
|
||||
secondary: "bg-slate-100 text-slate-700 hover:bg-slate-200 border border-slate-200",
|
||||
ghost: "bg-transparent text-slate-600 hover:bg-slate-100",
|
||||
outline: "border border-slate-200 bg-white text-slate-700 hover:bg-slate-50",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700 shadow-sm"
|
||||
},
|
||||
size: {
|
||||
default: "h-11",
|
||||
sm: "h-10 px-3 text-xs",
|
||||
lg: "h-12 px-6 text-base",
|
||||
icon: "h-10 w-10"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
26
components/ui/card.tsx
Normal file
26
components/ui/card.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[24px] border border-slate-200 bg-white text-slate-900 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("px-6 pt-6 pb-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("text-lg font-bold text-slate-900", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("px-6 pb-6", className)} {...props} />;
|
||||
}
|
||||
61
components/ui/confirm-dialog.tsx
Normal file
61
components/ui/confirm-dialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
variant?: "danger" | "default";
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Bestätigen",
|
||||
variant = "danger",
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-in fade-in duration-150">
|
||||
<div className="absolute inset-0 bg-slate-950/50" onClick={onCancel} />
|
||||
<div className={cn(
|
||||
"relative rounded-2xl border bg-white p-6 shadow-2xl max-w-sm w-full animate-in zoom-in-95 fade-in duration-150",
|
||||
variant === "danger" && "border-red-200"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full",
|
||||
variant === "danger" ? "bg-red-100" : "bg-amber-100"
|
||||
)}>
|
||||
<AlertTriangle className={cn("h-6 w-6", variant === "danger" ? "text-red-600" : "text-amber-600")} />
|
||||
</div>
|
||||
<h3 className="text-base font-black text-slate-900 mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-500 mb-6">{message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={loading}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant === "danger" ? "destructive" : "default"}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Bitte warten..." : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
components/ui/input.tsx
Normal file
20
components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm font-medium text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:border-indigo-600 focus-visible:ring-1 focus-visible:ring-indigo-600 disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
15
components/ui/label.tsx
Normal file
15
components/ui/label.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn("text-xs font-bold uppercase tracking-wide text-slate-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Label.displayName = "Label";
|
||||
5
components/ui/skeleton.tsx
Normal file
5
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("animate-pulse rounded-2xl bg-muted", className)} />;
|
||||
}
|
||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"min-h-[120px] w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:border-indigo-600 focus-visible:ring-1 focus-visible:ring-indigo-600 disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
444
deploy.sh
Executable file
444
deploy.sh
Executable file
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${ROOT_DIR}/.env"
|
||||
ENV_EXAMPLE="${ROOT_DIR}/.env.example"
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
GREEN='\033[32m'
|
||||
BLUE='\033[34m'
|
||||
YELLOW='\033[33m'
|
||||
RED='\033[31m'
|
||||
NC='\033[0m'
|
||||
|
||||
section() { echo -e "\n${BOLD}${BLUE}${*}${NC}"; }
|
||||
success() { echo -e "${GREEN}✓${NC} ${*}"; }
|
||||
info() { echo -e " ${DIM}${*}${NC}"; }
|
||||
warn() { echo -e "${YELLOW}⚠${NC} ${*}"; }
|
||||
error() { echo -e "${RED}✗${NC} ${*}" >&2; }
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────
|
||||
random() {
|
||||
openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c "${1:-32}"
|
||||
}
|
||||
|
||||
random_password() {
|
||||
openssl rand -base64 48 | tr -dc 'A-Za-z0-9@%+=_.-' | head -c "${1:-20}"
|
||||
}
|
||||
|
||||
ask() {
|
||||
local label="$1"; local default="${2:-}"; local answer
|
||||
if [[ -n "${default}" ]]; then
|
||||
read -r -p " ${label} [${default}]: " answer
|
||||
else
|
||||
read -r -p " ${label}: " answer
|
||||
fi
|
||||
echo "${answer:-${default}}"
|
||||
}
|
||||
|
||||
ask_yn() {
|
||||
local label="$1"; local default_yes="${2:-true}"; local hint answer
|
||||
[[ "${default_yes}" == "true" ]] && hint="J/n" || hint="j/N"
|
||||
while true; do
|
||||
read -r -p " ${label} [${hint}]: " answer
|
||||
answer="$(echo "${answer}" | tr '[:upper:]' '[:lower:]')"
|
||||
[[ -z "${answer}" && "${default_yes}" == "true" ]] && return 0
|
||||
[[ -z "${answer}" && "${default_yes}" == "false" ]] && return 1
|
||||
[[ "${answer}" =~ ^(j|ja|y|yes)$ ]] && return 0
|
||||
[[ "${answer}" =~ ^(n|nein|no)$ ]] && return 1
|
||||
echo " → Bitte mit j oder n antworten."
|
||||
done
|
||||
}
|
||||
|
||||
ask_choice() {
|
||||
local label="$1"; local default="$2"; shift 2; local options=("$@"); local value
|
||||
while true; do
|
||||
value="$(ask "${label} (${options[*]})" "${default}")"
|
||||
value="$(echo "${value}" | tr '[:upper:]' '[:lower:]')"
|
||||
for opt in "${options[@]}"; do
|
||||
[[ "${value}" == "${opt}" ]] && { echo "${value}"; return; }
|
||||
done
|
||||
echo " → Erlaubt: ${options[*]}"
|
||||
done
|
||||
}
|
||||
|
||||
get_env() {
|
||||
local key="$1"
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then echo ""; return; fi
|
||||
local line val
|
||||
line="$(grep -E "^${key}=" "${ENV_FILE}" | head -n1 || true)"
|
||||
[[ -z "${line}" ]] && { echo ""; return; }
|
||||
val="${line#*=}"
|
||||
val="${val#\"}"; val="${val%\"}"
|
||||
val="${val#\'}"; val="${val%\'}"
|
||||
echo "${val}"
|
||||
}
|
||||
|
||||
set_env() {
|
||||
local key="$1"; local value="$2"
|
||||
if grep -qE "^${key}=" "${ENV_FILE}" 2>/dev/null; then
|
||||
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
|
||||
else
|
||||
echo "${key}=${value}" >> "${ENV_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
is_placeholder() {
|
||||
local val; val="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
[[ -z "${val}" ]] && return 0
|
||||
[[ "${val}" == change_me* ]] && return 0
|
||||
[[ "${val}" == *random-secret* ]] && return 0
|
||||
[[ "${val}" == *random-key* ]] && return 0
|
||||
[[ "${val}" == *random-salt* ]] && return 0
|
||||
[[ "${val}" == bitte-* ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
mask() {
|
||||
local val="${1:-}"
|
||||
[[ ${#val} -le 8 ]] && { echo "********"; return; }
|
||||
echo "${val:0:4}...${val: -4}"
|
||||
}
|
||||
|
||||
urlencode() {
|
||||
local s="${1:-}"; local len="${#s}"; local out="" char c
|
||||
for ((i=0; i<len; i++)); do
|
||||
char="${s:i:1}"
|
||||
case "${char}" in
|
||||
[a-zA-Z0-9.~_-]) out+="${char}" ;;
|
||||
*) printf -v c '%%%02X' "'${char}"; out+="${c}" ;;
|
||||
esac
|
||||
done
|
||||
echo "${out}"
|
||||
}
|
||||
|
||||
check_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# ── Prerequisites ────────────────────────────────────────
|
||||
section "[1/5] Voraussetzungen prüfen"
|
||||
|
||||
if ! check_cmd docker; then
|
||||
error "Docker ist nicht installiert. Bitte installiere Docker zuerst."
|
||||
info "→ https://docs.docker.com/engine/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
error "Docker-Daemon läuft nicht oder du hast keine Zugriffsrechte."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Docker läuft"
|
||||
|
||||
if ! check_cmd openssl; then
|
||||
error "openssl wird benötigt (für Secret-Generierung)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPOSE_CMD="docker compose"
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
if check_cmd docker-compose; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
error "Weder 'docker compose' noch 'docker-compose' gefunden."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
success "Compose verfügbar (${COMPOSE_CMD})"
|
||||
|
||||
# ── Initialise .env ──────────────────────────────────────
|
||||
ENV_EXISTS=false
|
||||
[[ -f "${ENV_FILE}" ]] && ENV_EXISTS=true
|
||||
|
||||
if ${ENV_EXISTS}; then
|
||||
if ask_yn ".env existiert bereits. Werte übernehmen?" "true"; then
|
||||
info "Bestehende .env wird erweitert/aktualisiert."
|
||||
else
|
||||
cp "${ENV_EXAMPLE}" "${ENV_FILE}"
|
||||
info "Neue .env aus Vorlage erstellt."
|
||||
NEW_ENV=true
|
||||
fi
|
||||
else
|
||||
cp "${ENV_EXAMPLE}" "${ENV_FILE}"
|
||||
info ".env aus Vorlage erstellt."
|
||||
NEW_ENV=true
|
||||
fi
|
||||
|
||||
# ── [2/5] Base config ────────────────────────────────────
|
||||
section "[2/5] Basis-Konfiguration"
|
||||
|
||||
if ${ENV_EXISTS} && ! is_placeholder "$(get_env PUBLIC_URL)"; then
|
||||
_existing_url="$(get_env PUBLIC_URL)"
|
||||
else
|
||||
_existing_url="http://localhost:3000"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
PUBLIC_URL="$(ask "Öffentliche URL der App" "${_existing_url}")"
|
||||
if [[ "${PUBLIC_URL}" =~ ^https?:// ]]; then
|
||||
break
|
||||
fi
|
||||
echo " → Bitte mit http:// oder https:// beginnen."
|
||||
done
|
||||
|
||||
STACK_NAME="$(ask "Container-Präfix (für docker ps)" "$(get_env STACK_NAME)")"
|
||||
[[ -z "${STACK_NAME}" ]] && STACK_NAME="calbook"
|
||||
TIMEZONE="$(ask "Zeitzone" "$(get_env DEFAULT_TIMEZONE)")"
|
||||
[[ -z "${TIMEZONE}" ]] && TIMEZONE="Europe/Berlin"
|
||||
|
||||
DEPLOYMENT_MODE="$(ask_choice "Deployment-Modus" \
|
||||
"$([[ "$(get_env DEPLOYMENT_MODE)" == "proxy" ]] && echo "proxy" || echo "direct")" \
|
||||
"direct" "proxy")"
|
||||
|
||||
if [[ "${DEPLOYMENT_MODE}" == "direct" ]]; then
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker-compose.direct.yml"
|
||||
TRUST_PROXY="false"
|
||||
else
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker-compose.proxy.yml"
|
||||
TRUST_PROXY="true"
|
||||
|
||||
TRAEFIK_HOST="$(echo "${PUBLIC_URL}" | sed -E 's#^[a-zA-Z]+://##' | cut -d/ -f1 | cut -d: -f1)"
|
||||
if [[ -z "${TRAEFIK_HOST}" || "${TRAEFIK_HOST}" == "localhost" || "${TRAEFIK_HOST}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TRAEFIK_HOST="calbook.local"
|
||||
fi
|
||||
TRAEFIK_HOST="$(ask "Domain für Traefik (aus URL abgeleitet)" "${TRAEFIK_HOST}")"
|
||||
[[ -z "${TRAEFIK_HOST}" ]] && TRAEFIK_HOST="calbook.local"
|
||||
|
||||
TRAEFIK_ENTRYPOINTS="$(ask "Traefik Entrypoints" "$(get_env TRAEFIK_ENTRYPOINTS)")"
|
||||
[[ -z "${TRAEFIK_ENTRYPOINTS}" ]] && TRAEFIK_ENTRYPOINTS="websecure"
|
||||
|
||||
if [[ "${PUBLIC_URL}" == https://* ]]; then
|
||||
TRAEFIK_TLS="true"
|
||||
else
|
||||
TRAEFIK_TLS="false"
|
||||
fi
|
||||
TRAEFIK_TLS="$(ask_yn "TLS aktivieren?" "${TRAEFIK_TLS}" && echo "true" || echo "false")"
|
||||
|
||||
if [[ "${TRAEFIK_TLS}" == "true" ]]; then
|
||||
TRAEFIK_CERTRESOLVER="$(ask "Certresolver" "$(get_env TRAEFIK_CERTRESOLVER)")"
|
||||
[[ -z "${TRAEFIK_CERTRESOLVER}" ]] && TRAEFIK_CERTRESOLVER="tls_resolver"
|
||||
else
|
||||
TRAEFIK_CERTRESOLVER="$(get_env TRAEFIK_CERTRESOLVER)"
|
||||
[[ -z "${TRAEFIK_CERTRESOLVER}" ]] && TRAEFIK_CERTRESOLVER="tls_resolver"
|
||||
fi
|
||||
|
||||
TRAEFIK_NETWORK="$(ask "Traefik Docker-Netzwerk" "$(get_env TRAEFIK_DOCKER_NETWORK)")"
|
||||
[[ -z "${TRAEFIK_NETWORK}" ]] && TRAEFIK_NETWORK="proxy"
|
||||
|
||||
TRAEFIK_ROUTER="$(ask "Traefik Router-Name" "$(get_env TRAEFIK_ROUTER_NAME)")"
|
||||
[[ -z "${TRAEFIK_ROUTER}" ]] && TRAEFIK_ROUTER="calbook"
|
||||
|
||||
TRAEFIK_SERVICE="$(ask "Traefik Service-Name" "$(get_env TRAEFIK_SERVICE_NAME)")"
|
||||
[[ -z "${TRAEFIK_SERVICE}" ]] && TRAEFIK_SERVICE="calbook"
|
||||
fi
|
||||
|
||||
success "URL: ${PUBLIC_URL} | Stack: ${STACK_NAME} | Modus: ${DEPLOYMENT_MODE}"
|
||||
|
||||
# ── [3/5] Admin ──────────────────────────────────────────
|
||||
section "[3/5] Admin-Zugang"
|
||||
|
||||
ADMIN_EMAIL="$(ask "Admin E-Mail" "$(get_env ADMIN_EMAIL)")"
|
||||
[[ -z "${ADMIN_EMAIL}" ]] && ADMIN_EMAIL="admin@calbook.local"
|
||||
|
||||
ADMIN_NAME="$(ask "Admin Name" "$(get_env ADMIN_NAME)")"
|
||||
[[ -z "${ADMIN_NAME}" ]] && ADMIN_NAME="CalBook Admin"
|
||||
|
||||
existing_pw="$(get_env ADMIN_PASSWORD)"
|
||||
if ${ENV_EXISTS} && ! is_placeholder "${existing_pw}"; then
|
||||
if ask_yn "Admin-Passwort beibehalten?" "true"; then
|
||||
ADMIN_PASSWORD="${existing_pw}"
|
||||
else
|
||||
ADMIN_PASSWORD="$(ask "Neues Admin-Passwort (min. 12 Zeichen)" "")"
|
||||
[[ -z "${ADMIN_PASSWORD}" ]] && ADMIN_PASSWORD="$(random_password 20)"
|
||||
fi
|
||||
else
|
||||
ADMIN_PASSWORD="$(ask "Admin-Passwort (leer = automatisch generieren)" "")"
|
||||
[[ -z "${ADMIN_PASSWORD}" ]] && ADMIN_PASSWORD="$(random_password 20)"
|
||||
fi
|
||||
|
||||
success "Admin: ${ADMIN_EMAIL}"
|
||||
|
||||
# ── [4/5] SMTP & Jitsi ───────────────────────────────────
|
||||
section "[4/5] E-Mail & Videokonferenz"
|
||||
|
||||
SMTP_MODE="$(ask_choice "SMTP-Modus" \
|
||||
"$([[ "$(get_env SMTP_HOST)" == "mailhog" || -z "$(get_env SMTP_HOST)" ]] && echo "mailhog" || echo "custom")" \
|
||||
"mailhog" "custom" "off")"
|
||||
|
||||
if [[ "${SMTP_MODE}" == "mailhog" ]]; then
|
||||
SMTP_HOST="mailhog"
|
||||
SMTP_PORT="1025"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
elif [[ "${SMTP_MODE}" == "custom" ]]; then
|
||||
SMTP_HOST="$(ask "SMTP-Host" "$(get_env SMTP_HOST)")"
|
||||
SMTP_PORT="$(ask "SMTP-Port" "$(get_env SMTP_PORT)")"
|
||||
[[ -z "${SMTP_PORT}" ]] && SMTP_PORT="587"
|
||||
SMTP_USER="$(ask "SMTP-Benutzer (optional)" "$(get_env SMTP_USER)")"
|
||||
SMTP_PASS="$(ask "SMTP-Passwort (optional)" "$(get_env SMTP_PASS)")"
|
||||
else
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
fi
|
||||
|
||||
SMTP_FROM_NAME="$(ask "Absendername" "$(get_env SMTP_FROM_NAME)")"
|
||||
[[ -z "${SMTP_FROM_NAME}" ]] && SMTP_FROM_NAME="${ADMIN_NAME}"
|
||||
|
||||
SMTP_FROM="$(ask "Absender-E-Mail" "$(get_env SMTP_FROM)")"
|
||||
[[ -z "${SMTP_FROM}" ]] && SMTP_FROM="no-reply@calbook.local"
|
||||
|
||||
JITSI_MODE="$(ask_choice "Jitsi-Modus" "$(get_env JITSI_MEETING_MODE)" "public" "custom")"
|
||||
if [[ "${JITSI_MODE}" == "custom" ]]; then
|
||||
JITSI_URL="$(ask "Jitsi-Basis-URL" "$(get_env JITSI_BASE_URL)")"
|
||||
else
|
||||
JITSI_URL="https://meet.jit.si"
|
||||
fi
|
||||
JITSI_PREFIX="$(ask "Jitsi-Raum-Präfix" "$(get_env JITSI_ROOM_PREFIX)")"
|
||||
[[ -z "${JITSI_PREFIX}" ]] && JITSI_PREFIX="calbook"
|
||||
|
||||
success "SMTP: ${SMTP_MODE} | Jitsi: ${JITSI_MODE}"
|
||||
|
||||
# ── Generate secrets ─────────────────────────────────────
|
||||
NEXTAUTH_SECRET="$(random 64)"
|
||||
CRON_SECRET="$(random 48)"
|
||||
CALDAV_KEY="$(random 64)"
|
||||
JITSI_SALT="$(random 48)"
|
||||
POSTGRES_PASSWORD="$(random_password 24)"
|
||||
|
||||
# Keep existing strong secrets if present
|
||||
_secret_keys=(NEXTAUTH_SECRET CRON_SECRET CALDAV_ENCRYPTION_KEY JITSI_ROOM_SALT POSTGRES_PASSWORD)
|
||||
for key in "${_secret_keys[@]}"; do
|
||||
existing="$(get_env "${key}")"
|
||||
if [[ -n "${existing}" ]] && ! is_placeholder "${existing}"; then
|
||||
declare "${key}=${existing}"
|
||||
fi
|
||||
done
|
||||
|
||||
POSTGRES_DB="$(get_env POSTGRES_DB)"
|
||||
[[ -z "${POSTGRES_DB}" ]] && POSTGRES_DB="calbook"
|
||||
POSTGRES_USER="$(get_env POSTGRES_USER)"
|
||||
[[ -z "${POSTGRES_USER}" ]] && POSTGRES_USER="calbook"
|
||||
|
||||
DATABASE_URL="postgresql://$(urlencode "${POSTGRES_USER}"):$(urlencode "${POSTGRES_PASSWORD}")@db:5432/$(urlencode "${POSTGRES_DB}")?schema=public"
|
||||
|
||||
# ── Write .env ───────────────────────────────────────────
|
||||
set_env "STACK_NAME" "${STACK_NAME}"
|
||||
set_env "DEPLOYMENT_MODE" "${DEPLOYMENT_MODE}"
|
||||
set_env "PUBLIC_URL" "${PUBLIC_URL}"
|
||||
set_env "NEXTAUTH_URL" "${PUBLIC_URL}"
|
||||
set_env "APP_BASE_URL" "${PUBLIC_URL}"
|
||||
set_env "NEXTAUTH_SECRET" "${NEXTAUTH_SECRET}"
|
||||
set_env "CRON_SECRET" "${CRON_SECRET}"
|
||||
set_env "TRUST_PROXY_HEADERS" "${TRUST_PROXY}"
|
||||
set_env "DEFAULT_TIMEZONE" "${TIMEZONE}"
|
||||
|
||||
set_env "ADMIN_NAME" "${ADMIN_NAME}"
|
||||
set_env "ADMIN_EMAIL" "${ADMIN_EMAIL}"
|
||||
set_env "ADMIN_PASSWORD" "${ADMIN_PASSWORD}"
|
||||
|
||||
set_env "POSTGRES_DB" "${POSTGRES_DB}"
|
||||
set_env "POSTGRES_USER" "${POSTGRES_USER}"
|
||||
set_env "POSTGRES_PASSWORD" "${POSTGRES_PASSWORD}"
|
||||
set_env "DATABASE_URL" "${DATABASE_URL}"
|
||||
|
||||
set_env "CALDAV_ENCRYPTION_KEY" "${CALDAV_KEY}"
|
||||
set_env "JITSI_ROOM_SALT" "${JITSI_SALT}"
|
||||
|
||||
set_env "SMTP_HOST" "${SMTP_HOST}"
|
||||
set_env "SMTP_PORT" "${SMTP_PORT}"
|
||||
set_env "SMTP_USER" "${SMTP_USER}"
|
||||
set_env "SMTP_PASS" "${SMTP_PASS}"
|
||||
set_env "SMTP_FROM_NAME" "${SMTP_FROM_NAME}"
|
||||
set_env "SMTP_FROM" "${SMTP_FROM}"
|
||||
|
||||
set_env "JITSI_MEETING_MODE" "${JITSI_MODE}"
|
||||
set_env "JITSI_BASE_URL" "${JITSI_URL}"
|
||||
set_env "JITSI_ROOM_PREFIX" "${JITSI_PREFIX}"
|
||||
|
||||
if [[ "${DEPLOYMENT_MODE}" == "proxy" ]]; then
|
||||
set_env "ENABLE_TRAEFIK" "true"
|
||||
set_env "TRAEFIK_HOST" "${TRAEFIK_HOST}"
|
||||
set_env "TRAEFIK_ENTRYPOINTS" "${TRAEFIK_ENTRYPOINTS}"
|
||||
set_env "TRAEFIK_TLS" "${TRAEFIK_TLS}"
|
||||
set_env "TRAEFIK_CERTRESOLVER" "${TRAEFIK_CERTRESOLVER}"
|
||||
set_env "TRAEFIK_ROUTER_NAME" "${TRAEFIK_ROUTER}"
|
||||
set_env "TRAEFIK_SERVICE_NAME" "${TRAEFIK_SERVICE}"
|
||||
set_env "TRAEFIK_DOCKER_NETWORK" "${TRAEFIK_NETWORK}"
|
||||
else
|
||||
set_env "ENABLE_TRAEFIK" "false"
|
||||
fi
|
||||
|
||||
info ".env geschrieben."
|
||||
|
||||
# ── [5/5] Build & Start ──────────────────────────────────
|
||||
section "[5/5] Container starten & Datenbank einrichten"
|
||||
|
||||
if [[ "${DEPLOYMENT_MODE}" == "proxy" ]]; then
|
||||
docker network inspect "${TRAEFIK_NETWORK}" >/dev/null 2>&1 || {
|
||||
info "Erstelle Traefik-Netzwerk: ${TRAEFIK_NETWORK}"
|
||||
docker network create "${TRAEFIK_NETWORK}"
|
||||
}
|
||||
fi
|
||||
|
||||
if [[ "${NEW_ENV:-false}" == "true" ]]; then
|
||||
VOLUME_DIR="${ROOT_DIR}/volumes/postgres-${STACK_NAME}"
|
||||
if [[ -d "${VOLUME_DIR}" ]]; then
|
||||
if ask_yn "Alte DB-Daten für Stack '${STACK_NAME}' gefunden. Löschen für Neuaufsetzung?" "true"; then
|
||||
info "Lösche alte DB-Daten: ${VOLUME_DIR}"
|
||||
rm -rf "${VOLUME_DIR}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
SERVICES=(db calbook-app)
|
||||
if [[ "${SMTP_HOST}" == "mailhog" || -z "${SMTP_HOST}" ]]; then
|
||||
SERVICES+=(mailhog)
|
||||
fi
|
||||
|
||||
info "Starte: ${SERVICES[*]}"
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" up -d --build "${SERVICES[@]}"
|
||||
|
||||
info "DB einrichten (Prisma + Seed)..."
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" build calbook-tools
|
||||
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run prisma:generate
|
||||
|
||||
if ! ${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run prisma:migrate; then
|
||||
info "Migration fehlgeschlagen → prisma:push (Legacy-Fallback)..."
|
||||
${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run prisma:push
|
||||
fi
|
||||
|
||||
if ${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run db:seed; then
|
||||
success "Seed abgeschlossen"
|
||||
else
|
||||
warn "Seed fehlgeschlagen – Admin existiert möglicherweise bereits."
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════${NC}"
|
||||
echo -e "${BOLD}${GREEN} CalBook läuft!${NC}"
|
||||
echo
|
||||
echo -e " URL: ${BOLD}${PUBLIC_URL}${NC}"
|
||||
echo -e " Login: ${BOLD}/anmelden${NC}"
|
||||
echo -e " Admin: ${BOLD}${ADMIN_EMAIL}${NC}"
|
||||
echo -e " Passwort: ${BOLD}${ADMIN_PASSWORD}${NC}"
|
||||
echo -e " Compose: ${DIM}${COMPOSE_FILE}${NC}"
|
||||
echo -e " Modus: ${DEPLOYMENT_MODE}"
|
||||
if [[ "${SMTP_HOST}" == "mailhog" ]]; then
|
||||
if [[ "${DEPLOYMENT_MODE}" == "direct" ]]; then
|
||||
echo -e " Mailhog: ${BOLD}http://localhost:8025${NC}"
|
||||
else
|
||||
echo -e " Mailhog: ${DIM}intern (kein Host-Port)${NC}"
|
||||
fi
|
||||
fi
|
||||
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════${NC}"
|
||||
echo
|
||||
echo "Logs: ${COMPOSE_CMD} -f ${COMPOSE_FILE} logs -f calbook-app db"
|
||||
echo
|
||||
121
design.md
Normal file
121
design.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# design.md – Designsystem für das Buchungssystem
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses Dokument beschreibt das aktuelle Designsystem von CalBook. Es dient als Referenz für Konsistenz bei Weiterentwicklungen.
|
||||
|
||||
---
|
||||
|
||||
## 1) Produktkontext
|
||||
|
||||
Zwei Bereiche:
|
||||
|
||||
1. **Kundenansicht** (`/buchen`) – Öffentliche Buchungsoberfläche
|
||||
- Ablauf: Person wählen → Datum → Uhrzeit → Kontaktdaten → Bestätigung
|
||||
- Auto-Scroll zum nächsten Schritt
|
||||
- Framer-Motion-Animationen im Success-Screen
|
||||
|
||||
2. **Admin-Backend** (`/admin/*`) – Interne Verwaltungsoberfläche
|
||||
- 9 Seiten: Dashboard, Termine, Kalender, E-Mails, Branding, Rechtliches, Instant Meeting, Backup, Einstellungen
|
||||
- Seiten-Layout einheitlich: Sidebar (260px) + Content (`max-w-6xl`, zentriert)
|
||||
- Framer-Motion `AnimatedPage`-Wrapper für konsistente Page-Transitions
|
||||
|
||||
---
|
||||
|
||||
## 2) Farbstimmung
|
||||
|
||||
- **Kundenansicht**: slate-50 Hintergrund, indigo-600 als Akzentfarbe
|
||||
- **Admin**: slate-50 Hintergrund, slate-900 für Header/Aktionen, indigo-600 für aktive Elemente
|
||||
- **Statusfarben**: emerald (bestätigt/aktiv), amber (ausstehend/no-show), red (Fehler/storniert)
|
||||
- **Text**: slate-900 (primär), slate-500 (sekundär), slate-400 (deaktiviert)
|
||||
|
||||
---
|
||||
|
||||
## 3) Typografie
|
||||
|
||||
- **Schrift**: System-UI-Stack (`font-sans`) via Tailwind
|
||||
- **Hierarchie**: `text-3xl font-black` (Seitentitel) → `text-lg font-bold` (Sektionsheader) → `text-sm font-medium` (Fließtext) → `text-xs` (Metadaten)
|
||||
- **Labels**: `text-xs font-bold uppercase tracking-wider text-slate-500`
|
||||
|
||||
---
|
||||
|
||||
## 4) Komponenten
|
||||
|
||||
### Cards
|
||||
```
|
||||
rounded-2xl border border-slate-200 bg-white shadow-sm
|
||||
```
|
||||
|
||||
### Buttons
|
||||
- **Primär**: `bg-slate-900 text-white hover:bg-slate-800 rounded-xl`
|
||||
- **Sekundär**: `border border-slate-200 bg-white hover:bg-slate-50 rounded-xl`
|
||||
- **Destruktiv**: `bg-red-600 text-white hover:bg-red-700 rounded-xl`
|
||||
|
||||
### Inputs
|
||||
```
|
||||
h-11 rounded-xl border border-slate-200 bg-slate-50 px-4
|
||||
focus:border-indigo-600 focus:ring-1 focus:ring-indigo-600
|
||||
```
|
||||
|
||||
### Tabs
|
||||
```
|
||||
rounded-t-2xl border border-b-0 bg-slate-50/80
|
||||
Aktiv: border-b-2 border-slate-900 bg-white text-slate-900
|
||||
Inaktiv: text-slate-500 hover:bg-white/50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Admin-Layout
|
||||
|
||||
- **Desktop**: Fixierte Sidebar links (260px), Content rechts (`lg:pl-60`)
|
||||
- **Mobile**: Topbar mit Burger-Menü, Slide-Over-Navigation
|
||||
- **Animation**: `AnimatedPage`-Wrapper (framer-motion, key={pathname}) für Page-Transitions
|
||||
- **Loading**: `loading.tsx` mit Skeleton-Karten, erscheint sofort bei Navigation
|
||||
|
||||
---
|
||||
|
||||
## 6) Navigation
|
||||
|
||||
- **Sidebar**: 8 Items mit Icons (Dashboard, Termine, Kalender, E-Mails, Branding, Rechtliches, Instant Meeting, Backup, Einstellungen)
|
||||
- **Bottom**: Link zur öffentlichen Buchung + Logout
|
||||
- **Aktiver Zustand**: `bg-indigo-50 text-indigo-600`
|
||||
|
||||
---
|
||||
|
||||
## 7) Interaktionsmuster
|
||||
|
||||
- **Modale/Dialoge**: `ConfirmDialog`-Komponente mit Backdrop, Danger/Default-Variante
|
||||
- **Inline-Edit**: Kalender-Personen bearbeiten direkt in der Zeile statt Modal
|
||||
- **Expand/Collapse**: Chevron-Icons für Detail-Panels (Dashboard-Buchungen, Kalender-Ressourcen)
|
||||
- **Toast**: `sonner`-Bibliothek, Position `top-right`, `richColors`
|
||||
|
||||
---
|
||||
|
||||
## 8) Buchungs-Flow
|
||||
|
||||
- **Schritte**: 1. Person → 2. Datum → 3. Uhrzeit → 4. Kontaktdaten
|
||||
- **Auto-Scroll**: Nach jeder Auswahl scrollt die Seite zum nächsten Schritt
|
||||
- **Kalender**: Monatsraster mit Verfügbarkeits-Indikatoren
|
||||
- **Slots**: Flexible Buttons, 15-Minuten-Raster
|
||||
- **Formular**: Name, E-Mail, Telefon (optional), Thema (optional)
|
||||
- **Success**: Framer-Motion animierte Bestätigungskarte, ICS-Download, "Weiteren Termin"-Button
|
||||
|
||||
---
|
||||
|
||||
## 9) Email-Templates
|
||||
|
||||
- **Event-Typen**: 10 Kategorien (Bestätigung, Benachrichtigung, Stornierung, Erinnerungen, Instant Meeting, SMTP-Test)
|
||||
- **Sidebar**: Gruppierte Event-Typ-Auswahl (Buchung/Stornierung/Erinnerungen/Spezial)
|
||||
- **Live-Vorschau**: Immer sichtbar, aktualisiert bei Template-Wechsel
|
||||
- **Design-Styles**: 9 vordefinierte Farbschemas (Minimal, Corporate, Startup, Serif, Mono, Glass, Ink, Warm, Soft)
|
||||
- **Editor**: Inline unter der Template-Liste, Name/Betreff/Inhalt-Felder
|
||||
|
||||
---
|
||||
|
||||
## 10) Backup
|
||||
|
||||
- **Export**: JSON-Download mit allen Daten (inkl. CalDAV-Key für Re-Encryption)
|
||||
- **Import**: Upload mit Vorschau, Schritt-für-Schritt-Roadmap, Fehler pro Kategorie sichtbar
|
||||
- **URL-Filter**: PUBLIC_URL, NEXTAUTH_URL, APP_BASE_URL werden nicht exportiert/importiert
|
||||
- **Standalone-Script**: `scripts/export-backup.sh` für ältere Versionen ohne API
|
||||
50
docker-compose.direct.yml
Normal file
50
docker-compose.direct.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
name: ${STACK_NAME:-calbook}
|
||||
|
||||
services:
|
||||
calbook-app:
|
||||
container_name: ${STACK_NAME:-calbook}-app
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOSTNAME: 0.0.0.0
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
calbook-tools:
|
||||
build:
|
||||
context: .
|
||||
target: tools
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
- db
|
||||
profiles:
|
||||
- tools
|
||||
restart: "no"
|
||||
|
||||
db:
|
||||
container_name: ${STACK_NAME:-calbook}-db
|
||||
image: postgres:16-alpine
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./volumes/postgres-${STACK_NAME:-calbook}:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
mailhog:
|
||||
container_name: ${STACK_NAME:-calbook}-mailhog
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
65
docker-compose.proxy.yml
Normal file
65
docker-compose.proxy.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
name: ${STACK_NAME:-calbook}
|
||||
|
||||
services:
|
||||
calbook-app:
|
||||
container_name: ${STACK_NAME:-calbook}-app
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOSTNAME: 0.0.0.0
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=${ENABLE_TRAEFIK:-false}"
|
||||
- "traefik.http.routers.${TRAEFIK_ROUTER_NAME:-calbook}.rule=Host(`${TRAEFIK_HOST:-calbook.local}`)"
|
||||
- "traefik.http.routers.${TRAEFIK_ROUTER_NAME:-calbook}.entrypoints=${TRAEFIK_ENTRYPOINTS:-websecure}"
|
||||
- "traefik.http.routers.${TRAEFIK_ROUTER_NAME:-calbook}.tls=${TRAEFIK_TLS:-true}"
|
||||
- "traefik.http.routers.${TRAEFIK_ROUTER_NAME:-calbook}.tls.certresolver=${TRAEFIK_CERTRESOLVER:-tls_resolver}"
|
||||
- "traefik.http.routers.${TRAEFIK_ROUTER_NAME:-calbook}.service=${TRAEFIK_SERVICE_NAME:-calbook}"
|
||||
- "traefik.http.services.${TRAEFIK_SERVICE_NAME:-calbook}.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-proxy}"
|
||||
networks:
|
||||
- default
|
||||
- proxy
|
||||
|
||||
calbook-tools:
|
||||
build:
|
||||
context: .
|
||||
target: tools
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
- db
|
||||
profiles:
|
||||
- tools
|
||||
restart: "no"
|
||||
networks:
|
||||
- default
|
||||
|
||||
db:
|
||||
container_name: ${STACK_NAME:-calbook}-db
|
||||
image: postgres:16-alpine
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./volumes/postgres-${STACK_NAME:-calbook}:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
mailhog:
|
||||
container_name: ${STACK_NAME:-calbook}-mailhog
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
name: ${TRAEFIK_DOCKER_NETWORK:-proxy}
|
||||
7
instrumentation.ts
Normal file
7
instrumentation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { assertSecureRuntimeConfig } from "@/lib/security/config-guard";
|
||||
import { startSyncCron } from "@/lib/services/cron";
|
||||
|
||||
export async function register() {
|
||||
assertSecureRuntimeConfig();
|
||||
startSyncCron();
|
||||
}
|
||||
431
lib/all-timezones.ts
Normal file
431
lib/all-timezones.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
export const ALL_IANA_TIMEZONES: readonly string[] = [
|
||||
"Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
"Africa/Addis_Ababa",
|
||||
"Africa/Algiers",
|
||||
"Africa/Asmera",
|
||||
"Africa/Bamako",
|
||||
"Africa/Bangui",
|
||||
"Africa/Banjul",
|
||||
"Africa/Bissau",
|
||||
"Africa/Blantyre",
|
||||
"Africa/Brazzaville",
|
||||
"Africa/Bujumbura",
|
||||
"Africa/Cairo",
|
||||
"Africa/Casablanca",
|
||||
"Africa/Ceuta",
|
||||
"Africa/Conakry",
|
||||
"Africa/Dakar",
|
||||
"Africa/Dar_es_Salaam",
|
||||
"Africa/Djibouti",
|
||||
"Africa/Douala",
|
||||
"Africa/El_Aaiun",
|
||||
"Africa/Freetown",
|
||||
"Africa/Gaborone",
|
||||
"Africa/Harare",
|
||||
"Africa/Johannesburg",
|
||||
"Africa/Juba",
|
||||
"Africa/Kampala",
|
||||
"Africa/Khartoum",
|
||||
"Africa/Kigali",
|
||||
"Africa/Kinshasa",
|
||||
"Africa/Lagos",
|
||||
"Africa/Libreville",
|
||||
"Africa/Lome",
|
||||
"Africa/Luanda",
|
||||
"Africa/Lubumbashi",
|
||||
"Africa/Lusaka",
|
||||
"Africa/Malabo",
|
||||
"Africa/Maputo",
|
||||
"Africa/Maseru",
|
||||
"Africa/Mbabane",
|
||||
"Africa/Mogadishu",
|
||||
"Africa/Monrovia",
|
||||
"Africa/Nairobi",
|
||||
"Africa/Ndjamena",
|
||||
"Africa/Niamey",
|
||||
"Africa/Nouakchott",
|
||||
"Africa/Ouagadougou",
|
||||
"Africa/Porto-Novo",
|
||||
"Africa/Sao_Tome",
|
||||
"Africa/Tripoli",
|
||||
"Africa/Tunis",
|
||||
"Africa/Windhoek",
|
||||
"America/Adak",
|
||||
"America/Anchorage",
|
||||
"America/Anguilla",
|
||||
"America/Antigua",
|
||||
"America/Araguaina",
|
||||
"America/Argentina/La_Rioja",
|
||||
"America/Argentina/Rio_Gallegos",
|
||||
"America/Argentina/Salta",
|
||||
"America/Argentina/San_Juan",
|
||||
"America/Argentina/San_Luis",
|
||||
"America/Argentina/Tucuman",
|
||||
"America/Argentina/Ushuaia",
|
||||
"America/Aruba",
|
||||
"America/Asuncion",
|
||||
"America/Bahia",
|
||||
"America/Bahia_Banderas",
|
||||
"America/Barbados",
|
||||
"America/Belem",
|
||||
"America/Belize",
|
||||
"America/Blanc-Sablon",
|
||||
"America/Boa_Vista",
|
||||
"America/Bogota",
|
||||
"America/Boise",
|
||||
"America/Buenos_Aires",
|
||||
"America/Cambridge_Bay",
|
||||
"America/Campo_Grande",
|
||||
"America/Cancun",
|
||||
"America/Caracas",
|
||||
"America/Catamarca",
|
||||
"America/Cayenne",
|
||||
"America/Cayman",
|
||||
"America/Chicago",
|
||||
"America/Chihuahua",
|
||||
"America/Coral_Harbour",
|
||||
"America/Cordoba",
|
||||
"America/Costa_Rica",
|
||||
"America/Creston",
|
||||
"America/Cuiaba",
|
||||
"America/Curacao",
|
||||
"America/Danmarkshavn",
|
||||
"America/Dawson",
|
||||
"America/Dawson_Creek",
|
||||
"America/Denver",
|
||||
"America/Detroit",
|
||||
"America/Dominica",
|
||||
"America/Edmonton",
|
||||
"America/Eirunepe",
|
||||
"America/El_Salvador",
|
||||
"America/Fort_Nelson",
|
||||
"America/Fortaleza",
|
||||
"America/Glace_Bay",
|
||||
"America/Godthab",
|
||||
"America/Goose_Bay",
|
||||
"America/Grand_Turk",
|
||||
"America/Grenada",
|
||||
"America/Guadeloupe",
|
||||
"America/Guatemala",
|
||||
"America/Guayaquil",
|
||||
"America/Guyana",
|
||||
"America/Halifax",
|
||||
"America/Havana",
|
||||
"America/Hermosillo",
|
||||
"America/Indiana/Knox",
|
||||
"America/Indiana/Marengo",
|
||||
"America/Indiana/Petersburg",
|
||||
"America/Indiana/Tell_City",
|
||||
"America/Indiana/Vevay",
|
||||
"America/Indiana/Vincennes",
|
||||
"America/Indiana/Winamac",
|
||||
"America/Indianapolis",
|
||||
"America/Inuvik",
|
||||
"America/Iqaluit",
|
||||
"America/Jamaica",
|
||||
"America/Jujuy",
|
||||
"America/Juneau",
|
||||
"America/Kentucky/Monticello",
|
||||
"America/Kralendijk",
|
||||
"America/La_Paz",
|
||||
"America/Lima",
|
||||
"America/Los_Angeles",
|
||||
"America/Louisville",
|
||||
"America/Lower_Princes",
|
||||
"America/Maceio",
|
||||
"America/Managua",
|
||||
"America/Manaus",
|
||||
"America/Marigot",
|
||||
"America/Martinique",
|
||||
"America/Matamoros",
|
||||
"America/Mazatlan",
|
||||
"America/Mendoza",
|
||||
"America/Menominee",
|
||||
"America/Merida",
|
||||
"America/Metlakatla",
|
||||
"America/Mexico_City",
|
||||
"America/Miquelon",
|
||||
"America/Moncton",
|
||||
"America/Monterrey",
|
||||
"America/Montevideo",
|
||||
"America/Montreal",
|
||||
"America/Montserrat",
|
||||
"America/Nassau",
|
||||
"America/New_York",
|
||||
"America/Nipigon",
|
||||
"America/Nome",
|
||||
"America/Noronha",
|
||||
"America/North_Dakota/Beulah",
|
||||
"America/North_Dakota/Center",
|
||||
"America/North_Dakota/New_Salem",
|
||||
"America/Ojinaga",
|
||||
"America/Panama",
|
||||
"America/Pangnirtung",
|
||||
"America/Paramaribo",
|
||||
"America/Phoenix",
|
||||
"America/Port_of_Spain",
|
||||
"America/Port-au-Prince",
|
||||
"America/Porto_Velho",
|
||||
"America/Puerto_Rico",
|
||||
"America/Punta_Arenas",
|
||||
"America/Rainy_River",
|
||||
"America/Rankin_Inlet",
|
||||
"America/Recife",
|
||||
"America/Regina",
|
||||
"America/Resolute",
|
||||
"America/Rio_Branco",
|
||||
"America/Santa_Isabel",
|
||||
"America/Santarem",
|
||||
"America/Santiago",
|
||||
"America/Santo_Domingo",
|
||||
"America/Sao_Paulo",
|
||||
"America/Scoresbysund",
|
||||
"America/Sitka",
|
||||
"America/St_Barthelemy",
|
||||
"America/St_Johns",
|
||||
"America/St_Kitts",
|
||||
"America/St_Lucia",
|
||||
"America/St_Thomas",
|
||||
"America/St_Vincent",
|
||||
"America/Swift_Current",
|
||||
"America/Tegucigalpa",
|
||||
"America/Thule",
|
||||
"America/Thunder_Bay",
|
||||
"America/Tijuana",
|
||||
"America/Toronto",
|
||||
"America/Tortola",
|
||||
"America/Vancouver",
|
||||
"America/Whitehorse",
|
||||
"America/Winnipeg",
|
||||
"America/Yakutat",
|
||||
"America/Yellowknife",
|
||||
"Antarctica/Casey",
|
||||
"Antarctica/Davis",
|
||||
"Antarctica/DumontDUrville",
|
||||
"Antarctica/Macquarie",
|
||||
"Antarctica/Mawson",
|
||||
"Antarctica/McMurdo",
|
||||
"Antarctica/Palmer",
|
||||
"Antarctica/Rothera",
|
||||
"Antarctica/Syowa",
|
||||
"Antarctica/Troll",
|
||||
"Antarctica/Vostok",
|
||||
"Arctic/Longyearbyen",
|
||||
"Asia/Aden",
|
||||
"Asia/Almaty",
|
||||
"Asia/Amman",
|
||||
"Asia/Anadyr",
|
||||
"Asia/Aqtau",
|
||||
"Asia/Aqtobe",
|
||||
"Asia/Ashgabat",
|
||||
"Asia/Atyrau",
|
||||
"Asia/Baghdad",
|
||||
"Asia/Bahrain",
|
||||
"Asia/Baku",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Barnaul",
|
||||
"Asia/Beirut",
|
||||
"Asia/Bishkek",
|
||||
"Asia/Brunei",
|
||||
"Asia/Calcutta",
|
||||
"Asia/Chita",
|
||||
"Asia/Choibalsan",
|
||||
"Asia/Colombo",
|
||||
"Asia/Damascus",
|
||||
"Asia/Dhaka",
|
||||
"Asia/Dili",
|
||||
"Asia/Dubai",
|
||||
"Asia/Dushanbe",
|
||||
"Asia/Famagusta",
|
||||
"Asia/Gaza",
|
||||
"Asia/Hebron",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Hovd",
|
||||
"Asia/Irkutsk",
|
||||
"Asia/Jakarta",
|
||||
"Asia/Jayapura",
|
||||
"Asia/Jerusalem",
|
||||
"Asia/Kabul",
|
||||
"Asia/Kamchatka",
|
||||
"Asia/Karachi",
|
||||
"Asia/Katmandu",
|
||||
"Asia/Khandyga",
|
||||
"Asia/Krasnoyarsk",
|
||||
"Asia/Kuala_Lumpur",
|
||||
"Asia/Kuching",
|
||||
"Asia/Kuwait",
|
||||
"Asia/Macau",
|
||||
"Asia/Magadan",
|
||||
"Asia/Makassar",
|
||||
"Asia/Manila",
|
||||
"Asia/Muscat",
|
||||
"Asia/Nicosia",
|
||||
"Asia/Novokuznetsk",
|
||||
"Asia/Novosibirsk",
|
||||
"Asia/Omsk",
|
||||
"Asia/Oral",
|
||||
"Asia/Phnom_Penh",
|
||||
"Asia/Pontianak",
|
||||
"Asia/Pyongyang",
|
||||
"Asia/Qatar",
|
||||
"Asia/Qostanay",
|
||||
"Asia/Qyzylorda",
|
||||
"Asia/Rangoon",
|
||||
"Asia/Riyadh",
|
||||
"Asia/Saigon",
|
||||
"Asia/Sakhalin",
|
||||
"Asia/Samarkand",
|
||||
"Asia/Seoul",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Srednekolymsk",
|
||||
"Asia/Taipei",
|
||||
"Asia/Tashkent",
|
||||
"Asia/Tbilisi",
|
||||
"Asia/Tehran",
|
||||
"Asia/Thimphu",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Tomsk",
|
||||
"Asia/Ulaanbaatar",
|
||||
"Asia/Urumqi",
|
||||
"Asia/Ust-Nera",
|
||||
"Asia/Vientiane",
|
||||
"Asia/Vladivostok",
|
||||
"Asia/Yakutsk",
|
||||
"Asia/Yekaterinburg",
|
||||
"Asia/Yerevan",
|
||||
"Atlantic/Azores",
|
||||
"Atlantic/Bermuda",
|
||||
"Atlantic/Canary",
|
||||
"Atlantic/Cape_Verde",
|
||||
"Atlantic/Faeroe",
|
||||
"Atlantic/Madeira",
|
||||
"Atlantic/Reykjavik",
|
||||
"Atlantic/South_Georgia",
|
||||
"Atlantic/St_Helena",
|
||||
"Atlantic/Stanley",
|
||||
"Australia/Adelaide",
|
||||
"Australia/Brisbane",
|
||||
"Australia/Broken_Hill",
|
||||
"Australia/Currie",
|
||||
"Australia/Darwin",
|
||||
"Australia/Eucla",
|
||||
"Australia/Hobart",
|
||||
"Australia/Lindeman",
|
||||
"Australia/Lord_Howe",
|
||||
"Australia/Melbourne",
|
||||
"Australia/Perth",
|
||||
"Australia/Sydney",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Andorra",
|
||||
"Europe/Astrakhan",
|
||||
"Europe/Athens",
|
||||
"Europe/Belgrade",
|
||||
"Europe/Berlin",
|
||||
"Europe/Bratislava",
|
||||
"Europe/Brussels",
|
||||
"Europe/Bucharest",
|
||||
"Europe/Budapest",
|
||||
"Europe/Busingen",
|
||||
"Europe/Chisinau",
|
||||
"Europe/Copenhagen",
|
||||
"Europe/Dublin",
|
||||
"Europe/Gibraltar",
|
||||
"Europe/Guernsey",
|
||||
"Europe/Helsinki",
|
||||
"Europe/Isle_of_Man",
|
||||
"Europe/Istanbul",
|
||||
"Europe/Jersey",
|
||||
"Europe/Kaliningrad",
|
||||
"Europe/Kiev",
|
||||
"Europe/Kirov",
|
||||
"Europe/Lisbon",
|
||||
"Europe/Ljubljana",
|
||||
"Europe/London",
|
||||
"Europe/Luxembourg",
|
||||
"Europe/Madrid",
|
||||
"Europe/Malta",
|
||||
"Europe/Mariehamn",
|
||||
"Europe/Minsk",
|
||||
"Europe/Monaco",
|
||||
"Europe/Moscow",
|
||||
"Europe/Oslo",
|
||||
"Europe/Paris",
|
||||
"Europe/Podgorica",
|
||||
"Europe/Prague",
|
||||
"Europe/Riga",
|
||||
"Europe/Rome",
|
||||
"Europe/Samara",
|
||||
"Europe/San_Marino",
|
||||
"Europe/Sarajevo",
|
||||
"Europe/Saratov",
|
||||
"Europe/Simferopol",
|
||||
"Europe/Skopje",
|
||||
"Europe/Sofia",
|
||||
"Europe/Stockholm",
|
||||
"Europe/Tallinn",
|
||||
"Europe/Tirane",
|
||||
"Europe/Ulyanovsk",
|
||||
"Europe/Uzhgorod",
|
||||
"Europe/Vaduz",
|
||||
"Europe/Vatican",
|
||||
"Europe/Vienna",
|
||||
"Europe/Vilnius",
|
||||
"Europe/Volgograd",
|
||||
"Europe/Warsaw",
|
||||
"Europe/Zagreb",
|
||||
"Europe/Zaporozhye",
|
||||
"Europe/Zurich",
|
||||
"Indian/Antananarivo",
|
||||
"Indian/Chagos",
|
||||
"Indian/Christmas",
|
||||
"Indian/Cocos",
|
||||
"Indian/Comoro",
|
||||
"Indian/Kerguelen",
|
||||
"Indian/Mahe",
|
||||
"Indian/Maldives",
|
||||
"Indian/Mauritius",
|
||||
"Indian/Mayotte",
|
||||
"Indian/Reunion",
|
||||
"Pacific/Apia",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Bougainville",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Easter",
|
||||
"Pacific/Efate",
|
||||
"Pacific/Enderbury",
|
||||
"Pacific/Fakaofo",
|
||||
"Pacific/Fiji",
|
||||
"Pacific/Funafuti",
|
||||
"Pacific/Galapagos",
|
||||
"Pacific/Gambier",
|
||||
"Pacific/Guadalcanal",
|
||||
"Pacific/Guam",
|
||||
"Pacific/Honolulu",
|
||||
"Pacific/Johnston",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Kosrae",
|
||||
"Pacific/Kwajalein",
|
||||
"Pacific/Majuro",
|
||||
"Pacific/Marquesas",
|
||||
"Pacific/Midway",
|
||||
"Pacific/Nauru",
|
||||
"Pacific/Niue",
|
||||
"Pacific/Norfolk",
|
||||
"Pacific/Noumea",
|
||||
"Pacific/Pago_Pago",
|
||||
"Pacific/Palau",
|
||||
"Pacific/Pitcairn",
|
||||
"Pacific/Ponape",
|
||||
"Pacific/Port_Moresby",
|
||||
"Pacific/Rarotonga",
|
||||
"Pacific/Saipan",
|
||||
"Pacific/Tahiti",
|
||||
"Pacific/Tarawa",
|
||||
"Pacific/Tongatapu",
|
||||
"Pacific/Truk",
|
||||
"Pacific/Wake",
|
||||
"Pacific/Wallis",
|
||||
"UTC"
|
||||
];
|
||||
21
lib/api.ts
Normal file
21
lib/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function ok(data: unknown, status = 200) {
|
||||
return NextResponse.json(data, { status });
|
||||
}
|
||||
|
||||
export function fail(message: string, status = 400, details?: unknown) {
|
||||
return NextResponse.json({ message, details }, { status });
|
||||
}
|
||||
|
||||
export function handleAuthError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "UNAUTHORIZED") {
|
||||
return fail("Nicht angemeldet", 401);
|
||||
}
|
||||
if (error.message === "FORBIDDEN") {
|
||||
return fail("Keine Berechtigung", 403);
|
||||
}
|
||||
}
|
||||
return fail("Interner Fehler", 500);
|
||||
}
|
||||
87
lib/auth/options.ts
Normal file
87
lib/auth/options.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { compare } from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { consumeRateLimit, getClientIpFromHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8)
|
||||
});
|
||||
|
||||
function limitLoginAttempt(
|
||||
headers: Headers | Record<string, string | string[] | undefined> | undefined,
|
||||
email: string
|
||||
) {
|
||||
const ip = getClientIpFromHeaders(headers ?? {});
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
return consumeRateLimit(
|
||||
`auth-login:${ip}:${normalizedEmail}`,
|
||||
8,
|
||||
10 * 60_000
|
||||
);
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 2 * 60 * 60 // 2 Stunden
|
||||
},
|
||||
pages: {
|
||||
signIn: "/anmelden"
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Anmeldung",
|
||||
credentials: {
|
||||
email: { label: "E-Mail", type: "email" },
|
||||
password: { label: "Passwort", type: "password" }
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
const parsed = credentialsSchema.safeParse(credentials);
|
||||
const rawEmail =
|
||||
typeof credentials?.email === "string" ? credentials.email : "unknown";
|
||||
const limit = limitLoginAttempt(req?.headers, rawEmail);
|
||||
if (!limit.ok) return null;
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: parsed.data.email }
|
||||
});
|
||||
|
||||
if (!user || !user.isActive || user.role !== "ADMIN") return null;
|
||||
|
||||
const valid = await compare(parsed.data.password, user.hashedPassword);
|
||||
if (!valid) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
slug: user.slug
|
||||
};
|
||||
}
|
||||
})
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = user.role;
|
||||
token.slug = user.slug;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id;
|
||||
session.user.role = token.role;
|
||||
session.user.slug = token.slug;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET
|
||||
};
|
||||
18
lib/auth/session.ts
Normal file
18
lib/auth/session.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth/options";
|
||||
|
||||
export async function requireSession() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const session = await requireSession();
|
||||
if (session.user.role !== "ADMIN") {
|
||||
throw new Error("FORBIDDEN");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
165
lib/constants.ts
Normal file
165
lib/constants.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export const SETTING_KEYS = {
|
||||
COMPANY_NAME: "company_name",
|
||||
COMPANY_LOGO_URL: "company_logo_url",
|
||||
BRANDING_ACCENT_COLOR: "branding_accent_color",
|
||||
FRONTEND_HEADER_TEXT: "frontend_header_text",
|
||||
FRONTEND_HEADER_LOGO_URL: "frontend_header_logo_url",
|
||||
FOOTER_PRIVACY_LABEL: "footer_privacy_label",
|
||||
FOOTER_PRIVACY_URL: "footer_privacy_url",
|
||||
FOOTER_IMPRINT_LABEL: "footer_imprint_label",
|
||||
FOOTER_IMPRINT_URL: "footer_imprint_url",
|
||||
FOOTER_COPYRIGHT_TEXT: "footer_copyright_text",
|
||||
PRIVACY_PAGE_TITLE: "privacy_page_title",
|
||||
PRIVACY_PAGE_CONTENT: "privacy_page_content",
|
||||
IMPRINT_PAGE_TITLE: "imprint_page_title",
|
||||
IMPRINT_PAGE_CONTENT: "imprint_page_content",
|
||||
CONTACT_EMAIL: "contact_email",
|
||||
DEFAULT_DURATION_MINUTES: "default_duration_minutes",
|
||||
BUFFER_MINUTES: "buffer_minutes",
|
||||
BOOKING_LEAD_HOURS: "booking_lead_hours",
|
||||
BOOKING_WINDOW_DAYS: "booking_window_days",
|
||||
BOOKING_ALLOWED_WEEKDAYS: "booking_allowed_weekdays",
|
||||
BOOKING_DAY_START_TIME: "booking_day_start_time",
|
||||
BOOKING_DAY_END_TIME: "booking_day_end_time",
|
||||
BOOKING_NOTICE_TEXT: "booking_notice_text",
|
||||
CANCEL_LIMIT_HOURS: "cancel_limit_hours",
|
||||
REMINDER_PRIMARY_HOURS: "reminder_primary_hours",
|
||||
REMINDER_SECONDARY_HOURS: "reminder_secondary_hours",
|
||||
JITSI_MEETING_MODE: "jitsi_meeting_mode",
|
||||
JITSI_BASE_URL: "jitsi_base_url",
|
||||
JITSI_ROOM_PREFIX: "jitsi_room_prefix",
|
||||
SMTP_HOST: "smtp_host",
|
||||
SMTP_PORT: "smtp_port",
|
||||
SMTP_USER: "smtp_user",
|
||||
SMTP_PASS: "smtp_pass",
|
||||
SMTP_FROM_NAME: "smtp_from_name",
|
||||
SMTP_FROM: "smtp_from",
|
||||
EMAIL_SUBJECT_CUSTOMER_CONFIRM: "email_subject_customer_confirm",
|
||||
EMAIL_SUBJECT_STAFF_NOTIFY: "email_subject_staff_notify",
|
||||
EMAIL_SUBJECT_CANCELLATION_CUSTOMER: "email_subject_cancellation_customer",
|
||||
EMAIL_SUBJECT_CANCELLATION_STAFF: "email_subject_cancellation_staff",
|
||||
EMAIL_SUBJECT_REMINDER_CUSTOMER: "email_subject_reminder_customer",
|
||||
EMAIL_SUBJECT_REMINDER_STAFF: "email_subject_reminder_staff",
|
||||
EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY: "email_subject_reminder_customer_primary",
|
||||
EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY: "email_subject_reminder_customer_secondary",
|
||||
EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY: "email_subject_reminder_staff_primary",
|
||||
EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY: "email_subject_reminder_staff_secondary",
|
||||
EMAIL_SUBJECT_SMTP_TEST: "email_subject_smtp_test",
|
||||
EMAIL_TEMPLATE_CUSTOMER_CONFIRM: "email_template_customer_confirm",
|
||||
EMAIL_TEMPLATE_STAFF_NOTIFY: "email_template_staff_notify",
|
||||
EMAIL_TEMPLATE_CANCELLATION: "email_template_cancellation",
|
||||
EMAIL_TEMPLATE_CANCELLATION_CUSTOMER: "email_template_cancellation_customer",
|
||||
EMAIL_TEMPLATE_CANCELLATION_STAFF: "email_template_cancellation_staff",
|
||||
EMAIL_TEMPLATE_REMINDER_CUSTOMER: "email_template_reminder_customer",
|
||||
EMAIL_TEMPLATE_REMINDER_STAFF: "email_template_reminder_staff",
|
||||
EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY: "email_template_reminder_customer_primary",
|
||||
EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY: "email_template_reminder_customer_secondary",
|
||||
EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY: "email_template_reminder_staff_primary",
|
||||
EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY: "email_template_reminder_staff_secondary",
|
||||
EMAIL_TEMPLATE_SMTP_TEST: "email_template_smtp_test",
|
||||
EMAIL_STYLE_TEMPLATE_ID: "email_style_template_id",
|
||||
EMAIL_TEMPLATE_ACTIVE_ID: "email_template_active_id",
|
||||
EMAIL_TEMPLATE_CUSTOM_LIBRARY: "email_template_custom_library",
|
||||
EMAIL_TEMPLATE_VERSION_LIBRARY: "email_template_version_library",
|
||||
EMAIL_TEMPLATE_LIVE_VERSION_ID: "email_template_live_version_id",
|
||||
EMAIL_TEMPLATE_DRAFT_VERSION_ID: "email_template_draft_version_id",
|
||||
LATEST_BOOKINGS_ARCHIVED_KEYS: "latest_bookings_archived_keys",
|
||||
INSTANT_MEETING_EMAIL_SUBJECT: "instant_meeting_email_subject",
|
||||
INSTANT_MEETING_EMAIL_TEMPLATE: "instant_meeting_email_template",
|
||||
INSTANT_MEETING_EMAIL_CACHE: "instant_meeting_email_cache",
|
||||
UI_COLOR_MODE: "ui_color_mode"
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_SETTINGS: Record<string, string> = {
|
||||
[SETTING_KEYS.COMPANY_NAME]: "CalBook",
|
||||
[SETTING_KEYS.COMPANY_LOGO_URL]: "",
|
||||
[SETTING_KEYS.BRANDING_ACCENT_COLOR]: "#4f46e5",
|
||||
[SETTING_KEYS.FRONTEND_HEADER_TEXT]: "Gespräch",
|
||||
[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL]: "",
|
||||
[SETTING_KEYS.FOOTER_PRIVACY_LABEL]: "Datenschutz",
|
||||
[SETTING_KEYS.FOOTER_PRIVACY_URL]: "/datenschutz",
|
||||
[SETTING_KEYS.FOOTER_IMPRINT_LABEL]: "Impressum",
|
||||
[SETTING_KEYS.FOOTER_IMPRINT_URL]: "/impressum",
|
||||
[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT]: "© {{year}} {{companyName}}",
|
||||
[SETTING_KEYS.PRIVACY_PAGE_TITLE]: "Datenschutz",
|
||||
[SETTING_KEYS.PRIVACY_PAGE_CONTENT]:
|
||||
"Diese Seite ist eine Vorlage. Bitte ergänze hier deine vollständige Datenschutzerklärung gemäß DSGVO.\n\nVerantwortlich:\n{{companyName}}\n\nZur Terminbuchung werden Kontaktdaten und Terminangaben verarbeitet, damit das Gespräch geplant und durchgeführt werden kann.",
|
||||
[SETTING_KEYS.IMPRINT_PAGE_TITLE]: "Impressum",
|
||||
[SETTING_KEYS.IMPRINT_PAGE_CONTENT]:
|
||||
"Diese Seite ist eine Vorlage. Bitte trage hier die rechtlich notwendigen Angaben für dein Unternehmen ein.\n\nAngaben gemäß § 5 TMG\n{{companyName}}\nStraße und Hausnummer\nPLZ Ort\n\nKontakt\nE-Mail: kontakt@example.com",
|
||||
[SETTING_KEYS.CONTACT_EMAIL]: process.env.ADMIN_EMAIL ?? "kontakt@calbook.local",
|
||||
[SETTING_KEYS.DEFAULT_DURATION_MINUTES]: process.env.DEFAULT_DURATION_MINUTES ?? "60",
|
||||
[SETTING_KEYS.BUFFER_MINUTES]: process.env.DEFAULT_BUFFER_MINUTES ?? "10",
|
||||
[SETTING_KEYS.BOOKING_LEAD_HOURS]: process.env.DEFAULT_BOOKING_LEAD_HOURS ?? "2",
|
||||
[SETTING_KEYS.BOOKING_WINDOW_DAYS]: process.env.DEFAULT_BOOKING_WINDOW_DAYS ?? "60",
|
||||
[SETTING_KEYS.BOOKING_ALLOWED_WEEKDAYS]:
|
||||
process.env.DEFAULT_BOOKING_ALLOWED_WEEKDAYS ?? "0,1,2,3,4",
|
||||
[SETTING_KEYS.BOOKING_DAY_START_TIME]:
|
||||
process.env.DEFAULT_BOOKING_DAY_START_TIME ?? "09:00",
|
||||
[SETTING_KEYS.BOOKING_DAY_END_TIME]:
|
||||
process.env.DEFAULT_BOOKING_DAY_END_TIME ?? "17:00",
|
||||
[SETTING_KEYS.BOOKING_NOTICE_TEXT]:
|
||||
"Erzähl uns kurz, worum es bei dir geht - damit wir uns optimal vorbereiten können.",
|
||||
[SETTING_KEYS.CANCEL_LIMIT_HOURS]: process.env.DEFAULT_CANCEL_HOURS ?? "24",
|
||||
[SETTING_KEYS.REMINDER_PRIMARY_HOURS]:
|
||||
process.env.DEFAULT_REMINDER_PRIMARY_HOURS ?? "24",
|
||||
[SETTING_KEYS.REMINDER_SECONDARY_HOURS]:
|
||||
process.env.DEFAULT_REMINDER_SECONDARY_HOURS ?? "1",
|
||||
[SETTING_KEYS.JITSI_MEETING_MODE]: process.env.JITSI_MEETING_MODE ?? "public",
|
||||
[SETTING_KEYS.JITSI_BASE_URL]: process.env.JITSI_BASE_URL ?? "https://meet.jit.si",
|
||||
[SETTING_KEYS.JITSI_ROOM_PREFIX]: process.env.JITSI_ROOM_PREFIX ?? "calbook",
|
||||
[SETTING_KEYS.SMTP_HOST]: process.env.SMTP_HOST ?? "",
|
||||
[SETTING_KEYS.SMTP_PORT]: process.env.SMTP_PORT ?? "587",
|
||||
[SETTING_KEYS.SMTP_USER]: process.env.SMTP_USER ?? "",
|
||||
[SETTING_KEYS.SMTP_PASS]: process.env.SMTP_PASS ?? "",
|
||||
[SETTING_KEYS.SMTP_FROM_NAME]: process.env.SMTP_FROM_NAME ?? "CalBook",
|
||||
[SETTING_KEYS.SMTP_FROM]: process.env.SMTP_FROM ?? "no-reply@calbook.local",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM]: "Dein Termin am {{date}} um {{time}} - Bestätigung",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY]: "Neue Buchung: {{customerName}} am {{date}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER]: "Termin storniert: {{date}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF]: "Termin storniert: {{customerName}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER]: "Erinnerung: Dein Termin am {{date}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF]: "Erinnerung: Termin mit {{customerName}} am {{date}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY]: "Erinnerung: Dein Termin am {{date}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY]: "Dein Termin startet bald: {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY]: "Erinnerung: Termin mit {{customerName}} am {{date}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY]: "Termin startet bald: {{customerName}} um {{time}}",
|
||||
[SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST]: "SMTP-Test von {{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM]:
|
||||
"Hallo {{customerName}},\n\ndein Gespräch am {{date}} um {{time}} wurde bestätigt.\n\nZugewiesene Person(en): {{staffNames}}\nDauer: {{duration}} Minuten\n\n{{meetingButton}}\n\nFalls du absagen musst: {{cancelUrl}}\nFalls du umbuchen möchtest: {{rescheduleUrl}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY]:
|
||||
"Neue Buchung für dich:\n\nKunde: {{customerName}}\nTermin: {{date}} um {{time}}\nTelefon: {{phone}}\nE-Mail: {{email}}\nNotizen: {{notes}}\n\n{{meetingButton}}\n\nDashboard: {{dashboardUrl}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION]:
|
||||
"Der Termin am {{date}} um {{time}} wurde storniert.\n\nKunde: {{customerName}}\nPerson(en): {{staffNames}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER]:
|
||||
"Hallo {{customerName}},\n\ndein Termin am {{date}} um {{time}} wurde storniert.\n\nPerson(en): {{staffNames}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF]:
|
||||
"Hallo {{staffName}},\n\nder Termin mit {{customerName}} am {{date}} um {{time}} wurde storniert.\n\nPerson(en): {{staffNames}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER]:
|
||||
"Hallo {{customerName}},\n\ndein Gespräch startet {{reminderLabel}}.\n\nDatum: {{date}}\nUhrzeit: {{time}}\nDauer: {{duration}} Minuten\nPerson(en): {{staffNames}}\n\n{{meetingButton}}\n\nFalls du absagen musst: {{cancelUrl}}\nFalls du umbuchen möchtest: {{rescheduleUrl}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF]:
|
||||
"Hallo {{staffName}},\n\nkurze Erinnerung: Der Termin mit {{customerName}} startet {{reminderLabel}}.\n\nDatum: {{date}}\nUhrzeit: {{time}}\nDauer: {{duration}} Minuten\nTelefon: {{phone}}\nE-Mail: {{email}}\nNotizen: {{notes}}\n\n{{meetingButton}}\n\nDashboard: {{dashboardUrl}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY]:
|
||||
"Hallo {{customerName}},\n\ndein Gespräch startet {{reminderLabel}}.\n\nDatum: {{date}}\nUhrzeit: {{time}}\nDauer: {{duration}} Minuten\nPerson(en): {{staffNames}}\n\n{{meetingButton}}\n\nFalls du absagen musst: {{cancelUrl}}\nFalls du umbuchen möchtest: {{rescheduleUrl}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY]:
|
||||
"Hallo {{customerName}},\n\nkurze Erinnerung: Dein Gespräch startet {{reminderLabel}}.\n\nDatum: {{date}}\nUhrzeit: {{time}}\n\n{{meetingButton}}\n\nFalls du absagen musst: {{cancelUrl}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY]:
|
||||
"Hallo {{staffName}},\n\nkurze Erinnerung: Der Termin mit {{customerName}} startet {{reminderLabel}}.\n\nDatum: {{date}}\nUhrzeit: {{time}}\nDauer: {{duration}} Minuten\nTelefon: {{phone}}\nE-Mail: {{email}}\nNotizen: {{notes}}\n\n{{meetingButton}}\n\nDashboard: {{dashboardUrl}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY]:
|
||||
"Hallo {{staffName}},\n\nder Termin mit {{customerName}} startet {{reminderLabel}}.\n\nUhrzeit: {{time}}\nTelefon: {{phone}}\nE-Mail: {{email}}\nNotizen: {{notes}}\n\n{{meetingButton}}\n\nDashboard: {{dashboardUrl}}",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST]:
|
||||
"Diese Testmail bestätigt, dass SMTP in CalBook funktioniert.\n\nEmpfänger: {{recipientEmail}}\nZeitpunkt: {{timestamp}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID]: "minimal",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_ACTIVE_ID]: "standard:klassisch",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOM_LIBRARY]: "[]",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_VERSION_LIBRARY]: "[]",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_LIVE_VERSION_ID]: "",
|
||||
[SETTING_KEYS.EMAIL_TEMPLATE_DRAFT_VERSION_ID]: "",
|
||||
[SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS]: "[]",
|
||||
[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT]:
|
||||
"Sofort-Meeting: {{companyName}}",
|
||||
[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE]:
|
||||
"Hallo {{recipientName}},\n\nhier ist dein spontaner Meeting-Link:\n\n{{meetingButton}}\n\nMeeting-Link: {{meetingUrl}}\nAuswahl: {{scopeLabel}}\nGesendet von: {{initiatorName}}\n\n{{customMessage}}\n\nViele Grüße\n{{companyName}}",
|
||||
[SETTING_KEYS.INSTANT_MEETING_EMAIL_CACHE]: "[]",
|
||||
[SETTING_KEYS.UI_COLOR_MODE]: "light"
|
||||
};
|
||||
48
lib/crypto.ts
Normal file
48
lib/crypto.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
function getKey() {
|
||||
const env = process.env.CALDAV_ENCRYPTION_KEY;
|
||||
if (!env || env.length < 32) {
|
||||
throw new Error("CALDAV_ENCRYPTION_KEY muss mindestens 32 Zeichen lang sein");
|
||||
}
|
||||
return Buffer.from(env.slice(0, 32));
|
||||
}
|
||||
|
||||
function encryptWithKey(value: string, key: Buffer): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
||||
}
|
||||
|
||||
function decryptWithKey(value: string, key: Buffer): string {
|
||||
const [ivHex, tagHex, encryptedHex] = value.split(":");
|
||||
if (!ivHex || !tagHex || !encryptedHex) {
|
||||
throw new Error("Ungültiges Secret-Format");
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"));
|
||||
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedHex, "hex")),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
|
||||
export function encryptSecret(value: string): string {
|
||||
return encryptWithKey(value, getKey());
|
||||
}
|
||||
|
||||
export function decryptSecret(value: string): string {
|
||||
return decryptWithKey(value, getKey());
|
||||
}
|
||||
|
||||
export function reEncryptWithNewKey(encrypted: string, oldKeyHex: string): string {
|
||||
if (!encrypted || !oldKeyHex || oldKeyHex.length < 32) return encrypted;
|
||||
const oldKey = Buffer.from(oldKeyHex.slice(0, 32));
|
||||
const plaintext = decryptWithKey(encrypted, oldKey);
|
||||
return encryptWithKey(plaintext, getKey());
|
||||
}
|
||||
129
lib/date.ts
Normal file
129
lib/date.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { de } from "date-fns/locale";
|
||||
import { format } from "date-fns";
|
||||
import { toZonedTime, fromZonedTime } from "date-fns-tz";
|
||||
|
||||
export const DEFAULT_TIMEZONE = process.env.DEFAULT_TIMEZONE ?? "Europe/Berlin";
|
||||
|
||||
export function isValidTimeZone(value: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat("de-DE", { timeZone: value }).format(new Date());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimeZone(value?: string | null): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return DEFAULT_TIMEZONE;
|
||||
return isValidTimeZone(trimmed) ? trimmed : DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
export function formatDateDE(
|
||||
date: Date,
|
||||
withWeekday = false,
|
||||
timeZone: string = DEFAULT_TIMEZONE
|
||||
): string {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
return format(toZonedTime(date, zone), withWeekday ? "EEEE, dd.MM.yyyy" : "dd.MM.yyyy", {
|
||||
locale: de
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTimeDE(date: Date, timeZone: string = DEFAULT_TIMEZONE): string {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
return format(toZonedTime(date, zone), "HH:mm", { locale: de });
|
||||
}
|
||||
|
||||
export function zonedDateOnlyToUtc(date: string, timeZone: string = DEFAULT_TIMEZONE): Date {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
return fromZonedTime(`${date} 00:00:00`, zone);
|
||||
}
|
||||
|
||||
export function zonedDateFromParts(
|
||||
date: string,
|
||||
time: string,
|
||||
timeZone: string = DEFAULT_TIMEZONE
|
||||
): Date {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
return fromZonedTime(`${date} ${time}:00`, zone);
|
||||
}
|
||||
|
||||
export function atStartOfDayInZone(date: Date, timeZone: string = DEFAULT_TIMEZONE): Date {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
const zoned = toZonedTime(date, zone);
|
||||
const yyyy = format(zoned, "yyyy");
|
||||
const mm = format(zoned, "MM");
|
||||
const dd = format(zoned, "dd");
|
||||
return fromZonedTime(`${yyyy}-${mm}-${dd} 00:00:00`, zone);
|
||||
}
|
||||
|
||||
export function atEndOfDayInZone(date: Date, timeZone: string = DEFAULT_TIMEZONE): Date {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
const zoned = toZonedTime(date, zone);
|
||||
const yyyy = format(zoned, "yyyy");
|
||||
const mm = format(zoned, "MM");
|
||||
const dd = format(zoned, "dd");
|
||||
return fromZonedTime(`${yyyy}-${mm}-${dd} 23:59:59`, zone);
|
||||
}
|
||||
|
||||
export function combineDateAndTime(
|
||||
date: Date,
|
||||
hhmm: string,
|
||||
timeZone: string = DEFAULT_TIMEZONE
|
||||
): Date {
|
||||
const zone = resolveTimeZone(timeZone);
|
||||
const zoned = toZonedTime(date, zone);
|
||||
const yyyy = format(zoned, "yyyy");
|
||||
const mm = format(zoned, "MM");
|
||||
const dd = format(zoned, "dd");
|
||||
return fromZonedTime(`${yyyy}-${mm}-${dd} ${hhmm}:00`, zone);
|
||||
}
|
||||
|
||||
function parseIsoDateToUtcNoon(value: string) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
|
||||
if (
|
||||
!Number.isInteger(year) ||
|
||||
!Number.isInteger(month) ||
|
||||
!Number.isInteger(day)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
export function isoDateRangeInclusive(startDateIso: string, endDateIso: string) {
|
||||
const start = parseIsoDateToUtcNoon(startDateIso);
|
||||
const end = parseIsoDateToUtcNoon(endDateIso);
|
||||
if (!start || !end) return [] as string[];
|
||||
|
||||
const from = start <= end ? start : end;
|
||||
const to = start <= end ? end : start;
|
||||
|
||||
const values: string[] = [];
|
||||
for (let ts = from.getTime(); ts <= to.getTime(); ts += 24 * 60 * 60 * 1000) {
|
||||
values.push(new Date(ts).toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function minutesBetween(start: Date, end: Date): number {
|
||||
return Math.round((end.getTime() - start.getTime()) / 60000);
|
||||
}
|
||||
815
lib/email/mailer.ts
Normal file
815
lib/email/mailer.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { DEFAULT_SETTINGS, SETTING_KEYS } from "@/lib/constants";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
formatDateDE,
|
||||
formatTimeDE,
|
||||
resolveTimeZone
|
||||
} from "@/lib/date";
|
||||
import { renderTemplate } from "@/lib/email/template-engine";
|
||||
import { renderStyledEmail } from "@/lib/email/style-renderer";
|
||||
import { normalizeMeetingButtonTemplate } from "@/lib/email/shortcodes";
|
||||
import { withRetry } from "@/lib/services/retry";
|
||||
import { buildPublicUrl } from "@/lib/public-url";
|
||||
import {
|
||||
reportDeliveryFailure,
|
||||
resolveDeliveryIssues
|
||||
} from "@/lib/services/delivery-issues";
|
||||
|
||||
type StaffRecipient = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type ReminderKind = "primary" | "secondary";
|
||||
|
||||
type SmtpConfigInput = {
|
||||
host?: string;
|
||||
port?: string | number;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
fromName?: string;
|
||||
from?: string;
|
||||
secure?: boolean;
|
||||
};
|
||||
|
||||
function sanitizeHeaderValue(value: string) {
|
||||
return value.replace(/[\r\n]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function extractFromAddress(value?: string) {
|
||||
if (!value) return "";
|
||||
const match = value.match(/<([^>]+)>/);
|
||||
if (match?.[1]) return sanitizeHeaderValue(match[1]);
|
||||
return sanitizeHeaderValue(value);
|
||||
}
|
||||
|
||||
function extractFromName(value?: string) {
|
||||
if (!value) return "";
|
||||
const match = value.match(/^([^<]+)<[^>]+>$/);
|
||||
if (!match?.[1]) return "";
|
||||
return sanitizeHeaderValue(match[1].replace(/^"(.+)"$/, "$1"));
|
||||
}
|
||||
|
||||
function isEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
function uniqueStaff(staffList: StaffRecipient[]): StaffRecipient[] {
|
||||
const byEmail = new Map<string, StaffRecipient>();
|
||||
for (const staff of staffList) {
|
||||
if (!byEmail.has(staff.email)) {
|
||||
byEmail.set(staff.email, staff);
|
||||
}
|
||||
}
|
||||
return Array.from(byEmail.values());
|
||||
}
|
||||
|
||||
function isRetryableMailError(error: unknown) {
|
||||
const maybeCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code ?? "")
|
||||
: "";
|
||||
const code = maybeCode.toUpperCase();
|
||||
|
||||
if (["EAUTH", "EENVELOPE", "EADDRESS"].includes(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes("authentication") ||
|
||||
message.includes("invalid login") ||
|
||||
message.includes("bad credentials")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendMailWithRetry(
|
||||
transporter: nodemailer.Transporter,
|
||||
mail: nodemailer.SendMailOptions,
|
||||
meta: {
|
||||
operation: string;
|
||||
target: string;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await withRetry(
|
||||
async () => transporter.sendMail(mail),
|
||||
{
|
||||
attempts: 4,
|
||||
baseDelayMs: 700,
|
||||
maxDelayMs: 5_000,
|
||||
shouldRetry: isRetryableMailError
|
||||
}
|
||||
);
|
||||
|
||||
await resolveDeliveryIssues({
|
||||
channel: "SMTP",
|
||||
operation: meta.operation,
|
||||
target: meta.target
|
||||
});
|
||||
} catch (error) {
|
||||
await reportDeliveryFailure({
|
||||
channel: "SMTP",
|
||||
operation: meta.operation,
|
||||
target: meta.target,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTransportConfig(overrides?: SmtpConfigInput) {
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.SMTP_HOST,
|
||||
SETTING_KEYS.SMTP_PORT,
|
||||
SETTING_KEYS.SMTP_USER,
|
||||
SETTING_KEYS.SMTP_PASS,
|
||||
SETTING_KEYS.SMTP_FROM_NAME,
|
||||
SETTING_KEYS.SMTP_FROM
|
||||
]);
|
||||
|
||||
const host =
|
||||
overrides && "host" in overrides
|
||||
? overrides.host?.trim()
|
||||
: settings[SETTING_KEYS.SMTP_HOST] || process.env.SMTP_HOST;
|
||||
const portValue =
|
||||
overrides && "port" in overrides
|
||||
? overrides.port
|
||||
: settings[SETTING_KEYS.SMTP_PORT] || process.env.SMTP_PORT || 587;
|
||||
const parsedPort = Number(portValue || 587);
|
||||
const port = Number.isFinite(parsedPort) ? parsedPort : 587;
|
||||
const secureFromEnv =
|
||||
(process.env.SMTP_SECURE ?? "").toLowerCase() === "true" ||
|
||||
process.env.SMTP_SECURE === "1";
|
||||
const secure =
|
||||
overrides && "secure" in overrides && typeof overrides.secure === "boolean"
|
||||
? overrides.secure
|
||||
: secureFromEnv || port === 465;
|
||||
const smtpUser =
|
||||
overrides && "user" in overrides
|
||||
? overrides.user?.trim() || ""
|
||||
: settings[SETTING_KEYS.SMTP_USER] || process.env.SMTP_USER || "";
|
||||
const smtpPass =
|
||||
overrides && "pass" in overrides
|
||||
? overrides.pass ?? ""
|
||||
: settings[SETTING_KEYS.SMTP_PASS] || process.env.SMTP_PASS || "";
|
||||
const smtpUserAddress = isEmail(smtpUser) ? smtpUser : "";
|
||||
const rawFromAddress =
|
||||
overrides && "from" in overrides
|
||||
? overrides.from?.trim() || ""
|
||||
: settings[SETTING_KEYS.SMTP_FROM] || process.env.SMTP_FROM || "";
|
||||
const configuredFromAddress = extractFromAddress(rawFromAddress);
|
||||
const fromAddress =
|
||||
smtpUserAddress ||
|
||||
(isEmail(configuredFromAddress) ? configuredFromAddress : "") ||
|
||||
"no-reply@calbook.local";
|
||||
const fromNameRaw =
|
||||
overrides && "fromName" in overrides
|
||||
? overrides.fromName || extractFromName(rawFromAddress) || "CalBook"
|
||||
: settings[SETTING_KEYS.SMTP_FROM_NAME] ||
|
||||
process.env.SMTP_FROM_NAME ||
|
||||
extractFromName(rawFromAddress) ||
|
||||
"CalBook";
|
||||
const fromName = sanitizeHeaderValue(fromNameRaw) || "CalBook";
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth:
|
||||
smtpUser
|
||||
? {
|
||||
user: smtpUser,
|
||||
pass: smtpPass
|
||||
}
|
||||
: undefined,
|
||||
from: {
|
||||
name: fromName,
|
||||
address: fromAddress
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createTransporter(overrides?: SmtpConfigInput) {
|
||||
const cfg = await getTransportConfig(overrides);
|
||||
if (!cfg.host) return null;
|
||||
return {
|
||||
cfg,
|
||||
transporter: nodemailer.createTransport(cfg)
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendBookingEmails(params: {
|
||||
appointment: {
|
||||
customerEmail: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerPhone?: string | null;
|
||||
notes?: string | null;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
durationMinutes: number;
|
||||
cancellationToken: string;
|
||||
meetingUrl: string;
|
||||
customerTimezone?: string;
|
||||
};
|
||||
staffList: StaffRecipient[];
|
||||
companyName: string;
|
||||
}) {
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY,
|
||||
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
||||
SETTING_KEYS.COMPANY_NAME
|
||||
]);
|
||||
|
||||
const transport = await createTransporter();
|
||||
if (!transport) return;
|
||||
const { cfg, transporter } = transport;
|
||||
|
||||
const staffRecipients = uniqueStaff(params.staffList);
|
||||
if (staffRecipients.length === 0) return;
|
||||
|
||||
const customerName = `${params.appointment.customerFirstName} ${params.appointment.customerLastName}`;
|
||||
const customerTimezone = resolveTimeZone(params.appointment.customerTimezone);
|
||||
const customerDate = formatDateDE(params.appointment.startAt, false, customerTimezone);
|
||||
const customerTime = formatTimeDE(params.appointment.startAt, customerTimezone);
|
||||
const staffDate = formatDateDE(params.appointment.startAt, false, DEFAULT_TIMEZONE);
|
||||
const staffTime = formatTimeDE(params.appointment.startAt, DEFAULT_TIMEZONE);
|
||||
const staffNames = staffRecipients.map((staff) => staff.name).join(", ");
|
||||
const cancelUrl = buildPublicUrl(
|
||||
`/stornieren?token=${encodeURIComponent(params.appointment.cancellationToken)}`
|
||||
);
|
||||
const rescheduleUrl = buildPublicUrl(
|
||||
`/buchen?rescheduleToken=${encodeURIComponent(params.appointment.cancellationToken)}`
|
||||
);
|
||||
const dashboardUrl = buildPublicUrl("/admin/termine");
|
||||
|
||||
const customerBaseValues: Record<string, string> = {
|
||||
customerName,
|
||||
date: customerDate,
|
||||
time: customerTime,
|
||||
duration: String(params.appointment.durationMinutes),
|
||||
staffNames,
|
||||
companyName: params.companyName,
|
||||
cancelUrl,
|
||||
rescheduleUrl,
|
||||
phone: params.appointment.customerPhone ?? "-",
|
||||
email: params.appointment.customerEmail,
|
||||
notes: params.appointment.notes ?? "-",
|
||||
meetingUrl: params.appointment.meetingUrl,
|
||||
dashboardUrl,
|
||||
staffName: staffRecipients[0]?.name ?? "Person",
|
||||
timezone: customerTimezone
|
||||
};
|
||||
|
||||
const staffBaseValues: Record<string, string> = {
|
||||
...customerBaseValues,
|
||||
date: staffDate,
|
||||
time: staffTime,
|
||||
timezone: DEFAULT_TIMEZONE
|
||||
};
|
||||
|
||||
const customerSubject = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM],
|
||||
customerBaseValues
|
||||
);
|
||||
const customerTemplate = normalizeMeetingButtonTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM]
|
||||
);
|
||||
const customerBody = renderTemplate(
|
||||
customerTemplate,
|
||||
customerBaseValues
|
||||
);
|
||||
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
||||
|
||||
const customerStyled = renderStyledEmail({
|
||||
styleId,
|
||||
subject: customerSubject,
|
||||
companyName: params.companyName,
|
||||
heading: customerSubject,
|
||||
body: customerBody,
|
||||
ctaLabel: "Meeting beitreten",
|
||||
ctaUrl: params.appointment.meetingUrl,
|
||||
secondaryCtaLabel: "Termin stornieren",
|
||||
secondaryCtaUrl: cancelUrl,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
await sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: params.appointment.customerEmail,
|
||||
subject: customerSubject,
|
||||
text: customerStyled.text,
|
||||
html: customerStyled.html
|
||||
},
|
||||
{
|
||||
operation: "booking-customer",
|
||||
target: params.appointment.customerEmail
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
staffRecipients.map((staff) => {
|
||||
const values = {
|
||||
...staffBaseValues,
|
||||
staffName: staff.name
|
||||
};
|
||||
const staffBody = renderTemplate(
|
||||
normalizeMeetingButtonTemplate(settings[SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY]),
|
||||
values
|
||||
);
|
||||
const staffSubject = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY],
|
||||
values
|
||||
);
|
||||
const staffStyled = renderStyledEmail({
|
||||
styleId,
|
||||
subject: staffSubject,
|
||||
companyName: params.companyName,
|
||||
heading: staffSubject,
|
||||
body: staffBody,
|
||||
ctaLabel: "Meeting beitreten",
|
||||
ctaUrl: params.appointment.meetingUrl,
|
||||
secondaryCtaLabel: "Termin stornieren",
|
||||
secondaryCtaUrl: cancelUrl,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
return sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: staff.email,
|
||||
subject: staffSubject,
|
||||
text: staffStyled.text,
|
||||
html: staffStyled.html
|
||||
},
|
||||
{
|
||||
operation: "booking-staff",
|
||||
target: staff.email
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCancellationEmails(params: {
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
staffList: StaffRecipient[];
|
||||
date: Date;
|
||||
customerTimezone?: string;
|
||||
companyName: string;
|
||||
}) {
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF,
|
||||
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID
|
||||
]);
|
||||
const transport = await createTransporter();
|
||||
if (!transport) return;
|
||||
const { cfg, transporter } = transport;
|
||||
|
||||
const staffRecipients = uniqueStaff(params.staffList);
|
||||
|
||||
const customerTimezone = resolveTimeZone(params.customerTimezone);
|
||||
const customerDate = formatDateDE(params.date, false, customerTimezone);
|
||||
const customerTime = formatTimeDE(params.date, customerTimezone);
|
||||
const staffDate = formatDateDE(params.date, false, DEFAULT_TIMEZONE);
|
||||
const staffTime = formatTimeDE(params.date, DEFAULT_TIMEZONE);
|
||||
const staffNames = staffRecipients.map((staff) => staff.name).join(", ");
|
||||
|
||||
const customerValues: Record<string, string> = {
|
||||
customerName: params.customerName,
|
||||
date: customerDate,
|
||||
time: customerTime,
|
||||
companyName: params.companyName,
|
||||
staffNames,
|
||||
staffName: staffRecipients[0]?.name ?? "Person",
|
||||
timezone: customerTimezone
|
||||
};
|
||||
const staffValues: Record<string, string> = {
|
||||
...customerValues,
|
||||
date: staffDate,
|
||||
time: staffTime,
|
||||
timezone: DEFAULT_TIMEZONE
|
||||
};
|
||||
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
||||
const cancellationBody = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER] ||
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION],
|
||||
customerValues
|
||||
);
|
||||
const cancellationCustomerSubject = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER],
|
||||
customerValues
|
||||
);
|
||||
const cancellationCustomerStyled = renderStyledEmail({
|
||||
styleId,
|
||||
subject: cancellationCustomerSubject,
|
||||
companyName: params.companyName,
|
||||
heading: cancellationCustomerSubject,
|
||||
body: cancellationBody,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
await sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: params.customerEmail,
|
||||
subject: cancellationCustomerSubject,
|
||||
text: cancellationCustomerStyled.text,
|
||||
html: cancellationCustomerStyled.html
|
||||
},
|
||||
{
|
||||
operation: "cancel-customer",
|
||||
target: params.customerEmail
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
staffRecipients.map((staff) => {
|
||||
const values = { ...staffValues, staffName: staff.name };
|
||||
const subject = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF],
|
||||
values
|
||||
);
|
||||
const body = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF] ||
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION],
|
||||
values
|
||||
);
|
||||
const styled = renderStyledEmail({
|
||||
styleId,
|
||||
subject,
|
||||
companyName: params.companyName,
|
||||
heading: subject,
|
||||
body,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
return sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: staff.email,
|
||||
subject,
|
||||
text: styled.text,
|
||||
html: styled.html
|
||||
},
|
||||
{
|
||||
operation: "cancel-staff",
|
||||
target: staff.email
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendReminderEmails(params: {
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
customerPhone?: string | null;
|
||||
notes?: string | null;
|
||||
staffList: StaffRecipient[];
|
||||
date: Date;
|
||||
customerTimezone?: string;
|
||||
durationMinutes: number;
|
||||
cancellationToken: string;
|
||||
meetingUrl: string;
|
||||
companyName: string;
|
||||
reminderKind: ReminderKind;
|
||||
hoursBefore: number;
|
||||
}) {
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY
|
||||
]);
|
||||
const transport = await createTransporter();
|
||||
if (!transport) return;
|
||||
const { cfg, transporter } = transport;
|
||||
|
||||
const staffRecipients = uniqueStaff(params.staffList);
|
||||
if (staffRecipients.length === 0) return;
|
||||
const customerTimezone = resolveTimeZone(params.customerTimezone);
|
||||
const customerDate = formatDateDE(params.date, false, customerTimezone);
|
||||
const customerTime = formatTimeDE(params.date, customerTimezone);
|
||||
const staffDate = formatDateDE(params.date, false, DEFAULT_TIMEZONE);
|
||||
const staffTime = formatTimeDE(params.date, DEFAULT_TIMEZONE);
|
||||
const staffNames = staffRecipients.map((staff) => staff.name).join(", ");
|
||||
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
||||
const reminderHours = Math.max(1, Math.floor(params.hoursBefore));
|
||||
const reminderLabel =
|
||||
reminderHours === 1 ? "in 1 Stunde" : `in ${reminderHours} Stunden`;
|
||||
const cancelUrl = buildPublicUrl(
|
||||
`/stornieren?token=${encodeURIComponent(params.cancellationToken)}`
|
||||
);
|
||||
const rescheduleUrl = buildPublicUrl(
|
||||
`/buchen?rescheduleToken=${encodeURIComponent(params.cancellationToken)}`
|
||||
);
|
||||
const dashboardUrl = buildPublicUrl("/admin/termine");
|
||||
|
||||
const customerBaseValues: Record<string, string> = {
|
||||
customerName: params.customerName,
|
||||
date: customerDate,
|
||||
time: customerTime,
|
||||
duration: String(params.durationMinutes),
|
||||
staffNames,
|
||||
companyName: params.companyName,
|
||||
cancelUrl,
|
||||
rescheduleUrl,
|
||||
phone: params.customerPhone?.trim() || "-",
|
||||
email: params.customerEmail,
|
||||
notes: params.notes?.trim() || "-",
|
||||
meetingUrl: params.meetingUrl,
|
||||
dashboardUrl,
|
||||
staffName: staffRecipients[0]?.name ?? "Person",
|
||||
timezone: customerTimezone,
|
||||
reminderLabel,
|
||||
hoursBefore: String(reminderHours),
|
||||
reminderKind: params.reminderKind
|
||||
};
|
||||
const staffBaseValues: Record<string, string> = {
|
||||
...customerBaseValues,
|
||||
date: staffDate,
|
||||
time: staffTime,
|
||||
timezone: DEFAULT_TIMEZONE
|
||||
};
|
||||
|
||||
const customerSubjectTemplate =
|
||||
params.reminderKind === "secondary"
|
||||
? settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER]
|
||||
: settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER];
|
||||
const customerBodyTemplate =
|
||||
params.reminderKind === "secondary"
|
||||
? settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER]
|
||||
: settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER];
|
||||
|
||||
const subjectCustomer = renderTemplate(customerSubjectTemplate, customerBaseValues);
|
||||
const customerBody = renderTemplate(
|
||||
normalizeMeetingButtonTemplate(customerBodyTemplate),
|
||||
customerBaseValues
|
||||
);
|
||||
|
||||
const customerStyled = renderStyledEmail({
|
||||
styleId,
|
||||
subject: subjectCustomer,
|
||||
companyName: params.companyName,
|
||||
heading: subjectCustomer,
|
||||
lead: `Dein Termin startet ${reminderLabel}.`,
|
||||
body: customerBody,
|
||||
ctaLabel: "Meeting beitreten",
|
||||
ctaUrl: params.meetingUrl,
|
||||
secondaryCtaLabel: "Termin stornieren",
|
||||
secondaryCtaUrl: cancelUrl,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
await sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: params.customerEmail,
|
||||
subject: subjectCustomer,
|
||||
text: customerStyled.text,
|
||||
html: customerStyled.html
|
||||
},
|
||||
{
|
||||
operation: `reminder-${params.reminderKind}-customer`,
|
||||
target: params.customerEmail
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
staffRecipients.map((staff) => {
|
||||
const values = {
|
||||
...staffBaseValues,
|
||||
staffName: staff.name
|
||||
};
|
||||
const staffSubjectTemplate =
|
||||
params.reminderKind === "secondary"
|
||||
? settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF]
|
||||
: settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF];
|
||||
const staffBodyTemplate =
|
||||
params.reminderKind === "secondary"
|
||||
? settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF]
|
||||
: settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY] ||
|
||||
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF];
|
||||
|
||||
const subjectStaff = renderTemplate(staffSubjectTemplate, values);
|
||||
const staffBody = renderTemplate(
|
||||
normalizeMeetingButtonTemplate(staffBodyTemplate),
|
||||
values
|
||||
);
|
||||
const staffStyled = renderStyledEmail({
|
||||
styleId,
|
||||
subject: subjectStaff,
|
||||
companyName: params.companyName,
|
||||
heading: subjectStaff,
|
||||
lead: `Der Termin mit ${params.customerName} startet ${reminderLabel}.`,
|
||||
body: staffBody,
|
||||
ctaLabel: "Meeting beitreten",
|
||||
ctaUrl: params.meetingUrl,
|
||||
secondaryCtaLabel: "Termin stornieren",
|
||||
secondaryCtaUrl: cancelUrl,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
return sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: staff.email,
|
||||
subject: subjectStaff,
|
||||
text: staffStyled.text,
|
||||
html: staffStyled.html
|
||||
},
|
||||
{
|
||||
operation: `reminder-${params.reminderKind}-staff`,
|
||||
target: staff.email
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendInstantMeetingEmails(params: {
|
||||
recipients: Array<{ email: string; name?: string }>;
|
||||
meetingUrl: string;
|
||||
scopeLabel: string;
|
||||
initiatorName: string;
|
||||
companyName: string;
|
||||
customMessage?: string;
|
||||
subjectOverride?: string;
|
||||
}) {
|
||||
const transport = await createTransporter();
|
||||
if (!transport) {
|
||||
return { ok: false as const, message: "SMTP ist nicht konfiguriert." };
|
||||
}
|
||||
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
||||
SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT,
|
||||
SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE
|
||||
]);
|
||||
|
||||
const { cfg, transporter } = transport;
|
||||
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
||||
const subjectTemplate =
|
||||
params.subjectOverride?.trim() ||
|
||||
settings[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT] ||
|
||||
DEFAULT_SETTINGS[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT];
|
||||
const bodyTemplate =
|
||||
settings[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE] ||
|
||||
DEFAULT_SETTINGS[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE];
|
||||
|
||||
const recipients = uniqueStaff(
|
||||
params.recipients
|
||||
.map((recipient) => ({
|
||||
name: recipient.name?.trim() || recipient.email,
|
||||
email: recipient.email.trim().toLowerCase()
|
||||
}))
|
||||
.filter((recipient) => isEmail(recipient.email))
|
||||
);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return { ok: false as const, message: "Keine gültigen Empfänger gefunden." };
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
recipients.map(async (recipient) => {
|
||||
const values: Record<string, string> = {
|
||||
recipientName: recipient.name || recipient.email,
|
||||
companyName: params.companyName,
|
||||
meetingUrl: params.meetingUrl,
|
||||
scopeLabel: params.scopeLabel,
|
||||
initiatorName: params.initiatorName,
|
||||
customMessage: params.customMessage?.trim() || ""
|
||||
};
|
||||
|
||||
const subject = renderTemplate(subjectTemplate, values);
|
||||
const body = renderTemplate(bodyTemplate, values);
|
||||
const styled = renderStyledEmail({
|
||||
styleId,
|
||||
subject,
|
||||
companyName: params.companyName,
|
||||
heading: subject,
|
||||
body,
|
||||
ctaLabel: "Meeting beitreten",
|
||||
ctaUrl: params.meetingUrl,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
await sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: recipient.email,
|
||||
subject,
|
||||
text: styled.text,
|
||||
html: styled.html
|
||||
},
|
||||
{
|
||||
operation: "instant-meeting",
|
||||
target: recipient.email
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return { ok: true as const, sentCount: recipients.length };
|
||||
}
|
||||
|
||||
export async function sendSmtpTestEmail(params: {
|
||||
to: string;
|
||||
companyName: string;
|
||||
smtp?: SmtpConfigInput;
|
||||
}) {
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
||||
SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST,
|
||||
SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST
|
||||
]);
|
||||
const transport = await createTransporter(params.smtp);
|
||||
if (!transport) {
|
||||
return { ok: false as const, message: "SMTP ist nicht konfiguriert." };
|
||||
}
|
||||
|
||||
const { cfg, transporter } = transport;
|
||||
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
||||
const values: Record<string, string> = {
|
||||
companyName: params.companyName,
|
||||
recipientEmail: params.to,
|
||||
email: params.to,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
const subject = renderTemplate(
|
||||
settings[SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST],
|
||||
values
|
||||
);
|
||||
const body = renderTemplate(settings[SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST], values);
|
||||
const styled = renderStyledEmail({
|
||||
styleId,
|
||||
subject,
|
||||
companyName: params.companyName,
|
||||
heading: subject,
|
||||
body,
|
||||
footerNote: ""
|
||||
});
|
||||
|
||||
try {
|
||||
await sendMailWithRetry(
|
||||
transporter,
|
||||
{
|
||||
from: cfg.from,
|
||||
to: params.to,
|
||||
subject,
|
||||
text: styled.text,
|
||||
html: styled.html
|
||||
},
|
||||
{
|
||||
operation: "smtp-test",
|
||||
target: params.to
|
||||
}
|
||||
);
|
||||
return { ok: true as const };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "SMTP-Test fehlgeschlagen";
|
||||
return { ok: false as const, message };
|
||||
}
|
||||
}
|
||||
21
lib/email/shortcodes.ts
Normal file
21
lib/email/shortcodes.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const DEFAULT_MEETING_BUTTON_SHORTCODES = [
|
||||
"{{meetingButton}}",
|
||||
"{{jitsiButton}}",
|
||||
"[meeting_button]",
|
||||
"[jitsi_button]"
|
||||
];
|
||||
|
||||
export function normalizeMeetingButtonTemplate(
|
||||
template: string,
|
||||
shortcodes: string[] = DEFAULT_MEETING_BUTTON_SHORTCODES
|
||||
) {
|
||||
if (!template) return template;
|
||||
|
||||
const hasShortcode = shortcodes.some((token) => template.includes(token));
|
||||
if (hasShortcode) return template;
|
||||
|
||||
return template
|
||||
.replace(/Jitsi-Link:\s*\{\{meetingUrl\}\}/gi, "{{meetingButton}}")
|
||||
.replace(/Jitsi:\s*\{\{meetingUrl\}\}/gi, "{{meetingButton}}")
|
||||
.replace(/\{\{meetingUrl\}\}/g, "{{meetingButton}}");
|
||||
}
|
||||
182
lib/email/style-presets.ts
Normal file
182
lib/email/style-presets.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
export type EmailStylePreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
canvasColor: string;
|
||||
cardColor: string;
|
||||
headerColor: string;
|
||||
headingColor: string;
|
||||
textColor: string;
|
||||
mutedColor: string;
|
||||
borderColor: string;
|
||||
buttonColor: string;
|
||||
buttonTextColor: string;
|
||||
chipColor: string;
|
||||
chipTextColor: string;
|
||||
shadow: string;
|
||||
};
|
||||
|
||||
export const EMAIL_STYLE_PRESETS: EmailStylePreset[] = [
|
||||
{
|
||||
id: "minimal",
|
||||
name: "Minimal",
|
||||
description: "Reduziert, klar und neutral.",
|
||||
canvasColor: "#f8fafc",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#f8fafc",
|
||||
headingColor: "#0f172a",
|
||||
textColor: "#1e293b",
|
||||
mutedColor: "#64748b",
|
||||
borderColor: "#e2e8f0",
|
||||
buttonColor: "#0f172a",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#f1f5f9",
|
||||
chipTextColor: "#334155",
|
||||
shadow: "0 12px 24px rgba(15,23,42,0.08)"
|
||||
},
|
||||
{
|
||||
id: "corporate",
|
||||
name: "Corporate",
|
||||
description: "Professionell mit Indigo-Header.",
|
||||
canvasColor: "#eef2ff",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#4f46e5",
|
||||
headingColor: "#ffffff",
|
||||
textColor: "#1e293b",
|
||||
mutedColor: "#6366f1",
|
||||
borderColor: "#c7d2fe",
|
||||
buttonColor: "#4f46e5",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#eef2ff",
|
||||
chipTextColor: "#3730a3",
|
||||
shadow: "0 16px 36px rgba(79,70,229,0.18)"
|
||||
},
|
||||
{
|
||||
id: "startup",
|
||||
name: "Startup",
|
||||
description: "Gradient-lastig und modern.",
|
||||
canvasColor: "#f8fafc",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#eef2ff",
|
||||
headingColor: "#4f46e5",
|
||||
textColor: "#334155",
|
||||
mutedColor: "#6366f1",
|
||||
borderColor: "#cbd5e1",
|
||||
buttonColor: "#4f46e5",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#e0e7ff",
|
||||
chipTextColor: "#4338ca",
|
||||
shadow: "0 20px 36px rgba(79,70,229,0.16)"
|
||||
},
|
||||
{
|
||||
id: "serif",
|
||||
name: "Serif",
|
||||
description: "Magazin-Stil mit Serifen-Typografie.",
|
||||
canvasColor: "#faf8f5",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#f5f0e8",
|
||||
headingColor: "#1a1a1a",
|
||||
textColor: "#2b2721",
|
||||
mutedColor: "#78716c",
|
||||
borderColor: "#e7e0d5",
|
||||
buttonColor: "#1a1a1a",
|
||||
buttonTextColor: "#faf8f5",
|
||||
chipColor: "#f0ebe0",
|
||||
chipTextColor: "#44403c",
|
||||
shadow: "0 12px 28px rgba(43,39,33,0.08)"
|
||||
},
|
||||
{
|
||||
id: "mono",
|
||||
name: "Mono",
|
||||
description: "Schwarz-Weiß, klar und direkt.",
|
||||
canvasColor: "#ffffff",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#0f172a",
|
||||
headingColor: "#ffffff",
|
||||
textColor: "#1e293b",
|
||||
mutedColor: "#64748b",
|
||||
borderColor: "#0f172a",
|
||||
buttonColor: "#0f172a",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#f1f5f9",
|
||||
chipTextColor: "#0f172a",
|
||||
shadow: "0 8px 20px rgba(15,23,42,0.06)"
|
||||
},
|
||||
{
|
||||
id: "glass",
|
||||
name: "Glass",
|
||||
description: "Subtile Transparenz und Unschärfe.",
|
||||
canvasColor: "#f8fafc",
|
||||
cardColor: "rgba(255,255,255,0.7)",
|
||||
headerColor: "rgba(255,255,255,0.4)",
|
||||
headingColor: "#0f172a",
|
||||
textColor: "#334155",
|
||||
mutedColor: "#94a3b8",
|
||||
borderColor: "rgba(226,232,240,0.8)",
|
||||
buttonColor: "#0f172a",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "rgba(241,245,249,0.8)",
|
||||
chipTextColor: "#475569",
|
||||
shadow: "0 8px 32px rgba(15,23,42,0.04)"
|
||||
},
|
||||
{
|
||||
id: "ink",
|
||||
name: "Ink",
|
||||
description: "Tiefes Blau, edel und ruhig.",
|
||||
canvasColor: "#f0f4ff",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#1e3a5f",
|
||||
headingColor: "#e8f0fe",
|
||||
textColor: "#1e293b",
|
||||
mutedColor: "#475569",
|
||||
borderColor: "#cbd5e1",
|
||||
buttonColor: "#1e3a5f",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#e2e8f0",
|
||||
chipTextColor: "#1e3a5f",
|
||||
shadow: "0 14px 30px rgba(30,58,95,0.1)"
|
||||
},
|
||||
{
|
||||
id: "warm",
|
||||
name: "Warm",
|
||||
description: "Persönlich mit warmen Erdtönen.",
|
||||
canvasColor: "#fef7ed",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#b45309",
|
||||
headingColor: "#fff7ed",
|
||||
textColor: "#431407",
|
||||
mutedColor: "#92400e",
|
||||
borderColor: "#fde68a",
|
||||
buttonColor: "#b45309",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#fef3c7",
|
||||
chipTextColor: "#78350f",
|
||||
shadow: "0 14px 28px rgba(180,83,9,0.12)"
|
||||
},
|
||||
{
|
||||
id: "soft",
|
||||
name: "Soft",
|
||||
description: "Luftig, hell und unaufdringlich.",
|
||||
canvasColor: "#fafafa",
|
||||
cardColor: "#ffffff",
|
||||
headerColor: "#f5f5f5",
|
||||
headingColor: "#404040",
|
||||
textColor: "#525252",
|
||||
mutedColor: "#a3a3a3",
|
||||
borderColor: "#e5e5e5",
|
||||
buttonColor: "#737373",
|
||||
buttonTextColor: "#ffffff",
|
||||
chipColor: "#f5f5f5",
|
||||
chipTextColor: "#525252",
|
||||
shadow: "0 8px 20px rgba(0,0,0,0.03)"
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_EMAIL_STYLE_ID = EMAIL_STYLE_PRESETS[0]!.id;
|
||||
|
||||
export function getEmailStylePreset(styleId?: string) {
|
||||
return (
|
||||
EMAIL_STYLE_PRESETS.find((preset) => preset.id === styleId) ??
|
||||
EMAIL_STYLE_PRESETS[0]!
|
||||
);
|
||||
}
|
||||
353
lib/email/style-renderer.ts
Normal file
353
lib/email/style-renderer.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
DEFAULT_EMAIL_STYLE_ID,
|
||||
getEmailStylePreset
|
||||
} from "@/lib/email/style-presets";
|
||||
|
||||
export type StyledEmailInfoRow = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type StyledEmailPayload = {
|
||||
styleId?: string;
|
||||
subject: string;
|
||||
companyName: string;
|
||||
heading: string;
|
||||
lead?: string;
|
||||
body: string;
|
||||
infoRows?: StyledEmailInfoRow[];
|
||||
ctaLabel?: string;
|
||||
ctaUrl?: string;
|
||||
secondaryCtaLabel?: string;
|
||||
secondaryCtaUrl?: string;
|
||||
preheader?: string;
|
||||
footerNote?: string;
|
||||
};
|
||||
|
||||
const MEETING_BUTTON_SHORTCODES = [
|
||||
"{{meetingButton}}",
|
||||
"{{jitsiButton}}",
|
||||
"[meeting_button]",
|
||||
"[jitsi_button]"
|
||||
];
|
||||
const CANCEL_BUTTON_SHORTCODES = [
|
||||
"{{cancelButton}}",
|
||||
"{{stornoButton}}",
|
||||
"{{cancellationButton}}",
|
||||
"[cancel_button]"
|
||||
];
|
||||
|
||||
function normalizeTemplateLineBreaks(value: string) {
|
||||
return value
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\r/g, "\n")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
function normalizeInline(value: string) {
|
||||
return normalizeTemplateLineBreaks(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatTextAsHtml(value: string) {
|
||||
return escapeHtml(normalizeTemplateLineBreaks(value)).replace(/\n/g, "<br/>");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function stripCtaTokens(
|
||||
value: string,
|
||||
ctas: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
shortcodes: string[];
|
||||
lineLabelPattern: string;
|
||||
}>
|
||||
) {
|
||||
let output = normalizeTemplateLineBreaks(value);
|
||||
const resolvedCtas: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
marker: string;
|
||||
}> = [];
|
||||
|
||||
for (const cta of ctas) {
|
||||
if (!cta.url) continue;
|
||||
let triggered = false;
|
||||
const marker = `__CB_CTA_${cta.id.toUpperCase()}__`;
|
||||
|
||||
for (const shortcode of cta.shortcodes) {
|
||||
if (output.includes(shortcode)) {
|
||||
triggered = true;
|
||||
output = output.split(shortcode).join(marker);
|
||||
}
|
||||
}
|
||||
|
||||
if (output.includes(cta.url)) {
|
||||
triggered = true;
|
||||
const escapedUrl = escapeRegExp(cta.url);
|
||||
|
||||
output = output
|
||||
.replace(
|
||||
new RegExp(`^.*(?:${cta.lineLabelPattern}).*${escapedUrl}.*$`, "gim"),
|
||||
marker
|
||||
)
|
||||
.replace(new RegExp(`^\\s*${escapedUrl}\\s*$`, "gim"), marker)
|
||||
.replace(new RegExp(escapedUrl, "g"), marker);
|
||||
}
|
||||
|
||||
if (triggered) {
|
||||
output = output.replace(new RegExp(`(?:${marker})(?:\\s*${marker})+`, "g"), marker);
|
||||
resolvedCtas.push({
|
||||
id: cta.id,
|
||||
label: cta.label,
|
||||
url: cta.url,
|
||||
marker
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
output = output
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
body: output,
|
||||
ctas: resolvedCtas
|
||||
};
|
||||
}
|
||||
|
||||
function renderInlineCtaButton(
|
||||
cta: { id: string; label: string; url: string },
|
||||
style: ReturnType<typeof getEmailStylePreset>
|
||||
) {
|
||||
const href = escapeHtml(cta.url);
|
||||
const label = escapeHtml(cta.label);
|
||||
const isCancel = cta.id === "cancel";
|
||||
const buttonBg = isCancel ? "#dc2626" : style.buttonColor;
|
||||
const buttonText = isCancel ? "#ffffff" : style.buttonTextColor;
|
||||
|
||||
return `
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="display:inline-table;border-collapse:separate;margin:4px 8px 4px 0;vertical-align:middle;">
|
||||
<tr>
|
||||
<td align="center" style="background:${buttonBg};border-radius:12px;">
|
||||
<a
|
||||
href="${href}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display:inline-block;padding:12px 18px;border-radius:12px;color:${buttonText};text-decoration:none;font-weight:700;font-size:14px;line-height:1.2;"
|
||||
>
|
||||
${label}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function renderThemeCard(styleId: string, subjectHtml: string, bodyHtml: string) {
|
||||
if (styleId === "corporate") {
|
||||
return `<div style="background-color:#f1f5f9;padding:32px;font-family:Arial,sans-serif;border-radius:12px;max-width:600px;margin:0 auto;">
|
||||
<div style="background-color:#4f46e5;color:#ffffff;padding:20px;border-radius:12px 12px 0 0;font-weight:700;font-size:18px;">${subjectHtml}</div>
|
||||
<div style="background-color:#ffffff;padding:32px;border-radius:0 0 12px 12px;white-space:normal;color:#334155;line-height:1.6;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "serif") {
|
||||
return `<div style="background-color:#faf8f5;padding:48px;font-family:Georgia,'Times New Roman',serif;max-width:600px;margin:0 auto;color:#2b2721;border:1px solid #e7e0d5;">
|
||||
<div style="text-align:center;margin-bottom:36px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;letter-spacing:0.24em;color:#78716c;display:inline-block;border-bottom:1px solid #d6cec0;padding-bottom:16px;">${subjectHtml}</div>
|
||||
</div>
|
||||
<div style="white-space:normal;line-height:1.9;text-align:center;font-size:15px;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "mono") {
|
||||
return `<div style="background-color:#ffffff;padding:40px;font-family:'Helvetica Neue',Arial,sans-serif;max-width:600px;margin:0 auto;border:2px solid #0f172a;">
|
||||
<div style="background-color:#0f172a;color:#ffffff;padding:20px 24px;font-size:17px;font-weight:800;letter-spacing:-0.01em;">${subjectHtml}</div>
|
||||
<div style="padding:24px;white-space:normal;color:#1e293b;line-height:1.7;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "glass") {
|
||||
return `<div style="background:rgba(255,255,255,0.5);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:40px;font-family:Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:20px;border:1px solid rgba(226,232,240,0.9);box-shadow:0 10px 40px rgba(15,23,42,0.04);">
|
||||
<div style="background:rgba(255,255,255,0.3);padding:18px 22px;border-radius:14px;font-size:16px;font-weight:700;color:#0f172a;margin-bottom:24px;">${subjectHtml}</div>
|
||||
<div style="background:rgba(255,255,255,0.5);padding:24px;border-radius:14px;white-space:normal;color:#334155;line-height:1.7;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "ink") {
|
||||
return `<div style="background-color:#f0f4ff;padding:40px;font-family:'Segoe UI',Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:16px;border:1px solid #cbd5e1;">
|
||||
<div style="background-color:#1e3a5f;color:#e8f0fe;padding:22px 26px;border-radius:12px;font-size:18px;font-weight:700;margin-bottom:24px;">${subjectHtml}</div>
|
||||
<div style="background-color:#ffffff;padding:28px;border-radius:12px;white-space:normal;color:#1e293b;line-height:1.7;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "warm") {
|
||||
return `<div style="background-color:#fef7ed;padding:36px;font-family:Georgia,'Times New Roman',serif;max-width:600px;margin:0 auto;border-radius:20px;border:1px solid #fde68a;color:#431407;">
|
||||
<div style="background-color:#b45309;color:#fff7ed;display:inline-block;padding:10px 20px;border-radius:999px;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:0.12em;margin-bottom:24px;">${subjectHtml}</div>
|
||||
<div style="background-color:#ffffff;padding:24px;border-radius:14px;white-space:normal;line-height:1.7;font-size:15px;font-weight:500;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "soft") {
|
||||
return `<div style="background-color:#fafafa;padding:40px;font-family:Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:18px;border:1px solid #e5e5e5;color:#525252;">
|
||||
<div style="background-color:#f5f5f5;padding:16px 22px;border-radius:12px;font-size:15px;font-weight:600;color:#404040;margin-bottom:24px;">${subjectHtml}</div>
|
||||
<div style="padding:0 8px;white-space:normal;line-height:1.7;font-size:14px;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (styleId === "startup") {
|
||||
return `<div style="background:linear-gradient(135deg,#fdfbfb 0%,#ebedee 100%);padding:40px;font-family:'Helvetica Neue',Arial,sans-serif;max-width:600px;margin:0 auto;border-radius:16px;box-shadow:0 10px 20px rgba(0,0,0,0.05);">
|
||||
<div style="background:linear-gradient(to right,#6EE7B7,#3B82F6,#9333EA);-webkit-background-clip:text;color:transparent;font-size:20px;font-weight:800;text-transform:uppercase;letter-spacing:1px;margin-bottom:24px;border-bottom:2px solid #e5e7eb;padding-bottom:16px;">${subjectHtml}</div>
|
||||
<div style="white-space:normal;line-height:1.7;color:#374151;font-size:15px;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div style="background-color:#ffffff;padding:32px;font-family:Arial,sans-serif;border:1px solid #e2e8f0;border-radius:12px;max-width:600px;margin:0 auto;">
|
||||
<div style="text-transform:uppercase;font-size:12px;font-weight:700;color:#94a3b8;border-bottom:1px solid #f1f5f9;padding-bottom:16px;margin-bottom:24px;">${subjectHtml}</div>
|
||||
<div style="white-space:normal;color:#1e293b;line-height:1.6;font-weight:500;">${bodyHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderStyledEmail(payload: StyledEmailPayload) {
|
||||
const style = getEmailStylePreset(payload.styleId ?? DEFAULT_EMAIL_STYLE_ID);
|
||||
const infoRows = payload.infoRows ?? [];
|
||||
const normalizedLead = payload.lead ? normalizeTemplateLineBreaks(payload.lead) : "";
|
||||
const normalizedHeading = payload.heading ? normalizeTemplateLineBreaks(payload.heading) : "";
|
||||
const normalizedFooterNote = normalizeTemplateLineBreaks(
|
||||
payload.footerNote ?? `Viele Grüße\n${payload.companyName}`
|
||||
);
|
||||
const normalizedSubject = normalizeInline(payload.subject);
|
||||
const normalizedCtaUrl = normalizeTemplateLineBreaks(payload.ctaUrl ?? "").trim();
|
||||
const normalizedCtaLabel = normalizeInline(payload.ctaLabel ?? "Meeting beitreten");
|
||||
const normalizedSecondaryCtaUrl = normalizeTemplateLineBreaks(
|
||||
payload.secondaryCtaUrl ?? ""
|
||||
).trim();
|
||||
const normalizedSecondaryCtaLabel = normalizeInline(
|
||||
payload.secondaryCtaLabel ?? "Termin stornieren"
|
||||
);
|
||||
const ctaResolution = stripCtaTokens(payload.body, [
|
||||
{
|
||||
id: "meeting",
|
||||
label: normalizedCtaLabel,
|
||||
url: normalizedCtaUrl,
|
||||
shortcodes: MEETING_BUTTON_SHORTCODES,
|
||||
lineLabelPattern: "jitsi|meeting|video|raum|beitreten"
|
||||
},
|
||||
{
|
||||
id: "cancel",
|
||||
label: normalizedSecondaryCtaLabel,
|
||||
url: normalizedSecondaryCtaUrl,
|
||||
shortcodes: CANCEL_BUTTON_SHORTCODES,
|
||||
lineLabelPattern: "absag|storn|cancel"
|
||||
}
|
||||
]);
|
||||
const normalizedBody = ctaResolution.body;
|
||||
|
||||
const infoRowsText =
|
||||
infoRows.length === 0
|
||||
? ""
|
||||
: infoRows
|
||||
.map((row) => `${normalizeInline(row.label)}: ${normalizeInline(row.value)}`)
|
||||
.join("\n");
|
||||
|
||||
const bodySectionsHtml = [
|
||||
normalizedHeading && normalizeInline(normalizedHeading) !== normalizedSubject
|
||||
? normalizedHeading
|
||||
: "",
|
||||
normalizedLead,
|
||||
normalizedBody,
|
||||
infoRowsText,
|
||||
normalizedFooterNote
|
||||
].filter(Boolean);
|
||||
const rawHtmlContent = formatTextAsHtml(bodySectionsHtml.join("\n\n"));
|
||||
let contentHtml = rawHtmlContent;
|
||||
let contentText = bodySectionsHtml.join("\n\n");
|
||||
for (const cta of ctaResolution.ctas) {
|
||||
contentHtml = contentHtml.split(cta.marker).join(renderInlineCtaButton(cta, style));
|
||||
contentText = contentText
|
||||
.split(cta.marker)
|
||||
.join(`${normalizeInline(cta.label)}: ${normalizeTemplateLineBreaks(cta.url).trim()}`);
|
||||
}
|
||||
const subjectHtml = escapeHtml(normalizedSubject || normalizeInline(payload.heading) || "Nachricht");
|
||||
const preheaderSource =
|
||||
payload.preheader ?? (normalizedLead || normalizedHeading || payload.subject);
|
||||
|
||||
const html = `
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<title>${escapeHtml(payload.subject)}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light !important;
|
||||
supported-color-schemes: light !important;
|
||||
}
|
||||
|
||||
.cb-body,
|
||||
.cb-root,
|
||||
.cb-root * {
|
||||
color-scheme: light !important;
|
||||
}
|
||||
|
||||
.cb-body {
|
||||
background-color: ${style.canvasColor} !important;
|
||||
}
|
||||
|
||||
a[x-apple-data-detectors],
|
||||
u + #body a,
|
||||
#MessageViewBody a {
|
||||
color: inherit !important;
|
||||
text-decoration: inherit !important;
|
||||
font: inherit !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cb-body,
|
||||
.cb-root {
|
||||
background-color: ${style.canvasColor} !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body id="body" class="cb-body" bgcolor="${style.canvasColor}" style="margin:0;padding:20px 10px;background:${style.canvasColor};font-family:Inter,Arial,sans-serif;">
|
||||
<span style="display:none;visibility:hidden;opacity:0;max-height:0;overflow:hidden;mso-hide:all;">
|
||||
${escapeHtml(normalizeInline(preheaderSource))}
|
||||
</span>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="cb-root" bgcolor="${style.canvasColor}" style="border-collapse:collapse;background:${style.canvasColor};">
|
||||
<tr>
|
||||
<td align="center" style="padding:0;text-align:left;">
|
||||
<div style="max-width:620px;margin:0 auto;text-align:left;">
|
||||
${renderThemeCard(style.id, subjectHtml, contentHtml)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
const text = [normalizedSubject, contentText].filter(Boolean).join("\n\n");
|
||||
|
||||
return { html, text, styleId: style.id, styleName: style.name };
|
||||
}
|
||||
29
lib/email/template-engine.ts
Normal file
29
lib/email/template-engine.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function renderTemplate(template: string, values: Record<string, string>) {
|
||||
let output = template;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
output = output.replaceAll(`{{${key}}}`, value);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export const EMAIL_TEMPLATE_PREVIEW_VALUES: Record<string, string> = {
|
||||
customerName: "Max Mustermann",
|
||||
date: "20.04.2026",
|
||||
time: "10:15",
|
||||
duration: "60",
|
||||
staffName: "Anna Beispiel",
|
||||
staffNames: "Anna Beispiel, Ben Demo",
|
||||
phone: "+49 170 000000",
|
||||
email: "max@example.com",
|
||||
notes: "Ich möchte mich zu einem Projekt beraten lassen.",
|
||||
companyName: "CalBook",
|
||||
cancelUrl: "https://calbook.example/stornieren?token=demo",
|
||||
rescheduleUrl: "https://calbook.example/buchen?rescheduleToken=demo",
|
||||
meetingUrl: "https://meet.jit.si/calbook-demo-room",
|
||||
meetingButton: "{{meetingButton}}",
|
||||
reminderLabel: "in 24 Stunden",
|
||||
hoursBefore: "24",
|
||||
reminderKind: "primary",
|
||||
timezone: "Europe/Berlin",
|
||||
dashboardUrl: "https://calbook.example/admin/termine"
|
||||
};
|
||||
16
lib/prisma.ts
Normal file
16
lib/prisma.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
global.prisma ||
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"]
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.prisma = prisma;
|
||||
}
|
||||
55
lib/public-booking-config.ts
Normal file
55
lib/public-booking-config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/date";
|
||||
|
||||
export type PublicBookingInitialConfig = {
|
||||
companyName: string;
|
||||
bookingNoticeText: string;
|
||||
defaultDurationMinutes: number;
|
||||
defaultTimezone: string;
|
||||
headerText: string;
|
||||
headerLogoUrl: string;
|
||||
footerPrivacyLabel: string;
|
||||
footerPrivacyUrl: string;
|
||||
footerImprintLabel: string;
|
||||
footerImprintUrl: string;
|
||||
footerCopyrightText: string;
|
||||
};
|
||||
|
||||
export async function getPublicBookingInitialConfig(): Promise<PublicBookingInitialConfig> {
|
||||
const settings = await 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
|
||||
]).catch(() => ({} as Record<string, string>));
|
||||
|
||||
return {
|
||||
companyName: (settings[SETTING_KEYS.COMPANY_NAME] || "CalBook").trim() || "CalBook",
|
||||
bookingNoticeText:
|
||||
(settings[SETTING_KEYS.BOOKING_NOTICE_TEXT] ||
|
||||
"Erzähl uns kurz, worum es geht - damit wir uns optimal vorbereiten können.").trim() ||
|
||||
"Erzähl uns kurz, worum es geht - damit wir uns optimal vorbereiten können.",
|
||||
defaultDurationMinutes: Number(settings[SETTING_KEYS.DEFAULT_DURATION_MINUTES] || "60"),
|
||||
defaultTimezone: DEFAULT_TIMEZONE,
|
||||
headerText: (settings[SETTING_KEYS.FRONTEND_HEADER_TEXT] || "Gespräch").trim() || "Gespräch",
|
||||
headerLogoUrl: (settings[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] || "").trim(),
|
||||
footerPrivacyLabel:
|
||||
(settings[SETTING_KEYS.FOOTER_PRIVACY_LABEL] || "Datenschutz").trim() || "Datenschutz",
|
||||
footerPrivacyUrl:
|
||||
(settings[SETTING_KEYS.FOOTER_PRIVACY_URL] || "/datenschutz").trim() || "/datenschutz",
|
||||
footerImprintLabel:
|
||||
(settings[SETTING_KEYS.FOOTER_IMPRINT_LABEL] || "Impressum").trim() || "Impressum",
|
||||
footerImprintUrl:
|
||||
(settings[SETTING_KEYS.FOOTER_IMPRINT_URL] || "/impressum").trim() || "/impressum",
|
||||
footerCopyrightText:
|
||||
(settings[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] || "© {{year}} {{companyName}}").trim() ||
|
||||
"© {{year}} {{companyName}}"
|
||||
};
|
||||
}
|
||||
31
lib/public-url.ts
Normal file
31
lib/public-url.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const DEFAULT_PUBLIC_URL = "http://localhost:3000";
|
||||
|
||||
function normalizeBaseUrl(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString().replace(/\/+$/g, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPublicBaseUrl() {
|
||||
return (
|
||||
normalizeBaseUrl(process.env.PUBLIC_URL) ??
|
||||
normalizeBaseUrl(process.env.APP_BASE_URL) ??
|
||||
normalizeBaseUrl(process.env.NEXTAUTH_URL) ??
|
||||
DEFAULT_PUBLIC_URL
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPublicUrl(pathname: string) {
|
||||
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
return new URL(normalizedPath, `${getPublicBaseUrl()}/`).toString();
|
||||
}
|
||||
170
lib/rate-limit.ts
Normal file
170
lib/rate-limit.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { isIP } from "net";
|
||||
|
||||
type RateLimitEntry = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
type HeaderBag = Headers | Record<string, string | string[] | undefined>;
|
||||
|
||||
type EnforceRateLimitInput = {
|
||||
req: Request;
|
||||
scope: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
keySuffix?: string;
|
||||
};
|
||||
|
||||
type RateLimitResult = {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var calbookRateLimitStore: Map<string, RateLimitEntry> | undefined;
|
||||
}
|
||||
|
||||
const store = global.calbookRateLimitStore ?? new Map<string, RateLimitEntry>();
|
||||
if (!global.calbookRateLimitStore) {
|
||||
global.calbookRateLimitStore = store;
|
||||
}
|
||||
|
||||
const CLEANUP_EVERY = 200;
|
||||
let writes = 0;
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function cleanupExpired() {
|
||||
const now = nowMs();
|
||||
for (const [key, value] of store.entries()) {
|
||||
if (value.resetAt <= now) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(headers: HeaderBag, key: string): string | undefined {
|
||||
if (headers instanceof Headers) {
|
||||
return headers.get(key) ?? undefined;
|
||||
}
|
||||
const value = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value ?? undefined;
|
||||
}
|
||||
|
||||
function trustProxyHeaders() {
|
||||
const raw = (process.env.TRUST_PROXY_HEADERS ?? "").trim().toLowerCase();
|
||||
return raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
|
||||
function normalizeIpCandidate(value: string): string | null {
|
||||
let candidate = value.trim().replace(/^for=/i, "").replace(/^"|"$/g, "");
|
||||
if (!candidate) return null;
|
||||
|
||||
if (candidate.startsWith("[")) {
|
||||
const end = candidate.indexOf("]");
|
||||
if (end > 1) {
|
||||
candidate = candidate.slice(1, end);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.includes("%")) {
|
||||
candidate = candidate.split("%")[0] ?? candidate;
|
||||
}
|
||||
|
||||
if (candidate.includes(".") && candidate.includes(":") && isIP(candidate) === 0) {
|
||||
const withoutPort = candidate.split(":")[0];
|
||||
if (withoutPort) {
|
||||
candidate = withoutPort;
|
||||
}
|
||||
}
|
||||
|
||||
return isIP(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
export function getClientIpFromHeaders(headers: HeaderBag): string {
|
||||
if (!trustProxyHeaders()) {
|
||||
return "proxy-untrusted";
|
||||
}
|
||||
|
||||
const forwarded = readHeader(headers, "x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const parsed = forwarded
|
||||
.split(",")
|
||||
.map((part) => normalizeIpCandidate(part))
|
||||
.find((value): value is string => Boolean(value));
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
const realIp = readHeader(headers, "x-real-ip");
|
||||
if (realIp) {
|
||||
const parsed = normalizeIpCandidate(realIp);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
const cfIp = readHeader(headers, "cf-connecting-ip");
|
||||
if (cfIp) {
|
||||
const parsed = normalizeIpCandidate(cfIp);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function getClientIp(req: Request): string {
|
||||
return getClientIpFromHeaders(req.headers);
|
||||
}
|
||||
|
||||
export function consumeRateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowMs: number
|
||||
): RateLimitResult {
|
||||
writes += 1;
|
||||
if (writes % CLEANUP_EVERY === 0) {
|
||||
cleanupExpired();
|
||||
}
|
||||
|
||||
const now = nowMs();
|
||||
const existing = store.get(key);
|
||||
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
store.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + windowMs
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
remaining: Math.max(0, limit - 1),
|
||||
retryAfterSeconds: Math.ceil(windowMs / 1000)
|
||||
};
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
store.set(key, existing);
|
||||
|
||||
if (existing.count > limit) {
|
||||
return {
|
||||
ok: false,
|
||||
remaining: 0,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
remaining: Math.max(0, limit - existing.count),
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
|
||||
};
|
||||
}
|
||||
|
||||
export function enforceRateLimit(input: EnforceRateLimitInput): RateLimitResult {
|
||||
const ip = getClientIp(input.req);
|
||||
const suffix = input.keySuffix ? `:${input.keySuffix}` : "";
|
||||
const key = `${input.scope}:${ip}${suffix}`;
|
||||
return consumeRateLimit(key, input.limit, input.windowMs);
|
||||
}
|
||||
169
lib/security/config-guard.ts
Normal file
169
lib/security/config-guard.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
type ConfigIssue = {
|
||||
key: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const WEAK_DEFAULT_VALUES = new Set([
|
||||
"",
|
||||
"calbook",
|
||||
"calbook123",
|
||||
"admin",
|
||||
"admin123",
|
||||
"passwort",
|
||||
"password",
|
||||
"123456",
|
||||
"bitte-einen-langen-random-wert-setzen",
|
||||
"bitte-einen-random-cron-secret-setzen",
|
||||
"bitte-einen-random-salt-setzen",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"bittesicher123!",
|
||||
"change_me",
|
||||
"changeme",
|
||||
"replace_me",
|
||||
"todo"
|
||||
]);
|
||||
|
||||
function normalize(value: string | undefined | null) {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isWeakSecret(value: string | undefined | null) {
|
||||
const normalized = normalize(value);
|
||||
if (!normalized) return true;
|
||||
if (WEAK_DEFAULT_VALUES.has(normalized)) return true;
|
||||
if (normalized.startsWith("bitte-")) return true;
|
||||
if (normalized.includes("random-wert-setzen")) return true;
|
||||
if (normalized.includes("random-secret-setzen")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateRuntimeConfig(): ConfigIssue[] {
|
||||
const issues: ConfigIssue[] = [];
|
||||
|
||||
const nextAuthSecret = process.env.NEXTAUTH_SECRET;
|
||||
if (!nextAuthSecret || nextAuthSecret.trim().length < 32 || isWeakSecret(nextAuthSecret)) {
|
||||
issues.push({
|
||||
key: "NEXTAUTH_SECRET",
|
||||
message: "muss gesetzt, einzigartig und mindestens 32 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
if (!cronSecret || cronSecret.trim().length < 24 || isWeakSecret(cronSecret)) {
|
||||
issues.push({
|
||||
key: "CRON_SECRET",
|
||||
message: "muss gesetzt, einzigartig und mindestens 24 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const caldavKey = process.env.CALDAV_ENCRYPTION_KEY;
|
||||
if (!caldavKey || caldavKey.trim().length < 32 || isWeakSecret(caldavKey)) {
|
||||
issues.push({
|
||||
key: "CALDAV_ENCRYPTION_KEY",
|
||||
message: "muss gesetzt, einzigartig und mindestens 32 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const jitsiSalt = process.env.JITSI_ROOM_SALT;
|
||||
if (!jitsiSalt || jitsiSalt.trim().length < 24 || isWeakSecret(jitsiSalt)) {
|
||||
issues.push({
|
||||
key: "JITSI_ROOM_SALT",
|
||||
message: "muss gesetzt, einzigartig und mindestens 24 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
const publicUrl = process.env.PUBLIC_URL;
|
||||
if (publicUrl && publicUrl.trim()) {
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
issues.push({
|
||||
key: "PUBLIC_URL",
|
||||
message: "muss mit http:// oder https:// beginnen."
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
issues.push({
|
||||
key: "PUBLIC_URL",
|
||||
message: "ist kein gültiger URL-Wert."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl || !databaseUrl.trim()) {
|
||||
issues.push({
|
||||
key: "DATABASE_URL",
|
||||
message: "muss gesetzt sein."
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(databaseUrl);
|
||||
const dbPassword = decodeURIComponent(parsed.password ?? "");
|
||||
if (isWeakSecret(dbPassword) || dbPassword.length < 12) {
|
||||
issues.push({
|
||||
key: "DATABASE_URL",
|
||||
message: "nutzt ein schwaches Datenbank-Passwort."
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
issues.push({
|
||||
key: "DATABASE_URL",
|
||||
message: "ist kein gültiger URL-Wert."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateSeedConfig(): ConfigIssue[] {
|
||||
const issues: ConfigIssue[] = [];
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!adminPassword || adminPassword.trim().length < 12 || isWeakSecret(adminPassword)) {
|
||||
issues.push({
|
||||
key: "ADMIN_PASSWORD",
|
||||
message: "muss gesetzt, stark und mindestens 12 Zeichen lang sein."
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function formatIssues(label: string, issues: ConfigIssue[]) {
|
||||
return [
|
||||
`[security] ${label}`,
|
||||
...issues.map((issue) => `- ${issue.key}: ${issue.message}`)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function isProductionRuntime() {
|
||||
if (process.env.NODE_ENV !== "production") return false;
|
||||
if (process.env.NEXT_PHASE === "phase-production-build") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function assertSecureRuntimeConfig() {
|
||||
if (process.env.NODE_ENV === "test") return;
|
||||
|
||||
const issues = validateRuntimeConfig();
|
||||
if (issues.length === 0) return;
|
||||
|
||||
const message = formatIssues("Unsichere Runtime-Konfiguration erkannt.", issues);
|
||||
if (isProductionRuntime()) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function assertSecureSeedConfig() {
|
||||
if (process.env.NODE_ENV === "test") return;
|
||||
|
||||
const issues = validateSeedConfig();
|
||||
if (issues.length === 0) return;
|
||||
|
||||
throw new Error(formatIssues("Unsichere Seed-Konfiguration erkannt.", issues));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user