feat: Equipment-System, Buchungsbearbeitung, Kundenadresse, LexOffice-Fix

- Vintage Modell hinzugefuegt
- Equipment Multi-Select (Neue Buchung + Bearbeitung)
- Kundenadresse in Formularen
- Bearbeiten-Seite fuer Buchungen
- Abbau-Zeiten in Formularen und Uebersicht
- Vertrag PDF nur bei Privatkunden
- LexOffice Kontakt-Erstellung Fix (BUSINESS)
- Zurueck-Pfeil auf Touren-Seite
This commit is contained in:
Julia Wehden
2026-03-19 16:21:55 +01:00
parent 0b6e429329
commit a2c95c70e7
79 changed files with 7396 additions and 538 deletions

189
lib/booking-automation.ts Normal file
View File

@@ -0,0 +1,189 @@
import { prisma } from './prisma';
import { sendInitialBookingEmail } from './email-service';
import { nextcloudCalendar } from './nextcloud-calendar';
import { lexofficeService } from './lexoffice';
import { generateContractFromTemplate } from './pdf-template-service';
export class BookingAutomationService {
async runPostBookingActions(bookingId: string): Promise<{
emailSent: boolean;
calendarSynced: boolean;
lexofficeCreated: boolean;
contractGenerated: boolean;
errors: string[];
}> {
const errors: string[] = [];
let emailSent = false;
let calendarSynced = false;
let lexofficeCreated = false;
let contractGenerated = false;
try {
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: {
location: true,
photobox: true,
bookingEquipment: {
include: {
equipment: true,
},
},
},
});
if (!booking) {
errors.push('Buchung nicht gefunden');
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
}
let priceConfig = null;
if (booking.photobox?.model && booking.locationId) {
priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: booking.locationId,
model: booking.photobox.model,
},
},
});
}
const bookingWithPriceConfig = {
...booking,
priceConfig,
};
console.log(`🤖 Automatische Aktionen für Buchung ${booking.bookingNumber}...`);
let quotationPdf: Buffer | null = null;
let contractPdf: Buffer | null = null;
// 1. LexOffice Contact + Quotation erstellen
try {
console.log(' 💼 Erstelle LexOffice-Kontakt und Angebot...');
const contactId = await lexofficeService.createContactFromBooking(bookingWithPriceConfig);
console.log(` ✅ LexOffice-Kontakt erstellt: ${contactId}`);
const quotationId = await lexofficeService.createQuotationFromBooking(bookingWithPriceConfig, contactId);
console.log(` ✅ LexOffice-Angebot erstellt: ${quotationId}`);
await prisma.booking.update({
where: { id: booking.id },
data: {
lexofficeContactId: contactId,
lexofficeOfferId: quotationId,
},
});
quotationPdf = await lexofficeService.getQuotationPDF(quotationId);
console.log(' ✅ Angebots-PDF heruntergeladen');
lexofficeCreated = true;
} catch (error: any) {
console.error(' ❌ LexOffice Fehler:', error.message);
errors.push(`LexOffice: ${error.message}`);
}
// 2. Mietvertrag-PDF generieren
try {
console.log(' 📄 Generiere Mietvertrag-PDF...');
contractPdf = await generateContractFromTemplate(
bookingWithPriceConfig,
booking.location,
booking.photobox
);
await prisma.booking.update({
where: { id: booking.id },
data: {
contractGenerated: true,
contractGeneratedAt: new Date(),
},
});
console.log(' ✅ Mietvertrag-PDF generiert');
contractGenerated = true;
} catch (error: any) {
console.error(' ❌ PDF-Generierung Fehler:', error.message);
errors.push(`PDF: ${error.message}`);
}
// 3. E-Mail mit Angebot + Vertrag versenden
if (quotationPdf && contractPdf) {
try {
console.log(' 📧 Sende E-Mail mit Angebot und Vertrag...');
await sendInitialBookingEmail(bookingWithPriceConfig, quotationPdf, contractPdf);
await prisma.booking.update({
where: { id: booking.id },
data: {
contractSentAt: new Date(),
},
});
emailSent = true;
console.log(' ✅ E-Mail gesendet');
} catch (error: any) {
console.error(' ❌ E-Mail Fehler:', error.message);
errors.push(`E-Mail: ${error.message}`);
}
} else {
console.warn(' ⚠️ E-Mail nicht gesendet - PDFs fehlen');
errors.push('E-Mail: PDFs nicht verfügbar');
}
// 4. Automatischer Nextcloud Kalender-Sync
try {
console.log(' 📅 Synchronisiere mit Nextcloud-Kalender...');
await nextcloudCalendar.syncBookingToCalendar(booking);
await prisma.booking.update({
where: { id: booking.id },
data: {
calendarSynced: true,
calendarSyncedAt: new Date(),
},
});
calendarSynced = true;
console.log(' ✅ Kalender synchronisiert');
} catch (error: any) {
console.error(' ❌ Kalender-Sync Fehler:', error.message);
errors.push(`Kalender: ${error.message}`);
}
// 5. Admin-Benachrichtigung erstellen
try {
await prisma.notification.create({
data: {
type: 'NEW_BOOKING',
title: 'Neue Buchungsanfrage',
message: `${booking.customerName} hat eine ${booking.photobox?.model || 'Fotobox'} für ${booking.eventCity} am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} angefragt.`,
metadata: {
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
},
},
});
console.log(' ✅ Admin-Benachrichtigung erstellt');
} catch (error: any) {
console.error(' ❌ Notification Fehler:', error.message);
}
console.log(`✅ Automatische Aktionen abgeschlossen (${errors.length} Fehler)`);
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
} catch (error: any) {
console.error('❌ Booking Automation Fehler:', error);
errors.push(error.message);
return { emailSent, calendarSynced, lexofficeCreated, contractGenerated, errors };
}
}
}
export const bookingAutomationService = new BookingAutomationService();

