feat: initialize CalBook project with comprehensive scheduling, admin, and deployment infrastructure
This commit is contained in:
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