Initial commit - SaveTheMoment Atlas Basis-Setup
This commit is contained in:
225
lib/ai-service.ts
Normal file
225
lib/ai-service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
});
|
||||
|
||||
interface ParsedBookingData {
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
customerAddress?: string;
|
||||
customerCity?: string;
|
||||
customerZip?: string;
|
||||
companyName?: string;
|
||||
invoiceType: 'PRIVATE' | 'BUSINESS';
|
||||
eventDate: string;
|
||||
eventAddress: string;
|
||||
eventCity: string;
|
||||
eventZip: string;
|
||||
eventLocation?: string;
|
||||
setupTimeStart: string;
|
||||
setupTimeLatest?: string;
|
||||
photoboxModel?: 'VINTAGE_SMILE' | 'VINTAGE_PHOTOS' | 'NOSTALGIE' | 'MAGIC_MIRROR';
|
||||
specialRequests?: string;
|
||||
}
|
||||
|
||||
export class AIService {
|
||||
async parseBookingEmail(emailContent: string, subject: string): Promise<{
|
||||
parsed: ParsedBookingData;
|
||||
responseDraft: string;
|
||||
confidence: number;
|
||||
}> {
|
||||
const prompt = `Du bist ein KI-Assistent für ein Fotobox-Vermietungsunternehmen.
|
||||
Analysiere die folgende E-Mail-Anfrage und extrahiere alle relevanten Buchungsinformationen.
|
||||
|
||||
E-Mail-Betreff: ${subject}
|
||||
|
||||
E-Mail-Inhalt:
|
||||
${emailContent}
|
||||
|
||||
Extrahiere folgende Informationen (falls vorhanden):
|
||||
- Kundenname
|
||||
- E-Mail-Adresse
|
||||
- Telefonnummer
|
||||
- Adresse (Straße, PLZ, Stadt)
|
||||
- Firmenname (falls Geschäftskunde)
|
||||
- Event-Datum (Format: YYYY-MM-DD)
|
||||
- Event-Uhrzeit (wann soll die Fotobox aufgebaut sein?)
|
||||
- Event-Adresse (Straße, PLZ, Stadt)
|
||||
- Event-Location-Name
|
||||
- Gewünschtes Fotobox-Modell
|
||||
- Besondere Wünsche
|
||||
|
||||
Antworte im folgenden JSON-Format:
|
||||
{
|
||||
"customerData": {
|
||||
"name": "string",
|
||||
"email": "string",
|
||||
"phone": "string",
|
||||
"address": "string",
|
||||
"city": "string",
|
||||
"zip": "string",
|
||||
"companyName": "string oder null",
|
||||
"invoiceType": "PRIVATE" oder "BUSINESS"
|
||||
},
|
||||
"eventData": {
|
||||
"date": "YYYY-MM-DD",
|
||||
"time": "HH:MM",
|
||||
"address": "string",
|
||||
"city": "string",
|
||||
"zip": "string",
|
||||
"locationName": "string",
|
||||
"photoboxModel": "VINTAGE_SMILE" | "VINTAGE_PHOTOS" | "NOSTALGIE" | "MAGIC_MIRROR" oder null,
|
||||
"specialRequests": "string"
|
||||
},
|
||||
"confidence": 0.0 bis 1.0 (wie sicher bist du bei der Extraktion?)
|
||||
}`;
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4-turbo-preview',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Du bist ein Experte für die Analyse von Buchungsanfragen. Antworte immer nur mit validem JSON.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const result = JSON.parse(completion.choices[0].message.content || '{}');
|
||||
|
||||
const parsed: ParsedBookingData = {
|
||||
customerName: result.customerData.name,
|
||||
customerEmail: result.customerData.email,
|
||||
customerPhone: result.customerData.phone,
|
||||
customerAddress: result.customerData.address,
|
||||
customerCity: result.customerData.city,
|
||||
customerZip: result.customerData.zip,
|
||||
companyName: result.customerData.companyName,
|
||||
invoiceType: result.customerData.invoiceType,
|
||||
eventDate: `${result.eventData.date}T${result.eventData.time || '00:00'}:00.000Z`,
|
||||
eventAddress: result.eventData.address,
|
||||
eventCity: result.eventData.city,
|
||||
eventZip: result.eventData.zip,
|
||||
eventLocation: result.eventData.locationName,
|
||||
setupTimeStart: `${result.eventData.date}T${result.eventData.time || '00:00'}:00.000Z`,
|
||||
photoboxModel: result.eventData.photoboxModel,
|
||||
specialRequests: result.eventData.specialRequests,
|
||||
};
|
||||
|
||||
const responseDraft = await this.generateResponseDraft(parsed, emailContent);
|
||||
|
||||
return {
|
||||
parsed,
|
||||
responseDraft,
|
||||
confidence: result.confidence,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('AI Email Parsing Error:', error);
|
||||
throw new Error('KI-Analyse fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
async generateResponseDraft(bookingData: ParsedBookingData, originalEmail: string): Promise<string> {
|
||||
const prompt = `Du bist Kundenservice-Mitarbeiter bei "Save the Moment" - einem Premium-Fotobox-Vermietungsservice.
|
||||
|
||||
Erstelle eine professionelle, freundliche Antwort-E-Mail für diese Buchungsanfrage:
|
||||
|
||||
Kundendaten:
|
||||
- Name: ${bookingData.customerName}
|
||||
- Event-Datum: ${new Date(bookingData.eventDate).toLocaleDateString('de-DE')}
|
||||
- Event-Ort: ${bookingData.eventCity}
|
||||
${bookingData.specialRequests ? `- Besondere Wünsche: ${bookingData.specialRequests}` : ''}
|
||||
|
||||
Die E-Mail soll:
|
||||
1. Freundlich und professionell sein
|
||||
2. Die Anfrage bestätigen
|
||||
3. Die wichtigsten Details zusammenfassen
|
||||
4. Erwähnen, dass ein Angebot und Mietvertrag im Anhang sind
|
||||
5. Nächste Schritte erklären (Vertrag digital unterschreiben)
|
||||
6. Kontaktdaten für Rückfragen nennen
|
||||
|
||||
Schreibe die E-Mail auf Deutsch, ohne Betreff-Zeile (nur Body).`;
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4-turbo-preview',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Du bist ein freundlicher, professioneller Kundenservice-Mitarbeiter.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 500,
|
||||
});
|
||||
|
||||
return completion.choices[0].message.content || '';
|
||||
} catch (error) {
|
||||
console.error('AI Response Generation Error:', error);
|
||||
return `Sehr geehrte/r ${bookingData.customerName},
|
||||
|
||||
vielen Dank für Ihre Anfrage für den ${new Date(bookingData.eventDate).toLocaleDateString('de-DE')} in ${bookingData.eventCity}.
|
||||
|
||||
Wir haben Ihre Anfrage erhalten und freuen uns, Ihnen unser Angebot sowie den Mietvertrag im Anhang zu senden.
|
||||
|
||||
Bitte prüfen Sie die Unterlagen und unterschreiben Sie den Vertrag digital über den Link im Anhang.
|
||||
|
||||
Bei Fragen stehen wir Ihnen gerne zur Verfügung.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr Save the Moment Team`;
|
||||
}
|
||||
}
|
||||
|
||||
async improveContractText(standardContract: string, bookingData: any): Promise<string> {
|
||||
const prompt = `Personalisiere den folgenden Standard-Mietvertrag mit den spezifischen Buchungsdaten:
|
||||
|
||||
STANDARD-VERTRAG:
|
||||
${standardContract}
|
||||
|
||||
BUCHUNGSDATEN:
|
||||
- Kunde: ${bookingData.customerName}
|
||||
- Event-Datum: ${new Date(bookingData.eventDate).toLocaleDateString('de-DE')}
|
||||
- Event-Ort: ${bookingData.eventAddress}, ${bookingData.eventZip} ${bookingData.eventCity}
|
||||
- Aufbauzeit: ${new Date(bookingData.setupTimeStart).toLocaleTimeString('de-DE')}
|
||||
|
||||
Füge die Daten in die Platzhalter ein und passe die Formulierungen an. Der Vertrag soll rechtlich bindend bleiben.`;
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4-turbo-preview',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Du bist ein Experte für Vertragsformulierungen. Achte auf korrekte Rechtschreibung und Grammatik.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
max_tokens: 2000,
|
||||
});
|
||||
|
||||
return completion.choices[0].message.content || standardContract;
|
||||
} catch (error) {
|
||||
console.error('AI Contract Generation Error:', error);
|
||||
return standardContract;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiService = new AIService();
|
||||
75
lib/auth.ts
Normal file
75
lib/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import { compare } from 'bcryptjs';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPasswordValid = await compare(
|
||||
credentials.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
return {
|
||||
...token,
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id,
|
||||
role: token.role,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
512
lib/contract-template.tsx
Normal file
512
lib/contract-template.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import React from 'react';
|
||||
import { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer';
|
||||
import { formatDate } from './date-utils';
|
||||
|
||||
const COLORS = {
|
||||
primary: '#5B9A9F',
|
||||
headerBg: '#5B9A9F',
|
||||
tableHeaderBg: '#B8E6E6',
|
||||
accent: '#5B9A9F',
|
||||
text: '#000000',
|
||||
textLight: '#666666',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 50,
|
||||
paddingHorizontal: 50,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Helvetica',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
headerBand: {
|
||||
backgroundColor: COLORS.headerBg,
|
||||
height: 80,
|
||||
marginLeft: -50,
|
||||
marginRight: -50,
|
||||
marginBottom: 30,
|
||||
paddingRight: 50,
|
||||
paddingTop: 15,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
logoSmall: {
|
||||
width: 100,
|
||||
height: 'auto',
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 15,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
marginBottom: 3,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
accentText: {
|
||||
color: COLORS.accent,
|
||||
},
|
||||
parties: {
|
||||
marginTop: 30,
|
||||
marginBottom: 25,
|
||||
},
|
||||
partyBlock: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
partyLabel: {
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
fontSize: 11,
|
||||
},
|
||||
partyText: {
|
||||
marginBottom: 4,
|
||||
fontSize: 10,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 10,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
table: {
|
||||
border: '1px solid #000',
|
||||
marginTop: 15,
|
||||
marginBottom: 20,
|
||||
},
|
||||
tableHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: COLORS.tableHeaderBg,
|
||||
borderBottom: '1px solid #000',
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottom: '1px solid #000',
|
||||
},
|
||||
tableRowLast: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tableCell: {
|
||||
padding: 10,
|
||||
borderRight: '1px solid #000',
|
||||
fontSize: 9.5,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
tableCellLast: {
|
||||
padding: 10,
|
||||
fontSize: 9.5,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
tableCellLeft: {
|
||||
width: '35%',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tableCellMiddle: {
|
||||
width: '45%',
|
||||
},
|
||||
tableCellRight: {
|
||||
width: '20%',
|
||||
textAlign: 'center',
|
||||
},
|
||||
tableCellFull: {
|
||||
width: '50%',
|
||||
},
|
||||
radioOption: {
|
||||
marginBottom: 5,
|
||||
},
|
||||
signatureSection: {
|
||||
marginTop: 40,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
signatureBox: {
|
||||
width: '48%',
|
||||
},
|
||||
signatureLabel: {
|
||||
fontSize: 10,
|
||||
marginBottom: 5,
|
||||
},
|
||||
signatureLine: {
|
||||
borderTop: '1px solid #000',
|
||||
marginTop: 50,
|
||||
paddingTop: 8,
|
||||
fontSize: 10,
|
||||
},
|
||||
signature: {
|
||||
marginTop: 10,
|
||||
marginBottom: 5,
|
||||
maxHeight: 50,
|
||||
},
|
||||
contactInfo: {
|
||||
fontSize: 8,
|
||||
marginTop: 2,
|
||||
},
|
||||
bulletPoint: {
|
||||
marginLeft: 20,
|
||||
marginBottom: 6,
|
||||
},
|
||||
numberedList: {
|
||||
marginLeft: 10,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyLineForSignature: {
|
||||
borderBottom: '1px solid #999',
|
||||
height: 1,
|
||||
marginTop: 40,
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
|
||||
interface ContractData {
|
||||
booking: any;
|
||||
location: any;
|
||||
photobox: any;
|
||||
signatureData?: string;
|
||||
signedBy?: string;
|
||||
signedAt?: Date;
|
||||
signedIp?: string;
|
||||
logoBase64?: string;
|
||||
}
|
||||
|
||||
export const ContractPDF = ({
|
||||
booking,
|
||||
location,
|
||||
photobox,
|
||||
signatureData,
|
||||
signedBy,
|
||||
signedAt,
|
||||
signedIp,
|
||||
logoBase64
|
||||
}: ContractData) => {
|
||||
const basePrice = booking.calculatedPrice || 0;
|
||||
const distance = booking.distance || 0;
|
||||
const totalKm = distance * 2;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
{/* Page 1 */}
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header Band */}
|
||||
<View style={styles.headerBand}>
|
||||
{logoBase64 && <Image src={logoBase64} style={styles.logoSmall} />}
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text style={styles.mainTitle}>SAVE THE MOMENT MIETVERTRAG</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
über ein Fotobox-Modell inklusive sämtlicher Bestandteile und Zubehör gemäß Ziffer 1
|
||||
</Text>
|
||||
<Text style={[styles.subtitle, styles.accentText]}>
|
||||
(nachfolgend die „Mietgegenstände")
|
||||
</Text>
|
||||
|
||||
{/* Parties */}
|
||||
<View style={styles.parties}>
|
||||
<View style={styles.partyBlock}>
|
||||
<Text style={styles.partyLabel}>zwischen</Text>
|
||||
<Text style={styles.partyText}>Dennis Forte (DJ Forte), Bahnhofstrasse 14c, 23626 Ratekau</Text>
|
||||
<Text style={[styles.partyText, styles.accentText]}>(nachfolgend der „Vermieter")</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.partyBlock}>
|
||||
<Text style={styles.partyLabel}>und</Text>
|
||||
{booking.customerName && <Text style={styles.partyText}>{booking.customerName}</Text>}
|
||||
{booking.customerAddress && (
|
||||
<Text style={styles.partyText}>
|
||||
{booking.customerAddress}, {booking.customerZip} {booking.customerCity}
|
||||
</Text>
|
||||
)}
|
||||
{!booking.customerName && <View style={styles.emptyLineForSignature} />}
|
||||
<Text style={[styles.partyText, styles.accentText]}>(nachfolgend der „Mieter")</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Section 1 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>1. MIETGEGENSTÄNDE UND NEBENLEISTUNGEN:</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Mieter mietet vom Vermieter folgende Mietgegenstände inklusive der genannten Nebenleistungen:
|
||||
</Text>
|
||||
|
||||
<View style={styles.table}>
|
||||
{/* Header */}
|
||||
<View style={styles.tableHeaderRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Mietgegenstand / Leistung</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text> </Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>Bitte ankreuzen</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Modell */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Modell</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text style={styles.radioOption}>○ Fotobox Vintage ○ Smile ○ Photos</Text>
|
||||
<Text style={styles.radioOption}>○ Magic Mirror</Text>
|
||||
<Text style={styles.radioOption}>○ Nostalgie</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Fotodruck vor Ort */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Fotodruck vor Ort</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text style={styles.radioOption}>○ Ja, 140 Ausdrucke</Text>
|
||||
<Text style={styles.radioOption}>○ Ja, Flatrate (400 Ausdrucke soweit nicht anders vereinbart)</Text>
|
||||
<Text style={styles.radioOption}>○ Nein, nur digital in der Cloud</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Accessoires */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Accessoires</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text style={styles.radioOption}>○ Ja</Text>
|
||||
<Text>○ Nein</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* VIP Absperrung */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>VIP Absperrung</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text style={styles.radioOption}>○ Ja</Text>
|
||||
<Text>○ Nein</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Roter Teppich */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Roter Teppich</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text style={styles.radioOption}>○ Ja</Text>
|
||||
<Text>○ Nein</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Lieferung */}
|
||||
<View style={styles.tableRow}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Lieferung inkl. Aufbau</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text style={styles.radioOption}>○ Ja, einfache Strecke umfasst {distance.toFixed(1)} km. Gesamt = {totalKm.toFixed(1)} km</Text>
|
||||
<Text>○ Nein</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text>☐</Text>
|
||||
<Text>☐</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sonstiges */}
|
||||
<View style={styles.tableRowLast}>
|
||||
<View style={[styles.tableCell, styles.tableCellLeft]}>
|
||||
<Text>Sonstiges</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, { width: '45%' }]}>
|
||||
<Text> </Text>
|
||||
</View>
|
||||
<View style={[styles.tableCellLast, styles.tableCellRight]}>
|
||||
<Text> </Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Section 2 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>2. MIETZWECK</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Mieter wird die Mietgegenstände entsprechend der bestimmungsgemäßen Verwendung auf folgender Veranstaltung nutzen:
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Section 3 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>3. MIETZEIT</Text>
|
||||
<Text style={styles.paragraph}>Die Mietzeit geht vom:</Text>
|
||||
</View>
|
||||
|
||||
{/* Section 4 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>4. MIETPREIS</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Für die Überlassung der Mietgegenstände inklusive Nebenleistungen gemäß Ziffer 1 vereinbaren die Parteien einen Preis von {basePrice.toFixed(2)} € inkl. USt.
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
{/* Page 2 */}
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>5. ZAHLUNG</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Mietpreis für die vereinbarte Mietdauer ist im Voraus nach Rechnungstellung des Vermieters zu bezahlen.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>6. ÜBERGABE DER MIETGEGENSTÄNDE</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Je nach Vereinbarung liefert der Vermieter die Mietgegenstände an den Veranstaltungsort und baut diese dort auf oder stellt dem Mieter die Mietgegenstände an seinem Geschäftssitz zur Abholung bereit. Der Mieter hat Anspruch auf einen Testlauf. Macht der Mieter von diesem Recht keinen Gebrauch, gelten die Mietgegenstände als mangelfrei übergeben.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>7. VERFÜGBARKEIT VON FOTOS</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
7.1 Die mit den Mietgegenständen hergestellten Fotos sind zwei Wochen in einer Cloud digital abrufbar.
|
||||
</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
7.2 Bei Buchung einer Druck-Option besteht darüber hinaus die Möglichkeit, Fotos während der Veranstaltung vor Ort auszudrucken. Die Anzahl der Ausdrucke hängt von der gebuchten Option ab. Die Buchung der Flatrate umfasst den Druck von bis zu 400 Blättern, sofern nicht etwas anderes vereinbart wird. Diese Anzahl deckt üblicherweise den gesamten Bedarf. Ist ausnahmsweise von einem höheren Bedarf auszugehen, vereinbaren die Parteien eine höhere Anzahl von Ausdrucken.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>8. KÜNDIGUNG</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
8.1 Der Abschluss des Mietvertrags ist grundsätzlich verbindlich. Eine Kündigung ist nur aus wichtigem Grund möglich. Sofern der Vermieter den wichtigen Grund nicht gesetzt hat, schuldet der Mieter bei einer Kündigung des Mieters je nach Zeitpunkt der Kündigung folgende Entschädigung:
|
||||
</Text>
|
||||
<Text style={styles.bulletPoint}>• Kündigung mehr als 6 Monate vor Beginn der Mietzeit: 5 % des Netto-Mietpreises</Text>
|
||||
<Text style={styles.bulletPoint}>• Kündigung weniger als 6 Monate vor Beginn der Mietzeit: 20 % des Netto-Mietpreises</Text>
|
||||
<Text style={styles.bulletPoint}>• Kündigung weniger als 3 Monate vor Beginn der Mietzeit: 40 % des Netto-Mietpreises</Text>
|
||||
<Text style={styles.bulletPoint}>• Kündigung weniger als 1 Monat vor Beginn der Mietzeit: 80 % des Netto-Mietpreises</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Dem Mieter steht offen, einen im konkreten Fall gänzlich fehlenden oder wesentlich niedrigeren Anspruch des Vermieters nachzuweisen.
|
||||
</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
8.2 Ein dem Mieter gesetzlich zustehendes Widerrufsrecht bleibt von der vorstehenden Regelung unberührt.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>9. PERSÖNLICHE DATEN</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Mieter ist damit einverstanden, dass der Vermieter personenbezogene Daten wie Name, Anschrift aus dessen Personalausweis notiert und die erhobenen Daten zur eigenen Verwendung speichert.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>10. PFLICHTEN DES MIETERS</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Mieter verpflichtet sich, die Mietgegenstände nur für die vereinbarten Zwecke zu nutzen. Der Mieter verpflichtet sich weiter die Mietgegenstände pfleglich zu behandeln und sie jederzeit in geeigneter Weise vor Beschädigung, Verlust oder Diebstahl zu schützen. Bei Diebstahl verpflichtet er sich, unverzüglich den Vermieter zu informieren und auf dessen Verlangen hin bei der nächstgelegenen Polizeidienststelle Anzeige zu erstatten.
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
{/* Page 3 */}
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>11. HAFTUNG DES MIETERS</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Mieter haftet dem Vermieter für den Verlust der Mietgegenstände sowie für Schäden an diesen, die durch unsachgemäße Behandlung oder mutwillige Beschädigung der Mietgegenstände entstehen. Der Mieter hat auch für Schäden, die durch unsachgemäße Behandlung oder mutwillige Beschädigung durch Dritte, denen der Mieter Mietgegenstände zum Gebrauch überlässt, einzustehen. Bei einer zweckfremden Nutzung oder nicht berechtigten Weiterüberlassung der Mietgegenstände haftet der Mieter dem Vermieter für sämtliche Schäden an den Mietgegenständen.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>12. HAFTUNG DES VERMIETERS</Text>
|
||||
<Text style={styles.paragraph}>
|
||||
Der Vermieter hat den Mieter auf die ordnungsgemäße Handhabung der Mietgegenstände hingewiesen. Der Vermieter haftet darüber hinaus nicht für selbstverschuldete Unfälle des Mieters im Zusammenhang mit der Benutzung der Mietgegenstände. Eine sonstige Haftung des Vermieters für Sachschäden des Mieters bzw. Dritter bei Nutzung der Mietgegenstände ist mit Ausnahme von Vorsatz und grober Fahrlässigkeit des Vermieters ausgeschlossen, soweit sich aus den nachfolgenden Regelungen nichts Abweichendes ergibt. Im Falle der Verletzung wesentlicher Vertragspflichten (solche Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglichen und auf deren Einhaltung der Mieter regelmäßig vertrauen darf) haftet der Vermieter auch bei einfacher Fahrlässigkeit für Sachschäden, jedoch der Höhe nach auf den vorhersehbaren, vertragstypischen Schaden begrenzt. Für Personenschäden gelten die gesetzlichen Bestimmungen. Die Regelungen des Produkthaftungsgesetzes bleiben unberührt.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>13. RÜCKGABE, VERZUGSSCHADEN</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
13.1 Sofern keine abweichenden Vereinbarungen getroffen werden, gelten für die Rückgabe folgende Regelungen: Die Rückgabe der Mietgegenstände hat spätestens an dem Tag, an dem der Mietzeitraum endet, zu erfolgen. Hat der Mieter die Mietgegenstände inklusive Anlieferung und Abholung gebucht, hat er dem Vermieter nach Ende des Mietzeitraums an dem Tag, an dem der Mietzeitraum endet, die Abholung zu ermöglichen.
|
||||
</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
13.2 Bei verspäteter Rückgabe hat der Mieter dem Vermieter für jeden Verzugstag Schadensersatz in Höhe des sich aus dem Preis gemäß Ziffer 4 und der Mietzeit gemäß Ziffer 3 ergebenden Tagesmietpreises, begrenzt durch den Neuverkaufspreis der Mietgegenstände, zu bezahlen. Bei der Berechnung des Tagesmietpreises sind etwaige Kosten für Lieferung inkl. Aufbau sowie Fotopapier außer Betracht zu lassen. Bei vorzeitiger Rückgabe der Mietsache hat der Mieter keinen Anspruch auf anteilige Rückzahlung des Mietpreises.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>14. VERSCHIEDENES</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
14.1 Dieser Vertrag enthält die gesamte Vereinbarung. Mündliche Nebenabreden bestehen nicht.
|
||||
</Text>
|
||||
<Text style={styles.numberedList}>
|
||||
14.2 Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein oder werden, bleibt hiervon die Wirksamkeit der übrigen Bestimmungen des Vertrags unberührt. Die Parteien vereinbaren, dass sie im Falle einer unwirksamen Bestimmung dieses Mietvertrags über eine Ersatzregelung verhandeln, die dem von den Parteien mit der unwirksamen Bestimmung verfolgten wirtschaftlichen Zweck am nächsten kommt und die rechtlich zulässigen Inhalt hat.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Signatures */}
|
||||
<View style={styles.signatureSection}>
|
||||
<View style={styles.signatureBox}>
|
||||
<Text style={styles.signatureLabel}>Ort, Datum</Text>
|
||||
<Text style={styles.signatureLine}>Ratekau, {formatDate(new Date())}</Text>
|
||||
<Text style={{ marginTop: 10, fontSize: 10, fontWeight: 'bold' }}>Dennis Forte (DJ Forte)</Text>
|
||||
<Text style={styles.contactInfo}>Tel. 01729140374</Text>
|
||||
<Text style={styles.contactInfo}>E-Mail: info@fotobox-luebeck.de</Text>
|
||||
<Text style={styles.contactInfo}>Web: fotobox-luebeck.de</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.signatureBox}>
|
||||
<Text style={styles.signatureLabel}>Ort, Datum</Text>
|
||||
{signatureData ? (
|
||||
<>
|
||||
<Image src={signatureData} style={styles.signature} />
|
||||
<Text style={styles.signatureLine}>{signedBy}</Text>
|
||||
{signedAt && (
|
||||
<Text style={{ marginTop: 5, fontSize: 7, color: '#666' }}>
|
||||
Digital signiert am {formatDate(signedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.signatureLine}>Mieter</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
42
lib/date-utils.ts
Normal file
42
lib/date-utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return 'Ungültiges Datum';
|
||||
}
|
||||
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return 'Ungültiges Datum';
|
||||
}
|
||||
|
||||
const dateStr = formatDate(d);
|
||||
const hours = d.getHours().toString().padStart(2, '0');
|
||||
const minutes = d.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${dateStr}, ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function parseDateString(dateString: string): Date | null {
|
||||
if (!dateString) return null;
|
||||
|
||||
// Try German format: DD.MM.YYYY or D.M.YYYY
|
||||
const germanMatch = dateString.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (germanMatch) {
|
||||
const [, day, month, year] = germanMatch;
|
||||
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
|
||||
// Try ISO format or other standard formats
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
253
lib/email-parser.ts
Normal file
253
lib/email-parser.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
export interface ParsedBookingData {
|
||||
locationSlug: string;
|
||||
|
||||
// Event Details
|
||||
eventDate: string;
|
||||
eventType?: string;
|
||||
guestCount?: string;
|
||||
eventLocation?: string;
|
||||
contactPerson?: string;
|
||||
contactPhone?: string;
|
||||
|
||||
// Model & Options
|
||||
model: string;
|
||||
printOption?: string;
|
||||
accessories?: string;
|
||||
taskGame?: string;
|
||||
audioGuestbook?: string;
|
||||
delivery?: string;
|
||||
|
||||
// Price
|
||||
calculatedPrice?: number;
|
||||
|
||||
// Customer Data
|
||||
salutation?: string;
|
||||
companyName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
|
||||
// Address
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
|
||||
// Message
|
||||
message?: string;
|
||||
|
||||
// Raw Email Data
|
||||
rawSubject: string;
|
||||
rawBody: string;
|
||||
}
|
||||
|
||||
export class NinjaFormsEmailParser {
|
||||
private fieldMappings = {
|
||||
// Date fields
|
||||
date: ['Datum:', 'Datum'],
|
||||
|
||||
// Event fields
|
||||
eventType: ['Art der Veranstaltung:', 'Art der Veranstaltung'],
|
||||
guestCount: ['Gästeanzahl:', 'Gästeanzahl'],
|
||||
eventLocation: ['Location:', 'Location'],
|
||||
contactPerson: ['Ansprechpartner:', 'Ansprechpartner'],
|
||||
contactPhone: ['Durchwahl:', 'Durchwahl'],
|
||||
|
||||
// Model fields
|
||||
model: ['Modell:', 'Modell'],
|
||||
printOption: ['Vor Ort Druck?:', 'Vor Ort Druck'],
|
||||
accessories: ['Inklusive Accessiores?:', 'Inklusive Accessiores'],
|
||||
taskGame: ['Fotobox - Aufgabenspiel:', 'Aufgabenspiel'],
|
||||
audioGuestbook: ['Audio-Gästebuch:', 'Audio'],
|
||||
delivery: ['Lieferung (inkl. Auf-/Abbau):', 'Lieferung'],
|
||||
|
||||
// Price
|
||||
price: ['Preis:', 'Preis'],
|
||||
|
||||
// Customer fields
|
||||
salutation: ['Anrede:', 'Anrede'],
|
||||
company: ['Firma:', 'Firma'],
|
||||
firstName: ['Vorname:', 'Vorname'],
|
||||
lastName: ['Nachname:', 'Nachname'],
|
||||
email: ['E-Mail:', 'E-Mail', 'Email:'],
|
||||
phone: ['Telefon:', 'Telefon', 'Phone:'],
|
||||
|
||||
// Address
|
||||
street: ['Straße:', 'Strasse:', 'Straße'],
|
||||
zip: ['PLZ:', 'PLZ'],
|
||||
city: ['Ort:', 'Ort'],
|
||||
|
||||
// Message
|
||||
message: ['Nachricht:', 'Nachricht'],
|
||||
};
|
||||
|
||||
parseEmailBody(body: string, subject: string): ParsedBookingData | null {
|
||||
try {
|
||||
const lines = body.split('\n').map(line => line.trim()).filter(Boolean);
|
||||
|
||||
const data: any = {
|
||||
rawSubject: subject,
|
||||
rawBody: body,
|
||||
};
|
||||
|
||||
// Detect location from subject
|
||||
data.locationSlug = this.detectLocation(subject, body);
|
||||
|
||||
// Parse each line (check current and next line for key-value pairs)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const nextLine = lines[i + 1];
|
||||
|
||||
this.parseLine(line, nextLine, data);
|
||||
}
|
||||
|
||||
// Extract email and phone if not found
|
||||
if (!data.email) {
|
||||
data.email = this.extractEmail(body);
|
||||
}
|
||||
if (!data.phone) {
|
||||
data.phone = this.extractPhone(body);
|
||||
}
|
||||
|
||||
// Map model names
|
||||
data.model = this.mapModelName(data.model);
|
||||
|
||||
console.log('Parsed data before validation:', {
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
eventDate: data.eventDate,
|
||||
date: data.date,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!data.email || (!data.eventDate && !data.date)) {
|
||||
console.log('Validation failed: missing email or date');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize date field
|
||||
if (data.date && !data.eventDate) {
|
||||
data.eventDate = data.date;
|
||||
}
|
||||
|
||||
return data as ParsedBookingData;
|
||||
} catch (error) {
|
||||
console.error('Email parsing error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseLine(line: string, nextLine: string | undefined, data: any) {
|
||||
for (const [key, patterns] of Object.entries(this.fieldMappings)) {
|
||||
for (const pattern of patterns) {
|
||||
if (line.includes(pattern)) {
|
||||
// Try to get value from same line first
|
||||
let value = line.split(pattern)[1]?.trim();
|
||||
|
||||
// If no value on same line, check next line
|
||||
if (!value && nextLine) {
|
||||
value = nextLine.trim();
|
||||
}
|
||||
|
||||
if (value) {
|
||||
// Special handling for street - keep full address
|
||||
if (key === 'street' && value) {
|
||||
// Don't split on numbers, keep the full street name
|
||||
data[key] = value;
|
||||
} else if (key === 'price') {
|
||||
data.calculatedPrice = this.extractPrice(value);
|
||||
} else {
|
||||
data[key] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private detectLocation(subject: string, body: string): string {
|
||||
const text = (subject + ' ' + body).toLowerCase();
|
||||
|
||||
if (text.includes('lübeck') || text.includes('luebeck')) return 'luebeck';
|
||||
if (text.includes('hamburg')) return 'hamburg';
|
||||
if (text.includes('kiel')) return 'kiel';
|
||||
if (text.includes('berlin') || text.includes('potsdam')) return 'berlin';
|
||||
if (text.includes('rostock')) return 'rostock';
|
||||
|
||||
return 'luebeck'; // Default
|
||||
}
|
||||
|
||||
private mapModelName(modelString?: string): string {
|
||||
if (!modelString) return 'VINTAGE_SMILE';
|
||||
|
||||
const model = modelString.toLowerCase();
|
||||
|
||||
if (model.includes('smile')) return 'VINTAGE_SMILE';
|
||||
if (model.includes('foto') || model.includes('photo')) return 'VINTAGE_PHOTOS';
|
||||
if (model.includes('nostalgie')) return 'NOSTALGIE';
|
||||
if (model.includes('magic') || model.includes('spiegel') || model.includes('mirror')) return 'MAGIC_MIRROR';
|
||||
|
||||
return 'VINTAGE_SMILE';
|
||||
}
|
||||
|
||||
private extractPrice(value: string): number {
|
||||
const match = value.match(/(\d+)/);
|
||||
return match ? parseInt(match[1]) : 0;
|
||||
}
|
||||
|
||||
private extractEmail(text: string): string | null {
|
||||
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
||||
const match = text.match(emailRegex);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
private extractPhone(text: string): string | null {
|
||||
const phoneRegex = /(\+?49|0)[\s\-]?(\d{2,5})[\s\-]?(\d{3,})[\s\-]?(\d{3,})/;
|
||||
const match = text.match(phoneRegex);
|
||||
return match ? match[0].replace(/\s+/g, '') : null;
|
||||
}
|
||||
|
||||
parseFromNinjaFormsEmail(emailText: string, subject: string): ParsedBookingData | null {
|
||||
// Clean HTML if present
|
||||
let cleanText = emailText;
|
||||
|
||||
// Convert HTML table rows to newlines for better parsing
|
||||
cleanText = cleanText.replace(/<\/tr>/gi, '\n');
|
||||
cleanText = cleanText.replace(/<\/td>/gi, ' ');
|
||||
cleanText = cleanText.replace(/<br\s*\/?>/gi, '\n');
|
||||
cleanText = cleanText.replace(/<\/p>/gi, '\n');
|
||||
cleanText = cleanText.replace(/<\/div>/gi, '\n');
|
||||
|
||||
// Remove all other HTML tags
|
||||
cleanText = cleanText.replace(/<[^>]*>/g, '');
|
||||
|
||||
// Decode HTML entities
|
||||
cleanText = cleanText
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec));
|
||||
|
||||
// Clean up whitespace
|
||||
cleanText = cleanText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
console.log('=== CLEANED TEXT FOR PARSING ===');
|
||||
console.log(cleanText.substring(0, 800));
|
||||
console.log('=== END CLEANED TEXT ===');
|
||||
|
||||
const result = this.parseEmailBody(cleanText, subject);
|
||||
console.log('Parse result from parseEmailBody:', result ? 'SUCCESS' : 'FAILED');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const emailParser = new NinjaFormsEmailParser();
|
||||
295
lib/email-service.ts
Normal file
295
lib/email-service.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import path from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
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,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ SMTP transporter initialized');
|
||||
return transporter;
|
||||
}
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content?: Buffer;
|
||||
path?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function sendEmail(options: SendEmailOptions) {
|
||||
try {
|
||||
const transport = getTransporter();
|
||||
const from = process.env.SMTP_FROM || 'SaveTheMoment <noreply@savethemoment.photos>';
|
||||
|
||||
const info = await transport.sendMail({
|
||||
from,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html,
|
||||
attachments: options.attachments,
|
||||
});
|
||||
|
||||
console.log('✅ Email sent:', info.messageId);
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error: any) {
|
||||
console.error('❌ Email send error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendContractEmail(
|
||||
booking: any,
|
||||
contractPdfPath: string
|
||||
) {
|
||||
const signToken = Buffer.from(`${booking.id}-${Date.now()}`).toString('base64url');
|
||||
const signUrl = `${process.env.NEXTAUTH_URL}/contract/sign/${signToken}`;
|
||||
|
||||
const subject = `Ihr Mietvertrag für ${booking.eventLocation || 'Ihr Event'}`;
|
||||
|
||||
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;
|
||||
}
|
||||
.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</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>
|
||||
|
||||
<div class="details">
|
||||
<h3>📋 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>Location:</strong> ${booking.eventLocation || booking.eventAddress}</p>
|
||||
<p><strong>Fotobox:</strong> ${booking.photobox?.model || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
<p>Im Anhang finden Sie Ihren Mietvertrag als PDF-Datei.</p>
|
||||
|
||||
<p><strong>Nächste Schritte:</strong></p>
|
||||
<ol>
|
||||
<li>Bitte lesen Sie den Vertrag sorgfältig durch</li>
|
||||
<li>Signieren Sie den Vertrag online oder drucken Sie ihn aus und senden Sie ihn zurück</li>
|
||||
<li>Nach Erhalt der Unterschrift ist Ihre Buchung verbindlich bestätigt</li>
|
||||
</ol>
|
||||
|
||||
<center>
|
||||
<a href="${signUrl}" class="button">
|
||||
✍️ Vertrag online signieren
|
||||
</a>
|
||||
</center>
|
||||
|
||||
<p>Alternativ können Sie den Vertrag auch ausdrucken, unterschreiben und uns per E-Mail oder Post zurücksenden.</p>
|
||||
|
||||
<p>Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung!</p>
|
||||
|
||||
<p>Mit freundlichen Grüßen<br>
|
||||
Ihr SaveTheMoment Team</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>SaveTheMoment Fotoboxen<br>
|
||||
E-Mail: info@savethemoment.photos<br>
|
||||
Web: 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>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
const text = `
|
||||
Hallo ${booking.customerName},
|
||||
|
||||
vielen Dank für Ihre Buchung bei SaveTheMoment!
|
||||
|
||||
Buchungsdetails:
|
||||
- Buchungsnummer: ${booking.bookingNumber}
|
||||
- Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
||||
- Location: ${booking.eventLocation || booking.eventAddress}
|
||||
|
||||
Im Anhang finden Sie Ihren Mietvertrag als PDF-Datei.
|
||||
|
||||
Sie können den Vertrag online signieren unter:
|
||||
${signUrl}
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
SaveTheMoment Fotoboxen
|
||||
E-Mail: info@savethemoment.photos
|
||||
Web: www.savethemoment.photos
|
||||
`.trim();
|
||||
|
||||
let pdfBuffer: Buffer;
|
||||
try {
|
||||
pdfBuffer = await readFile(path.join(process.cwd(), 'public', contractPdfPath));
|
||||
} catch (error) {
|
||||
console.error('Failed to read contract PDF:', error);
|
||||
throw new Error('Contract PDF not found');
|
||||
}
|
||||
|
||||
return sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Mietvertrag_${booking.bookingNumber}.pdf`,
|
||||
content: pdfBuffer,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendBookingConfirmationEmail(booking: any) {
|
||||
const subject = `Buchungsbestätigung - ${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; }
|
||||
.details { background: white; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #DC2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>✅ Buchung bestätigt!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hallo ${booking.customerName},</p>
|
||||
<p>Ihre Buchung wurde erfolgreich bestätigt!</p>
|
||||
<div class="details">
|
||||
<h3>Buchungsdetails</h3>
|
||||
<p><strong>Buchungsnummer:</strong> ${booking.bookingNumber}</p>
|
||||
<p><strong>Event-Datum:</strong> ${new Date(booking.eventDate).toLocaleDateString('de-DE')}</p>
|
||||
<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>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
Hallo ${booking.customerName},
|
||||
|
||||
Ihre Buchung wurde erfolgreich bestätigt!
|
||||
|
||||
Buchungsnummer: ${booking.bookingNumber}
|
||||
Event-Datum: ${new Date(booking.eventDate).toLocaleDateString('de-DE')}
|
||||
Location: ${booking.eventLocation || booking.eventAddress}
|
||||
|
||||
Wir freuen uns auf Ihr Event!
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Ihr SaveTheMoment Team
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
354
lib/email-sync.ts
Normal file
354
lib/email-sync.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import Imap from 'imap';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { prisma } from './prisma';
|
||||
import { emailParser } from './email-parser';
|
||||
|
||||
export interface ImapConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
export class EmailSyncService {
|
||||
async syncLocationEmails(locationId: string): Promise<{
|
||||
success: boolean;
|
||||
newEmails: number;
|
||||
newBookings: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
console.log('=== Starting email sync for location:', locationId);
|
||||
|
||||
const location = await prisma.location.findUnique({
|
||||
where: { id: locationId },
|
||||
});
|
||||
|
||||
console.log('Location found:', location?.name, 'emailSyncEnabled:', location?.emailSyncEnabled);
|
||||
|
||||
if (!location || !location.emailSyncEnabled) {
|
||||
return { success: false, newEmails: 0, newBookings: 0, errors: ['Location not found or sync disabled'] };
|
||||
}
|
||||
|
||||
if (!location.imapHost || !location.imapUser || !location.imapPassword) {
|
||||
console.log('IMAP config incomplete:', {
|
||||
host: !!location.imapHost,
|
||||
user: !!location.imapUser,
|
||||
password: !!location.imapPassword,
|
||||
});
|
||||
return { success: false, newEmails: 0, newBookings: 0, errors: ['IMAP configuration incomplete'] };
|
||||
}
|
||||
|
||||
const config: ImapConfig = {
|
||||
host: location.imapHost,
|
||||
port: location.imapPort || 993,
|
||||
user: location.imapUser,
|
||||
password: location.imapPassword,
|
||||
secure: location.imapSecure ?? true,
|
||||
};
|
||||
|
||||
console.log('IMAP config:', { host: config.host, port: config.port, user: config.user, secure: config.secure });
|
||||
|
||||
return this.fetchNewEmails(config, location.slug, locationId);
|
||||
}
|
||||
|
||||
private async fetchNewEmails(
|
||||
config: ImapConfig,
|
||||
locationSlug: string,
|
||||
locationId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
newEmails: number;
|
||||
newBookings: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
console.log('=== fetchNewEmails called for', locationSlug);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const imap = new Imap({
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
tls: config.secure,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
console.log('IMAP instance created');
|
||||
|
||||
let newEmails = 0;
|
||||
let newBookings = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
imap.once('ready', () => {
|
||||
console.log('IMAP connection ready!');
|
||||
imap.openBox('INBOX', false, (err, box) => {
|
||||
if (err) {
|
||||
errors.push(`Failed to open inbox: ${err.message}`);
|
||||
imap.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch all emails from last 30 days (including seen/read ones for testing)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
imap.search([['SINCE', thirtyDaysAgo]], async (err, results) => {
|
||||
if (err) {
|
||||
console.error('IMAP search error:', err);
|
||||
errors.push(`Search failed: ${err.message}`);
|
||||
imap.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('IMAP search results:', results?.length || 0, 'emails found');
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
console.log('No emails found in search');
|
||||
imap.end();
|
||||
resolve({ success: true, newEmails: 0, newBookings: 0, errors });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Fetching', results.length, 'emails...');
|
||||
const fetch = imap.fetch(results, { bodies: '' });
|
||||
|
||||
fetch.on('message', (msg: any, seqno: number) => {
|
||||
console.log('Processing email', seqno);
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
simpleParser(stream, async (err: any, parsed: any) => {
|
||||
if (err) {
|
||||
console.error('Parse error for email', seqno, ':', err);
|
||||
errors.push(`Failed to parse email ${seqno}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Parsed email:', {
|
||||
subject: parsed.subject,
|
||||
from: parsed.from?.text,
|
||||
messageId: parsed.messageId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Save email to database
|
||||
const existingEmail = await prisma.email.findUnique({
|
||||
where: { messageId: parsed.messageId },
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
console.log('Email already exists, skipping:', parsed.messageId);
|
||||
return; // Already processed
|
||||
}
|
||||
|
||||
console.log('Saving new email to database...');
|
||||
const emailRecord = await prisma.email.create({
|
||||
data: {
|
||||
locationSlug,
|
||||
from: parsed.from?.text || '',
|
||||
to: parsed.to?.text || '',
|
||||
subject: parsed.subject || '',
|
||||
textBody: parsed.text || '',
|
||||
htmlBody: parsed.html || '',
|
||||
messageId: parsed.messageId || undefined,
|
||||
direction: 'INBOUND',
|
||||
receivedAt: parsed.date || new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Email saved with ID:', emailRecord.id);
|
||||
newEmails++;
|
||||
|
||||
// Try to parse as Ninjaforms booking
|
||||
console.log('Attempting to parse as Ninjaforms email...');
|
||||
console.log('Email subject:', parsed.subject);
|
||||
console.log('Text body length:', parsed.text?.length || 0);
|
||||
console.log('HTML body length:', parsed.html?.length || 0);
|
||||
console.log('First 500 chars of content:', (parsed.html || parsed.text || '').substring(0, 500));
|
||||
|
||||
const parsedData = emailParser.parseFromNinjaFormsEmail(
|
||||
parsed.html || parsed.text || '',
|
||||
parsed.subject || ''
|
||||
);
|
||||
|
||||
console.log('Parse result:', parsedData ? '✅ Parsed successfully' : '❌ Not a Ninjaforms email');
|
||||
if (parsedData) {
|
||||
console.log('Parsed data:', JSON.stringify(parsedData, null, 2));
|
||||
}
|
||||
|
||||
if (parsedData) {
|
||||
console.log('Creating booking from parsed data:', parsedData);
|
||||
// Create booking from parsed data
|
||||
const booking = await this.createBookingFromParsedData(parsedData, locationSlug);
|
||||
|
||||
if (booking) {
|
||||
console.log('✅ Booking created:', booking.id);
|
||||
newBookings++;
|
||||
|
||||
// Update email with booking ID
|
||||
await prisma.email.update({
|
||||
where: { id: emailRecord.id },
|
||||
data: {
|
||||
parsed: true,
|
||||
parsedData: parsedData as any,
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create notification
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
type: 'NEW_BOOKING_EMAIL',
|
||||
title: 'Neue Buchung per E-Mail',
|
||||
message: `${parsedData.firstName || parsedData.companyName} - ${parsedData.eventDate}`,
|
||||
metadata: {
|
||||
bookingId: booking.id,
|
||||
emailId: emailRecord.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('✅ Notification created');
|
||||
} else {
|
||||
console.log('❌ Booking creation failed');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errors.push(`Failed to process email ${seqno}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('end', async () => {
|
||||
// Update last sync time
|
||||
await prisma.location.update({
|
||||
where: { id: locationId },
|
||||
data: { lastEmailSync: new Date() },
|
||||
});
|
||||
|
||||
imap.end();
|
||||
resolve({ success: true, newEmails, newBookings, errors });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (err: any) => {
|
||||
errors.push(`IMAP connection error: ${err.message}`);
|
||||
resolve({ success: false, newEmails, newBookings, errors });
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
||||
private parseDateString(dateString: string): Date | null {
|
||||
if (!dateString) return null;
|
||||
|
||||
// Try German format: DD.MM.YYYY or D.M.YYYY
|
||||
const germanMatch = dateString.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (germanMatch) {
|
||||
const [, day, month, year] = germanMatch;
|
||||
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
|
||||
// Try ISO format or other standard formats
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
private async createBookingFromParsedData(data: any, locationSlug: string) {
|
||||
try {
|
||||
const location = await prisma.location.findUnique({
|
||||
where: { slug: locationSlug },
|
||||
});
|
||||
|
||||
if (!location) return null;
|
||||
|
||||
// Parse German date format
|
||||
const eventDate = this.parseDateString(data.eventDate);
|
||||
if (!eventDate) {
|
||||
console.error('Invalid event date:', data.eventDate);
|
||||
return null;
|
||||
}
|
||||
|
||||
const startOfDay = new Date(eventDate);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(eventDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const availablePhotobox = await prisma.photobox.findFirst({
|
||||
where: {
|
||||
locationId: location.id,
|
||||
model: data.model,
|
||||
active: true,
|
||||
NOT: {
|
||||
bookings: {
|
||||
some: {
|
||||
eventDate: {
|
||||
gte: startOfDay,
|
||||
lte: endOfDay,
|
||||
},
|
||||
status: {
|
||||
in: ['RESERVED', 'CONFIRMED'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!availablePhotobox) {
|
||||
console.warn('No photobox available for parsed booking');
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookingNumber = this.generateBookingNumber();
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
bookingNumber,
|
||||
locationId: location.id,
|
||||
photoboxId: availablePhotobox.id,
|
||||
status: 'RESERVED',
|
||||
|
||||
customerName: data.companyName || `${data.firstName || ''} ${data.lastName || ''}`.trim(),
|
||||
customerEmail: data.email,
|
||||
customerPhone: data.phone,
|
||||
customerAddress: data.street,
|
||||
customerCity: data.city,
|
||||
customerZip: data.zip,
|
||||
|
||||
invoiceType: data.companyName ? 'BUSINESS' : 'PRIVATE',
|
||||
companyName: data.companyName,
|
||||
|
||||
eventDate: eventDate,
|
||||
eventAddress: data.eventLocation || '',
|
||||
eventCity: data.city || '',
|
||||
eventZip: data.zip || '',
|
||||
eventLocation: data.eventLocation,
|
||||
|
||||
setupTimeStart: eventDate,
|
||||
setupTimeLatest: eventDate,
|
||||
|
||||
calculatedPrice: data.calculatedPrice || 0,
|
||||
notes: data.message,
|
||||
},
|
||||
});
|
||||
|
||||
return booking;
|
||||
} catch (error) {
|
||||
console.error('Failed to create booking from parsed data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private generateBookingNumber(): string {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear().toString().slice(-2);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `STM-${year}${month}-${random}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const emailSyncService = new EmailSyncService();
|
||||
233
lib/google-maps.ts
Normal file
233
lib/google-maps.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || '';
|
||||
|
||||
interface Stop {
|
||||
eventAddress: string;
|
||||
eventCity: string;
|
||||
eventZip: string;
|
||||
setupTimeStart: Date;
|
||||
setupTimeLatest?: Date;
|
||||
}
|
||||
|
||||
interface RouteResult {
|
||||
optimizedOrder: number[];
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
waypoints: Array<{
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
arrivalTime?: string;
|
||||
setupWindow?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function optimizeRouteBySchedule(stops: Stop[]): Promise<RouteResult> {
|
||||
if (!GOOGLE_MAPS_API_KEY) {
|
||||
throw new Error('Google Maps API key not configured');
|
||||
}
|
||||
|
||||
if (stops.length === 0) {
|
||||
return {
|
||||
optimizedOrder: [],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
waypoints: [],
|
||||
};
|
||||
}
|
||||
|
||||
const stopsWithTimes = stops.map((stop, index) => ({
|
||||
...stop,
|
||||
index,
|
||||
setupStart: new Date(stop.setupTimeStart),
|
||||
setupLatest: stop.setupTimeLatest ? new Date(stop.setupTimeLatest) : null,
|
||||
}));
|
||||
|
||||
stopsWithTimes.sort((a, b) => a.setupStart.getTime() - b.setupStart.getTime());
|
||||
|
||||
const sortedIndices = stopsWithTimes.map(s => s.index);
|
||||
|
||||
let totalDistance = 0;
|
||||
let totalDuration = 0;
|
||||
const waypoints: RouteResult['waypoints'] = [];
|
||||
|
||||
for (let i = 0; i < stopsWithTimes.length; i++) {
|
||||
const stop = stopsWithTimes[i];
|
||||
const address = `${stop.eventAddress}, ${stop.eventZip} ${stop.eventCity}`;
|
||||
const geocoded = await geocodeAddress(address);
|
||||
|
||||
const setupWindow = stop.setupLatest
|
||||
? `${stop.setupStart.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} - ${stop.setupLatest.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
|
||||
: `ab ${stop.setupStart.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
waypoints.push({
|
||||
address: `${stop.eventAddress}, ${stop.eventCity}`,
|
||||
lat: geocoded.lat,
|
||||
lng: geocoded.lng,
|
||||
setupWindow,
|
||||
});
|
||||
|
||||
if (i > 0) {
|
||||
const prevStop = stopsWithTimes[i - 1];
|
||||
const origin = `${prevStop.eventAddress}, ${prevStop.eventZip} ${prevStop.eventCity}`;
|
||||
const destination = address;
|
||||
|
||||
const { distance, duration } = await calculateDistance(origin, destination);
|
||||
totalDistance += distance;
|
||||
totalDuration += duration;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
optimizedOrder: sortedIndices,
|
||||
totalDistance,
|
||||
totalDuration,
|
||||
waypoints,
|
||||
};
|
||||
}
|
||||
|
||||
export async function optimizeRoute(stops: Stop[]): Promise<RouteResult> {
|
||||
if (!GOOGLE_MAPS_API_KEY) {
|
||||
throw new Error('Google Maps API key not configured');
|
||||
}
|
||||
|
||||
if (stops.length === 0) {
|
||||
return {
|
||||
optimizedOrder: [],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
waypoints: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (stops.length === 1) {
|
||||
const geocoded = await geocodeAddress(
|
||||
`${stops[0].eventAddress}, ${stops[0].eventZip} ${stops[0].eventCity}`
|
||||
);
|
||||
return {
|
||||
optimizedOrder: [0],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
waypoints: [
|
||||
{
|
||||
address: `${stops[0].eventAddress}, ${stops[0].eventCity}`,
|
||||
lat: geocoded.lat,
|
||||
lng: geocoded.lng,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const origin = `${stops[0].eventAddress}, ${stops[0].eventZip} ${stops[0].eventCity}`;
|
||||
const destination = `${stops[stops.length - 1].eventAddress}, ${stops[stops.length - 1].eventZip} ${stops[stops.length - 1].eventCity}`;
|
||||
|
||||
const waypoints = stops.slice(1, -1).map(stop =>
|
||||
`${stop.eventAddress}, ${stop.eventZip} ${stop.eventCity}`
|
||||
);
|
||||
|
||||
const url = new URL('https://maps.googleapis.com/maps/api/directions/json');
|
||||
url.searchParams.append('origin', origin);
|
||||
url.searchParams.append('destination', destination);
|
||||
if (waypoints.length > 0) {
|
||||
url.searchParams.append('waypoints', `optimize:true|${waypoints.join('|')}`);
|
||||
}
|
||||
url.searchParams.append('key', GOOGLE_MAPS_API_KEY);
|
||||
url.searchParams.append('mode', 'driving');
|
||||
url.searchParams.append('language', 'de');
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Google Maps API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'OK') {
|
||||
throw new Error(`Google Maps API returned status: ${data.status}`);
|
||||
}
|
||||
|
||||
const route = data.routes[0];
|
||||
const waypointOrder = route.waypoint_order || [];
|
||||
|
||||
const optimizedOrder = [
|
||||
0,
|
||||
...waypointOrder.map((i: number) => i + 1),
|
||||
stops.length - 1,
|
||||
];
|
||||
|
||||
let totalDistance = 0;
|
||||
let totalDuration = 0;
|
||||
|
||||
route.legs.forEach((leg: any) => {
|
||||
totalDistance += leg.distance.value;
|
||||
totalDuration += leg.duration.value;
|
||||
});
|
||||
|
||||
const waypointsData = await Promise.all(
|
||||
stops.map(async (stop, index) => {
|
||||
const address = `${stop.eventAddress}, ${stop.eventZip} ${stop.eventCity}`;
|
||||
const geocoded = await geocodeAddress(address);
|
||||
return {
|
||||
address: `${stop.eventAddress}, ${stop.eventCity}`,
|
||||
lat: geocoded.lat,
|
||||
lng: geocoded.lng,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
optimizedOrder,
|
||||
totalDistance: Math.round(totalDistance / 1000),
|
||||
totalDuration: Math.round(totalDuration / 60),
|
||||
waypoints: waypointsData,
|
||||
};
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number }> {
|
||||
const url = new URL('https://maps.googleapis.com/maps/api/geocode/json');
|
||||
url.searchParams.append('address', address);
|
||||
url.searchParams.append('key', GOOGLE_MAPS_API_KEY);
|
||||
url.searchParams.append('language', 'de');
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'OK' && data.results.length > 0) {
|
||||
const location = data.results[0].geometry.location;
|
||||
return {
|
||||
lat: location.lat,
|
||||
lng: location.lng,
|
||||
};
|
||||
}
|
||||
|
||||
return { lat: 0, lng: 0 };
|
||||
}
|
||||
|
||||
export async function calculateDistance(
|
||||
origin: string,
|
||||
destination: string
|
||||
): Promise<{ distance: number; duration: number }> {
|
||||
if (!GOOGLE_MAPS_API_KEY) {
|
||||
throw new Error('Google Maps API key not configured');
|
||||
}
|
||||
|
||||
const url = new URL('https://maps.googleapis.com/maps/api/distancematrix/json');
|
||||
url.searchParams.append('origins', origin);
|
||||
url.searchParams.append('destinations', destination);
|
||||
url.searchParams.append('key', GOOGLE_MAPS_API_KEY);
|
||||
url.searchParams.append('mode', 'driving');
|
||||
url.searchParams.append('language', 'de');
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'OK' && data.rows[0].elements[0].status === 'OK') {
|
||||
const element = data.rows[0].elements[0];
|
||||
return {
|
||||
distance: Math.round(element.distance.value / 1000),
|
||||
duration: Math.round(element.duration.value / 60),
|
||||
};
|
||||
}
|
||||
|
||||
return { distance: 0, duration: 0 };
|
||||
}
|
||||
300
lib/lexoffice.ts
Normal file
300
lib/lexoffice.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
interface LexOfficeContact {
|
||||
id?: string;
|
||||
organizationId: string;
|
||||
version: number;
|
||||
roles: {
|
||||
customer?: {
|
||||
number?: number;
|
||||
};
|
||||
};
|
||||
company?: {
|
||||
name: string;
|
||||
taxNumber?: string;
|
||||
vatRegistrationId?: string;
|
||||
allowTaxFreeInvoices?: boolean;
|
||||
contactPersons?: Array<{
|
||||
salutation?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
primary?: boolean;
|
||||
emailAddress?: string;
|
||||
phoneNumber?: string;
|
||||
}>;
|
||||
};
|
||||
person?: {
|
||||
salutation?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
addresses?: {
|
||||
billing?: Array<{
|
||||
supplement?: string;
|
||||
street?: string;
|
||||
zip?: string;
|
||||
city?: string;
|
||||
countryCode?: string;
|
||||
}>;
|
||||
};
|
||||
emailAddresses?: {
|
||||
business?: Array<string>;
|
||||
office?: Array<string>;
|
||||
private?: Array<string>;
|
||||
other?: Array<string>;
|
||||
};
|
||||
phoneNumbers?: {
|
||||
business?: Array<string>;
|
||||
office?: Array<string>;
|
||||
mobile?: Array<string>;
|
||||
private?: Array<string>;
|
||||
fax?: Array<string>;
|
||||
other?: Array<string>;
|
||||
};
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface LexOfficeQuotation {
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
createdDate?: string;
|
||||
updatedDate?: string;
|
||||
voucherNumber?: string;
|
||||
voucherDate: string;
|
||||
address: {
|
||||
contactId?: string;
|
||||
name?: string;
|
||||
supplement?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
zip?: string;
|
||||
countryCode: string;
|
||||
};
|
||||
lineItems: Array<{
|
||||
type: 'custom' | 'text';
|
||||
name?: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
unitName?: string;
|
||||
unitPrice?: {
|
||||
currency: string;
|
||||
netAmount: number;
|
||||
taxRatePercentage: number;
|
||||
};
|
||||
}>;
|
||||
totalPrice?: {
|
||||
currency: string;
|
||||
};
|
||||
taxConditions?: {
|
||||
taxType: 'net' | 'gross' | 'vatfree';
|
||||
};
|
||||
title?: string;
|
||||
introduction?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface LexOfficeInvoice extends Omit<LexOfficeQuotation, 'id'> {
|
||||
voucherStatus?: string;
|
||||
shippingConditions?: {
|
||||
shippingDate?: string;
|
||||
shippingType?: string;
|
||||
};
|
||||
paymentConditions?: {
|
||||
paymentTermLabel?: string;
|
||||
paymentTermDuration?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class LexOfficeService {
|
||||
private apiKey: string;
|
||||
private baseUrl = 'https://api.lexoffice.io/v1';
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.LEXOFFICE_API_KEY || '';
|
||||
if (!this.apiKey) {
|
||||
console.warn('LexOffice API Key nicht konfiguriert');
|
||||
}
|
||||
}
|
||||
|
||||
private async request(method: string, endpoint: string, data?: any) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`LexOffice API Error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('LexOffice API Request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createContact(contact: Partial<LexOfficeContact>): Promise<{ id: string; resourceUri: string }> {
|
||||
return this.request('POST', '/contacts', contact);
|
||||
}
|
||||
|
||||
async getContact(contactId: string): Promise<LexOfficeContact> {
|
||||
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 getQuotation(quotationId: string): Promise<LexOfficeQuotation> {
|
||||
return this.request('GET', `/quotations/${quotationId}`);
|
||||
}
|
||||
|
||||
async createInvoice(invoice: LexOfficeInvoice): Promise<{ id: string; resourceUri: string; createdDate: string; updatedDate: string; voucherNumber: string }> {
|
||||
return this.request('POST', '/invoices', invoice);
|
||||
}
|
||||
|
||||
async getInvoice(invoiceId: string): Promise<LexOfficeInvoice> {
|
||||
return this.request('GET', `/invoices/${invoiceId}`);
|
||||
}
|
||||
|
||||
async finalizeInvoice(invoiceId: string): Promise<{ id: string; resourceUri: string }> {
|
||||
return this.request('PUT', `/invoices/${invoiceId}/pursue`, {
|
||||
precedingSalesVoucherId: null,
|
||||
preserveVoucherNumber: false,
|
||||
});
|
||||
}
|
||||
|
||||
async createContactFromBooking(booking: any): Promise<string> {
|
||||
const contact: Partial<LexOfficeContact> = {
|
||||
roles: {
|
||||
customer: {},
|
||||
},
|
||||
addresses: {
|
||||
billing: [{
|
||||
street: booking.customerAddress,
|
||||
zip: booking.customerZip,
|
||||
city: booking.customerCity,
|
||||
countryCode: 'DE',
|
||||
}],
|
||||
},
|
||||
emailAddresses: {
|
||||
business: [booking.customerEmail],
|
||||
},
|
||||
phoneNumbers: {
|
||||
business: [booking.customerPhone],
|
||||
},
|
||||
};
|
||||
|
||||
if (booking.invoiceType === 'BUSINESS' && booking.companyName) {
|
||||
contact.company = {
|
||||
name: booking.companyName,
|
||||
contactPersons: [{
|
||||
firstName: booking.customerName.split(' ')[0],
|
||||
lastName: booking.customerName.split(' ').slice(1).join(' '),
|
||||
primary: true,
|
||||
emailAddress: booking.customerEmail,
|
||||
phoneNumber: booking.customerPhone,
|
||||
}],
|
||||
};
|
||||
} else {
|
||||
const [firstName, ...lastNameParts] = booking.customerName.split(' ');
|
||||
contact.person = {
|
||||
firstName: firstName,
|
||||
lastName: lastNameParts.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.createContact(contact);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async createQuotationFromBooking(booking: any, contactId: string): Promise<string> {
|
||||
const quotation: LexOfficeQuotation = {
|
||||
voucherDate: new Date().toISOString().split('T')[0],
|
||||
address: {
|
||||
contactId: contactId,
|
||||
countryCode: 'DE',
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'Fotobox-Vermietung',
|
||||
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}`,
|
||||
quantity: 1,
|
||||
unitName: 'Stück',
|
||||
unitPrice: {
|
||||
currency: 'EUR',
|
||||
netAmount: booking.calculatedPrice || 0,
|
||||
taxRatePercentage: 19,
|
||||
},
|
||||
},
|
||||
],
|
||||
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);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async createConfirmationFromBooking(booking: any, contactId: string): Promise<string> {
|
||||
const invoice: LexOfficeInvoice = {
|
||||
voucherDate: new Date().toISOString().split('T')[0],
|
||||
address: {
|
||||
contactId: contactId,
|
||||
countryCode: 'DE',
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'Fotobox-Vermietung',
|
||||
description: `Event am ${new Date(booking.eventDate).toLocaleDateString('de-DE')} in ${booking.eventCity}\nAufbau: ${new Date(booking.setupTimeStart).toLocaleString('de-DE')}\nOrt: ${booking.eventAddress}, ${booking.eventZip} ${booking.eventCity}`,
|
||||
quantity: 1,
|
||||
unitName: 'Stück',
|
||||
unitPrice: {
|
||||
currency: 'EUR',
|
||||
netAmount: booking.calculatedPrice || 0,
|
||||
taxRatePercentage: 19,
|
||||
},
|
||||
},
|
||||
],
|
||||
totalPrice: {
|
||||
currency: 'EUR',
|
||||
},
|
||||
taxConditions: {
|
||||
taxType: 'net',
|
||||
},
|
||||
title: `Auftragsbestätigung - ${booking.bookingNumber}`,
|
||||
introduction: 'Vielen Dank für Ihre Bestellung! Hiermit bestätigen wir Ihren Auftrag:',
|
||||
remark: 'Wir freuen uns auf Ihre Veranstaltung!',
|
||||
shippingConditions: {
|
||||
shippingDate: new Date(booking.eventDate).toISOString().split('T')[0],
|
||||
shippingType: 'service',
|
||||
},
|
||||
paymentConditions: {
|
||||
paymentTermLabel: 'Zahlung bei Lieferung',
|
||||
paymentTermDuration: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await this.createInvoice(invoice);
|
||||
|
||||
await this.finalizeInvoice(result.id);
|
||||
|
||||
return result.id;
|
||||
}
|
||||
}
|
||||
|
||||
export const lexofficeService = new LexOfficeService();
|
||||
202
lib/nextcloud-calendar.ts
Normal file
202
lib/nextcloud-calendar.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createDAVClient } from 'tsdav';
|
||||
|
||||
export interface CalendarEvent {
|
||||
uid: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class NextcloudCalendarService {
|
||||
private client: any;
|
||||
private initialized: boolean = false;
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
const serverUrl = process.env.NEXTCLOUD_URL;
|
||||
const username = process.env.NEXTCLOUD_USERNAME;
|
||||
const password = process.env.NEXTCLOUD_PASSWORD;
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
throw new Error('Nextcloud credentials not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = await createDAVClient({
|
||||
serverUrl: `${serverUrl}/remote.php/dav`,
|
||||
credentials: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
authMethod: 'Basic',
|
||||
defaultAccountType: 'caldav',
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
console.log('✅ Nextcloud CalDAV client initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize Nextcloud CalDAV client:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCalendars() {
|
||||
await this.initialize();
|
||||
try {
|
||||
const calendars = await this.client.fetchCalendars();
|
||||
return calendars;
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendars:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createEvent(calendarUrl: string, event: CalendarEvent) {
|
||||
await this.initialize();
|
||||
|
||||
const icsContent = this.generateICS(event);
|
||||
|
||||
try {
|
||||
const result = await this.client.createCalendarObject({
|
||||
calendar: { url: calendarUrl },
|
||||
filename: `${event.uid}.ics`,
|
||||
iCalString: icsContent,
|
||||
});
|
||||
|
||||
console.log('✅ Event created in Nextcloud:', event.summary);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateEvent(calendarUrl: string, event: CalendarEvent) {
|
||||
await this.initialize();
|
||||
|
||||
const icsContent = this.generateICS(event);
|
||||
|
||||
try {
|
||||
const result = await this.client.updateCalendarObject({
|
||||
calendar: { url: calendarUrl },
|
||||
filename: `${event.uid}.ics`,
|
||||
iCalString: icsContent,
|
||||
});
|
||||
|
||||
console.log('✅ Event updated in Nextcloud:', event.summary);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error updating event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEvent(calendarUrl: string, eventUid: string) {
|
||||
await this.initialize();
|
||||
|
||||
try {
|
||||
await this.client.deleteCalendarObject({
|
||||
calendar: { url: calendarUrl },
|
||||
filename: `${eventUid}.ics`,
|
||||
});
|
||||
|
||||
console.log('✅ Event deleted from Nextcloud:', eventUid);
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncBookingToCalendar(booking: any) {
|
||||
await this.initialize();
|
||||
|
||||
const calendars = await this.getCalendars();
|
||||
|
||||
if (calendars.length === 0) {
|
||||
throw new Error('No calendars found in Nextcloud');
|
||||
}
|
||||
|
||||
const calendar = calendars[0];
|
||||
|
||||
const event: CalendarEvent = {
|
||||
uid: `savethemoment-booking-${booking.id}`,
|
||||
summary: `${booking.customerName} - ${booking.location?.name || 'Unbekannt'}`,
|
||||
description: `
|
||||
Buchung #${booking.id}
|
||||
Kunde: ${booking.customerName}
|
||||
E-Mail: ${booking.customerEmail}
|
||||
Telefon: ${booking.customerPhone || 'N/A'}
|
||||
Event-Typ: ${booking.eventType}
|
||||
Status: ${booking.status}
|
||||
Fotobox: ${booking.photobox?.name || 'Keine Box'}
|
||||
Standort: ${booking.location?.name || 'Unbekannt'}
|
||||
`.trim(),
|
||||
location: booking.location?.address || '',
|
||||
startDate: new Date(booking.eventDate),
|
||||
endDate: new Date(new Date(booking.eventDate).getTime() + 4 * 60 * 60 * 1000),
|
||||
status: booking.status,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.createEvent(calendar.url, event);
|
||||
return event;
|
||||
} catch (error) {
|
||||
if (error.message?.includes('already exists') || error.response?.status === 412) {
|
||||
await this.updateEvent(calendar.url, event);
|
||||
return event;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeBookingFromCalendar(bookingId: string) {
|
||||
await this.initialize();
|
||||
|
||||
const calendars = await this.getCalendars();
|
||||
|
||||
if (calendars.length === 0) {
|
||||
throw new Error('No calendars found in Nextcloud');
|
||||
}
|
||||
|
||||
const calendar = calendars[0];
|
||||
const eventUid = `savethemoment-booking-${bookingId}`;
|
||||
|
||||
try {
|
||||
await this.deleteEvent(calendar.url, eventUid);
|
||||
} catch (error) {
|
||||
console.error('Error removing booking from calendar:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private generateICS(event: CalendarEvent): string {
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const dtstamp = formatDate(now);
|
||||
const dtstart = formatDate(event.startDate);
|
||||
const dtend = formatDate(event.endDate);
|
||||
|
||||
return `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//SaveTheMoment Atlas//DE
|
||||
BEGIN:VEVENT
|
||||
UID:${event.uid}
|
||||
DTSTAMP:${dtstamp}
|
||||
DTSTART:${dtstart}
|
||||
DTEND:${dtend}
|
||||
SUMMARY:${event.summary}
|
||||
DESCRIPTION:${event.description || ''}
|
||||
LOCATION:${event.location || ''}
|
||||
STATUS:${event.status === 'CONFIRMED' ? 'CONFIRMED' : 'TENTATIVE'}
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
}
|
||||
}
|
||||
|
||||
export const nextcloudCalendar = new NextcloudCalendarService();
|
||||
44
lib/pdf-service.ts
Normal file
44
lib/pdf-service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import { ContractPDF } from './contract-template';
|
||||
import { Readable } from 'stream';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { generateContractFromTemplate } from './pdf-template-service';
|
||||
|
||||
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('error', (err) => reject(err));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
async function getLogoBase64(): Promise<string> {
|
||||
try {
|
||||
const logoPath = path.join(process.cwd(), 'public', 'logo.png');
|
||||
const logoBuffer = await readFile(logoPath);
|
||||
return `data:image/png;base64,${logoBuffer.toString('base64')}`;
|
||||
} catch (error) {
|
||||
console.error('Logo loading error:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateContractPDF(booking: any, location: any, photobox: any) {
|
||||
// Use template-based generation
|
||||
return generateContractFromTemplate(booking, location, photobox);
|
||||
}
|
||||
|
||||
export async function generateSignedContractPDF(
|
||||
booking: any,
|
||||
location: any,
|
||||
photobox: any,
|
||||
signatureData: string,
|
||||
signedBy: string,
|
||||
signedAt: Date,
|
||||
signedIp: string
|
||||
) {
|
||||
// Use template-based generation with signature
|
||||
return generateContractFromTemplate(booking, location, photobox, signatureData);
|
||||
}
|
||||
171
lib/pdf-template-service.ts
Normal file
171
lib/pdf-template-service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
|
||||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { formatDate } from './date-utils';
|
||||
|
||||
export async function generateContractFromTemplate(
|
||||
booking: any,
|
||||
location: any,
|
||||
photobox: any,
|
||||
signatureData?: string
|
||||
) {
|
||||
try {
|
||||
// Load the template PDF
|
||||
const templatePath = path.join(process.cwd(), 'mietvertrag-vorlage.pdf');
|
||||
const templateBytes = await readFile(templatePath);
|
||||
|
||||
// Load PDF
|
||||
const pdfDoc = await PDFDocument.load(templateBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
const firstPage = pages[0];
|
||||
|
||||
// Embed font
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const { width, height } = firstPage.getSize();
|
||||
|
||||
// PDF coordinate system: (0,0) is bottom-left, Y increases upwards
|
||||
// A4 page height is ~842 points
|
||||
|
||||
// Page 1: Customer Info & Table
|
||||
|
||||
// Customer name (under "und") - around middle of page
|
||||
if (booking.customerName) {
|
||||
firstPage.drawText(booking.customerName, {
|
||||
x: 250,
|
||||
y: 465,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Customer address (next line)
|
||||
if (booking.customerAddress) {
|
||||
firstPage.drawText(
|
||||
`${booking.customerAddress}, ${booking.customerZip} ${booking.customerCity}`,
|
||||
{
|
||||
x: 250,
|
||||
y: 450,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Distance (in "Lieferung inkl. Aufbau" row)
|
||||
if (booking.distance) {
|
||||
const distance = booking.distance || 0;
|
||||
const totalKm = distance * 2;
|
||||
|
||||
// Fill in the km field
|
||||
firstPage.drawText(`${distance.toFixed(1)}`, {
|
||||
x: 735,
|
||||
y: 95,
|
||||
size: 9,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
|
||||
firstPage.drawText(`${totalKm.toFixed(1)}`, {
|
||||
x: 895,
|
||||
y: 95,
|
||||
size: 9,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Event location and date (Section 2 - MIETZWECK) - bottom of page 1
|
||||
if (booking.eventLocation && booking.eventDate) {
|
||||
firstPage.drawText(
|
||||
`${booking.eventLocation} am ${formatDate(booking.eventDate)}`,
|
||||
{
|
||||
x: 100,
|
||||
y: 35,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Page 2: Mietzeit & Mietpreis
|
||||
const secondPage = pages[1];
|
||||
|
||||
// Event date (Section 3 - MIETZEIT)
|
||||
if (booking.eventDate) {
|
||||
secondPage.drawText(formatDate(booking.eventDate), {
|
||||
x: 210,
|
||||
y: height - 85,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Price (Section 4 - MIETPREIS)
|
||||
if (booking.calculatedPrice) {
|
||||
const price = booking.calculatedPrice.toFixed(2);
|
||||
secondPage.drawText(`${price}`, {
|
||||
x: 475,
|
||||
y: height - 165,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Signature on last page if provided
|
||||
if (signatureData && pages.length >= 4) {
|
||||
const lastPage = pages[pages.length - 1];
|
||||
|
||||
// Convert base64 signature to image
|
||||
try {
|
||||
const signatureImage = await pdfDoc.embedPng(signatureData);
|
||||
const signatureDims = signatureImage.scale(0.25);
|
||||
|
||||
// Right side signature box
|
||||
lastPage.drawImage(signatureImage, {
|
||||
x: 420,
|
||||
y: 120,
|
||||
width: signatureDims.width,
|
||||
height: signatureDims.height,
|
||||
});
|
||||
|
||||
// Signature name below
|
||||
if (booking.customerName) {
|
||||
lastPage.drawText(booking.customerName, {
|
||||
x: 420,
|
||||
y: 100,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Signature date and info
|
||||
const sigDate = new Date();
|
||||
lastPage.drawText(`Digital signiert am ${formatDate(sigDate)}`, {
|
||||
x: 420,
|
||||
y: 85,
|
||||
size: 8,
|
||||
font: font,
|
||||
color: rgb(0.4, 0.4, 0.4),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Signature embedding error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize the PDF
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return Buffer.from(pdfBytes);
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF generation from template error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
9
lib/prisma.ts
Normal file
9
lib/prisma.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
192
lib/route-optimization.ts
Normal file
192
lib/route-optimization.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
interface RouteStop {
|
||||
address: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
bookingId?: string;
|
||||
setupTime?: string;
|
||||
}
|
||||
|
||||
interface OptimizedRoute {
|
||||
stops: RouteStop[];
|
||||
totalDistance: number;
|
||||
totalDuration: number;
|
||||
legs: any[];
|
||||
}
|
||||
|
||||
export class RouteOptimizationService {
|
||||
private apiKey: string;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.GOOGLE_MAPS_API_KEY || '';
|
||||
if (!this.apiKey) {
|
||||
console.warn('Google Maps API Key nicht konfiguriert');
|
||||
}
|
||||
}
|
||||
|
||||
async geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${this.apiKey}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'OK' && data.results.length > 0) {
|
||||
const location = data.results[0].geometry.location;
|
||||
return { lat: location.lat, lng: location.lng };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async calculateDistanceMatrix(
|
||||
origins: string[],
|
||||
destinations: string[]
|
||||
): Promise<any> {
|
||||
try {
|
||||
const originsParam = origins.map(o => encodeURIComponent(o)).join('|');
|
||||
const destinationsParam = destinations.map(d => encodeURIComponent(d)).join('|');
|
||||
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/distancematrix/json?origins=${originsParam}&destinations=${destinationsParam}&key=${this.apiKey}`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'OK') {
|
||||
return data;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Distance Matrix error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async optimizeRoute(stops: RouteStop[], startLocation?: string): Promise<OptimizedRoute | null> {
|
||||
try {
|
||||
if (stops.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const stop of stops) {
|
||||
if (!stop.lat || !stop.lng) {
|
||||
const coords = await this.geocodeAddress(stop.address);
|
||||
if (coords) {
|
||||
stop.lat = coords.lat;
|
||||
stop.lng = coords.lng;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stops.length === 1) {
|
||||
return {
|
||||
stops: stops,
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
legs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const origin = startLocation || stops[0].address;
|
||||
const destination = stops[stops.length - 1].address;
|
||||
const waypoints = stops.slice(1, -1).map(s => s.address);
|
||||
|
||||
const waypointsParam = waypoints.length > 0
|
||||
? `&waypoints=optimize:true|${waypoints.map(w => encodeURIComponent(w)).join('|')}`
|
||||
: '';
|
||||
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}${waypointsParam}&key=${this.apiKey}`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'OK' && data.routes.length > 0) {
|
||||
const route = data.routes[0];
|
||||
const legs = route.legs;
|
||||
|
||||
let totalDistance = 0;
|
||||
let totalDuration = 0;
|
||||
|
||||
legs.forEach((leg: any) => {
|
||||
totalDistance += leg.distance.value;
|
||||
totalDuration += leg.duration.value;
|
||||
});
|
||||
|
||||
const waypointOrder = route.waypoint_order || [];
|
||||
const optimizedStops = [stops[0]];
|
||||
|
||||
waypointOrder.forEach((index: number) => {
|
||||
optimizedStops.push(stops[index + 1]);
|
||||
});
|
||||
|
||||
if (stops.length > 1) {
|
||||
optimizedStops.push(stops[stops.length - 1]);
|
||||
}
|
||||
|
||||
return {
|
||||
stops: optimizedStops,
|
||||
totalDistance: totalDistance / 1000,
|
||||
totalDuration: Math.round(totalDuration / 60),
|
||||
legs: legs,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Route optimization error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async calculateSimpleRoute(
|
||||
startAddress: string,
|
||||
endAddress: string
|
||||
): Promise<{ distance: number; duration: number } | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent(startAddress)}&destination=${encodeURIComponent(endAddress)}&key=${this.apiKey}`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'OK' && data.routes.length > 0) {
|
||||
const route = data.routes[0];
|
||||
const leg = route.legs[0];
|
||||
|
||||
return {
|
||||
distance: leg.distance.value / 1000,
|
||||
duration: Math.round(leg.duration.value / 60),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Simple route calculation error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
sortStopsByTime(stops: RouteStop[]): RouteStop[] {
|
||||
return stops.sort((a, b) => {
|
||||
if (!a.setupTime || !b.setupTime) return 0;
|
||||
return new Date(a.setupTime).getTime() - new Date(b.setupTime).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
async optimizeRouteWithTimeWindows(
|
||||
stops: RouteStop[],
|
||||
startLocation?: string
|
||||
): Promise<OptimizedRoute | null> {
|
||||
const sortedStops = this.sortStopsByTime(stops);
|
||||
|
||||
return this.optimizeRoute(sortedStops, startLocation);
|
||||
}
|
||||
}
|
||||
|
||||
export const routeService = new RouteOptimizationService();
|
||||
Reference in New Issue
Block a user