View File

@@ -0,0 +1,88 @@
interface Coordinates {
lat: number;
lon: number;
}
interface DistanceResult {
distance: number;
duration: number;
}
export class DistanceCalculator {
private static readonly OSRM_API = 'https://router.project-osrm.org/route/v1/driving';
static async geocodeAddress(address: string): Promise<Coordinates | null> {
try {
const nominatimUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`;
const response = await fetch(nominatimUrl, {
headers: {
'User-Agent': 'SaveTheMoment-Atlas/1.0',
},
});
if (!response.ok) {
throw new Error(`Geocoding failed: ${response.statusText}`);
}
const data = await response.json();
if (!data || data.length === 0) {
console.warn(`No results found for address: ${address}`);
return null;
}
return {
lat: parseFloat(data[0].lat),
lon: parseFloat(data[0].lon),
};
} catch (error) {
console.error('Geocoding error:', error);
return null;
}
}
static async calculateDistance(
fromAddress: string,
toAddress: string
): Promise<DistanceResult | null> {
try {
const fromCoords = await this.geocodeAddress(fromAddress);
const toCoords = await this.geocodeAddress(toAddress);
if (!fromCoords || !toCoords) {
console.error('Failed to geocode one or both addresses');
return null;
}
const url = `${this.OSRM_API}/${fromCoords.lon},${fromCoords.lat};${toCoords.lon},${toCoords.lat}?overview=false`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`OSRM API failed: ${response.statusText}`);
}
const data = await response.json();
if (!data.routes || data.routes.length === 0) {
console.error('No route found');
return null;
}
const route = data.routes[0];
return {
distance: Math.round(route.distance / 1000 * 100) / 100,
duration: Math.round(route.duration / 60),
};
} catch (error) {
console.error('Distance calculation error:', error);
return null;
}
}
static formatAddress(address: string, zip: string, city: string): string {
return `${address}, ${zip} ${city}`.trim();
}
}

View File

@@ -2,34 +2,25 @@ import nodemailer from 'nodemailer';
import path from 'path';
import { readFile } from 'fs/promises';
let transporter: nodemailer.Transporter | null = null;
interface LocationSmtpConfig {
host: string;
port: number;
user: string;
password: string;
secure: boolean;
from: string;
}
function getTransporter() {
if (transporter) return transporter;
const smtpHost = process.env.SMTP_HOST;
const smtpPort = parseInt(process.env.SMTP_PORT || '587');
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
const smtpFrom = process.env.SMTP_FROM || 'noreply@savethemoment.photos';
if (!smtpHost || !smtpUser || !smtpPass) {
console.warn('⚠️ SMTP credentials not configured. Email sending disabled.');
throw new Error('SMTP not configured');
}
transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpPort === 465,
function createTransporter(config: LocationSmtpConfig) {
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: smtpUser,
pass: smtpPass,
user: config.user,
pass: config.password,
},
});
console.log('✅ SMTP transporter initialized');
return transporter;
}
interface SendEmailOptions {
@@ -37,6 +28,7 @@ interface SendEmailOptions {
subject: string;
text: string;
html: string;
smtpConfig: LocationSmtpConfig;
attachments?: {
filename: string;
content?: Buffer;
@@ -45,21 +37,59 @@ interface SendEmailOptions {
}
export async function sendEmail(options: SendEmailOptions) {
const emailEnabled = process.env.EMAIL_ENABLED !== 'false';
const testMode = process.env.TEST_MODE === 'true';
const testRecipient = process.env.TEST_EMAIL_RECIPIENT;
// E-Mail komplett deaktiviert
if (!emailEnabled) {
console.log('📧 [EMAIL DISABLED] E-Mail würde gesendet an:', options.to);
console.log(' Betreff:', options.subject);
console.log(' Von:', options.smtpConfig.from);
console.log(' ⚠️ EMAIL_ENABLED=false - Kein echter Versand!');
return { success: true, messageId: 'test-disabled', mode: 'disabled' };
}
// Test-Modus: Umleitung an Test-E-Mail
let actualRecipient = options.to;
if (testMode && testRecipient) {
console.log('🧪 [TEST MODE] E-Mail umgeleitet!');
console.log(' Original-Empfänger:', options.to);
console.log(' Test-Empfänger:', testRecipient);
actualRecipient = testRecipient;
// Füge Hinweis in Betreff ein
options.subject = `[TEST] ${options.subject}`;
// Füge Hinweis in E-Mail ein
options.html = `
<div style="background: #FEF3C7; border: 2px solid #F59E0B; padding: 15px; margin-bottom: 20px; border-radius: 8px;">
<p style="margin: 0; color: #92400E; font-weight: bold;">🧪 TEST-MODUS AKTIV</p>
<p style="margin: 5px 0 0 0; color: #92400E; font-size: 14px;">
Diese E-Mail wäre ursprünglich an <strong>${options.to}</strong> gegangen.
</p>
</div>
${options.html}
`;
}
try {
const transport = getTransporter();
const from = process.env.SMTP_FROM || 'SaveTheMoment <noreply@savethemoment.photos>';
const transport = createTransporter(options.smtpConfig);
const info = await transport.sendMail({
from,
to: options.to,
from: options.smtpConfig.from,
to: actualRecipient,
subject: options.subject,
text: options.text,
html: options.html,
attachments: options.attachments,
});
console.log('✅ Email sent:', info.messageId);
return { success: true, messageId: info.messageId };
console.log('✅ Email sent:', info.messageId, 'from:', options.smtpConfig.from);
if (testMode) {
console.log(' 🧪 TEST MODE - E-Mail an:', actualRecipient, '(Original:', options.to, ')');
}
return { success: true, messageId: info.messageId, mode: testMode ? 'test' : 'production' };
} catch (error: any) {
console.error('❌ Email send error:', error);
throw error;
@@ -70,6 +100,21 @@ export async function sendContractEmail(
booking: any,
contractPdfPath: string
) {
const location = booking.location;
if (!location || !location.smtpHost || !location.smtpPassword) {
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
}
const smtpConfig: LocationSmtpConfig = {
host: location.smtpHost,
port: location.smtpPort || 465,
user: location.smtpUser || location.contactEmail,
password: location.smtpPassword,
secure: location.smtpSecure !== false,
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
};
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
@@ -130,14 +175,14 @@ export async function sendContractEmail(
</head>
<body>
<div class="header">
<h1>🎉 SaveTheMoment</h1>
<h1>🎉 SaveTheMoment ${location.name}</h1>
<p>Ihr Mietvertrag ist bereit!</p>
</div>
<div class="content">
<p>Hallo ${booking.customerName},</p>
<p>vielen Dank für Ihre Buchung bei SaveTheMoment! Wir freuen uns sehr, Teil Ihres besonderen Anlasses zu sein.</p>
<p>vielen Dank für Ihre Buchung bei SaveTheMoment ${location.name}! Wir freuen uns sehr, Teil Ihres besonderen Anlasses zu sein.</p>
<div class="details">
<h3>📋 Buchungsdetails</h3>
@@ -171,13 +216,13 @@ export async function sendContractEmail(
<p>Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung!</p>
<p>Mit freundlichen Grüßen<br>
Ihr SaveTheMoment Team</p>
Ihr SaveTheMoment Team ${location.name}</p>
</div>
<div class="footer">
<p>SaveTheMoment Fotoboxen<br>
E-Mail: info@savethemoment.photos<br>
Web: www.savethemoment.photos</p>
<p>SaveTheMoment ${location.name}<br>
E-Mail: ${location.contactEmail}<br>
Web: ${location.websiteUrl || 'www.savethemoment.photos'}</p>
<p style="color: #999; font-size: 11px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht direkt auf diese E-Mail.
</p>
@@ -189,7 +234,7 @@ export async function sendContractEmail(
const text = `
Hallo ${booking.customerName},
vielen Dank für Ihre Buchung bei SaveTheMoment!
vielen Dank für Ihre Buchung bei SaveTheMoment ${location.name}!
Buchungsdetails:
- Buchungsnummer: ${booking.bookingNumber}
@@ -206,12 +251,12 @@ Oder drucken Sie ihn aus und senden Sie ihn uns zurück.
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
Mit freundlichen Grüßen
Ihr SaveTheMoment Team
Ihr SaveTheMoment Team ${location.name}
---
SaveTheMoment Fotoboxen
E-Mail: info@savethemoment.photos
Web: www.savethemoment.photos
SaveTheMoment ${location.name}
E-Mail: ${location.contactEmail}
Web: ${location.websiteUrl || 'www.savethemoment.photos'}
`.trim();
let pdfBuffer: Buffer;
@@ -227,6 +272,7 @@ Web: www.savethemoment.photos
subject,
text,
html,
smtpConfig,
attachments: [
{
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
@@ -236,7 +282,234 @@ Web: www.savethemoment.photos
});
}
export async function sendInitialBookingEmail(
booking: any,
quotationPdf: Buffer,
contractPdf: Buffer
) {
const location = booking.location;
if (!location || !location.smtpHost || !location.smtpPassword) {
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
}
const smtpConfig: LocationSmtpConfig = {
host: location.smtpHost,
port: location.smtpPort || 465,
user: location.smtpUser || location.contactEmail,
password: location.smtpPassword,
secure: location.smtpSecure !== false,
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
};
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
const subject = `Ihre Anfrage bei SaveTheMoment ${location.name} - ${booking.bookingNumber}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #DC2626 0%, #EC4899 100%);
color: white;
padding: 30px;
text-align: center;
border-radius: 10px 10px 0 0;
}
.content {
background: #f9fafb;
padding: 30px;
border-radius: 0 0 10px 10px;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #DC2626 0%, #EC4899 100%);
color: white;
padding: 15px 30px;
text-decoration: none;
border-radius: 8px;
margin: 20px 0;
font-weight: bold;
}
.details {
background: white;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #DC2626;
}
.price-box {
background: #FEF3C7;
border: 2px solid #F59E0B;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
text-align: center;
}
.footer {
text-align: center;
color: #666;
font-size: 12px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
</style>
</head>
<body>
<div class="header">
<h1>🎉 SaveTheMoment ${location.name}</h1>
<p>Vielen Dank für Ihre Anfrage!</p>
</div>
<div class="content">
<p>Hallo ${booking.customerName},</p>
<p>herzlichen Dank für Ihre Anfrage! Wir freuen uns sehr, dass Sie sich für SaveTheMoment entschieden haben.</p>
<div class="details">
<h3>📋 Ihre Buchungsdetails</h3>
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
<p><strong>Event-Datum:</strong> ${new Date(booking.eventDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}</p>
<p><strong>Event-Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
<p><strong>Fotobox:</strong> ${booking.photobox?.model || 'N/A'}</p>
${booking.distance ? `<p><strong>Entfernung:</strong> ${booking.distance.toFixed(1)} km (einfach)</p>` : ''}
</div>
${booking.calculatedPrice ? `
<div class="price-box">
<h3 style="margin: 0 0 10px 0; color: #92400E;">💰 Gesamtpreis</h3>
<p style="font-size: 28px; font-weight: bold; margin: 0; color: #92400E;">
${booking.calculatedPrice.toFixed(2)}
</p>
<p style="font-size: 14px; margin: 5px 0 0 0; color: #92400E;">inkl. 19% MwSt.</p>
</div>
` : ''}
<p><strong>📎 Im Anhang finden Sie:</strong></p>
<ol>
<li><strong>Ihr persönliches Angebot</strong> mit allen Details und Positionen</li>
<li><strong>Ihren Mietvertrag</strong> zum Durchlesen</li>
</ol>
<p><strong>✅ Nächste Schritte:</strong></p>
<ol>
<li>Prüfen Sie bitte das Angebot und den Mietvertrag</li>
<li>Signieren Sie den Vertrag online oder laden Sie ihn unterschrieben hoch</li>
<li>Nach Ihrer Unterschrift wird Ihre Buchung verbindlich bestätigt</li>
</ol>
<center>
<a href="${signUrl}" class="button">
✍️ Vertrag jetzt online signieren
</a>
</center>
<p>Alternativ können Sie den Vertrag auch ausdrucken, unterschreiben und uns per E-Mail zurücksenden.</p>
<p><strong>Haben Sie Fragen oder Änderungswünsche?</strong><br>
Antworten Sie einfach auf diese E-Mail wir sind für Sie da!</p>
<p>Mit freundlichen Grüßen<br>
Ihr SaveTheMoment Team ${location.name}</p>
</div>
<div class="footer">
<p>SaveTheMoment ${location.name}<br>
E-Mail: ${location.contactEmail}<br>
Web: ${location.websiteUrl || 'www.savethemoment.photos'}</p>
<p style="color: #999; font-size: 11px;">
Diese E-Mail wurde automatisch generiert.
</p>
</div>
</body>
</html>
`.trim();
const text = `
Hallo ${booking.customerName},
vielen Dank für Ihre Anfrage bei SaveTheMoment ${location.name}!
Buchungsdetails:
- Buchungsnummer: ${booking.bookingNumber}
- Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
- Location: ${booking.eventLocation || booking.eventAddress}
- Fotobox: ${booking.photobox?.model || 'N/A'}
${booking.distance ? `- Entfernung: ${booking.distance.toFixed(1)} km` : ''}
${booking.calculatedPrice ? `\nGesamtpreis: ${booking.calculatedPrice.toFixed(2)} € (inkl. 19% MwSt.)` : ''}
Im Anhang finden Sie:
1. Ihr persönliches Angebot
2. Ihren Mietvertrag
Nächste Schritte:
1. Prüfen Sie bitte das Angebot und den Mietvertrag
2. Signieren Sie den Vertrag online: ${signUrl}
3. Nach Ihrer Unterschrift wird Ihre Buchung verbindlich bestätigt
Bei Fragen stehen wir Ihnen jederzeit zur Verfügung!
Mit freundlichen Grüßen
Ihr SaveTheMoment Team ${location.name}
---
SaveTheMoment ${location.name}
E-Mail: ${location.contactEmail}
Web: ${location.websiteUrl || 'www.savethemoment.photos'}
`.trim();
return sendEmail({
to: booking.customerEmail,
subject,
text,
html,
smtpConfig,
attachments: [
{
filename: `Angebot_${booking.bookingNumber}.pdf`,
content: quotationPdf,
},
{
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
content: contractPdf,
},
],
});
}
export async function sendBookingConfirmationEmail(booking: any) {
const location = booking.location;
if (!location || !location.smtpHost || !location.smtpPassword) {
throw new Error(`SMTP not configured for location: ${location?.name || 'Unknown'}`);
}
const smtpConfig: LocationSmtpConfig = {
host: location.smtpHost,
port: location.smtpPort || 465,
user: location.smtpUser || location.contactEmail,
password: location.smtpPassword,
secure: location.smtpSecure !== false,
from: `SaveTheMoment ${location.name} <${location.contactEmail}>`,
};
const subject = `Buchungsbestätigung - ${booking.bookingNumber}`;
const html = `
@@ -257,7 +530,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
</div>
<div class="content">
<p>Hallo ${booking.customerName},</p>
<p>Ihre Buchung wurde erfolgreich bestätigt!</p>
<p>Ihre Buchung bei SaveTheMoment ${location.name} wurde erfolgreich bestätigt!</p>
<div class="details">
<h3>Buchungsdetails</h3>
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
@@ -265,7 +538,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
<p><strong>Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
</div>
<p>Wir freuen uns auf Ihr Event!</p>
<p>Mit freundlichen Grüßen<br>Ihr SaveTheMoment Team</p>
<p>Mit freundlichen Grüßen<br>Ihr SaveTheMoment Team ${location.name}</p>
</div>
</body>
</html>
@@ -274,7 +547,7 @@ export async function sendBookingConfirmationEmail(booking: any) {
const text = `
Hallo ${booking.customerName},
Ihre Buchung wurde erfolgreich bestätigt!
Ihre Buchung bei SaveTheMoment ${location.name} wurde erfolgreich bestätigt!
Buchungsnummer: ${booking.bookingNumber}
Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
@@ -283,7 +556,7 @@ Location: ${booking.eventLocation || booking.eventAddress}
Wir freuen uns auf Ihr Event!
Mit freundlichen Grüßen
Ihr SaveTheMoment Team
Ihr SaveTheMoment Team ${location.name}
`;
return sendEmail({
@@ -291,5 +564,6 @@ Ihr SaveTheMoment Team
subject,
text,
html,
smtpConfig,
});
}

View File

@@ -2,6 +2,7 @@ import Imap from 'imap';
import { simpleParser } from 'mailparser';
import { prisma } from './prisma';
import { emailParser } from './email-parser';
import { bookingAutomationService } from './booking-automation';
export interface ImapConfig {
host: string;
@@ -335,6 +336,12 @@ export class EmailSyncService {
},
});
// 🤖 Automatische Post-Booking Aktionen
console.log('📢 Starte automatische Aktionen...');
bookingAutomationService.runPostBookingActions(booking.id).catch(err => {
console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err);
});
return booking;
} catch (error) {
console.error('Failed to create booking from parsed data:', error);

View File

@@ -146,14 +146,22 @@ export class LexOfficeService {
return this.request('GET', `/contacts/${contactId}`);
}
async createQuotation(quotation: LexOfficeQuotation): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
return this.request('POST', '/quotations', quotation);
async createQuotation(quotation: LexOfficeQuotation, finalize: boolean = false): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
const url = finalize ? '/quotations?finalize=true' : '/quotations';
console.log(`📍 Creating quotation with URL: ${this.baseUrl}${url}, finalize=${finalize}`);
return this.request('POST', url, quotation);
}
async getQuotation(quotationId: string): Promise<LexOfficeQuotation> {
return this.request('GET', `/quotations/${quotationId}`);
}
async finalizeQuotation(quotationId: string): Promise<{ id: string; resourceUri: string }> {
return this.request('PUT', `/quotations/${quotationId}/pursue`, {
precedingSalesVoucherId: null,
});
}
async createInvoice(invoice: LexOfficeInvoice): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
return this.request('POST', '/invoices', invoice);
}
@@ -169,6 +177,52 @@ export class LexOfficeService {
});
}
async getQuotationPDF(quotationId: string): Promise<Buffer> {
try {
const response = await fetch(`${this.baseUrl}/quotations/${quotationId}/document`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Accept': 'application/pdf',
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('LexOffice PDF download failed:', error);
throw error;
}
}
async getInvoicePDF(invoiceId: string): Promise<Buffer> {
try {
const response = await fetch(`${this.baseUrl}/invoices/${invoiceId}/document`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Accept': 'application/pdf',
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`LexOffice PDF Error: ${response.status} - ${errorText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
console.error('LexOffice PDF download failed:', error);
throw error;
}
}
async createContactFromBooking(booking: any): Promise<string> {
const contact: Partial<LexOfficeContact> = {
roles: {
@@ -191,21 +245,26 @@ export class LexOfficeService {
};
if (booking.invoiceType === 'BUSINESS' && booking.companyName) {
const nameParts = booking.customerName.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Ansprechpartner';
contact.company = {
name: booking.companyName,
contactPersons: [{
firstName: booking.customerName.split(' ')[0],
lastName: booking.customerName.split(' ').slice(1).join(' '),
firstName: firstName,
lastName: lastName,
primary: true,
emailAddress: booking.customerEmail,
phoneNumber: booking.customerPhone,
}],
};
} else {
const [firstName, ...lastNameParts] = booking.customerName.split(' ');
const nameParts = booking.customerName.trim().split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : nameParts[0] || 'Unbekannt';
contact.person = {
firstName: firstName,
lastName: lastNameParts.join(' '),
lastName: lastName,
};
}
@@ -214,46 +273,203 @@ export class LexOfficeService {
}
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
const quotation: LexOfficeQuotation = {
voucherDate: new Date().toISOString().split('T')[0],
address: {
contactId: contactId,
countryCode: 'DE',
const lineItems: Array<any> = [];
console.log('📊 Booking Data:', {
id: booking.id,
bookingNumber: booking.bookingNumber,
hasPhotobox: !!booking.photobox,
photoboxModel: booking.photobox?.model,
locationId: booking.locationId,
withPrintFlat: booking.withPrintFlat,
});
// WICHTIG: priceConfig separat laden, da keine direkte Relation existiert
let priceConfig = booking.priceConfig;
if (!priceConfig && booking.photobox?.model && booking.locationId) {
console.log('🔍 Lade PriceConfig nach...');
const { prisma } = await import('./prisma');
priceConfig = await prisma.priceConfig.findUnique({
where: {
locationId_model: {
locationId: booking.locationId,
model: booking.photobox.model,
},
},
});
console.log('📊 PriceConfig geladen:', priceConfig);
}
if (!priceConfig) {
throw new Error('Keine Preiskonfiguration gefunden. Bitte konfiguriere zuerst die Preise für dieses Fotobox-Modell.');
}
// 1. Fotobox als Hauptposition (IMMER als custom lineItem)
const withPrintFlat = booking.withPrintFlat !== false; // Default: true
const boxName = booking.photobox?.model || 'Fotobox';
const flatSuffix = withPrintFlat ? ' mit Druckflatrate' : ' (nur digital)';
const photoboxItem: any = {
type: 'custom',
name: `${boxName}${flatSuffix}`,
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
quantity: 1,
unitName: 'Stück',
unitPrice: {
currency: 'EUR',
netAmount: priceConfig?.basePrice || 1,
taxRatePercentage: 19,
},
lineItems: [
{
};
lineItems.push(photoboxItem);
console.log('📦 Photobox LineItem (custom):', photoboxItem);
// 2. Kilometerpauschale (falls Distanz vorhanden)
if (booking.distance && booking.distance > 0) {
// 2a. Kilometer-Pauschale (bis X km) - IMMER als custom lineItem
if (priceConfig?.kmFlatRate && priceConfig.kmFlatRate > 0) {
const kmFlatItem: any = {
type: 'custom',
name: 'Fotobox-Vermietung',
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
name: `Kilometerpauschale (bis ${priceConfig.kmFlatRateUpTo}km)`,
description: `Entfernung: ${booking.distance.toFixed(1)}km`,
quantity: 1,
unitName: 'Pauschale',
unitPrice: {
currency: 'EUR',
netAmount: priceConfig.kmFlatRate / 1.19,
taxRatePercentage: 19,
},
};
lineItems.push(kmFlatItem);
}
// 2b. Zusätzliche Kilometer (wenn über Flatrate) - IMMER als custom lineItem
if (priceConfig && booking.distance > priceConfig.kmFlatRateUpTo) {
const extraKm = booking.distance - priceConfig.kmFlatRateUpTo;
const totalExtraKm = extraKm * priceConfig.kmMultiplier;
const kmExtraItem: any = {
type: 'custom',
name: `Zusatzkilometer`,
description: `${extraKm.toFixed(1)}km × ${priceConfig.kmMultiplier} Strecken = ${totalExtraKm.toFixed(1)}km`,
quantity: totalExtraKm,
unitName: 'km',
unitPrice: {
currency: 'EUR',
netAmount: priceConfig.pricePerKm,
taxRatePercentage: 19,
},
};
lineItems.push(kmExtraItem);
}
}
// 3. Equipment/Extras (wenn vorhanden) - IMMER als custom lineItem
if (booking.bookingEquipment && booking.bookingEquipment.length > 0) {
for (const eq of booking.bookingEquipment) {
const equipmentItem: any = {
type: 'custom',
name: eq.equipment.name,
quantity: eq.quantity || 1,
unitName: 'Stück',
unitPrice: {
currency: 'EUR',
netAmount: booking.calculatedPrice || 0,
netAmount: eq.equipment.price || 1,
taxRatePercentage: 19,
},
},
],
};
lineItems.push(equipmentItem);
}
}
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const isDST = (date: Date) => {
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
return Math.max(jan, jul) !== date.getTimezoneOffset();
};
const timezone = isDST(now) ? '+02:00' : '+01:00';
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
const expirationDateObj = new Date(now);
expirationDateObj.setDate(expirationDateObj.getDate() + 14);
const expYear = expirationDateObj.getFullYear();
const expMonth = String(expirationDateObj.getMonth() + 1).padStart(2, '0');
const expDay = String(expirationDateObj.getDate()).padStart(2, '0');
const expirationDate = `${expYear}-${expMonth}-${expDay}T00:00:00.000${timezone}`;
const quotation: LexOfficeQuotation = {
voucherDate,
expirationDate,
address: {
contactId: contactId,
name: booking.customerName,
street: booking.customerAddress || undefined,
zip: booking.customerZip || undefined,
city: booking.customerCity || undefined,
countryCode: 'DE',
},
lineItems,
totalPrice: {
currency: 'EUR',
},
taxConditions: {
taxType: 'net',
},
title: `Angebot Fotobox-Vermietung - ${booking.bookingNumber}`,
introduction: 'Vielen Dank für Ihre Anfrage! Gerne erstellen wir Ihnen folgendes Angebot:',
remark: 'Wir freuen uns auf Ihre Bestellung!',
};
const result = await this.createQuotation(quotation);
console.log('📤 Sending to LexOffice:', JSON.stringify(quotation, null, 2));
// Schritt 1: Erstelle Quotation als Draft
const result = await this.createQuotation(quotation, false);
console.log('✅ Quotation created (draft):', result.id);
// Schritt 2: Finalisiere Quotation sofort
try {
console.log('🔄 Finalizing quotation...');
await this.finalizeQuotation(result.id);
console.log('✅ Quotation finalized to OPEN status');
} catch (error: any) {
console.error('⚠️ Quotation finalization failed:', error.message);
console.log(' Quotation bleibt im DRAFT status');
}
return result.id;
}
async createConfirmationFromBooking(booking: any, contactId: string): Promise<string> {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const isDST = (date: Date) => {
const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset();
const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
return Math.max(jan, jul) !== date.getTimezoneOffset();
};
const timezone = isDST(now) ? '+02:00' : '+01:00';
const voucherDate = `${year}-${month}-${day}T00:00:00.000${timezone}`;
const invoice: LexOfficeInvoice = {
voucherDate: new Date().toISOString().split('T')[0],
voucherDate,
address: {
contactId: contactId,
name: booking.customerName,
countryCode: 'DE',
},
lineItems: [
@@ -276,7 +492,7 @@ export class LexOfficeService {
taxConditions: {
taxType: 'net',
},
title: `Auftragsbestätigung - ${booking.bookingNumber}`,
title: `AB ${booking.bookingNumber}`,
introduction: 'Vielen Dank für Ihre Bestellung! Hiermit bestätigen wir Ihren Auftrag:',
remark: 'Wir freuen uns auf Ihre Veranstaltung!',
shippingConditions: {

View File

@@ -13,19 +13,46 @@ export interface CalendarEvent {
export class NextcloudCalendarService {
private client: any;
private initialized: boolean = false;
private initPromise: Promise<void> | null = null;
async initialize() {
if (this.initialized) return;
// Wenn bereits am Initialisieren, warte auf Abschluss
if (this.initPromise) {
return this.initPromise;
}
// Wenn bereits initialisiert, fertig
if (this.initialized && this.client) {
return Promise.resolve();
}
// Neue Initialisierung starten
this.initPromise = this._doInitialize();
try {
await this.initPromise;
} finally {
this.initPromise = null;
}
}
private async _doInitialize() {
const serverUrl = process.env.NEXTCLOUD_URL;
const username = process.env.NEXTCLOUD_USERNAME;
const password = process.env.NEXTCLOUD_PASSWORD;
console.log('🔍 Nextcloud credentials check:');
console.log(' URL:', serverUrl);
console.log(' Username:', username);
console.log(' Password length:', password?.length, 'chars');
if (!serverUrl || !username || !password) {
throw new Error('Nextcloud credentials not configured');
}
try {
console.log('⏳ Creating Nextcloud CalDAV client...');
this.client = await createDAVClient({
serverUrl: `${serverUrl}/remote.php/dav`,
credentials: {
@@ -37,10 +64,12 @@ export class NextcloudCalendarService {
});
this.initialized = true;
console.log('✅ Nextcloud CalDAV client initialized');
} catch (error) {
console.log('✅ Nextcloud CalDAV client initialized successfully');
} catch (error: any) {
console.error('❌ Failed to initialize Nextcloud CalDAV client:', error);
throw error;
this.initialized = false;
this.client = null;
throw new Error(`Nextcloud initialization failed: ${error.message}`);
}
}
@@ -120,22 +149,33 @@ export class NextcloudCalendarService {
throw new Error('No calendars found in Nextcloud');
}
const calendar = calendars[0];
// Suche nach "Buchungen" Kalender, sonst verwende ersten
let calendar = calendars.find((cal: any) =>
cal.displayName?.toLowerCase().includes('buchung')
);
if (!calendar) {
console.warn('⚠️ Kein "Buchungen"-Kalender gefunden, verwende:', calendars[0].displayName);
calendar = calendars[0];
} else {
console.log('✅ Verwende Kalender:', calendar.displayName);
}
const event: CalendarEvent = {
uid: `savethemoment-booking-${booking.id}`,
summary: `${booking.customerName} - ${booking.location?.name || 'Unbekannt'}`,
description: `
Buchung #${booking.id}
Buchung #${booking.bookingNumber || booking.id}
Kunde: ${booking.customerName}
E-Mail: ${booking.customerEmail}
Telefon: ${booking.customerPhone || 'N/A'}
Event-Typ: ${booking.eventType}
Event-Location: ${booking.eventLocation || booking.eventAddress}
Status: ${booking.status}
Fotobox: ${booking.photobox?.name || 'Keine Box'}
Fotobox: ${booking.photobox?.model || 'Keine Box'}
Standort: ${booking.location?.name || 'Unbekannt'}
Preis: ${booking.calculatedPrice || 0}
`.trim(),
location: booking.location?.address || '',
location: `${booking.eventAddress || ''}, ${booking.eventZip || ''} ${booking.eventCity || ''}`.trim(),
startDate: new Date(booking.eventDate),
endDate: new Date(new Date(booking.eventDate).getTime() + 4 * 60 * 60 * 1000),
status: booking.status,
@@ -143,10 +183,12 @@ Standort: ${booking.location?.name || 'Unbekannt'}
try {
await this.createEvent(calendar.url, event);
console.log('✅ Event in Nextcloud erstellt:', event.summary);
return event;
} catch (error) {
} catch (error: any) {
if (error.message?.includes('already exists') || error.response?.status === 412) {
await this.updateEvent(calendar.url, event);
console.log('✅ Event in Nextcloud aktualisiert:', event.summary);
return event;
}
throw error;
@@ -162,11 +204,20 @@ Standort: ${booking.location?.name || 'Unbekannt'}
throw new Error('No calendars found in Nextcloud');
}
const calendar = calendars[0];
// Suche nach "Buchungen" Kalender, sonst verwende ersten
let calendar = calendars.find((cal: any) =>
cal.displayName?.toLowerCase().includes('buchung')
);
if (!calendar) {
calendar = calendars[0];
}
const eventUid = `savethemoment-booking-${bookingId}`;
try {
await this.deleteEvent(calendar.url, eventUid);
console.log('✅ Event aus Nextcloud gelöscht:', eventUid);
} catch (error) {
console.error('Error removing booking from calendar:', error);
}

108
lib/price-calculator.ts Normal file
View File

@@ -0,0 +1,108 @@
interface PriceConfig {
basePrice: number;
kmFlatRate: number;
kmFlatRateUpTo: number;
pricePerKm: number;
kmMultiplier: number;
}
interface PriceBreakdown {
basePrice: number;
kmFlatRate: number;
kmAdditionalNet: number;
kmAdditionalGross: number;
kmTotalGross: number;
totalPrice: number;
distance?: number;
}
export class PriceCalculator {
private static readonly VAT_RATE = 1.19;
static calculateKmCharge(
distance: number,
config: PriceConfig
): {
kmTotalGross: number;
kmFlatRate: number;
kmAdditionalNet: number;
kmAdditionalGross: number;
} {
const { kmFlatRate, kmFlatRateUpTo, pricePerKm, kmMultiplier } = config;
if (distance <= kmFlatRateUpTo) {
return {
kmTotalGross: kmFlatRate,
kmFlatRate: kmFlatRate,
kmAdditionalNet: 0,
kmAdditionalGross: 0,
};
}
const extraKm = distance - kmFlatRateUpTo;
const kmAdditionalNet = extraKm * kmMultiplier * pricePerKm;
const kmAdditionalGross = Math.round(kmAdditionalNet * this.VAT_RATE * 100) / 100;
const kmTotalGross = Math.round((kmFlatRate + kmAdditionalGross) * 100) / 100;
return {
kmTotalGross,
kmFlatRate,
kmAdditionalNet: Math.round(kmAdditionalNet * 100) / 100,
kmAdditionalGross,
};
}
static calculateTotalPrice(
basePrice: number,
distance: number | null | undefined,
config: PriceConfig
): PriceBreakdown {
if (!distance || distance <= 0) {
return {
basePrice,
kmFlatRate: 0,
kmAdditionalNet: 0,
kmAdditionalGross: 0,
kmTotalGross: 0,
totalPrice: basePrice,
};
}
const kmCharge = this.calculateKmCharge(distance, config);
const totalPrice = Math.round((basePrice + kmCharge.kmTotalGross) * 100) / 100;
return {
basePrice,
kmFlatRate: kmCharge.kmFlatRate,
kmAdditionalNet: kmCharge.kmAdditionalNet,
kmAdditionalGross: kmCharge.kmAdditionalGross,
kmTotalGross: kmCharge.kmTotalGross,
totalPrice,
distance,
};
}
static formatPriceBreakdown(breakdown: PriceBreakdown): string {
const lines = [
`Grundpreis Fotobox: ${breakdown.basePrice.toFixed(2)}`,
];
if (breakdown.distance && breakdown.distance > 0) {
lines.push(`\nKilometerpauschale (${breakdown.distance.toFixed(1)}km):`);
if (breakdown.kmFlatRate > 0) {
lines.push(` - Pauschale: ${breakdown.kmFlatRate.toFixed(2)}`);
}
if (breakdown.kmAdditionalGross > 0) {
lines.push(` - Zusätzlich: ${breakdown.kmAdditionalGross.toFixed(2)}`);
}
lines.push(` = Gesamt KM: ${breakdown.kmTotalGross.toFixed(2)}`);
}
lines.push(`\nGesamtpreis: ${breakdown.totalPrice.toFixed(2)}`);
return lines.join('\n');
}
}