import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { z } from "zod"; const bodySchema = z.object({ apartmentId: z.string().min(1), icalUrl: z.string().url(), source: z.enum(["airbnb", "booking"]), }); /** Minimal iCal parser for Airbnb / Booking.com VCALENDAR feeds. */ function parseIcal(text: string) { // Unfold continuation lines (RFC 5545 §3.1) const unfolded = text.replace(/\r?\n[ \t]/g, ""); const lines = unfolded.split(/\r?\n/); const events: Array<{ uid: string; start: Date; end: Date }> = []; let inEvent = false; let uid = ""; let start: Date | null = null; let end: Date | null = null; for (const line of lines) { if (line === "BEGIN:VEVENT") { inEvent = true; uid = ""; start = null; end = null; continue; } if (line === "END:VEVENT") { inEvent = false; if (uid && start && end && end > start) { events.push({ uid, start, end }); } continue; } if (!inEvent) continue; // Split on first colon, ignoring property params like ;VALUE=DATE const colonIdx = line.indexOf(":"); if (colonIdx === -1) continue; const propFull = line.slice(0, colonIdx); const value = line.slice(colonIdx + 1).trim(); const prop = propFull.split(";")[0].toUpperCase(); if (prop === "DTSTART" || prop === "DTEND") { const parsed = parseIcalDate(value); if (parsed) { if (prop === "DTSTART") start = parsed; else end = parsed; } } else if (prop === "UID") { uid = value; } } return events; } function parseIcalDate(value: string): Date | null { // DATE: YYYYMMDD // DATETIME: YYYYMMDDTHHMMSSz or YYYYMMDDTHHMMSSZz const digits = value.replace(/[TZ]/g, "").slice(0, 8); if (digits.length < 8) return null; const y = parseInt(digits.slice(0, 4)); const m = parseInt(digits.slice(4, 6)) - 1; const d = parseInt(digits.slice(6, 8)); if (isNaN(y) || isNaN(m) || isNaN(d)) return null; return new Date(Date.UTC(y, m, d)); } export async function POST(request: Request) { const body = await request.json().catch(() => null); const parsed = bodySchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); } const { apartmentId, icalUrl, source } = parsed.data; // Verify apartment exists const apartment = await prisma.apartment.findUnique({ where: { id: apartmentId } }); if (!apartment) { return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 }); } // Fetch the iCal feed let icalText: string; try { const res = await fetch(icalUrl, { headers: { "User-Agent": "Spreewaldzeit-CalSync/1.0" }, signal: AbortSignal.timeout(10_000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); icalText = await res.text(); } catch (err) { return NextResponse.json( { error: `Kalender konnte nicht geladen werden: ${err instanceof Error ? err.message : "Unbekannt"}` }, { status: 502 } ); } const events = parseIcal(icalText); // Delete all previous blocks from this source for this apartment const deleted = await prisma.block.deleteMany({ where: { apartmentId, source }, }); // Insert new blocks if (events.length > 0) { await prisma.block.createMany({ data: events.map((e) => ({ apartmentId, startDate: e.start, endDate: e.end, reason: "booking", source, note: `iCal-Import (${source}) · UID: ${e.uid.slice(0, 40)}`, })), }); } return NextResponse.json({ ok: true, deleted: deleted.count, imported: events.length, }); }