feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure

This commit is contained in:
2026-05-07 13:04:02 +02:00
parent 51acfe9488
commit ee48a93824
133 changed files with 26049 additions and 0 deletions

18
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
.next
node_modules
volumes
coverage
*.config.js
next-env.d.ts
*.tsbuildinfo

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

18
.gitignore vendored Normal file
View 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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

33
Dockerfile Normal file
View 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
View 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

View File

@@ -0,0 +1,7 @@
import { BackupPanel } from "@/components/admin/backup-panel";
export const dynamic = "force-dynamic";
export default function AdminBackupPage() {
return <BackupPanel />;
}

View File

@@ -0,0 +1,7 @@
import { BrandingPanel } from "@/components/admin/branding-panel";
export const dynamic = "force-dynamic";
export default function AdminBrandingPage() {
return <BrandingPanel />;
}

View File

@@ -0,0 +1,7 @@
import { SettingsPanel } from "@/components/admin/settings-panel";
export const dynamic = "force-dynamic";
export default function AdminSettingsPage() {
return <SettingsPanel />;
}

View File

@@ -0,0 +1,7 @@
import { EmailTemplatesPanel } from "@/components/admin/email-templates-panel";
export const dynamic = "force-dynamic";
export default function AdminEmailTemplatesPage() {
return <EmailTemplatesPanel />;
}

View File

@@ -0,0 +1,7 @@
import { InstantMeetingPanel } from "@/components/admin/instant-meeting-panel";
export const dynamic = "force-dynamic";
export default function AdminInstantMeetingPage() {
return <InstantMeetingPanel />;
}

View File

@@ -0,0 +1,7 @@
import { CalendarPersonPanel } from "@/components/admin/calendar-person-panel";
export const dynamic = "force-dynamic";
export default function AdminCalendarsPage() {
return <CalendarPersonPanel />;
}

View 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>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/uebersicht");
}

View 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 />;
}

View File

@@ -0,0 +1,7 @@
import { AppointmentsPanel } from "@/components/admin/appointments-panel";
export const dynamic = "force-dynamic";
export default function AdminAppointmentsPage() {
return <AppointmentsPanel />;
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
</>
);
}

View 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}
/>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { SharedLegalPage } from "@/components/booking/shared-legal-page";
export default function DatenschutzPage() {
return <SharedLegalPage type="privacy" />;
}

View File

@@ -0,0 +1,5 @@
import { SharedLegalPage } from "@/components/booking/shared-legal-page";
export default function ImpressumPage() {
return <SharedLegalPage type="imprint" />;
}

View 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}}"
}
/>
);
}

View 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 });
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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 };

View 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 });
}

View 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);
}

View 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
}
});
}

View 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 });
}

View 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 });
}

View 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" });
}

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/buchen");
}

24
app/providers.tsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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
View 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
View 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} />;
}

View 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
View 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
View 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";

View 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)} />;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 };
}

View 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
View 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;
}

View 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
View 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
View 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);
}

View 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