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', ); } return { sessionToken: response.sessionToken, userId: response.userId, sessionExpires: response.sessionExpires, }; } catch (error: any) { throw new NodeApiError(pollFunctions.getNode(), error, { message: 'Authentifizierung fehlgeschlagen', }); } } /** * 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; 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 || []; } /** * Detaillierte Reservierungsdaten abrufen */ async function getReservationDetails( pollFunctions: IPollFunctions, baseUrl: string, session: LibreBookingSession, referenceNumber: string, ): Promise { 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; } /** * Zeitfenster berechnen */ function getTimeWindow(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) * Nur relevante Felder berücksichtigen, die Änderungen anzeigen */ 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 * * Überwacht neue und geänderte Reservierungen in LibreBooking. * * WICHTIG: Beim ersten Poll werden nur die IDs/Hashes gespeichert, * aber keine Events getriggert. Dies verhindert, dass alle * existierenden Reservierungen als "neu" getriggert werden. */ 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["event"]}}', defaults: { name: 'LibreBooking Trigger', }, inputs: [], outputs: ['main'], credentials: [ { name: 'libreBookingApi', required: true, }, ], polling: true, properties: [ { displayName: 'Event', name: 'event', type: 'options', options: [ { name: 'Neue Reservierung', value: 'newReservation', description: 'Wird bei neuen Reservierungen ausgelöst (nicht beim ersten Poll)' }, { name: 'Geänderte Reservierung', value: 'updatedReservation', description: 'Wird bei geänderten Reservierungen ausgelöst' }, { name: 'Alle Reservierungen', value: 'allReservations', description: 'Wird bei neuen und geänderten Reservierungen ausgelöst' }, ], default: 'newReservation', }, { displayName: 'Hinweis', name: 'notice', type: 'notice', default: '', displayOptions: { show: { event: ['newReservation', 'allReservations'], }, }, description: 'Beim ersten Poll werden existierende Reservierungen gespeichert, aber nicht getriggert. Nur nachfolgende neue Reservierungen lösen den Trigger aus.', }, { displayName: 'Filter', name: 'filters', type: 'collection', placeholder: 'Filter hinzufügen', default: {}, options: [ { displayName: 'Ressourcen-ID', name: 'resourceId', type: 'number', default: '' }, { displayName: 'Zeitplan-ID', name: 'scheduleId', type: 'number', default: '' }, { displayName: 'Benutzer-ID', name: 'userId', type: 'number', default: '' }, ], }, { displayName: 'Zeitfenster', name: 'timeWindow', type: 'options', 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', }, { 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 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 event = this.getNodeParameter('event') as string; const filters = this.getNodeParameter('filters', {}) as any; const timeWindow = this.getNodeParameter('timeWindow', '14days') as string; const options = this.getNodeParameter('options', {}) as any; // Workflow Static Data für State-Management const webhookData = this.getWorkflowStaticData('node') as WorkflowStaticData; // Debug-Modus const debugMode = options.debugMode || false; let session: LibreBookingSession; try { session = await authenticateTrigger(this, baseUrl, username, password); } catch (error) { throw error; } try { const { start, end } = getTimeWindow(timeWindow); const reservations = await getReservations( this, baseUrl, session, start, end, filters, ); const returnData: INodeExecutionData[] = []; // ========================================== // EVENT: Neue Reservierungen // ========================================== if (event === 'newReservation') { // Initialisiere seenIds beim ersten Poll if (!webhookData.seenIds) { webhookData.seenIds = []; webhookData.isFirstPoll = true; } const currentIds = reservations.map((r: ReservationData) => r.referenceNumber); // 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, _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) { return null; } // Neue Reservierungen verarbeiten for (const reservation of newReservations) { let reservationData = reservation; if (options.fetchDetails) { try { reservationData = await getReservationDetails( this, baseUrl, session, reservation.referenceNumber, ); } catch (error) { reservationData = reservation; } } returnData.push({ json: { ...reservationData, _eventType: 'new', _triggeredAt: new Date().toISOString(), }, }); } } // ========================================== // EVENT: Geänderte Reservierungen // ========================================== else if (event === 'updatedReservation') { // Initialisiere reservationHashes beim ersten Poll if (!webhookData.reservationHashes) { webhookData.reservationHashes = {}; webhookData.isFirstPoll = true; } // 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) { return null; } // Geänderte Reservierungen verarbeiten for (const reservation of updatedReservations) { let reservationData = reservation; if (options.fetchDetails) { try { reservationData = await getReservationDetails( this, baseUrl, session, reservation.referenceNumber, ); } catch (error) { reservationData = reservation; } } returnData.push({ json: { ...reservationData, _eventType: 'updated', _triggeredAt: new Date().toISOString(), }, }); } } // ========================================== // EVENT: Alle Reservierungen (Neu + Geändert) // ========================================== else if (event === 'allReservations') { // Initialisiere beide Tracking-Strukturen beim ersten Poll if (!webhookData.seenIds || !webhookData.reservationHashes) { webhookData.seenIds = []; webhookData.reservationHashes = {}; webhookData.isFirstPoll = true; } // Beim ersten Poll: IDs und Hashes speichern, NICHT triggern if (webhookData.isFirstPoll) { webhookData.seenIds = reservations.map((r: ReservationData) => r.referenceNumber); 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 - IDs und Hashes wurden gespeichert, keine Events getriggert', _savedIds: webhookData.seenIds.length, _savedHashes: Object.keys(webhookData.reservationHashes).length, _timestamp: webhookData.lastPollTime, }, }]]; } return null; } const newHashes: Record = {}; const currentIds: string[] = []; for (const reservation of reservations) { const refNumber = reservation.referenceNumber; const currentHash = getReservationHash(reservation); currentIds.push(refNumber); newHashes[refNumber] = currentHash; const isNew = !webhookData.seenIds!.includes(refNumber); const oldHash = webhookData.reservationHashes![refNumber]; const isUpdated = oldHash && currentHash !== oldHash; if (isNew || isUpdated) { let reservationData = reservation; if (options.fetchDetails) { try { reservationData = await getReservationDetails( this, baseUrl, session, refNumber, ); } catch (error) { reservationData = reservation; } } returnData.push({ json: { ...reservationData, _eventType: isNew ? 'new' : 'updated', _triggeredAt: new Date().toISOString(), }, }); } } // Update State webhookData.seenIds = currentIds; webhookData.reservationHashes = newHashes; webhookData.lastPollTime = new Date().toISOString(); } if (returnData.length === 0) { return null; } return [returnData]; } finally { await signOutTrigger(this, baseUrl, session); } } }