feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure
This commit is contained in:
286
app/api/admin/backup/route.ts
Normal file
286
app/api/admin/backup/route.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { reEncryptWithNewKey } from "@/lib/crypto";
|
||||
|
||||
const SKIP_SETTING_KEYS = new Set(["PUBLIC_URL", "NEXTAUTH_URL", "APP_BASE_URL"]);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const caldavKey = process.env.CALDAV_ENCRYPTION_KEY ?? "";
|
||||
|
||||
const [
|
||||
settings,
|
||||
users,
|
||||
calendarConns,
|
||||
appointments,
|
||||
busyBlocks,
|
||||
deliveryIssues,
|
||||
syncRuns
|
||||
] = await Promise.all([
|
||||
prisma.setting.findMany(),
|
||||
prisma.user.findMany({ select: { id: true, name: true, email: true, hashedPassword: true, role: true, slug: true, bio: true, avatarUrl: true, timezone: true, isActive: true, createdAt: true, updatedAt: true } }),
|
||||
prisma.calendarConn.findMany({ select: { id: true, userId: true, name: true, bookingAllowedWeekdays: true, bookingDayStartTime: true, bookingDayEndTime: true, bookingDayRangesJson: true, url: true, username: true, notificationEmail: true, encryptedPassword: true, color: true, syncEnabled: true, lastSyncedAt: true, createdAt: true, updatedAt: true } }),
|
||||
prisma.appointment.findMany(),
|
||||
prisma.busyBlock.findMany(),
|
||||
prisma.deliveryIssue.findMany(),
|
||||
prisma.calendarSyncRun.findMany({ include: { entries: true } })
|
||||
]);
|
||||
|
||||
const backup = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
caldavEncryptionKey: caldavKey || undefined,
|
||||
settings: settings.filter((s) => !SKIP_SETTING_KEYS.has(s.key)).map((s) => ({ key: s.key, value: s.value })),
|
||||
users,
|
||||
calendarConns,
|
||||
appointments,
|
||||
busyBlocks,
|
||||
deliveryIssues,
|
||||
syncRuns
|
||||
};
|
||||
|
||||
const json = JSON.stringify(backup, null, 2);
|
||||
|
||||
return new NextResponse(json, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="calbook-backup-${new Date().toISOString().slice(0, 10)}.json"`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "UNAUTHORIZED") return NextResponse.json({ message: "Nicht autorisiert" }, { status: 401 });
|
||||
if (error.message === "FORBIDDEN") return NextResponse.json({ message: "Keine Admin-Rechte" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json({ message: "Backup konnte nicht erstellt werden" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const body = await req.json();
|
||||
if (!body || typeof body !== "object" || !body.version) {
|
||||
return NextResponse.json({ message: "Ungültiges Backup-Format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const importedAt = new Date().toISOString();
|
||||
const steps: Array<{
|
||||
label: string;
|
||||
status: "ok" | "error" | "skipped";
|
||||
detail: string;
|
||||
}> = [];
|
||||
const userIdMap = new Map<string, string>();
|
||||
const backupCaldavKey: string =
|
||||
typeof body.caldavEncryptionKey === "string" && body.caldavEncryptionKey.length >= 32
|
||||
? body.caldavEncryptionKey
|
||||
: "";
|
||||
|
||||
async function addStep(label: string, fn: () => Promise<string>) {
|
||||
try {
|
||||
const detail = await fn();
|
||||
steps.push({ label, status: "ok", detail });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||||
steps.push({ label, status: "error", detail: msg });
|
||||
}
|
||||
}
|
||||
|
||||
await addStep("Einstellungen", async () => {
|
||||
if (!Array.isArray(body.settings)) return "Keine Settings im Backup";
|
||||
const filtered = body.settings.filter((s: { key: string }) => !SKIP_SETTING_KEYS.has(s.key));
|
||||
await prisma.$transaction(
|
||||
filtered.map((s: { key: string; value: string }) =>
|
||||
prisma.setting.upsert({
|
||||
where: { key: s.key },
|
||||
create: { key: s.key, value: s.value },
|
||||
update: { value: s.value }
|
||||
})
|
||||
)
|
||||
);
|
||||
return `${filtered.length} Settings wiederhergestellt`;
|
||||
});
|
||||
|
||||
await addStep("Benutzer", async () => {
|
||||
if (!Array.isArray(body.users)) return "Keine Benutzer im Backup";
|
||||
const existingEmails = await prisma.user.findMany({ select: { email: true, id: true } });
|
||||
const emailToExistingId = new Map(existingEmails.map((u) => [u.email, u.id]));
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const u of body.users) {
|
||||
const hasPw = typeof u.hashedPassword === "string" && u.hashedPassword.length > 0;
|
||||
const existingId = emailToExistingId.get(u.email);
|
||||
if (existingId) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
name: u.name, role: u.role, slug: u.slug,
|
||||
bio: u.bio ?? null, avatarUrl: u.avatarUrl ?? null,
|
||||
timezone: u.timezone ?? "Europe/Berlin", isActive: u.isActive ?? true
|
||||
};
|
||||
if (hasPw) updateData.hashedPassword = u.hashedPassword;
|
||||
await prisma.user.update({ where: { email: u.email }, data: updateData });
|
||||
userIdMap.set(u.id, existingId);
|
||||
updated++;
|
||||
} else {
|
||||
const createdUser = await prisma.user.create({
|
||||
data: {
|
||||
name: u.name, email: u.email, hashedPassword: hasPw ? u.hashedPassword : "",
|
||||
role: u.role ?? "STAFF",
|
||||
slug: u.slug ?? u.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
bio: u.bio ?? null, avatarUrl: u.avatarUrl ?? null,
|
||||
timezone: u.timezone ?? "Europe/Berlin", isActive: u.isActive ?? true
|
||||
}
|
||||
});
|
||||
userIdMap.set(u.id, createdUser.id);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
return `${created} erstellt, ${updated} aktualisiert`;
|
||||
});
|
||||
|
||||
await addStep("Kalender-Verbindungen", async () => {
|
||||
if (!Array.isArray(body.calendarConns)) return "Keine Kalender im Backup";
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let reEncrypted = 0;
|
||||
for (const cc of body.calendarConns) {
|
||||
const resolvedUserId = userIdMap.get(cc.userId) ?? cc.userId;
|
||||
const resolvedPassword = backupCaldavKey && cc.encryptedPassword
|
||||
? reEncryptWithNewKey(String(cc.encryptedPassword), backupCaldavKey)
|
||||
: (cc.encryptedPassword ?? "");
|
||||
if (backupCaldavKey && cc.encryptedPassword && resolvedPassword !== cc.encryptedPassword) reEncrypted++;
|
||||
const exists = await prisma.calendarConn.findUnique({ where: { id: cc.id } });
|
||||
if (exists) {
|
||||
await prisma.calendarConn.update({
|
||||
where: { id: cc.id },
|
||||
data: {
|
||||
name: cc.name, bookingAllowedWeekdays: cc.bookingAllowedWeekdays ?? "0,1,2,3,4",
|
||||
bookingDayStartTime: cc.bookingDayStartTime ?? "09:00",
|
||||
bookingDayEndTime: cc.bookingDayEndTime ?? "17:00",
|
||||
bookingDayRangesJson: cc.bookingDayRangesJson ?? null,
|
||||
url: cc.url, username: cc.username,
|
||||
notificationEmail: cc.notificationEmail ?? null,
|
||||
encryptedPassword: resolvedPassword,
|
||||
color: cc.color ?? null, syncEnabled: cc.syncEnabled ?? true
|
||||
}
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.calendarConn.create({
|
||||
data: {
|
||||
id: cc.id, userId: resolvedUserId, name: cc.name,
|
||||
bookingAllowedWeekdays: cc.bookingAllowedWeekdays ?? "0,1,2,3,4",
|
||||
bookingDayStartTime: cc.bookingDayStartTime ?? "09:00",
|
||||
bookingDayEndTime: cc.bookingDayEndTime ?? "17:00",
|
||||
bookingDayRangesJson: cc.bookingDayRangesJson ?? null,
|
||||
url: cc.url, username: cc.username,
|
||||
notificationEmail: cc.notificationEmail ?? null,
|
||||
encryptedPassword: resolvedPassword,
|
||||
color: cc.color ?? null, syncEnabled: cc.syncEnabled ?? true
|
||||
}
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
const reEncNote = backupCaldavKey && reEncrypted > 0 ? `, ${reEncrypted} Passwörter neu verschlüsselt` : "";
|
||||
return `${created} erstellt, ${updated} aktualisiert${reEncNote}`;
|
||||
});
|
||||
|
||||
await addStep("Termine", async () => {
|
||||
if (!Array.isArray(body.appointments)) return "Keine Termine im Backup";
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const a of body.appointments) {
|
||||
const resolvedStaffId = userIdMap.get(a.staffId) ?? a.staffId;
|
||||
const exists = await prisma.appointment.findUnique({ where: { id: a.id } });
|
||||
if (exists) {
|
||||
await prisma.appointment.update({
|
||||
where: { id: a.id },
|
||||
data: {
|
||||
bookingGroupId: a.bookingGroupId ?? null, staffId: resolvedStaffId,
|
||||
customerFirstName: a.customerFirstName, customerLastName: a.customerLastName,
|
||||
customerEmail: a.customerEmail, customerPhone: a.customerPhone ?? null,
|
||||
notes: a.notes ?? null, startAt: new Date(a.startAt), endAt: new Date(a.endAt),
|
||||
durationMinutes: a.durationMinutes ?? 60, status: a.status ?? "CONFIRMED",
|
||||
cancellationToken: a.cancellationToken, calendarEventUid: a.calendarEventUid ?? null,
|
||||
cancelledAt: a.cancelledAt ? new Date(a.cancelledAt) : null,
|
||||
noShowAt: a.noShowAt ? new Date(a.noShowAt) : null,
|
||||
reminder24hSentAt: a.reminder24hSentAt ? new Date(a.reminder24hSentAt) : null,
|
||||
reminder2hSentAt: a.reminder2hSentAt ? new Date(a.reminder2hSentAt) : null
|
||||
}
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.appointment.create({
|
||||
data: {
|
||||
id: a.id, bookingGroupId: a.bookingGroupId ?? null, staffId: resolvedStaffId,
|
||||
customerFirstName: a.customerFirstName, customerLastName: a.customerLastName,
|
||||
customerEmail: a.customerEmail, customerPhone: a.customerPhone ?? null,
|
||||
notes: a.notes ?? null, startAt: new Date(a.startAt), endAt: new Date(a.endAt),
|
||||
durationMinutes: a.durationMinutes ?? 60, status: a.status ?? "CONFIRMED",
|
||||
cancellationToken: a.cancellationToken, calendarEventUid: a.calendarEventUid ?? null,
|
||||
cancelledAt: a.cancelledAt ? new Date(a.cancelledAt) : null,
|
||||
noShowAt: a.noShowAt ? new Date(a.noShowAt) : null,
|
||||
reminder24hSentAt: a.reminder24hSentAt ? new Date(a.reminder24hSentAt) : null,
|
||||
reminder2hSentAt: a.reminder2hSentAt ? new Date(a.reminder2hSentAt) : null
|
||||
}
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
return `${created} erstellt, ${updated} aktualisiert`;
|
||||
});
|
||||
|
||||
await addStep("Zustellfehler", async () => {
|
||||
if (!Array.isArray(body.deliveryIssues)) return "Keine Zustellfehler im Backup";
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const di of body.deliveryIssues) {
|
||||
const exists = await prisma.deliveryIssue.findUnique({ where: { id: di.id } });
|
||||
if (exists) {
|
||||
await prisma.deliveryIssue.update({
|
||||
where: { id: di.id },
|
||||
data: {
|
||||
channel: di.channel, operation: di.operation, target: di.target,
|
||||
lastError: di.lastError, attemptCount: di.attemptCount ?? 1,
|
||||
firstSeenAt: new Date(di.firstSeenAt),
|
||||
lastSeenAt: di.lastSeenAt ? new Date(di.lastSeenAt) : undefined,
|
||||
resolvedAt: di.resolvedAt ? new Date(di.resolvedAt) : null
|
||||
}
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.deliveryIssue.create({
|
||||
data: {
|
||||
id: di.id, channel: di.channel, operation: di.operation, target: di.target,
|
||||
lastError: di.lastError, attemptCount: di.attemptCount ?? 1,
|
||||
firstSeenAt: new Date(di.firstSeenAt),
|
||||
lastSeenAt: di.lastSeenAt ? new Date(di.lastSeenAt) : new Date(di.firstSeenAt),
|
||||
resolvedAt: di.resolvedAt ? new Date(di.resolvedAt) : null
|
||||
}
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
return `${created} erstellt, ${updated} aktualisiert`;
|
||||
});
|
||||
|
||||
const hasErrors = steps.some((s) => s.status === "error");
|
||||
|
||||
return NextResponse.json({
|
||||
message: hasErrors ? "Import mit Fehlern abgeschlossen" : "Import erfolgreich",
|
||||
importedAt,
|
||||
steps
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "UNAUTHORIZED") return NextResponse.json({ message: "Nicht autorisiert" }, { status: 401 });
|
||||
if (error.message === "FORBIDDEN") return NextResponse.json({ message: "Keine Admin-Rechte" }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json({ message: "Import fehlgeschlagen" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
38
app/api/admin/einstellungen/route.ts
Normal file
38
app/api/admin/einstellungen/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import { getSettings, setSettings } from "@/lib/settings";
|
||||
import { settingsSchema } from "@/lib/validators/admin";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const settings = await getSettings();
|
||||
return ok({ settings });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 512 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = settingsSchema.safeParse(bodyResult.data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Einstellungen", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
await setSettings(parsed.data.values);
|
||||
return ok({ message: "Einstellungen gespeichert" });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
67
app/api/admin/einstellungen/test-smtp/route.ts
Normal file
67
app/api/admin/einstellungen/test-smtp/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { sendSmtpTestEmail } from "@/lib/email/mailer";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
const bodySchema = z.object({
|
||||
to: z.string().email("Bitte gültige Empfänger-E-Mail angeben"),
|
||||
smtp: z
|
||||
.object({
|
||||
host: z.string().trim().optional().default(""),
|
||||
port: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d+$/, "Bitte einen numerischen SMTP-Port eingeben")
|
||||
.optional()
|
||||
.default("587"),
|
||||
user: z.string().trim().optional().default(""),
|
||||
pass: z.string().optional().default(""),
|
||||
fromName: z.string().trim().optional().default("CalBook"),
|
||||
from: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
||||
"Bitte eine gültige Absender-E-Mail eingeben"
|
||||
)
|
||||
.optional()
|
||||
.default("")
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 16 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = bodySchema.safeParse(bodyResult.data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Eingabe", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const settings = await getSettings([SETTING_KEYS.COMPANY_NAME]);
|
||||
const result = await sendSmtpTestEmail({
|
||||
to: parsed.data.to,
|
||||
companyName: settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook",
|
||||
smtp: parsed.data.smtp
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return fail(result.message ?? "SMTP-Test fehlgeschlagen", 400);
|
||||
}
|
||||
|
||||
return ok({ message: "SMTP-Testmail erfolgreich versendet" });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
105
app/api/admin/instant-meeting/route.ts
Normal file
105
app/api/admin/instant-meeting/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { randomToken } from "@/lib/utils";
|
||||
import { createMeetingUrlWithConfig } from "@/lib/services/meeting-links";
|
||||
import {
|
||||
getInstantMeetingBootstrap,
|
||||
resolveInstantMeetingSelection,
|
||||
updateInstantMeetingEmailCache
|
||||
} from "@/lib/services/instant-meeting";
|
||||
import { sendInstantMeetingEmails } from "@/lib/email/mailer";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
const sendSchema = z.object({
|
||||
personId: z.string().min(1),
|
||||
additionalEmails: z.array(z.string().email()).max(100).default([]),
|
||||
customMessage: z.string().max(4000).optional(),
|
||||
subjectOverride: z.string().max(200).optional()
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const bootstrap = await getInstantMeetingBootstrap();
|
||||
return ok(bootstrap);
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
const session = await requireAdmin();
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 64 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = sendSchema.safeParse(bodyResult.data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Instant-Meeting Daten", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const selection = await resolveInstantMeetingSelection({
|
||||
scopeType: "person",
|
||||
scopeId: parsed.data.personId,
|
||||
additionalEmails: parsed.data.additionalEmails
|
||||
});
|
||||
|
||||
if (!selection.ok) {
|
||||
return fail(selection.message, 400);
|
||||
}
|
||||
|
||||
const settings = await getSettings([
|
||||
SETTING_KEYS.COMPANY_NAME,
|
||||
SETTING_KEYS.JITSI_MEETING_MODE,
|
||||
SETTING_KEYS.JITSI_BASE_URL,
|
||||
SETTING_KEYS.JITSI_ROOM_PREFIX
|
||||
]);
|
||||
|
||||
const meetingUrl = createMeetingUrlWithConfig(randomToken(24), {
|
||||
mode: settings[SETTING_KEYS.JITSI_MEETING_MODE],
|
||||
baseUrl: settings[SETTING_KEYS.JITSI_BASE_URL],
|
||||
roomPrefix: settings[SETTING_KEYS.JITSI_ROOM_PREFIX]
|
||||
});
|
||||
|
||||
const companyName = (settings[SETTING_KEYS.COMPANY_NAME] || "CalBook").trim() || "CalBook";
|
||||
const sendResult = await sendInstantMeetingEmails({
|
||||
recipients: selection.recipients,
|
||||
meetingUrl,
|
||||
scopeLabel: selection.scopeLabel,
|
||||
initiatorName: session.user.name?.trim() || "Admin",
|
||||
companyName,
|
||||
customMessage: parsed.data.customMessage,
|
||||
subjectOverride: parsed.data.subjectOverride
|
||||
});
|
||||
|
||||
if (!sendResult.ok) {
|
||||
return fail(sendResult.message, 400);
|
||||
}
|
||||
|
||||
await updateInstantMeetingEmailCache(selection.recipients);
|
||||
|
||||
return ok({
|
||||
message: "Instant Meeting erfolgreich versendet.",
|
||||
meetingUrl,
|
||||
sentCount: sendResult.sentCount,
|
||||
recipients: selection.recipients,
|
||||
scopeLabel: selection.scopeLabel
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.message === "UNAUTHORIZED" || error.message === "FORBIDDEN")) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Instant Meeting konnte nicht versendet werden.";
|
||||
return fail(message, 500);
|
||||
}
|
||||
}
|
||||
174
app/api/admin/kalender/[id]/route.ts
Normal file
174
app/api/admin/kalender/[id]/route.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import {
|
||||
deletePersonCalendarResource,
|
||||
updatePersonCalendarResource
|
||||
} from "@/lib/services/person-calendar-resources";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
import {
|
||||
deriveLegacyAvailability,
|
||||
hasAtLeastOneEnabledDay,
|
||||
normalizeWeekdayAvailability,
|
||||
serializeWeekdayAvailability
|
||||
} from "@/lib/weekday-availability";
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
const dayRangeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
start: z.string().regex(TIME_RE),
|
||||
end: z.string().regex(TIME_RE)
|
||||
});
|
||||
|
||||
const weekdayRangesSchema = z
|
||||
.object({
|
||||
"0": dayRangeSchema,
|
||||
"1": dayRangeSchema,
|
||||
"2": dayRangeSchema,
|
||||
"3": dayRangeSchema,
|
||||
"4": dayRangeSchema,
|
||||
"5": dayRangeSchema,
|
||||
"6": dayRangeSchema
|
||||
})
|
||||
.superRefine((ranges, ctx) => {
|
||||
for (const day of ["0", "1", "2", "3", "4", "5", "6"] as const) {
|
||||
const value = ranges[day];
|
||||
if (value.enabled && value.start >= value.end) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Ungültiges Zeitfenster",
|
||||
path: [day, "end"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateSchema = z
|
||||
.object({
|
||||
resourceName: z.string().trim().min(2).max(120).optional(),
|
||||
resourceBio: z.string().trim().max(500).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
calendarName: z.string().trim().min(2).max(120).optional(),
|
||||
bookingDayRanges: weekdayRangesSchema.optional(),
|
||||
bookingAllowedWeekdays: z
|
||||
.string()
|
||||
.regex(/^([0-6](,[0-6])*)$/)
|
||||
.optional(),
|
||||
bookingDayStartTime: z
|
||||
.string()
|
||||
.regex(TIME_RE)
|
||||
.optional(),
|
||||
bookingDayEndTime: z
|
||||
.string()
|
||||
.regex(TIME_RE)
|
||||
.optional(),
|
||||
url: z.string().url().max(1024).optional(),
|
||||
username: z.string().trim().min(1).max(160).optional(),
|
||||
notificationEmail: z.string().trim().email().max(320).optional(),
|
||||
password: z.string().min(1).max(2000).optional(),
|
||||
color: z.string().trim().max(64).optional(),
|
||||
syncEnabled: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.bookingDayRanges && !Object.values(data.bookingDayRanges).some((day) => day.enabled)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Mindestens ein Wochentag muss aktiv sein.",
|
||||
path: ["bookingDayRanges", "0", "enabled"]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.bookingDayStartTime &&
|
||||
data.bookingDayEndTime &&
|
||||
data.bookingDayStartTime >= data.bookingDayEndTime
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Ungültiges Zeitfenster",
|
||||
path: ["bookingDayEndTime"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const { id } = await params;
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 64 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = updateSchema.safeParse(bodyResult.data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Kalenderdaten", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const updatePayload: Parameters<typeof updatePersonCalendarResource>[1] = {
|
||||
resourceName: parsed.data.resourceName,
|
||||
resourceBio: parsed.data.resourceBio,
|
||||
isActive: parsed.data.isActive,
|
||||
calendarName: parsed.data.calendarName,
|
||||
bookingAllowedWeekdays: parsed.data.bookingAllowedWeekdays,
|
||||
bookingDayStartTime: parsed.data.bookingDayStartTime,
|
||||
bookingDayEndTime: parsed.data.bookingDayEndTime,
|
||||
url: parsed.data.url,
|
||||
username: parsed.data.username,
|
||||
notificationEmail: parsed.data.notificationEmail,
|
||||
password: parsed.data.password,
|
||||
color: parsed.data.color,
|
||||
syncEnabled: parsed.data.syncEnabled
|
||||
};
|
||||
|
||||
if (parsed.data.bookingDayRanges) {
|
||||
const normalizedRanges = normalizeWeekdayAvailability(parsed.data.bookingDayRanges);
|
||||
if (!hasAtLeastOneEnabledDay(normalizedRanges)) {
|
||||
return fail("Mindestens ein aktiver Wochentag mit gültiger Uhrzeit ist erforderlich.", 400);
|
||||
}
|
||||
const legacy = deriveLegacyAvailability(normalizedRanges);
|
||||
updatePayload.bookingAllowedWeekdays = legacy.bookingAllowedWeekdays;
|
||||
updatePayload.bookingDayStartTime = legacy.bookingDayStartTime;
|
||||
updatePayload.bookingDayEndTime = legacy.bookingDayEndTime;
|
||||
updatePayload.bookingDayRangesJson = serializeWeekdayAvailability(normalizedRanges);
|
||||
}
|
||||
|
||||
const resource = await updatePersonCalendarResource(id, updatePayload);
|
||||
if (!resource) {
|
||||
return fail("Personen-Kalender nicht gefunden", 404);
|
||||
}
|
||||
|
||||
return ok({ resource });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const { id } = await params;
|
||||
const deleted = await deletePersonCalendarResource(id);
|
||||
|
||||
if (!deleted) {
|
||||
return fail("Personen-Kalender nicht gefunden", 404);
|
||||
}
|
||||
|
||||
return ok({ message: "Personen-Kalender entfernt" });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
112
app/api/admin/kalender/[id]/sync/route.ts
Normal file
112
app/api/admin/kalender/[id]/sync/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { syncCalendarConnectionWithLogger } from "@/lib/services/caldav";
|
||||
import {
|
||||
appendCalendarSyncLog,
|
||||
finishCalendarSyncRun,
|
||||
getCalendarSyncRunWithLogs,
|
||||
startCalendarSyncRun
|
||||
} from "@/lib/services/caldav-sync-logs";
|
||||
import { validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
async function getConnection(id: string) {
|
||||
return prisma.calendarConn.findFirst({
|
||||
where: {
|
||||
id,
|
||||
user: {
|
||||
role: "STAFF"
|
||||
}
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
const connection = await getConnection(id);
|
||||
if (!connection) {
|
||||
return fail("Personen-Kalender nicht gefunden", 404);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const runId = searchParams.get("runId")?.trim() || undefined;
|
||||
const run = await getCalendarSyncRunWithLogs({
|
||||
calendarConnId: connection.id,
|
||||
runId
|
||||
});
|
||||
|
||||
return ok({ run });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
const connection = await getConnection(id);
|
||||
|
||||
if (!connection) {
|
||||
return fail("Personen-Kalender nicht gefunden", 404);
|
||||
}
|
||||
|
||||
const run = await startCalendarSyncRun(connection.id);
|
||||
void (async () => {
|
||||
try {
|
||||
await appendCalendarSyncLog(run.id, "INFO", "Sync-Job wurde gestartet.");
|
||||
const result = await syncCalendarConnectionWithLogger(
|
||||
connection.id,
|
||||
async (level, message) => {
|
||||
await appendCalendarSyncLog(run.id, level, message);
|
||||
}
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
await finishCalendarSyncRun(
|
||||
run.id,
|
||||
"SUCCESS",
|
||||
`Synchronisiert: ${result.count ?? 0} Termin(e).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await finishCalendarSyncRun(
|
||||
run.id,
|
||||
"FAILED",
|
||||
result.message ?? "Synchronisierung fehlgeschlagen"
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
await appendCalendarSyncLog(run.id, "ERROR", message);
|
||||
await finishCalendarSyncRun(run.id, "FAILED", message);
|
||||
}
|
||||
})();
|
||||
|
||||
return ok(
|
||||
{
|
||||
message: "Kalender-Synchronisierung gestartet",
|
||||
runId: run.id
|
||||
},
|
||||
202
|
||||
);
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
180
app/api/admin/kalender/route.ts
Normal file
180
app/api/admin/kalender/route.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import {
|
||||
createPersonCalendarResource,
|
||||
listPersonCalendarResources
|
||||
} from "@/lib/services/person-calendar-resources";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
import {
|
||||
createWeekdayAvailabilityFromLegacy,
|
||||
deriveLegacyAvailability,
|
||||
hasAtLeastOneEnabledDay,
|
||||
normalizeWeekdayAvailability,
|
||||
serializeWeekdayAvailability
|
||||
} from "@/lib/weekday-availability";
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
const dayRangeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
start: z.string().regex(TIME_RE),
|
||||
end: z.string().regex(TIME_RE)
|
||||
});
|
||||
|
||||
const weekdayRangesSchema = z
|
||||
.object({
|
||||
"0": dayRangeSchema,
|
||||
"1": dayRangeSchema,
|
||||
"2": dayRangeSchema,
|
||||
"3": dayRangeSchema,
|
||||
"4": dayRangeSchema,
|
||||
"5": dayRangeSchema,
|
||||
"6": dayRangeSchema
|
||||
})
|
||||
.superRefine((ranges, ctx) => {
|
||||
for (const day of ["0", "1", "2", "3", "4", "5", "6"] as const) {
|
||||
const value = ranges[day];
|
||||
if (value.enabled && value.start >= value.end) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Ungültiges Zeitfenster",
|
||||
path: [day, "end"]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.values(ranges).some((value) => value.enabled)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Mindestens ein Wochentag muss aktiv sein.",
|
||||
path: ["0", "enabled"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const createSchema = z
|
||||
.object({
|
||||
resourceName: z.string().trim().min(2).max(120),
|
||||
resourceBio: z.string().trim().max(500).optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
calendarName: z.string().trim().min(2).max(120),
|
||||
bookingDayRanges: weekdayRangesSchema.optional(),
|
||||
bookingAllowedWeekdays: z
|
||||
.string()
|
||||
.regex(/^([0-6](,[0-6])*)$/)
|
||||
.optional(),
|
||||
bookingDayStartTime: z.string().regex(TIME_RE).optional(),
|
||||
bookingDayEndTime: z.string().regex(TIME_RE).optional(),
|
||||
url: z.string().url(),
|
||||
username: z.string().trim().min(1).max(160),
|
||||
notificationEmail: z.string().trim().email().max(320),
|
||||
password: z.string().min(1).max(2000),
|
||||
color: z.string().trim().max(64).optional(),
|
||||
syncEnabled: z.boolean().default(true)
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.bookingDayRanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.bookingAllowedWeekdays) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Fehlende Wochentage",
|
||||
path: ["bookingAllowedWeekdays"]
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.bookingDayStartTime) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Fehlende Startzeit",
|
||||
path: ["bookingDayStartTime"]
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.bookingDayEndTime) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Fehlende Endzeit",
|
||||
path: ["bookingDayEndTime"]
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
data.bookingDayStartTime &&
|
||||
data.bookingDayEndTime &&
|
||||
data.bookingDayStartTime >= data.bookingDayEndTime
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Ungültiges Zeitfenster",
|
||||
path: ["bookingDayEndTime"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const data = await listPersonCalendarResources();
|
||||
return ok(data);
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 64 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = createSchema.safeParse(bodyResult.data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Kalenderdaten", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const normalizedRanges = normalizeWeekdayAvailability(
|
||||
parsed.data.bookingDayRanges
|
||||
? parsed.data.bookingDayRanges
|
||||
: createWeekdayAvailabilityFromLegacy(
|
||||
parsed.data.bookingAllowedWeekdays ?? "0,1,2,3,4",
|
||||
parsed.data.bookingDayStartTime ?? "09:00",
|
||||
parsed.data.bookingDayEndTime ?? "17:00"
|
||||
)
|
||||
);
|
||||
|
||||
if (!hasAtLeastOneEnabledDay(normalizedRanges)) {
|
||||
return fail("Mindestens ein aktiver Wochentag mit gültiger Uhrzeit ist erforderlich.", 400);
|
||||
}
|
||||
|
||||
const legacy = deriveLegacyAvailability(normalizedRanges);
|
||||
|
||||
const resource = await createPersonCalendarResource({
|
||||
resourceName: parsed.data.resourceName,
|
||||
resourceBio: parsed.data.resourceBio,
|
||||
isActive: parsed.data.isActive,
|
||||
calendarName: parsed.data.calendarName,
|
||||
bookingAllowedWeekdays: legacy.bookingAllowedWeekdays,
|
||||
bookingDayStartTime: legacy.bookingDayStartTime,
|
||||
bookingDayEndTime: legacy.bookingDayEndTime,
|
||||
bookingDayRangesJson: serializeWeekdayAvailability(normalizedRanges),
|
||||
url: parsed.data.url,
|
||||
username: parsed.data.username,
|
||||
notificationEmail: parsed.data.notificationEmail,
|
||||
password: parsed.data.password,
|
||||
color: parsed.data.color,
|
||||
syncEnabled: parsed.data.syncEnabled
|
||||
});
|
||||
return ok({ resource }, 201);
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
43
app/api/admin/kalender/test-connection/route.ts
Normal file
43
app/api/admin/kalender/test-connection/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { fail, handleAuthError, ok } from "@/lib/api";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
import { testCaldavConnection } from "@/lib/services/caldav";
|
||||
|
||||
const testConnectionSchema = z.object({
|
||||
url: z.string().trim().url("Bitte eine gültige CalDAV-URL eingeben"),
|
||||
username: z.string().trim().min(1, "Benutzername ist erforderlich").max(160),
|
||||
password: z.string().min(1, "Passwort ist erforderlich").max(2000)
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 16 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const parsed = testConnectionSchema.safeParse(bodyResult.data);
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Verbindungsdaten", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const result = await testCaldavConnection(parsed.data);
|
||||
return ok({
|
||||
message: `${result.calendarCount} Kalender gefunden`,
|
||||
...result
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const authResponse = handleAuthError(error);
|
||||
if (authResponse.status !== 500) return authResponse;
|
||||
return fail(error.message || "CalDAV-Verbindung fehlgeschlagen", 502);
|
||||
}
|
||||
|
||||
return fail("CalDAV-Verbindung fehlgeschlagen", 502);
|
||||
}
|
||||
}
|
||||
218
app/api/admin/letzte-buchungen/route.ts
Normal file
218
app/api/admin/letzte-buchungen/route.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { fail, handleAuthError, ok } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getSetting, setSettings } from "@/lib/settings";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
const sortSchema = z.enum([
|
||||
"date_desc",
|
||||
"date_asc",
|
||||
"customer_asc",
|
||||
"customer_desc",
|
||||
"person_asc",
|
||||
"person_desc"
|
||||
]);
|
||||
|
||||
const actionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
action: z.enum(["archive", "delete"])
|
||||
});
|
||||
|
||||
type GroupedBooking = {
|
||||
key: string;
|
||||
id: string;
|
||||
customerFirstName: string;
|
||||
customerLastName: string;
|
||||
customerEmail: string;
|
||||
startAt: Date;
|
||||
staffNames: string[];
|
||||
staffCount: number;
|
||||
};
|
||||
|
||||
function parseArchivedKeys(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter((value) => value.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function bookingKey(row: { id: string; bookingGroupId: string | null }) {
|
||||
return row.bookingGroupId ?? row.id;
|
||||
}
|
||||
|
||||
function sortBookings(items: GroupedBooking[], sort: z.infer<typeof sortSchema>) {
|
||||
const sorted = [...items];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
if (sort === "date_desc") {
|
||||
return b.startAt.getTime() - a.startAt.getTime();
|
||||
}
|
||||
|
||||
if (sort === "date_asc") {
|
||||
return a.startAt.getTime() - b.startAt.getTime();
|
||||
}
|
||||
|
||||
if (sort === "customer_asc") {
|
||||
return `${a.customerLastName} ${a.customerFirstName}`.localeCompare(
|
||||
`${b.customerLastName} ${b.customerFirstName}`
|
||||
);
|
||||
}
|
||||
|
||||
if (sort === "customer_desc") {
|
||||
return `${b.customerLastName} ${b.customerFirstName}`.localeCompare(
|
||||
`${a.customerLastName} ${a.customerFirstName}`
|
||||
);
|
||||
}
|
||||
|
||||
if (sort === "person_asc") {
|
||||
return (a.staffNames[0] ?? "").localeCompare(b.staffNames[0] ?? "");
|
||||
}
|
||||
|
||||
return (b.staffNames[0] ?? "").localeCompare(a.staffNames[0] ?? "");
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsedSort = sortSchema.safeParse(url.searchParams.get("sort") ?? "date_desc");
|
||||
if (!parsedSort.success) {
|
||||
return fail("Ungültige Sortierung", 400, parsedSort.error.flatten());
|
||||
}
|
||||
|
||||
const archivedRaw = await getSetting(SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS);
|
||||
const archivedSet = new Set(parseArchivedKeys(archivedRaw));
|
||||
|
||||
const rows = await prisma.appointment.findMany({
|
||||
where: {
|
||||
status: "CONFIRMED"
|
||||
},
|
||||
include: {
|
||||
staff: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
startAt: "desc"
|
||||
},
|
||||
take: 300
|
||||
});
|
||||
|
||||
const grouped = new Map<string, GroupedBooking>();
|
||||
for (const row of rows) {
|
||||
const key = bookingKey(row);
|
||||
if (archivedSet.has(key)) continue;
|
||||
|
||||
const existing = grouped.get(key);
|
||||
if (!existing) {
|
||||
grouped.set(key, {
|
||||
key,
|
||||
id: row.id,
|
||||
customerFirstName: row.customerFirstName,
|
||||
customerLastName: row.customerLastName,
|
||||
customerEmail: row.customerEmail,
|
||||
startAt: row.startAt,
|
||||
staffNames: [row.staff.name],
|
||||
staffCount: 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!existing.staffNames.includes(row.staff.name)) {
|
||||
existing.staffNames.push(row.staff.name);
|
||||
existing.staffCount = existing.staffNames.length;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = sortBookings(Array.from(grouped.values()), parsedSort.data).slice(0, 50);
|
||||
|
||||
return ok({
|
||||
bookings: sorted
|
||||
});
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 16 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = actionSchema.safeParse(bodyResult.data);
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Aktion", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const target = await prisma.appointment.findUnique({
|
||||
where: {
|
||||
id: parsed.data.id
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
bookingGroupId: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return fail("Buchung nicht gefunden", 404);
|
||||
}
|
||||
|
||||
const key = bookingKey(target);
|
||||
|
||||
if (parsed.data.action === "archive") {
|
||||
const archivedRaw = await getSetting(SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS);
|
||||
const archived = new Set(parseArchivedKeys(archivedRaw));
|
||||
archived.add(key);
|
||||
|
||||
await setSettings({
|
||||
[SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS]: JSON.stringify(Array.from(archived))
|
||||
});
|
||||
|
||||
return ok({ message: "Buchung archiviert." });
|
||||
}
|
||||
|
||||
if (target.bookingGroupId) {
|
||||
await prisma.appointment.deleteMany({
|
||||
where: {
|
||||
bookingGroupId: target.bookingGroupId
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.appointment.delete({
|
||||
where: {
|
||||
id: target.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const archivedRaw = await getSetting(SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS);
|
||||
const archived = parseArchivedKeys(archivedRaw).filter((entry) => entry !== key);
|
||||
await setSettings({
|
||||
[SETTING_KEYS.LATEST_BOOKINGS_ARCHIVED_KEYS]: JSON.stringify(archived)
|
||||
});
|
||||
|
||||
return ok({ message: "Buchung gelöscht." });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
297
app/api/admin/termine/route.ts
Normal file
297
app/api/admin/termine/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { parseISO } from "date-fns";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/session";
|
||||
import { handleAuthError, fail, ok } from "@/lib/api";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { appointmentsFilterSchema } from "@/lib/validators/admin";
|
||||
import { sendCancellationEmails } from "@/lib/email/mailer";
|
||||
import { getSetting } from "@/lib/settings";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { deleteEventInCaldav } from "@/lib/services/caldav";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
const patchSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
status: z.enum(["CONFIRMED", "CANCELLED"]).optional(),
|
||||
noShow: z.boolean().optional()
|
||||
})
|
||||
.refine((data) => Boolean(data.status) || typeof data.noShow === "boolean", {
|
||||
message: "status oder noShow ist erforderlich",
|
||||
path: ["status"]
|
||||
});
|
||||
|
||||
function resolveNotificationEmail(staff: {
|
||||
email: string;
|
||||
calendars: Array<{ notificationEmail: string | null }>;
|
||||
}) {
|
||||
const direct = staff.calendars
|
||||
.map((entry) => entry.notificationEmail?.trim() ?? "")
|
||||
.find((value) => value.length > 0);
|
||||
return direct ?? staff.email;
|
||||
}
|
||||
|
||||
async function deleteCalendarEventsForAppointments(
|
||||
appointments: Array<{
|
||||
id: string;
|
||||
staffId: string;
|
||||
calendarEventUid: string | null;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
}>
|
||||
) {
|
||||
const deletedAppointmentIds = (
|
||||
await Promise.all(
|
||||
appointments.map(async (appointment) => {
|
||||
if (!appointment.calendarEventUid) return null;
|
||||
|
||||
const deleted = await deleteEventInCaldav(appointment.staffId, {
|
||||
eventUid: appointment.calendarEventUid,
|
||||
startAt: appointment.startAt,
|
||||
endAt: appointment.endAt
|
||||
});
|
||||
|
||||
return deleted ? appointment.id : null;
|
||||
})
|
||||
)
|
||||
).filter((id): id is string => Boolean(id));
|
||||
|
||||
if (deletedAppointmentIds.length > 0) {
|
||||
await prisma.appointment.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: deletedAppointmentIds
|
||||
}
|
||||
},
|
||||
data: {
|
||||
calendarEventUid: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const url = new URL(req.url);
|
||||
|
||||
const parsed = appointmentsFilterSchema.safeParse({
|
||||
mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined,
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
noShow: url.searchParams.get("noShow") ?? undefined,
|
||||
q: url.searchParams.get("q") ?? undefined,
|
||||
von: url.searchParams.get("von") ?? undefined,
|
||||
bis: url.searchParams.get("bis") ?? undefined
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Filter", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const { mitarbeiterId, status, noShow, q, von, bis } = parsed.data;
|
||||
|
||||
const termine = await prisma.appointment.findMany({
|
||||
where: {
|
||||
...(mitarbeiterId ? { staffId: mitarbeiterId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(noShow === "true"
|
||||
? { noShowAt: { not: null } }
|
||||
: noShow === "false"
|
||||
? { noShowAt: null }
|
||||
: {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ customerFirstName: { contains: q, mode: "insensitive" } },
|
||||
{ customerLastName: { contains: q, mode: "insensitive" } },
|
||||
{ customerEmail: { contains: q, mode: "insensitive" } }
|
||||
]
|
||||
}
|
||||
: {}),
|
||||
...(von || bis
|
||||
? {
|
||||
startAt: {
|
||||
...(von ? { gte: parseISO(von) } : {}),
|
||||
...(bis ? { lte: parseISO(bis) } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
include: {
|
||||
staff: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
slug: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { startAt: "asc" }
|
||||
});
|
||||
|
||||
return ok({ termine });
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
await requireAdmin();
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 32 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsedBody = patchSchema.safeParse(bodyResult.data);
|
||||
if (!parsedBody.success) {
|
||||
return fail("Ungültige Eingaben", 400, parsedBody.error.flatten());
|
||||
}
|
||||
|
||||
const { id, status, noShow } = parsedBody.data;
|
||||
|
||||
const appointment = await prisma.appointment.findUnique({
|
||||
where: { id },
|
||||
include: { staff: true }
|
||||
});
|
||||
|
||||
if (!appointment) return fail("Termin nicht gefunden", 404);
|
||||
|
||||
if (typeof noShow === "boolean") {
|
||||
const targetAppointments = await prisma.appointment.findMany({
|
||||
where: appointment.bookingGroupId
|
||||
? {
|
||||
bookingGroupId: appointment.bookingGroupId,
|
||||
status: "CONFIRMED"
|
||||
}
|
||||
: {
|
||||
id: appointment.id,
|
||||
status: "CONFIRMED"
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
});
|
||||
|
||||
if (targetAppointments.length === 0) {
|
||||
return fail("Kein bestätigter Termin für No-Show-Markierung gefunden", 409);
|
||||
}
|
||||
|
||||
await prisma.appointment.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: targetAppointments.map((item) => item.id)
|
||||
}
|
||||
},
|
||||
data: {
|
||||
noShowAt: noShow ? new Date() : null
|
||||
}
|
||||
});
|
||||
|
||||
const refreshed = await prisma.appointment.findUnique({
|
||||
where: { id: appointment.id },
|
||||
include: {
|
||||
staff: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
slug: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ok({
|
||||
termin: refreshed,
|
||||
betroffen: targetAppointments.length
|
||||
});
|
||||
}
|
||||
|
||||
const targetAppointments = await prisma.appointment.findMany({
|
||||
where: appointment.bookingGroupId
|
||||
? {
|
||||
bookingGroupId: appointment.bookingGroupId,
|
||||
status: "CONFIRMED"
|
||||
}
|
||||
: {
|
||||
id: appointment.id,
|
||||
status: "CONFIRMED"
|
||||
},
|
||||
include: {
|
||||
staff: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
calendars: {
|
||||
select: {
|
||||
notificationEmail: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (targetAppointments.length === 0) {
|
||||
return fail("Termin ist bereits storniert", 409);
|
||||
}
|
||||
|
||||
await prisma.appointment.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: targetAppointments.map((item) => item.id)
|
||||
}
|
||||
},
|
||||
data: {
|
||||
status,
|
||||
cancelledAt: status === "CANCELLED" ? new Date() : null,
|
||||
...(status === "CANCELLED" ? { noShowAt: null } : {})
|
||||
}
|
||||
});
|
||||
|
||||
const updated = targetAppointments[0]!;
|
||||
|
||||
if (status === "CANCELLED") {
|
||||
await deleteCalendarEventsForAppointments(
|
||||
targetAppointments.map((item) => ({
|
||||
id: item.id,
|
||||
staffId: item.staffId,
|
||||
calendarEventUid: item.calendarEventUid,
|
||||
startAt: item.startAt,
|
||||
endAt: item.endAt
|
||||
}))
|
||||
);
|
||||
|
||||
const companyName = await getSetting(SETTING_KEYS.COMPANY_NAME);
|
||||
try {
|
||||
await sendCancellationEmails({
|
||||
customerEmail: updated.customerEmail,
|
||||
customerName: `${updated.customerFirstName} ${updated.customerLastName}`,
|
||||
staffList: targetAppointments.map((item) => ({
|
||||
name: item.staff.name,
|
||||
email: resolveNotificationEmail(item.staff)
|
||||
})),
|
||||
date: updated.startAt,
|
||||
companyName
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[calbook] sendCancellationEmails(Admin) fehlgeschlagen", error);
|
||||
}
|
||||
}
|
||||
|
||||
return ok({
|
||||
termin: updated,
|
||||
betroffen: targetAppointments.length
|
||||
});
|
||||
} catch (error) {
|
||||
return handleAuthError(error);
|
||||
}
|
||||
}
|
||||
8
app/api/auth/[...nextauth]/route.ts
Normal file
8
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth/options";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
32
app/api/cron/sync/route.ts
Normal file
32
app/api/cron/sync/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { syncAllEnabledCalendars } from "@/lib/services/caldav";
|
||||
import { runAppointmentReminders } from "@/lib/services/reminders";
|
||||
|
||||
function safeSecretMatch(expected: string, provided?: string | null) {
|
||||
if (!provided) return false;
|
||||
const expectedBytes = Buffer.from(expected, "utf8");
|
||||
const providedBytes = Buffer.from(provided, "utf8");
|
||||
if (expectedBytes.length !== providedBytes.length) return false;
|
||||
return crypto.timingSafeEqual(expectedBytes, providedBytes);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return fail("CRON_SECRET ist nicht konfiguriert", 503);
|
||||
}
|
||||
|
||||
const provided = req.headers.get("x-cron-secret");
|
||||
if (!safeSecretMatch(secret, provided)) {
|
||||
return fail("Nicht erlaubt", 403);
|
||||
}
|
||||
|
||||
const [results, reminders] = await Promise.all([
|
||||
syncAllEnabledCalendars(),
|
||||
runAppointmentReminders()
|
||||
]);
|
||||
return ok({ results, reminders });
|
||||
}
|
||||
35
app/api/public/buchen/route.ts
Normal file
35
app/api/public/buchen/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { createAppointment } from "@/lib/services/appointments";
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { enforceRateLimit } from "@/lib/rate-limit";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
const limit = enforceRateLimit({
|
||||
req,
|
||||
scope: "public-book",
|
||||
limit: 20,
|
||||
windowMs: 60_000
|
||||
});
|
||||
|
||||
if (!limit.ok) {
|
||||
return fail("Zu viele Buchungsversuche. Bitte kurz warten.", 429, {
|
||||
retryAfterSeconds: limit.retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 32 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const result = await createAppointment(bodyResult.data);
|
||||
|
||||
if (!result.ok) {
|
||||
return fail(result.message ?? "Buchung fehlgeschlagen", result.status ?? 400, "errors" in result ? result.errors : undefined);
|
||||
}
|
||||
|
||||
return ok(result.data, result.status);
|
||||
}
|
||||
75
app/api/public/mitarbeiter/route.ts
Normal file
75
app/api/public/mitarbeiter/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { SETTING_KEYS } from "@/lib/constants";
|
||||
import { enforceRateLimit } from "@/lib/rate-limit";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/date";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const limit = enforceRateLimit({
|
||||
req,
|
||||
scope: "public-staff",
|
||||
limit: 120,
|
||||
windowMs: 60_000
|
||||
});
|
||||
|
||||
if (!limit.ok) {
|
||||
return fail("Zu viele Anfragen. Bitte kurz warten.", 429, {
|
||||
retryAfterSeconds: limit.retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
const [mitarbeiter, settings] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
role: "STAFF",
|
||||
calendars: { some: {} }
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc"
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
bio: true,
|
||||
avatarUrl: true,
|
||||
timezone: true
|
||||
}
|
||||
}),
|
||||
getSettings([
|
||||
SETTING_KEYS.COMPANY_NAME,
|
||||
SETTING_KEYS.BOOKING_NOTICE_TEXT,
|
||||
SETTING_KEYS.DEFAULT_DURATION_MINUTES,
|
||||
SETTING_KEYS.FRONTEND_HEADER_TEXT,
|
||||
SETTING_KEYS.FRONTEND_HEADER_LOGO_URL,
|
||||
SETTING_KEYS.FOOTER_PRIVACY_LABEL,
|
||||
SETTING_KEYS.FOOTER_PRIVACY_URL,
|
||||
SETTING_KEYS.FOOTER_IMPRINT_LABEL,
|
||||
SETTING_KEYS.FOOTER_IMPRINT_URL,
|
||||
SETTING_KEYS.FOOTER_COPYRIGHT_TEXT
|
||||
])
|
||||
]);
|
||||
|
||||
return ok({
|
||||
mitarbeiter,
|
||||
config: {
|
||||
companyName: settings[SETTING_KEYS.COMPANY_NAME] ?? "CalBook",
|
||||
bookingNoticeText: settings[SETTING_KEYS.BOOKING_NOTICE_TEXT] ?? "",
|
||||
defaultDurationMinutes: Number(settings[SETTING_KEYS.DEFAULT_DURATION_MINUTES] ?? "60"),
|
||||
headerText: settings[SETTING_KEYS.FRONTEND_HEADER_TEXT] ?? "Gespräch",
|
||||
headerLogoUrl: settings[SETTING_KEYS.FRONTEND_HEADER_LOGO_URL] ?? "",
|
||||
footerPrivacyLabel: settings[SETTING_KEYS.FOOTER_PRIVACY_LABEL] ?? "Datenschutz",
|
||||
footerPrivacyUrl: settings[SETTING_KEYS.FOOTER_PRIVACY_URL] ?? "/datenschutz",
|
||||
footerImprintLabel: settings[SETTING_KEYS.FOOTER_IMPRINT_LABEL] ?? "Impressum",
|
||||
footerImprintUrl: settings[SETTING_KEYS.FOOTER_IMPRINT_URL] ?? "/impressum",
|
||||
footerCopyrightText:
|
||||
settings[SETTING_KEYS.FOOTER_COPYRIGHT_TEXT] ?? "© {{year}} {{companyName}}",
|
||||
defaultTimezone: DEFAULT_TIMEZONE,
|
||||
personCount: mitarbeiter.length
|
||||
}
|
||||
});
|
||||
}
|
||||
180
app/api/public/slots-monat/route.ts
Normal file
180
app/api/public/slots-monat/route.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { eachDayOfInterval } from "date-fns";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
calculateSlotsForDisplayDate,
|
||||
loadSlotConfig
|
||||
} from "@/lib/services/availability";
|
||||
import { monthSlotsQuerySchema } from "@/lib/validators/public";
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { enforceRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveTimeZone } from "@/lib/date";
|
||||
|
||||
function getMonthDays(monat: string) {
|
||||
const [yearRaw, monthRaw] = monat.split("-");
|
||||
const year = Number(yearRaw);
|
||||
const month = Number(monthRaw);
|
||||
|
||||
// Use UTC noon to avoid any timezone/DST rollover to adjacent dates.
|
||||
const start = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
|
||||
const end = new Date(Date.UTC(year, month, 0, 12, 0, 0));
|
||||
|
||||
return eachDayOfInterval({ start, end }).map((day) => day.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
function countSlotsForDay(
|
||||
results: Array<{ slots: string[] }>,
|
||||
requireAll: boolean
|
||||
) {
|
||||
if (results.length === 0) return 0;
|
||||
|
||||
if (requireAll) {
|
||||
const first = results[0];
|
||||
if (!first) return 0;
|
||||
|
||||
const intersection = new Set(first.slots);
|
||||
for (const item of results.slice(1)) {
|
||||
const next = new Set(item.slots);
|
||||
for (const value of Array.from(intersection)) {
|
||||
if (!next.has(value)) {
|
||||
intersection.delete(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return intersection.size;
|
||||
}
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const item of results) {
|
||||
for (const slot of item.slots) {
|
||||
unique.add(slot);
|
||||
}
|
||||
}
|
||||
return unique.size;
|
||||
}
|
||||
|
||||
type MonthAvailabilityCacheEntry = {
|
||||
availability: Record<string, number>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var calbookMonthAvailabilityCache: Map<string, MonthAvailabilityCacheEntry> | undefined;
|
||||
}
|
||||
|
||||
const monthAvailabilityCache =
|
||||
global.calbookMonthAvailabilityCache ?? new Map<string, MonthAvailabilityCacheEntry>();
|
||||
if (!global.calbookMonthAvailabilityCache) {
|
||||
global.calbookMonthAvailabilityCache = monthAvailabilityCache;
|
||||
}
|
||||
|
||||
const MONTH_CACHE_TTL_MS = Number(process.env.SLOTS_MONTH_CACHE_TTL_MS ?? "12000");
|
||||
const DAYS_CONCURRENCY = Math.max(1, Number(process.env.SLOTS_MONTH_CONCURRENCY ?? "4"));
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<R>
|
||||
) {
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (current >= items.length) return;
|
||||
results[current] = await mapper(items[current] as T);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const limit = enforceRateLimit({
|
||||
req,
|
||||
scope: "public-slots-month",
|
||||
limit: 60,
|
||||
windowMs: 60_000
|
||||
});
|
||||
|
||||
if (!limit.ok) {
|
||||
return fail("Zu viele Anfragen. Bitte kurz warten.", 429, {
|
||||
retryAfterSeconds: limit.retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = monthSlotsQuerySchema.safeParse({
|
||||
mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined,
|
||||
monat: url.searchParams.get("monat"),
|
||||
timezone: url.searchParams.get("timezone") ?? undefined,
|
||||
requireAll: url.searchParams.get("requireAll") ?? undefined
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Parameter", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const timezone = resolveTimeZone(parsed.data.timezone);
|
||||
const cacheKey = [
|
||||
parsed.data.monat,
|
||||
parsed.data.mitarbeiterId ?? "all",
|
||||
timezone,
|
||||
parsed.data.requireAll ? "all-required" : "any"
|
||||
].join("|");
|
||||
const cached = monthAvailabilityCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return ok({ availability: cached.availability });
|
||||
}
|
||||
|
||||
const dayKeys = getMonthDays(parsed.data.monat);
|
||||
const slotConfig = await loadSlotConfig();
|
||||
|
||||
const availability: Record<string, number> = {};
|
||||
const sharedStaffIds =
|
||||
parsed.data.mitarbeiterId === undefined
|
||||
? (
|
||||
await prisma.user.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
role: "STAFF",
|
||||
calendars: { some: {} }
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
).map((staff) => staff.id)
|
||||
: undefined;
|
||||
|
||||
const pairs = await mapWithConcurrency(dayKeys, DAYS_CONCURRENCY, async (dayKey) => {
|
||||
const dayResults = await calculateSlotsForDisplayDate(
|
||||
parsed.data.mitarbeiterId,
|
||||
dayKey,
|
||||
{
|
||||
displayTimezone: timezone,
|
||||
staffIds: sharedStaffIds,
|
||||
config: slotConfig
|
||||
}
|
||||
);
|
||||
const count = countSlotsForDay(dayResults, Boolean(parsed.data.requireAll));
|
||||
return {
|
||||
dayKey,
|
||||
count
|
||||
};
|
||||
});
|
||||
|
||||
for (const pair of pairs) {
|
||||
availability[pair.dayKey] = pair.count;
|
||||
}
|
||||
|
||||
monthAvailabilityCache.set(cacheKey, {
|
||||
availability,
|
||||
expiresAt: Date.now() + MONTH_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
return ok({ availability });
|
||||
}
|
||||
69
app/api/public/slots/route.ts
Normal file
69
app/api/public/slots/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { calculateSlotsForDisplayDate } from "@/lib/services/availability";
|
||||
import { slotsQuerySchema } from "@/lib/validators/public";
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { enforceRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveTimeZone } from "@/lib/date";
|
||||
|
||||
type DaySlotsCacheEntry = {
|
||||
slots: Awaited<ReturnType<typeof calculateSlotsForDisplayDate>>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var calbookDaySlotsCache: Map<string, DaySlotsCacheEntry> | undefined;
|
||||
}
|
||||
|
||||
const daySlotsCache = global.calbookDaySlotsCache ?? new Map<string, DaySlotsCacheEntry>();
|
||||
if (!global.calbookDaySlotsCache) {
|
||||
global.calbookDaySlotsCache = daySlotsCache;
|
||||
}
|
||||
|
||||
const DAY_SLOTS_CACHE_TTL_MS = Number(process.env.SLOTS_DAY_CACHE_TTL_MS ?? "6000");
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const limit = enforceRateLimit({
|
||||
req,
|
||||
scope: "public-slots",
|
||||
limit: 120,
|
||||
windowMs: 60_000
|
||||
});
|
||||
|
||||
if (!limit.ok) {
|
||||
return fail("Zu viele Anfragen. Bitte kurz warten.", 429, {
|
||||
retryAfterSeconds: limit.retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = slotsQuerySchema.safeParse({
|
||||
mitarbeiterId: url.searchParams.get("mitarbeiterId") ?? undefined,
|
||||
datum: url.searchParams.get("datum"),
|
||||
timezone: url.searchParams.get("timezone") ?? undefined
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Ungültige Parameter", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const timezone = resolveTimeZone(parsed.data.timezone);
|
||||
const cacheKey = `${parsed.data.mitarbeiterId ?? "all"}|${parsed.data.datum}|${timezone}`;
|
||||
const cached = daySlotsCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return ok({ slots: cached.slots });
|
||||
}
|
||||
|
||||
const results = await calculateSlotsForDisplayDate(
|
||||
parsed.data.mitarbeiterId,
|
||||
parsed.data.datum,
|
||||
{ displayTimezone: timezone }
|
||||
);
|
||||
daySlotsCache.set(cacheKey, {
|
||||
slots: results,
|
||||
expiresAt: Date.now() + DAY_SLOTS_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
return ok({ slots: results });
|
||||
}
|
||||
47
app/api/public/stornieren/route.ts
Normal file
47
app/api/public/stornieren/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { cancelAppointmentByToken } from "@/lib/services/appointments";
|
||||
import { cancelSchema } from "@/lib/validators/public";
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { enforceRateLimit } from "@/lib/rate-limit";
|
||||
import { readJsonBody, validateMutationRequestOrigin } from "@/lib/security/request";
|
||||
|
||||
export async function GET() {
|
||||
return fail("Bitte verwende POST für die Stornierung.", 405);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const originError = validateMutationRequestOrigin(req);
|
||||
if (originError) return originError;
|
||||
|
||||
const bodyResult = await readJsonBody(req, { maxBytes: 8 * 1024 });
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
const parsed = cancelSchema.safeParse({
|
||||
token: (bodyResult.data as { token?: string }).token
|
||||
});
|
||||
|
||||
const limit = enforceRateLimit({
|
||||
req,
|
||||
scope: "public-cancel",
|
||||
limit: 30,
|
||||
windowMs: 60_000,
|
||||
...(parsed.success ? { keySuffix: parsed.data.token.slice(0, 12) } : {})
|
||||
});
|
||||
|
||||
if (!limit.ok) {
|
||||
return fail("Zu viele Anfragen. Bitte kurz warten.", 429, {
|
||||
retryAfterSeconds: limit.retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Token fehlt oder ist ungültig", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const result = await cancelAppointmentByToken(parsed.data.token);
|
||||
if (!result.ok) {
|
||||
return fail(result.message ?? "Stornierung fehlgeschlagen", result.status ?? 400);
|
||||
}
|
||||
|
||||
return ok({ message: "Termin erfolgreich storniert" });
|
||||
}
|
||||
38
app/api/public/umbuchen/route.ts
Normal file
38
app/api/public/umbuchen/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { fail, ok } from "@/lib/api";
|
||||
import { enforceRateLimit } from "@/lib/rate-limit";
|
||||
import { cancelSchema } from "@/lib/validators/public";
|
||||
import { getRescheduleInfo } from "@/lib/services/appointments";
|
||||
import { resolveTimeZone } from "@/lib/date";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const parsed = cancelSchema.safeParse({ token: url.searchParams.get("token") });
|
||||
const timezone = resolveTimeZone(url.searchParams.get("timezone"));
|
||||
|
||||
const limit = enforceRateLimit({
|
||||
req,
|
||||
scope: "public-reschedule-info",
|
||||
limit: 40,
|
||||
windowMs: 60_000,
|
||||
...(parsed.success ? { keySuffix: parsed.data.token.slice(0, 12) } : {})
|
||||
});
|
||||
|
||||
if (!limit.ok) {
|
||||
return fail("Zu viele Anfragen. Bitte kurz warten.", 429, {
|
||||
retryAfterSeconds: limit.retryAfterSeconds
|
||||
});
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return fail("Token fehlt oder ist ungültig", 400, parsed.error.flatten());
|
||||
}
|
||||
|
||||
const result = await getRescheduleInfo(parsed.data.token, timezone);
|
||||
if (!result.ok) {
|
||||
return fail(result.message, result.status);
|
||||
}
|
||||
|
||||
return ok(result.data);
|
||||
}
|
||||
Reference in New Issue
Block a user