import { INodeType, INodeTypeDescription, IPollFunctions, INodeExecutionData, NodeApiError, NodeOperationError, } from 'n8n-workflow'; interface LibreBookingSession { sessionToken: string; userId: number; sessionExpires: string; } interface ReservationData { referenceNumber: string; startDate: string; endDate: string; title: string; resourceId: number; userId: number; description?: string; resourceName?: string; startDateTime?: string; endDateTime?: string; [key: string]: any; } interface WorkflowStaticData { seenIds?: string[]; reservationHashes?: Record; isFirstPoll?: boolean; lastPollTime?: string; } /** * Authentifizierung bei LibreBooking */ async function authenticateTrigger( pollFunctions: IPollFunctions, baseUrl: string, username: string, password: string, ): Promise { try { const response = await pollFunctions.helpers.httpRequest({ method: 'POST', url: `${baseUrl}/Web/Services/index.php/Authentication/Authenticate`, headers: { 'Content-Type': 'application/json' }, body: { username, password }, json: true, }); if (!response.isAuthenticated) { throw new NodeOperationError( pollFunctions.getNode(), 'Authentifizierung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.', ); } return { sessionToken: response.sessionToken, userId: response.userId, sessionExpires: response.sessionExpires, }; } catch (error: any) { throw new NodeApiError(pollFunctions.getNode(), error, { message: 'Authentifizierung fehlgeschlagen', description: 'Überprüfen Sie die LibreBooking URL und Ihre Zugangsdaten.', }); } } /** * Abmeldung */ async function signOutTrigger( pollFunctions: IPollFunctions, baseUrl: string, session: LibreBookingSession, ): Promise { try { await pollFunctions.helpers.httpRequest({ method: 'POST', url: `${baseUrl}/Web/Services/index.php/Authentication/SignOut`, headers: { 'Content-Type': 'application/json' }, body: { userId: session.userId, sessionToken: session.sessionToken, }, json: true, }); } catch (error) { // Ignoriere SignOut-Fehler } } /** * Reservierungen abrufen */ async function getReservations( pollFunctions: IPollFunctions, baseUrl: string, session: LibreBookingSession, startDateTime: string, endDateTime: string, filters: any, ): Promise { const qs: any = { startDateTime, endDateTime, }; if (filters.resourceId) qs.resourceId = filters.resourceId; if (filters.scheduleId) qs.scheduleId = filters.scheduleId; if (filters.userId) qs.userId = filters.userId; try { const response = await pollFunctions.helpers.httpRequest({ method: 'GET', url: `${baseUrl}/Web/Services/index.php/Reservations/`, headers: { 'Content-Type': 'application/json', 'X-Booked-SessionToken': session.sessionToken, 'X-Booked-UserId': session.userId.toString(), }, qs, json: true, }); return response.reservations || []; } catch (error: any) { throw new NodeApiError(pollFunctions.getNode(), error, { message: 'Fehler beim Abrufen der Reservierungen', }); } } /** * Detaillierte Reservierungsdaten abrufen (inkl. Custom Attributes) */ async function getReservationDetails( pollFunctions: IPollFunctions, baseUrl: string, session: LibreBookingSession, referenceNumber: string, ): Promise { try { const response = await pollFunctions.helpers.httpRequest({ method: 'GET', url: `${baseUrl}/Web/Services/index.php/Reservations/${referenceNumber}`, headers: { 'Content-Type': 'application/json', 'X-Booked-SessionToken': session.sessionToken, 'X-Booked-UserId': session.userId.toString(), }, json: true, }); return response; } catch (error) { return null; } } /** * Berechnet Zeitraum basierend auf der gewählten Option */ function getDateRange(dateRange: string): { startDate: string; endDate: string } { const now = new Date(); let startDate: Date; let endDate: Date; switch (dateRange) { case 'thisWeek': // Montag dieser Woche (ISO: Montag = 1, Sonntag = 0) startDate = new Date(now); const dayOfWeek = now.getDay(); const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; startDate.setDate(now.getDate() + diffToMonday); startDate.setHours(0, 0, 0, 0); // Sonntag dieser Woche endDate = new Date(startDate); endDate.setDate(startDate.getDate() + 6); endDate.setHours(23, 59, 59, 999); break; case 'next2Weeks': startDate = new Date(now); startDate.setHours(0, 0, 0, 0); endDate = new Date(now); endDate.setDate(now.getDate() + 14); endDate.setHours(23, 59, 59, 999); break; case 'thisMonth': startDate = new Date(now.getFullYear(), now.getMonth(), 1); startDate.setHours(0, 0, 0, 0); endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); endDate.setHours(23, 59, 59, 999); break; case 'next2Months': startDate = new Date(now); startDate.setHours(0, 0, 0, 0); endDate = new Date(now); endDate.setMonth(now.getMonth() + 2); endDate.setHours(23, 59, 59, 999); break; case 'thisYear': startDate = new Date(now.getFullYear(), 0, 1); startDate.setHours(0, 0, 0, 0); endDate = new Date(now.getFullYear(), 11, 31); endDate.setHours(23, 59, 59, 999); break; default: // custom return { startDate: '', endDate: '' }; } return { startDate: startDate.toISOString(), endDate: endDate.toISOString(), }; } /** * Zeitfenster berechnen für "Get All" Mode */ function getTimeWindowForGetAll( dateRange: string, customStartDate?: string, customEndDate?: string, defaultDays: number = 14, ): { start: string; end: string } { // Wenn ein vordefinierter Zeitraum gewählt wurde if (dateRange && dateRange !== 'custom') { const { startDate, endDate } = getDateRange(dateRange); return { start: startDate, end: endDate }; } // Custom Modus mit manuellen Daten if (customStartDate && customEndDate) { return { start: new Date(customStartDate).toISOString(), end: new Date(customEndDate).toISOString(), }; } // Fallback: Ab heute für defaultDays const now = new Date(); const endDate = new Date(now); endDate.setDate(endDate.getDate() + defaultDays); return { start: now.toISOString(), end: endDate.toISOString(), }; } /** * Zeitfenster berechnen für Polling */ function getTimeWindowForPolling(timeWindow: string): { start: string; end: string } { const now = new Date(); const start = now.toISOString(); let endDate = new Date(now); switch (timeWindow) { case '7days': endDate.setDate(endDate.getDate() + 7); break; case '14days': endDate.setDate(endDate.getDate() + 14); break; case '30days': endDate.setDate(endDate.getDate() + 30); break; case '90days': endDate.setDate(endDate.getDate() + 90); break; case '180days': endDate.setDate(endDate.getDate() + 180); break; default: endDate.setDate(endDate.getDate() + 14); } return { start, end: endDate.toISOString(), }; } /** * Filter Reservierungen nach Zeitpunkt */ function filterByTime(reservations: ReservationData[], timeFilter: string): ReservationData[] { if (timeFilter === 'all') { return reservations; } const now = new Date(); now.setHours(0, 0, 0, 0); return reservations.filter((reservation) => { // Verwende startDate oder startDateTime const dateStr = reservation.startDateTime || reservation.startDate; if (!dateStr) return true; const startDate = new Date(dateStr); startDate.setHours(0, 0, 0, 0); if (timeFilter === 'today') { return startDate.getTime() === now.getTime(); } if (timeFilter === 'next3Days') { const threeDaysFromNow = new Date(now); threeDaysFromNow.setDate(now.getDate() + 3); return startDate >= now && startDate <= threeDaysFromNow; } if (timeFilter === 'next7Days') { const sevenDaysFromNow = new Date(now); sevenDaysFromNow.setDate(now.getDate() + 7); return startDate >= now && startDate <= sevenDaysFromNow; } return true; }); } /** * Hash für Reservierung generieren (für Änderungserkennung) */ function getReservationHash(reservation: ReservationData): string { const relevantData = { referenceNumber: reservation.referenceNumber, startDate: reservation.startDate || reservation.startDateTime, endDate: reservation.endDate || reservation.endDateTime, title: reservation.title || '', description: reservation.description || '', resourceId: reservation.resourceId, resourceName: reservation.resourceName || '', userId: reservation.userId, requiresApproval: reservation.requiresApproval, participants: reservation.participants || [], invitees: reservation.invitees || [], statusId: reservation.statusId, }; return JSON.stringify(relevantData); } /** * LibreBooking Trigger Node * * Drei Modi: * 1. Get All (One-Time): Alle Reservierungen für einen Zeitraum abrufen * 2. New Reservations (Poll): Bei neuen Reservierungen triggern * 3. Updated Reservations (Poll): Bei geänderten Reservierungen triggern */ export class LibreBookingTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'LibreBooking Trigger', name: 'libreBookingTrigger', icon: 'file:librebooking.svg', group: ['trigger'], version: 1, description: 'Wird bei neuen oder geänderten Reservierungen in LibreBooking ausgelöst', subtitle: '={{$parameter["triggerMode"]}}', defaults: { name: 'LibreBooking Trigger', }, inputs: [], outputs: ['main'], credentials: [ { name: 'libreBookingApi', required: true, }, ], polling: true, properties: [ // ===================================================== // TRIGGER MODE SELECTOR // ===================================================== { displayName: 'Trigger-Modus', name: 'triggerMode', type: 'options', options: [ { name: 'Alle Abrufen (Einmalig)', value: 'getAll', description: 'Alle Reservierungen für einen Zeitraum abrufen (bei jedem Poll)', }, { name: 'Neue Reservierungen (Polling)', value: 'newReservations', description: 'Nur bei neuen Reservierungen triggern', }, { name: 'Geänderte Reservierungen (Polling)', value: 'updatedReservations', description: 'Nur bei geänderten Reservierungen triggern', }, ], default: 'getAll', description: 'Wählen Sie den Trigger-Modus', }, // ===================================================== // GET ALL MODE - DATE RANGE SELECTOR // ===================================================== { displayName: 'Zeitraum', name: 'dateRange', type: 'options', displayOptions: { show: { triggerMode: ['getAll'], }, }, options: [ { name: 'Benutzerdefiniert', value: 'custom', description: 'Start- und Enddatum manuell angeben', }, { name: 'Diese Woche', value: 'thisWeek', description: 'Von Montag bis Sonntag der aktuellen Woche', }, { name: 'Nächste 2 Wochen', value: 'next2Weeks', description: 'Ab heute bis 14 Tage in die Zukunft', }, { name: 'Dieser Monat', value: 'thisMonth', description: 'Vom 1. bis zum letzten Tag des aktuellen Monats', }, { name: 'Nächste 2 Monate', value: 'next2Months', description: 'Ab heute bis 2 Monate in die Zukunft', }, { name: 'Dieses Jahr', value: 'thisYear', description: 'Vom 1. Januar bis 31. Dezember des aktuellen Jahres', }, ], default: 'custom', description: 'Vordefinierter Zeitraum für den Abruf', }, { displayName: 'Startdatum', name: 'startDate', type: 'dateTime', displayOptions: { show: { triggerMode: ['getAll'], dateRange: ['custom'], }, }, default: '', description: 'Startdatum für den Abruf (leer = heute)', }, { displayName: 'Enddatum', name: 'endDate', type: 'dateTime', displayOptions: { show: { triggerMode: ['getAll'], dateRange: ['custom'], }, }, default: '', description: 'Enddatum für den Abruf (leer = 14 Tage in der Zukunft)', }, // ===================================================== // POLLING MODE - TIME WINDOW // ===================================================== { displayName: 'Zeitfenster', name: 'timeWindow', type: 'options', displayOptions: { show: { triggerMode: ['newReservations', 'updatedReservations'], }, }, options: [ { name: 'Nächste 7 Tage', value: '7days' }, { name: 'Nächste 14 Tage', value: '14days' }, { name: 'Nächste 30 Tage', value: '30days' }, { name: 'Nächste 90 Tage', value: '90days' }, { name: 'Nächste 180 Tage (6 Monate)', value: '180days' }, ], default: '14days', description: 'Zeitfenster für die Überwachung von Reservierungen', }, // ===================================================== // TIME FILTER FOR NEW/UPDATED MODES // ===================================================== { displayName: 'Zeit-Filter', name: 'timeFilter', type: 'options', displayOptions: { show: { triggerMode: ['newReservations', 'updatedReservations'], }, }, options: [ { name: 'Alle (Kein Filter)', value: 'all', description: 'Alle neuen/geänderten Reservierungen, unabhängig vom Datum', }, { name: 'Nur Heute', value: 'today', description: 'Nur Reservierungen, die heute stattfinden', }, { name: 'Nächste 3 Tage', value: 'next3Days', description: 'Nur Reservierungen, die in den nächsten 3 Tagen stattfinden', }, { name: 'Nächste 7 Tage', value: 'next7Days', description: 'Nur Reservierungen, die in den nächsten 7 Tagen stattfinden', }, ], default: 'all', description: 'Filtert Reservierungen nach ihrem Startdatum', }, { displayName: 'Hinweis', name: 'pollingNotice', type: 'notice', default: '', displayOptions: { show: { triggerMode: ['newReservations', 'updatedReservations'], }, }, description: 'Beim ersten Poll werden existierende Reservierungen gespeichert, aber nicht getriggert. Nur nachfolgende Änderungen lösen den Trigger aus.', }, // ===================================================== // FILTERS (ALL MODES) // ===================================================== { displayName: 'Filter', name: 'filters', type: 'collection', placeholder: 'Filter hinzufügen', default: {}, options: [ { displayName: 'Ressourcen-ID', name: 'resourceId', type: 'number', default: '', description: 'Nur Reservierungen für diese Ressource', }, { displayName: 'Zeitplan-ID', name: 'scheduleId', type: 'number', default: '', description: 'Nur Reservierungen für diesen Zeitplan', }, { displayName: 'Benutzer-ID', name: 'userId', type: 'number', default: '', description: 'Nur Reservierungen für diesen Benutzer', }, ], }, // ===================================================== // OPTIONS (ALL MODES) // ===================================================== { displayName: 'Optionen', name: 'options', type: 'collection', placeholder: 'Option hinzufügen', default: {}, options: [ { displayName: 'Detaillierte Daten Abrufen', name: 'fetchDetails', type: 'boolean', default: false, description: 'Ruft vollständige Reservierungsdaten inkl. Custom Attributes ab (zusätzliche API-Aufrufe)', }, { displayName: 'Debug-Modus', name: 'debugMode', type: 'boolean', default: false, description: 'Gibt zusätzliche Debug-Informationen aus', }, ], }, ], }; async poll(this: IPollFunctions): Promise { const credentials = await this.getCredentials('libreBookingApi'); const baseUrl = (credentials.url as string).replace(/\/$/, ''); const username = credentials.username as string; const password = credentials.password as string; const triggerMode = this.getNodeParameter('triggerMode') as string; const filters = this.getNodeParameter('filters', {}) as any; const options = this.getNodeParameter('options', {}) as any; // Debug-Modus const debugMode = options.debugMode || false; const fetchDetails = options.fetchDetails || false; // Workflow Static Data für State-Management const webhookData = this.getWorkflowStaticData('node') as WorkflowStaticData; let session: LibreBookingSession; try { session = await authenticateTrigger(this, baseUrl, username, password); } catch (error) { throw error; } try { const returnData: INodeExecutionData[] = []; // ========================================== // MODE: Get All (One-Time / Every Poll) // ========================================== if (triggerMode === 'getAll') { const dateRange = this.getNodeParameter('dateRange', 'custom') as string; const startDate = this.getNodeParameter('startDate', '') as string; const endDate = this.getNodeParameter('endDate', '') as string; const { start, end } = getTimeWindowForGetAll( dateRange, startDate || undefined, endDate || undefined, 14, ); const reservations = await getReservations(this, baseUrl, session, start, end, filters); if (debugMode) { console.log( `[LibreBooking Trigger] Get All Mode - Found ${reservations.length} reservations`, ); console.log(`[LibreBooking Trigger] Date Range: ${dateRange}`); console.log(`[LibreBooking Trigger] Period: ${start} to ${end}`); } if (reservations.length === 0) { if (debugMode) { return [ [ { json: { _debug: true, _message: 'Keine Reservierungen im Zeitraum gefunden', _dateRange: dateRange, _startDate: start, _endDate: end, _count: 0, }, }, ], ]; } return null; } // Alle Reservierungen zurückgeben for (const reservation of reservations) { let reservationData = reservation; if (fetchDetails) { try { const details = await getReservationDetails( this, baseUrl, session, reservation.referenceNumber, ); if (details) { reservationData = details; } } catch (error) { // Fallback auf Basisdaten } } returnData.push({ json: { ...reservationData, _eventType: 'getAll', _dateRange: dateRange, _triggeredAt: new Date().toISOString(), }, }); } } // ========================================== // MODE: New Reservations (Polling) // ========================================== else if (triggerMode === 'newReservations') { const timeWindow = this.getNodeParameter('timeWindow', '14days') as string; const timeFilter = this.getNodeParameter('timeFilter', 'all') as string; const { start, end } = getTimeWindowForPolling(timeWindow); const reservations = await getReservations(this, baseUrl, session, start, end, filters); // Initialisiere seenIds beim ersten Poll if (!webhookData.seenIds) { webhookData.seenIds = []; webhookData.isFirstPoll = true; } const currentIds = reservations.map((r: ReservationData) => r.referenceNumber); if (debugMode) { console.log(`[LibreBooking Trigger] New Reservations Mode`); console.log(`[LibreBooking Trigger] First Poll: ${webhookData.isFirstPoll}`); console.log(`[LibreBooking Trigger] Time Filter: ${timeFilter}`); console.log( `[LibreBooking Trigger] Current IDs: ${currentIds.length}, Seen IDs: ${webhookData.seenIds.length}`, ); } // Beim ersten Poll: Nur IDs speichern, NICHT triggern if (webhookData.isFirstPoll) { webhookData.seenIds = currentIds; webhookData.isFirstPoll = false; webhookData.lastPollTime = new Date().toISOString(); if (debugMode) { return [ [ { json: { _debug: true, _message: 'Erster Poll - IDs wurden gespeichert, keine Events getriggert', _savedIds: currentIds.length, _ids: currentIds, _timestamp: webhookData.lastPollTime, }, }, ], ]; } return null; // Nichts triggern beim ersten Poll } // Nur NEUE Reservierungen (die wir noch nicht gesehen haben) let newReservations = reservations.filter( (r: ReservationData) => !webhookData.seenIds!.includes(r.referenceNumber), ); // Update seenIds mit allen aktuellen IDs webhookData.seenIds = currentIds; webhookData.lastPollTime = new Date().toISOString(); if (debugMode) { console.log( `[LibreBooking Trigger] Found ${newReservations.length} new reservations before filter`, ); } if (newReservations.length === 0) { return null; } // Zeit-Filter anwenden newReservations = filterByTime(newReservations, timeFilter); if (debugMode) { console.log( `[LibreBooking Trigger] ${newReservations.length} reservations after time filter (${timeFilter})`, ); } if (newReservations.length === 0) { return null; } // Neue Reservierungen verarbeiten for (const reservation of newReservations) { let reservationData = reservation; if (fetchDetails) { try { const details = await getReservationDetails( this, baseUrl, session, reservation.referenceNumber, ); if (details) { reservationData = details; } } catch (error) { // Fallback auf Basisdaten } } returnData.push({ json: { ...reservationData, _eventType: 'new', _timeFilter: timeFilter, _triggeredAt: new Date().toISOString(), }, }); } if (debugMode && returnData.length > 0) { console.log(`[LibreBooking Trigger] Triggering ${returnData.length} new reservations`); } } // ========================================== // MODE: Updated Reservations (Polling) // ========================================== else if (triggerMode === 'updatedReservations') { const timeWindow = this.getNodeParameter('timeWindow', '14days') as string; const timeFilter = this.getNodeParameter('timeFilter', 'all') as string; const { start, end } = getTimeWindowForPolling(timeWindow); const reservations = await getReservations(this, baseUrl, session, start, end, filters); // Initialisiere reservationHashes beim ersten Poll if (!webhookData.reservationHashes) { webhookData.reservationHashes = {}; webhookData.isFirstPoll = true; } if (debugMode) { console.log(`[LibreBooking Trigger] Updated Reservations Mode`); console.log(`[LibreBooking Trigger] First Poll: ${webhookData.isFirstPoll}`); console.log(`[LibreBooking Trigger] Time Filter: ${timeFilter}`); console.log( `[LibreBooking Trigger] Current: ${reservations.length}, Stored hashes: ${Object.keys(webhookData.reservationHashes).length}`, ); } // Beim ersten Poll: Nur Hashes speichern, NICHT triggern if (webhookData.isFirstPoll) { for (const reservation of reservations) { webhookData.reservationHashes[reservation.referenceNumber] = getReservationHash(reservation); } webhookData.isFirstPoll = false; webhookData.lastPollTime = new Date().toISOString(); if (debugMode) { return [ [ { json: { _debug: true, _message: 'Erster Poll - Hashes wurden gespeichert, keine Events getriggert', _savedHashes: Object.keys(webhookData.reservationHashes).length, _timestamp: webhookData.lastPollTime, }, }, ], ]; } return null; // Nichts triggern beim ersten Poll } // Geänderte Reservierungen finden let updatedReservations: ReservationData[] = []; const newHashes: Record = {}; for (const reservation of reservations) { const currentHash = getReservationHash(reservation); const oldHash = webhookData.reservationHashes[reservation.referenceNumber]; newHashes[reservation.referenceNumber] = currentHash; // Nur als "geändert" markieren, wenn: // 1. Wir die Reservierung schon kennen (nicht neu) // 2. Der Hash sich geändert hat if (oldHash && currentHash !== oldHash) { updatedReservations.push(reservation); } } // Update Hashes mit allen aktuellen Reservierungen webhookData.reservationHashes = newHashes; webhookData.lastPollTime = new Date().toISOString(); if (debugMode) { console.log( `[LibreBooking Trigger] Found ${updatedReservations.length} updated reservations before filter`, ); } if (updatedReservations.length === 0) { return null; } // Zeit-Filter anwenden updatedReservations = filterByTime(updatedReservations, timeFilter); if (debugMode) { console.log( `[LibreBooking Trigger] ${updatedReservations.length} reservations after time filter (${timeFilter})`, ); } if (updatedReservations.length === 0) { return null; } // Geänderte Reservierungen verarbeiten for (const reservation of updatedReservations) { let reservationData = reservation; if (fetchDetails) { try { const details = await getReservationDetails( this, baseUrl, session, reservation.referenceNumber, ); if (details) { reservationData = details; } } catch (error) { // Fallback auf Basisdaten } } returnData.push({ json: { ...reservationData, _eventType: 'updated', _timeFilter: timeFilter, _triggeredAt: new Date().toISOString(), }, }); } if (debugMode && returnData.length > 0) { console.log( `[LibreBooking Trigger] Triggering ${returnData.length} updated reservations`, ); } } if (returnData.length === 0) { return null; } return [returnData]; } finally { await signOutTrigger(this, baseUrl, session); } } }