Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)

This commit is contained in:
2026-06-03 14:08:48 +02:00
committed by Ihor_Zhekov
commit bf5d79a919
94 changed files with 12480 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apartmentUpdateSchema } from "@/lib/validations";
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json().catch(() => null);
const parsed = apartmentUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const data = parsed.data;
try {
const updated = await prisma.apartment.update({
where: { id: params.id },
data: {
name: data.name,
tagline: data.tagline,
shortDescription: data.shortDescription,
description: data.description,
priceFrom: data.priceFrom,
maxGuests: data.maxGuests,
bedrooms: data.bedrooms,
sizeSqm: data.sizeSqm,
features: JSON.stringify(data.features),
images: JSON.stringify(data.images),
airbnbUrl: data.airbnbUrl || null,
bookingUrl: data.bookingUrl || null,
published: data.published,
},
});
return NextResponse.json({ ok: true, apartment: updated });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.block.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { blockSchema } from "@/lib/validations";
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = blockSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { apartmentId, startDate, endDate, note, reason } = parsed.data;
const apt = await prisma.apartment.findUnique({ where: { id: apartmentId } });
if (!apt) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
const block = await prisma.block.create({
data: {
apartmentId,
startDate: new Date(startDate),
endDate: new Date(endDate),
note: note || null,
reason,
source: "manual",
},
});
return NextResponse.json({ ok: true, block }, { status: 201 });
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
const patchSchema = z.object({
status: z.enum(["new", "read", "confirmed", "declined", "archived"]),
});
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
try {
const updated = await prisma.inquiry.update({
where: { id: params.id },
data: { status: parsed.data.status },
});
return NextResponse.json({ ok: true, inquiry: updated });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}
export async function DELETE(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
await prisma.inquiry.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Nicht gefunden." }, { status: 404 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth";
import { loginSchema } from "@/lib/validations";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
}
const { email, password } = parsed.data;
const admin = await prisma.admin.findUnique({ where: { email } });
if (!admin) {
return NextResponse.json({ error: "Zugangsdaten falsch." }, { status: 401 });
}
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) {
return NextResponse.json({ error: "Zugangsdaten falsch." }, { status: 401 });
}
await createSession({ sub: admin.id, email: admin.email });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { clearSession } from "@/lib/auth";
export async function POST() {
clearSession();
return NextResponse.json({ ok: true });
}

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