Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
43
app/api/admin/apartments/[id]/route.ts
Normal file
43
app/api/admin/apartments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
app/api/admin/blocks/[id]/route.ts
Normal file
14
app/api/admin/blocks/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
app/api/admin/blocks/route.ts
Normal file
34
app/api/admin/blocks/route.ts
Normal 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 });
|
||||
}
|
||||
40
app/api/admin/inquiries/[id]/route.ts
Normal file
40
app/api/admin/inquiries/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
app/api/admin/login/route.ts
Normal file
33
app/api/admin/login/route.ts
Normal 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 });
|
||||
}
|
||||
7
app/api/admin/logout/route.ts
Normal file
7
app/api/admin/logout/route.ts
Normal 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 });
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
40
app/api/availability/[slug]/route.ts
Normal file
40
app/api/availability/[slug]/route.ts
Normal 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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
81
app/api/available-periods/[slug]/route.ts
Normal file
81
app/api/available-periods/[slug]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
86
app/api/inquiries/route.ts
Normal file
86
app/api/inquiries/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user