Initial commit - SaveTheMoment Atlas Basis-Setup
This commit is contained in:
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();
|
||||
Reference in New Issue
Block a user