- Vintage Modell hinzugefuegt - Equipment Multi-Select (Neue Buchung + Bearbeitung) - Kundenadresse in Formularen - Bearbeiten-Seite fuer Buchungen - Abbau-Zeiten in Formularen und Uebersicht - Vertrag PDF nur bei Privatkunden - LexOffice Kontakt-Erstellung Fix (BUSINESS) - Zurueck-Pfeil auf Touren-Seite
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import Imap from 'imap';
|
|
import { simpleParser } from 'mailparser';
|
|
import { prisma } from './prisma';
|
|
import { emailParser } from './email-parser';
|
|
import { bookingAutomationService } from './booking-automation';
|
|
|
|
export interface ImapConfig {
|
|
host: string;
|
|
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,
|
|
},
|
|
});
|
|
|
|
// 🤖 Automatische Post-Booking Aktionen
|
|
console.log('📢 Starte automatische Aktionen...');
|
|
bookingAutomationService.runPostBookingActions(booking.id).catch(err => {
|
|
console.error('⚠️ Automatische Aktionen fehlgeschlagen:', err);
|
|
});
|
|
|
|
return booking;
|
|
} catch (error) {
|
|
console.error('Failed to create booking from parsed data:', error);
|
|
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();
|