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

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