Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
130
app/api/admin/sync-ical/route.ts
Normal file
130
app/api/admin/sync-ical/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user