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

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: { slug: string } }
) {
const apartment = await prisma.apartment.findUnique({
where: { slug: params.slug },
select: { id: true },
});
if (!apartment) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
// Wir geben nur zukünftige & heute aktive Blöcke zurück.
const today = new Date();
today.setHours(0, 0, 0, 0);
const blocks = await prisma.block.findMany({
where: {
apartmentId: apartment.id,
endDate: { gte: today },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: "asc" },
});
return NextResponse.json(
{
blocks: blocks.map((b) => ({
start: b.startDate.toISOString(),
end: b.endDate.toISOString(),
})),
},
{
headers: { "Cache-Control": "no-store" },
}
);
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Period {
start: string;
end: string;
nights: number;
}
export async function GET(
_req: Request,
{ params }: { params: { slug: string } }
) {
const apartment = await prisma.apartment.findUnique({
where: { slug: params.slug },
select: { id: true },
});
if (!apartment) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const horizon = new Date(today);
horizon.setDate(horizon.getDate() + 180);
// Fetch all blocks that overlap the window
const rows = await prisma.block.findMany({
where: {
apartmentId: apartment.id,
startDate: { lt: horizon },
endDate: { gt: today },
},
select: { startDate: true, endDate: true },
orderBy: { startDate: "asc" },
});
const blocks = rows.map((b) => ({
from: new Date(b.startDate),
to: new Date(b.endDate),
}));
// Walk forward from tomorrow, find free 7-night windows
const DEFAULT_NIGHTS = 7;
const MAX_SUGGESTIONS = 8;
const suggestions: Period[] = [];
const cursor = new Date(today);
cursor.setDate(cursor.getDate() + 1); // start from tomorrow
while (cursor < horizon && suggestions.length < MAX_SUGGESTIONS) {
const tripEnd = new Date(cursor);
tripEnd.setDate(tripEnd.getDate() + DEFAULT_NIGHTS);
if (tripEnd > horizon) break;
// Find any block that overlaps [cursor, tripEnd)
const overlap = blocks.find((b) => cursor < b.to && tripEnd > b.from);
if (!overlap) {
suggestions.push({
start: cursor.toISOString().slice(0, 10),
end: tripEnd.toISOString().slice(0, 10),
nights: DEFAULT_NIGHTS,
});
// Jump forward: end of this trip + small gap
cursor.setDate(cursor.getDate() + DEFAULT_NIGHTS + 7);
} else {
// Jump past the blocking block
cursor.setTime(overlap.to.getTime());
cursor.setDate(cursor.getDate() + 1);
}
}
return NextResponse.json({ periods: suggestions }, {
headers: { "Cache-Control": "no-store" },
});
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { inquirySchema } from "@/lib/validations";
import { sendInquiryMails } from "@/lib/email";
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 = inquirySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Bitte prüfen Sie Ihre Eingaben.",
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// Honeypot: wenn `website` gefüllt ist, ist es vermutlich ein Bot.
if (parsed.data.website) {
return NextResponse.json({ ok: true }, { status: 200 });
}
const { apartmentSlug, arrival, departure, guests, name, email, phone, message } =
parsed.data;
const apartment = await prisma.apartment.findUnique({
where: { slug: apartmentSlug },
});
if (!apartment) {
return NextResponse.json({ error: "Wohnung nicht gefunden." }, { status: 404 });
}
const arrivalDate = new Date(arrival);
const departureDate = new Date(departure);
if (guests > apartment.maxGuests) {
return NextResponse.json(
{ error: `Für diese Wohnung sind maximal ${apartment.maxGuests} Personen möglich.` },
{ status: 400 }
);
}
// Hinweis: wir blockieren eine Anfrage NICHT, nur weil die Zeit schon belegt ist —
// der Gast bekommt ggf. eine Absage, und der Vermieter sieht alles im Admin.
// Aber wir markieren im Memo-Feld nichts — Vermieter entscheidet.
const inquiry = await prisma.inquiry.create({
data: {
apartmentId: apartment.id,
arrival: arrivalDate,
departure: departureDate,
guests,
name,
email,
phone: phone || null,
message: message || "",
status: "new",
},
});
// Mails senden — Fehler dürfen den Erfolg nicht verhindern
try {
await sendInquiryMails({
apartmentName: apartment.name,
arrival: arrivalDate,
departure: departureDate,
guests,
name,
email,
phone,
message,
inquiryId: inquiry.id,
});
} catch (err) {
console.error("Mailversand fehlgeschlagen:", err);
}
return NextResponse.json({ ok: true, id: inquiry.id }, { status: 201 });
}