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; [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; } } /** * Zeitfenster berechnen für "Get All" Mode */ function getTimeWindowForGetAll( customStartDate?: string, customEndDate?: string, defaultDays: number = 14 ): { start: string; end: string } { if (customStartDate && customEndDate) { return { start: new Date(customStartDate).toISOString(), end: new Date(customEndDate).toISOString(), }; } 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; default: endDate.setDate(endDate.getDate() + 14); } return { start, end: endDate.toISOString(), }; } /** * Hash für Reservierung generieren (für Änderungserkennung) */ function getReservationHash(reservation: ReservationData): string { const relevantData = { referenceNumber: reservation.referenceNumber, startDate: reservation.startDate, endDate: reservation.endDate, title: reservation.title || '', description: reservation.description || '', resourceId: reservation.resourceId, resourceName: reservation.resourceName || '', userId: reservation.userId, requiresApproval: reservation.requiresApproval, participants: reservation.participants || [], invitees: reservation.invitees || [], }; 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 // ===================================================== { displayName: 'Startdatum', name: 'startDate', type: 'dateTime', displayOptions: { show: { triggerMode: ['getAll'], }, }, default: '', description: 'Startdatum für den Abruf (leer = heute)', }, { displayName: 'Enddatum', name: 'endDate', type: 'dateTime', displayOptions: { show: { triggerMode: ['getAll'], }, }, 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' }, ], default: '14days', description: 'Zeitfenster für die Überwachung von Reservierungen', }, { 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 startDate = this.getNodeParameter('startDate', '') as string; const endDate = this.getNodeParameter('endDate', '') as string; const { start, end } = getTimeWindowForGetAll( 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: ${start} to ${end}`); } if (reservations.length === 0) { if (debugMode) { return [[{ json: { _debug: true, _message: 'Keine Reservierungen im Zeitraum gefunden', _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', _triggeredAt: new Date().toISOString(), }, }); } } // ========================================== // MODE: New Reservations (Polling) // ========================================== else if (triggerMode === 'newReservations') { const timeWindow = this.getNodeParameter('timeWindow', '14days') 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] 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) const 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 (newReservations.length === 0) { if (debugMode) { console.log(`[LibreBooking Trigger] No new reservations found`); } 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', _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 { 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] 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 const 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 (updatedReservations.length === 0) { if (debugMode) { console.log(`[LibreBooking Trigger] No updated reservations found`); } 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', _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); } } }