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

55
lib/auth.ts Normal file
View File

@@ -0,0 +1,55 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const COOKIE_NAME = "sz_session";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 Tage
function getSecret(): Uint8Array {
const secret = process.env.AUTH_SECRET;
if (!secret || secret.length < 32) {
throw new Error(
"AUTH_SECRET fehlt oder ist zu kurz (min. 32 Zeichen). Bitte .env prüfen."
);
}
return new TextEncoder().encode(secret);
}
export interface SessionPayload {
sub: string; // Admin-ID
email: string;
iat?: number;
exp?: number;
}
export async function createSession(payload: Omit<SessionPayload, "iat" | "exp">) {
const token = await new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${COOKIE_MAX_AGE}s`)
.sign(getSecret());
cookies().set(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: COOKIE_MAX_AGE,
});
}
export async function getSession(): Promise<SessionPayload | null> {
const token = cookies().get(COOKIE_NAME)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, getSecret());
return payload as unknown as SessionPayload;
} catch {
return null;
}
}
export function clearSession() {
cookies().delete(COOKIE_NAME);
}
export const SESSION_COOKIE = COOKIE_NAME;

12
lib/db.ts Normal file
View File

@@ -0,0 +1,12 @@
import { PrismaClient } from "@prisma/client";
// Prisma-Singleton: verhindert im Dev-Modus zu viele Verbindungen durch Hot-Reload.
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

128
lib/email.ts Normal file
View File

@@ -0,0 +1,128 @@
import nodemailer, { type Transporter } from "nodemailer";
import { formatDate, nightsBetween } from "./utils";
// --------------------------------------------------------------------
// Transporter
// --------------------------------------------------------------------
// Wenn kein SMTP-Host konfiguriert ist, fällt der Versand auf "Console-Log"
// zurück. So funktioniert die Entwicklung ohne echten Mailserver.
// --------------------------------------------------------------------
let cachedTransporter: Transporter | null = null;
function getTransporter(): Transporter | null {
if (cachedTransporter) return cachedTransporter;
const host = process.env.SMTP_HOST;
if (!host) return null;
cachedTransporter = nodemailer.createTransport({
host,
port: Number(process.env.SMTP_PORT ?? 587),
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
auth:
process.env.SMTP_USER && process.env.SMTP_PASSWORD
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
});
return cachedTransporter;
}
interface SendMailParams {
to: string;
subject: string;
text: string;
html?: string;
replyTo?: string;
}
export async function sendMail({ to, subject, text, html, replyTo }: SendMailParams) {
const from = process.env.SMTP_FROM ?? "Spreewaldzeit <noreply@spreewaldzeit.de>";
const transporter = getTransporter();
if (!transporter) {
// Dev-Fallback: loggen
console.log(
"\n─────── 📬 MAIL (Dev-Fallback, kein SMTP gesetzt) ───────\n" +
`An: ${to}\n` +
`Von: ${from}\n` +
`Betreff: ${subject}\n` +
(replyTo ? `Reply-To: ${replyTo}\n` : "") +
`\n${text}\n` +
"──────────────────────────────────────────────────────────\n"
);
return { devFallback: true };
}
await transporter.sendMail({ from, to, subject, text, html, replyTo });
return { devFallback: false };
}
// --------------------------------------------------------------------
// Vorgefertigte Templates
// --------------------------------------------------------------------
interface InquiryMailData {
apartmentName: string;
arrival: Date;
departure: Date;
guests: number;
name: string;
email: string;
phone?: string | null;
message?: string | null;
inquiryId: string;
}
export async function sendInquiryMails(data: InquiryMailData) {
const nights = nightsBetween(data.arrival, data.departure);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// 1) Mail an Vermieter
const ownerEmail = process.env.OWNER_EMAIL;
if (ownerEmail) {
await sendMail({
to: ownerEmail,
replyTo: data.email,
subject: `Neue Anfrage: ${data.apartmentName} (${formatDate(data.arrival)} ${formatDate(
data.departure
)})`,
text: [
`Neue Anfrage über Spreewaldzeit`,
``,
`Wohnung: ${data.apartmentName}`,
`Zeitraum: ${formatDate(data.arrival)} ${formatDate(data.departure)} (${nights} Nächte)`,
`Gäste: ${data.guests}`,
``,
`Name: ${data.name}`,
`E-Mail: ${data.email}`,
`Telefon: ${data.phone || "—"}`,
``,
`Nachricht:`,
data.message?.trim() || "(keine)",
``,
`Im Admin öffnen: ${siteUrl}/admin/anfragen`,
].join("\n"),
});
}
// 2) Bestätigung an Gast
await sendMail({
to: data.email,
subject: `Ihre Anfrage bei Spreewaldzeit ${data.apartmentName}`,
text: [
`Hallo ${data.name},`,
``,
`vielen Dank für Ihre Anfrage. Wir haben Ihre Daten erhalten und melden uns`,
`in der Regel innerhalb von 24 Stunden.`,
``,
`Ihre Anfrage im Überblick:`,
` Wohnung: ${data.apartmentName}`,
` Zeitraum: ${formatDate(data.arrival)} ${formatDate(data.departure)} (${nights} Nächte)`,
` Gäste: ${data.guests}`,
``,
`Herzliche Grüße`,
`Ihr Spreewaldzeit-Team`,
``,
``,
`Diese Mail wurde automatisch versendet.`,
].join("\n"),
});
}

48
lib/utils.ts Normal file
View File

@@ -0,0 +1,48 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(cents: number): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(cents / 100);
}
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(d);
}
export function formatDateShort(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
}).format(d);
}
export function nightsBetween(arrival: Date | string, departure: Date | string): number {
const a = typeof arrival === "string" ? new Date(arrival) : arrival;
const d = typeof departure === "string" ? new Date(departure) : departure;
return Math.round((d.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
export function parseJsonArray<T = string>(raw: string | null | undefined): T[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}

82
lib/validations.ts Normal file
View File

@@ -0,0 +1,82 @@
import { z } from "zod";
// --------------------------------------------------
// Anfrageformular (öffentlich)
// --------------------------------------------------
export const inquirySchema = z
.object({
apartmentSlug: z.string().min(1, "Bitte eine Wohnung wählen."),
arrival: z
.string()
.min(1, "Bitte Anreisedatum angeben.")
.refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
departure: z
.string()
.min(1, "Bitte Abreisedatum angeben.")
.refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
guests: z.coerce.number().int().min(1, "Mindestens 1 Gast.").max(20),
name: z.string().trim().min(2, "Bitte Ihren Namen angeben."),
email: z.string().trim().email("Bitte eine gültige E-Mail-Adresse angeben."),
phone: z.string().trim().max(40).optional().or(z.literal("")),
message: z.string().trim().max(2000).optional().or(z.literal("")),
gdpr: z.literal(true, {
errorMap: () => ({ message: "Bitte der Datenschutzerklärung zustimmen." }),
}),
// Honeypot-Feld gegen Bots — muss leer bleiben
website: z.string().max(0).optional().or(z.literal("")),
})
.refine(
(d) => new Date(d.departure).getTime() > new Date(d.arrival).getTime(),
{ message: "Abreise muss nach Anreise liegen.", path: ["departure"] }
);
export type InquiryInput = z.infer<typeof inquirySchema>;
// --------------------------------------------------
// Admin: Zeitraum sperren
// --------------------------------------------------
export const blockSchema = z
.object({
apartmentId: z.string().min(1),
startDate: z.string().refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
endDate: z.string().refine((s) => !Number.isNaN(Date.parse(s)), "Ungültiges Datum."),
note: z.string().max(500).optional().or(z.literal("")),
reason: z.enum(["manual", "maintenance", "booking"]).default("manual"),
})
.refine((d) => new Date(d.endDate).getTime() > new Date(d.startDate).getTime(), {
message: "Enddatum muss nach Startdatum liegen.",
path: ["endDate"],
});
export type BlockInput = z.infer<typeof blockSchema>;
// --------------------------------------------------
// Admin: Wohnungsdaten
// --------------------------------------------------
export const apartmentUpdateSchema = z.object({
name: z.string().min(2).max(120),
tagline: z.string().min(2).max(200),
shortDescription: z.string().min(10).max(500),
description: z.string().min(10).max(5000),
priceFrom: z.coerce.number().int().min(0), // in Cent
maxGuests: z.coerce.number().int().min(1).max(20),
bedrooms: z.coerce.number().int().min(0).max(20),
sizeSqm: z.coerce.number().int().min(1).max(2000),
features: z.array(z.string().min(1)).default([]),
images: z.array(z.string().url()).default([]),
airbnbUrl: z.string().url().optional().or(z.literal("")),
bookingUrl: z.string().url().optional().or(z.literal("")),
published: z.boolean().default(true),
});
export type ApartmentUpdateInput = z.infer<typeof apartmentUpdateSchema>;
// --------------------------------------------------
// Login
// --------------------------------------------------
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export type LoginInput = z.infer<typeof loginSchema>;