Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)
This commit is contained in:
55
lib/auth.ts
Normal file
55
lib/auth.ts
Normal 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
12
lib/db.ts
Normal 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
128
lib/email.ts
Normal 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
48
lib/utils.ts
Normal 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
82
lib/validations.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user