Version 1.2.1

This commit is contained in:
Sebastian Zell 2026-01-25 23:21:52 +01:00
parent 88b8bbd6a6
commit e59aa0241f
29 changed files with 5233 additions and 1683 deletions

File diff suppressed because one or more lines are too long

46
.gitignore vendored
View File

@ -1,46 +0,0 @@
# Node modules
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
dist/
*.tsbuildinfo
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Environment
.env
.env.local
.env.*.local
# Test
coverage/
.nyc_output/
# Temporary files
*.tmp
*.temp
.cache/
# Archives (optional - kann entfernt werden wenn man sie im Repo haben will)
*.tar.gz
*.zip
*.bundle
# Docker build artifacts
dist-for-docker/

View File

@ -2,6 +2,37 @@
Alle wichtigen Änderungen werden hier dokumentiert. Alle wichtigen Änderungen werden hier dokumentiert.
## [1.2.1] - 2026-01-25
### Behoben
- 🐛 **allowParticipation Fehler**: API-Fehler "Undefined property: stdClass::$allowParticipation" behoben. Das Feld wird jetzt immer im Request-Body gesendet.
- 🐛 **Trigger "Alle Abrufen" funktioniert nicht**: Trigger-Modi komplett überarbeitet mit drei klaren Optionen:
- "Alle Abrufen (Einmalig)" - Ruft alle Reservierungen für einen Zeitraum ab
- "Neue Reservierungen (Polling)" - Erkennt neue Reservierungen
- "Geänderte Reservierungen (Polling)" - Erkennt Änderungen
- 🐛 **Custom Attributes bei GetAll**: Option fehlt
### Hinzugefügt
- ⭐ **Include Custom Attributes Option**: Neues "Custom Attributes Einschließen" Checkbox bei:
- Reservierungen → Alle Abrufen
- Ressourcen → Alle Abrufen
- Benutzer → Alle Abrufen
- 📋 **TEST-RESULTS.md**: Detaillierte Test-Dokumentation mit echten API-Tests
- 📋 **test-api.ts**: Verbessertes Test-Skript für alle API-Endpunkte
### Geändert
- **Trigger Node**: Komplett überarbeitete UI mit klarerer Trennung der Modi
- **Trigger Zeitraum**: Optionale Start-/Enddatum-Felder für "Alle Abrufen" Mode
- **Reservierung erstellen/aktualisieren**: allowParticipation wird immer gesetzt (API-Pflichtfeld)
### Getestet
- ✅ 19 API-Tests erfolgreich bestanden
- ✅ Alle Trigger-Modi getestet
- ✅ Custom Attributes Integration getestet
- **Test-URL**: https://librebooking.zell-cloud.de
---
## [1.2.0] - 2026-01-25 ## [1.2.0] - 2026-01-25
### Hinzugefügt ### Hinzugefügt

View File

@ -128,6 +128,62 @@ Gleiche Vorgehensweise wie beim Erstellen.
- **Nur Admin**: Nur für Admins sichtbar? - **Nur Admin**: Nur für Admins sichtbar?
- **Mögliche Werte**: Für Auswahllisten (komma-getrennt) - **Mögliche Werte**: Für Auswahllisten (komma-getrennt)
## Elegante Lösung: Attribute automatisch abrufen (NEU in v1.2.1)
### Das Problem
Bisher musste man Attribut-IDs manuell eingeben, was umständlich war.
### Die Lösung: "Custom Attributes Einschließen"
Bei den GetAll-Operationen gibt es jetzt eine neue Option, die automatisch die Custom Attribute Values für jeden Eintrag abruft.
### Verwendung für Reservierungen
1. Wählen Sie **Ressource**: `Reservierung`
2. Wählen Sie **Operation**: `Alle Abrufen`
3. Unter **Filter** aktivieren Sie **Custom Attributes Einschließen**
**Ergebnis:**
```json
{
"reservations": [
{
"referenceNumber": "abc123",
"title": "Meeting",
"startDate": "2026-02-07T10:00:00",
"customAttributes": [
{
"id": 1,
"label": "Mietername",
"value": "Max Mustermann"
},
{
"id": 3,
"label": "Adresse",
"value": "Hauptstraße 1, 12345 Stadt"
}
]
}
]
}
```
### Verwendung für Ressourcen
1. Wählen Sie **Ressource**: `Ressource`
2. Wählen Sie **Operation**: `Alle Abrufen`
3. Unter **Ressourcen-Abruf-Optionen** aktivieren Sie **Custom Attributes Einschließen**
### Verwendung für Benutzer
1. Wählen Sie **Ressource**: `Benutzer`
2. Wählen Sie **Operation**: `Alle Abrufen`
3. Unter **Benutzer-Filter** aktivieren Sie **Custom Attributes Einschließen**
### Wichtiger Hinweis
Diese Option führt für jeden Eintrag einen zusätzlichen API-Call durch. Bei vielen Einträgen kann dies länger dauern.
---
## Tipps ## Tipps
### Attribut-IDs herausfinden ### Attribut-IDs herausfinden

Binary file not shown.

143
TEST-RESULTS.md Normal file
View File

@ -0,0 +1,143 @@
# LibreBooking n8n Node - Test Results
## Test Datum: 25.01.2026
### Test-Umgebung
- **URL**: https://librebooking.zell-cloud.de
- **Benutzer**: sebastian.zell@zell-aufmass.de
- **n8n Node Version**: 1.2.1
---
## Test-Ergebnisse
### 1. Authentifizierung ✅
- Login erfolgreich
- Session Token wird korrekt generiert
- User ID wird zurückgegeben
### 2. Reservierungen ✅
| Operation | Status | Details |
|-----------|--------|---------|
| Get All | ✅ | 12 Reservierungen gefunden |
| Get All (mit Datumsfilter) | ✅ | Filtert korrekt nach Zeitraum |
| Get (Einzeln) | ✅ | Custom Attributes werden zurückgegeben |
| Create | ✅ | allowParticipation wird korrekt gesetzt |
| Update | ✅ | Änderungen werden übernommen |
| Delete | ✅ | Reservierung wird gelöscht |
**Custom Attributes für Reservierungen (9 gefunden):**
- Mietername (ID: 1, Typ: Text, Pflicht: ✅)
- Telefon (ID: 2, Typ: Text, Pflicht: ❌)
- Adresse (ID: 3, Typ: Text, Pflicht: ✅)
- Lage der Wohnung Gebäudeart (ID: 11, Typ: Auswahl)
- Geschoss (ID: 9, Typ: Auswahl)
- Lage der Wohnung Lage im Grundriss (ID: 10, Typ: Auswahl)
- Quadratmeter (ID: 12, Typ: Text)
- Clustername (ID: 4, Typ: Text, Pflicht: ✅)
- Status (ID: 8, Typ: Auswahl, Pflicht: ✅)
### 3. Ressourcen ✅
| Operation | Status | Details |
|-----------|--------|---------|
| Get All | ✅ | 4 Ressourcen gefunden |
| Get (Einzeln) | ✅ | Details werden abgerufen |
**Ressourcen:**
- Aufmass Team 1 (ID: 1)
- Aufmass Team 2 (ID: 2)
- Aufmass Team 3 (ID: 3)
- Aufmass Team 4 (ID: 4)
### 4. Benutzer ✅
| Operation | Status | Details |
|-----------|--------|---------|
| Get All | ✅ | 3 Benutzer gefunden |
| Get (Einzeln) | ✅ | Details werden abgerufen |
### 5. Zeitpläne ✅
| Operation | Status | Details |
|-----------|--------|---------|
| Get All | ✅ | 1 Zeitplan gefunden |
### 6. Attribute (nach Kategorie) ✅
| Kategorie | Anzahl |
|-----------|--------|
| Reservierung (1) | 9 |
| Benutzer (2) | 0 |
| Ressource (4) | 0 |
| Ressourcen-Typ (5) | 0 |
### 7. Gruppen ✅
| Operation | Status | Details |
|-----------|--------|---------|
| Get All | ✅ | 2 Gruppen gefunden |
### 8. Zubehör ✅
| Operation | Status | Details |
|-----------|--------|---------|
| Get All | ✅ | 0 Zubehörteile (keine konfiguriert) |
### 9. Sign Out ✅
- Session wird korrekt beendet
---
## Trigger Node Tests
### "Alle Abrufen" (Get All) Mode ✅
- Ruft alle Reservierungen für den angegebenen Zeitraum ab
- Optionale Start-/Enddatum-Filter funktionieren
- "Detaillierte Daten Abrufen" Option lädt Custom Attributes
### "Neue Reservierungen" (Poll) Mode ✅
- Erster Poll: Speichert IDs, triggert nicht
- Folgende Polls: Erkennt neue Reservierungen
- Debug-Modus zeigt gespeicherte IDs an
### "Geänderte Reservierungen" (Poll) Mode ✅
- Erster Poll: Speichert Hashes, triggert nicht
- Folgende Polls: Erkennt Änderungen durch Hash-Vergleich
- Änderungen an Titel, Beschreibung, Zeitraum werden erkannt
---
## Behobene Probleme
### 1. allowParticipation Fehler ✅
**Problem**: API-Fehler "Undefined property: stdClass::$allowParticipation"
**Lösung**: `allowParticipation` wird jetzt immer im Request-Body gesendet (ist ein Pflichtfeld).
### 2. Trigger "Alle Abrufen" funktioniert nicht ✅
**Problem**: Mode war unklar, nutzte Polling-Logik
**Lösung**: Neuer "Alle Abrufen (Einmalig)" Mode mit optionalen Datum-Parametern.
### 3. Custom Attributes nicht elegant abrufbar ✅
**Problem**: Manuelles Eingeben von Attribut-IDs nötig
**Lösung**: "Custom Attributes Einschließen" Option bei GetAll-Operationen für:
- Reservierungen
- Ressourcen
- Benutzer
---
## Test-Zusammenfassung
| Kategorie | Tests | Bestanden | Fehlgeschlagen |
|-----------|-------|-----------|----------------|
| API-Endpunkte | 19 | 19 | 0 |
| Trigger Modes | 3 | 3 | 0 |
| Custom Attributes | 4 | 4 | 0 |
| **Gesamt** | **26** | **26** | **0** |
✅ **Alle Tests erfolgreich bestanden!**

BIN
TEST-RESULTS.pdf Normal file

Binary file not shown.

View File

@ -0,0 +1,15 @@
import { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';
/**
* LibreBooking API Credentials
*
* LibreBooking verwendet Session-basierte Authentifizierung.
* Der Node holt bei jeder Ausführung einen neuen Session-Token.
*/
export declare class LibreBookingApi implements ICredentialType {
name: string;
displayName: string;
documentationUrl: string;
properties: INodeProperties[];
test: ICredentialTestRequest;
}
//# sourceMappingURL=LibreBookingApi.credentials.d.ts.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LibreBookingApi.credentials.d.ts","sourceRoot":"","sources":["../../credentials/LibreBookingApi.credentials.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,sBAAsB,EACtB,eAAe,EACf,eAAe,EACf,MAAM,cAAc,CAAC;AAEtB;;;;;GAKG;AACH,qBAAa,eAAgB,YAAW,eAAe;IACtD,IAAI,SAAqB;IACzB,WAAW,SAAsB;IACjC,gBAAgB,SAAuC;IAEvD,UAAU,EAAE,eAAe,EAAE,CA6B3B;IAGF,IAAI,EAAE,sBAAsB,CAa1B;CACF"}

View File

@ -0,0 +1,63 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LibreBookingApi = void 0;
/**
* LibreBooking API Credentials
*
* LibreBooking verwendet Session-basierte Authentifizierung.
* Der Node holt bei jeder Ausführung einen neuen Session-Token.
*/
class LibreBookingApi {
constructor() {
this.name = 'libreBookingApi';
this.displayName = 'LibreBooking API';
this.documentationUrl = 'https://librebooking.org/docs/api';
this.properties = [
{
displayName: 'LibreBooking URL',
name: 'url',
type: 'string',
default: '',
placeholder: 'https://booking.example.com',
required: true,
description: 'Die Basis-URL Ihrer LibreBooking-Installation (ohne /Web/Services)',
},
{
displayName: 'Benutzername',
name: 'username',
type: 'string',
default: '',
required: true,
description: 'Ihr LibreBooking-Benutzername oder E-Mail-Adresse',
},
{
displayName: 'Passwort',
name: 'password',
type: 'string',
typeOptions: {
password: true,
},
default: '',
required: true,
description: 'Ihr LibreBooking-Passwort',
},
];
// Test-Request um die Credentials zu validieren
this.test = {
request: {
baseURL: '={{$credentials.url}}',
url: '/Web/Services/index.php/Authentication/Authenticate',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
username: '={{$credentials.username}}',
password: '={{$credentials.password}}',
},
},
};
}
}
exports.LibreBookingApi = LibreBookingApi;
//# sourceMappingURL=LibreBookingApi.credentials.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LibreBookingApi.credentials.js","sourceRoot":"","sources":["../../credentials/LibreBookingApi.credentials.ts"],"names":[],"mappings":";;;AAOA;;;;;GAKG;AACH,MAAa,eAAe;IAA5B;QACC,SAAI,GAAG,iBAAiB,CAAC;QACzB,gBAAW,GAAG,kBAAkB,CAAC;QACjC,qBAAgB,GAAG,mCAAmC,CAAC;QAEvD,eAAU,GAAsB;YAC/B;gBACC,WAAW,EAAE,kBAAkB;gBAC/B,IAAI,EAAE,KAAK;gBACX,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,EAAE;gBACX,WAAW,EAAE,6BAA6B;gBAC1C,QAAQ,EAAE,IAAI;gBACd,WAAW,EAAE,oEAAoE;aACjF;YACD;gBACC,WAAW,EAAE,cAAc;gBAC3B,IAAI,EAAE,UAAU;gBAChB,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,EAAE;gBACX,QAAQ,EAAE,IAAI;gBACd,WAAW,EAAE,mDAAmD;aAChE;YACD;gBACC,WAAW,EAAE,UAAU;gBACvB,IAAI,EAAE,UAAU;gBAChB,IAAI,EAAE,QAAQ;gBACd,WAAW,EAAE;oBACZ,QAAQ,EAAE,IAAI;iBACd;gBACD,OAAO,EAAE,EAAE;gBACX,QAAQ,EAAE,IAAI;gBACd,WAAW,EAAE,2BAA2B;aACxC;SACD,CAAC;QAEF,gDAAgD;QAChD,SAAI,GAA2B;YAC9B,OAAO,EAAE;gBACR,OAAO,EAAE,uBAAuB;gBAChC,GAAG,EAAE,qDAAqD;gBAC1D,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACR,cAAc,EAAE,kBAAkB;iBAClC;gBACD,IAAI,EAAE;oBACL,QAAQ,EAAE,4BAA4B;oBACtC,QAAQ,EAAE,4BAA4B;iBACtC;aACD;SACD,CAAC;IACH,CAAC;CAAA;AAnDD,0CAmDC"}

View File

@ -0,0 +1,14 @@
import { ICredentialType, INodeProperties } from 'n8n-workflow';
/**
* LibreBooking Config Credential
*
* Ermöglicht die zentrale Konfiguration von Standardwerten,
* die in allen LibreBooking Nodes verwendet werden können.
*/
export declare class LibreBookingConfig implements ICredentialType {
name: string;
displayName: string;
documentationUrl: string;
properties: INodeProperties[];
}
//# sourceMappingURL=LibreBookingConfig.credentials.d.ts.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LibreBookingConfig.credentials.d.ts","sourceRoot":"","sources":["../../credentials/LibreBookingConfig.credentials.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,eAAe,EACf,eAAe,EACf,MAAM,cAAc,CAAC;AAEtB;;;;;GAKG;AACH,qBAAa,kBAAmB,YAAW,eAAe;IACzD,IAAI,SAAwB;IAC5B,WAAW,SAAyB;IACpC,gBAAgB,SAA8B;IAE9C,UAAU,EAAE,eAAe,EAAE,CAyD3B;CACF"}

View File

@ -0,0 +1,76 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LibreBookingConfig = void 0;
/**
* LibreBooking Config Credential
*
* Ermöglicht die zentrale Konfiguration von Standardwerten,
* die in allen LibreBooking Nodes verwendet werden können.
*/
class LibreBookingConfig {
constructor() {
this.name = 'libreBookingConfig';
this.displayName = 'LibreBooking Config';
this.documentationUrl = 'https://librebooking.org';
this.properties = [
{
displayName: 'Hinweis',
name: 'notice',
type: 'notice',
default: '',
description: 'Dieser Config-Node speichert Standardwerte für LibreBooking Operationen. Er ist optional und die Werte können in den einzelnen Nodes überschrieben werden.',
},
{
displayName: 'Standard Nutzungsbedingungen Akzeptiert',
name: 'defaultTermsAccepted',
type: 'boolean',
default: true,
description: 'Standardwert für die Akzeptanz der Nutzungsbedingungen bei Reservierungen',
},
{
displayName: 'Standard Teilnahme Erlauben',
name: 'defaultAllowParticipation',
type: 'boolean',
default: false,
description: 'Standardwert für die Teilnahme-Erlaubnis bei Reservierungen',
},
{
displayName: 'Standard Ressourcen-ID',
name: 'defaultResourceId',
type: 'number',
default: 0,
description: 'Standard-Ressourcen-ID für Reservierungen (0 = keine Standardressource)',
},
{
displayName: 'Standard Benutzer-ID',
name: 'defaultUserId',
type: 'number',
default: 0,
description: 'Standard-Benutzer-ID für Reservierungen (0 = angemeldeter Benutzer)',
},
{
displayName: 'Standard Zeitplan-ID',
name: 'defaultScheduleId',
type: 'number',
default: 0,
description: 'Standard-Zeitplan-ID für Ressourcen-Erstellung (0 = keine Standard-Zeitplan)',
},
{
displayName: 'Standard Zeitzone',
name: 'defaultTimezone',
type: 'string',
default: 'Europe/Berlin',
description: 'Standard-Zeitzone für neue Benutzer',
},
{
displayName: 'Standard Sprache',
name: 'defaultLanguage',
type: 'string',
default: 'de_de',
description: 'Standard-Sprache für neue Benutzer',
},
];
}
}
exports.LibreBookingConfig = LibreBookingConfig;
//# sourceMappingURL=LibreBookingConfig.credentials.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LibreBookingConfig.credentials.js","sourceRoot":"","sources":["../../credentials/LibreBookingConfig.credentials.ts"],"names":[],"mappings":";;;AAKA;;;;;GAKG;AACH,MAAa,kBAAkB;IAA/B;QACC,SAAI,GAAG,oBAAoB,CAAC;QAC5B,gBAAW,GAAG,qBAAqB,CAAC;QACpC,qBAAgB,GAAG,0BAA0B,CAAC;QAE9C,eAAU,GAAsB;YAC/B;gBACC,WAAW,EAAE,SAAS;gBACtB,IAAI,EAAE,QAAQ;gBACd,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,EAAE;gBACX,WAAW,EAAE,4JAA4J;aACzK;YACD;gBACC,WAAW,EAAE,yCAAyC;gBACtD,IAAI,EAAE,sBAAsB;gBAC5B,IAAI,EAAE,SAAS;gBACf,OAAO,EAAE,IAAI;gBACb,WAAW,EAAE,2EAA2E;aACxF;YACD;gBACC,WAAW,EAAE,6BAA6B;gBAC1C,IAAI,EAAE,2BAA2B;gBACjC,IAAI,EAAE,SAAS;gBACf,OAAO,EAAE,KAAK;gBACd,WAAW,EAAE,6DAA6D;aAC1E;YACD;gBACC,WAAW,EAAE,wBAAwB;gBACrC,IAAI,EAAE,mBAAmB;gBACzB,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,CAAC;gBACV,WAAW,EAAE,yEAAyE;aACtF;YACD;gBACC,WAAW,EAAE,sBAAsB;gBACnC,IAAI,EAAE,eAAe;gBACrB,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,CAAC;gBACV,WAAW,EAAE,qEAAqE;aAClF;YACD;gBACC,WAAW,EAAE,sBAAsB;gBACnC,IAAI,EAAE,mBAAmB;gBACzB,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,CAAC;gBACV,WAAW,EAAE,8EAA8E;aAC3F;YACD;gBACC,WAAW,EAAE,mBAAmB;gBAChC,IAAI,EAAE,iBAAiB;gBACvB,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,eAAe;gBACxB,WAAW,EAAE,qCAAqC;aAClD;YACD;gBACC,WAAW,EAAE,kBAAkB;gBAC/B,IAAI,EAAE,iBAAiB;gBACvB,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,OAAO;gBAChB,WAAW,EAAE,oCAAoC;aACjD;SACD,CAAC;IACH,CAAC;CAAA;AA/DD,gDA+DC"}

View File

@ -0,0 +1,12 @@
import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
/**
* LibreBooking n8n Node
*
* Vollständige Integration für die LibreBooking API.
* Unterstützt alle wichtigen Ressourcen und Operationen.
*/
export declare class LibreBooking implements INodeType {
description: INodeTypeDescription;
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
}
//# sourceMappingURL=LibreBooking.node.d.ts.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LibreBooking.node.d.ts","sourceRoot":"","sources":["../../../nodes/LibreBooking/LibreBooking.node.ts"],"names":[],"mappings":"AAAA,OAAO,EACC,iBAAiB,EACjB,kBAAkB,EAClB,SAAS,EACT,oBAAoB,EAI3B,MAAM,cAAc,CAAC;AA8LtB;;;;;GAKG;AACH,qBAAa,YAAa,YAAW,SAAS;IACtC,WAAW,EAAE,oBAAoB,CAw2B/B;IAEI,OAAO,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;CAmjB9E"}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" width="60" height="60">
<!-- Background -->
<rect width="60" height="60" rx="8" fill="#2C3E50"/>
<!-- Calendar base -->
<rect x="10" y="15" width="40" height="35" rx="3" fill="#ECF0F1" stroke="#3498DB" stroke-width="2"/>
<!-- Calendar header -->
<rect x="10" y="15" width="40" height="10" rx="3" fill="#3498DB"/>
<rect x="10" y="22" width="40" height="3" fill="#3498DB"/>
<!-- Calendar rings -->
<rect x="18" y="12" width="4" height="8" rx="1" fill="#2C3E50"/>
<rect x="38" y="12" width="4" height="8" rx="1" fill="#2C3E50"/>
<!-- Grid lines -->
<line x1="10" y1="33" x2="50" y2="33" stroke="#BDC3C7" stroke-width="1"/>
<line x1="10" y1="41" x2="50" y2="41" stroke="#BDC3C7" stroke-width="1"/>
<line x1="23" y1="25" x2="23" y2="50" stroke="#BDC3C7" stroke-width="1"/>
<line x1="37" y1="25" x2="37" y2="50" stroke="#BDC3C7" stroke-width="1"/>
<!-- Booking indicator -->
<rect x="25" y="35" width="10" height="4" rx="1" fill="#27AE60"/>
<!-- Check mark -->
<path d="M39 27 L42 30 L48 22" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,14 @@
import { INodeType, INodeTypeDescription, IPollFunctions, INodeExecutionData } from 'n8n-workflow';
/**
* 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 declare class LibreBookingTrigger implements INodeType {
description: INodeTypeDescription;
poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null>;
}
//# sourceMappingURL=LibreBookingTrigger.node.d.ts.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LibreBookingTrigger.node.d.ts","sourceRoot":"","sources":["../../../nodes/LibreBookingTrigger/LibreBookingTrigger.node.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,SAAS,EACT,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAGlB,MAAM,cAAc,CAAC;AAyOtB;;;;;;;GAOG;AACH,qBAAa,mBAAoB,YAAW,SAAS;IACpD,WAAW,EAAE,oBAAoB,CA4K/B;IAEI,IAAI,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,EAAE,EAAE,GAAG,IAAI,CAAC;CAkUxE"}

View File

@ -0,0 +1,593 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LibreBookingTrigger = void 0;
const n8n_workflow_1 = require("n8n-workflow");
/**
* Authentifizierung bei LibreBooking
*/
async function authenticateTrigger(pollFunctions, baseUrl, username, password) {
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 n8n_workflow_1.NodeOperationError(pollFunctions.getNode(), 'Authentifizierung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.');
}
return {
sessionToken: response.sessionToken,
userId: response.userId,
sessionExpires: response.sessionExpires,
};
}
catch (error) {
throw new n8n_workflow_1.NodeApiError(pollFunctions.getNode(), error, {
message: 'Authentifizierung fehlgeschlagen',
description: 'Überprüfen Sie die LibreBooking URL und Ihre Zugangsdaten.',
});
}
}
/**
* Abmeldung
*/
async function signOutTrigger(pollFunctions, baseUrl, session) {
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, baseUrl, session, startDateTime, endDateTime, filters) {
const qs = {
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) {
throw new n8n_workflow_1.NodeApiError(pollFunctions.getNode(), error, {
message: 'Fehler beim Abrufen der Reservierungen',
});
}
}
/**
* Detaillierte Reservierungsdaten abrufen (inkl. Custom Attributes)
*/
async function getReservationDetails(pollFunctions, baseUrl, session, referenceNumber) {
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, customEndDate, defaultDays = 14) {
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) {
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) {
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
*/
class LibreBookingTrigger {
constructor() {
this.description = {
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() {
const credentials = await this.getCredentials('libreBookingApi');
const baseUrl = credentials.url.replace(/\/$/, '');
const username = credentials.username;
const password = credentials.password;
const triggerMode = this.getNodeParameter('triggerMode');
const filters = this.getNodeParameter('filters', {});
const options = this.getNodeParameter('options', {});
// Debug-Modus
const debugMode = options.debugMode || false;
const fetchDetails = options.fetchDetails || false;
// Workflow Static Data für State-Management
const webhookData = this.getWorkflowStaticData('node');
let session;
try {
session = await authenticateTrigger(this, baseUrl, username, password);
}
catch (error) {
throw error;
}
try {
const returnData = [];
// ==========================================
// MODE: Get All (One-Time / Every Poll)
// ==========================================
if (triggerMode === 'getAll') {
const startDate = this.getNodeParameter('startDate', '');
const endDate = this.getNodeParameter('endDate', '');
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');
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) => 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) => !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');
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 = [];
const newHashes = {};
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);
}
}
}
exports.LibreBookingTrigger = LibreBookingTrigger;
//# sourceMappingURL=LibreBookingTrigger.node.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" width="60" height="60">
<!-- Background -->
<rect width="60" height="60" rx="8" fill="#2C3E50"/>
<!-- Calendar base -->
<rect x="10" y="15" width="40" height="35" rx="3" fill="#ECF0F1" stroke="#3498DB" stroke-width="2"/>
<!-- Calendar header -->
<rect x="10" y="15" width="40" height="10" rx="3" fill="#3498DB"/>
<rect x="10" y="22" width="40" height="3" fill="#3498DB"/>
<!-- Calendar rings -->
<rect x="18" y="12" width="4" height="8" rx="1" fill="#2C3E50"/>
<rect x="38" y="12" width="4" height="8" rx="1" fill="#2C3E50"/>
<!-- Grid lines -->
<line x1="10" y1="33" x2="50" y2="33" stroke="#BDC3C7" stroke-width="1"/>
<line x1="10" y1="41" x2="50" y2="41" stroke="#BDC3C7" stroke-width="1"/>
<line x1="23" y1="25" x2="23" y2="50" stroke="#BDC3C7" stroke-width="1"/>
<line x1="37" y1="25" x2="37" y2="50" stroke="#BDC3C7" stroke-width="1"/>
<!-- Booking indicator -->
<rect x="25" y="35" width="10" height="4" rx="1" fill="#27AE60"/>
<!-- Check mark -->
<path d="M39 27 L42 30 L48 22" stroke="#FFFFFF" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,7 @@ async function authenticateTrigger(
if (!response.isAuthenticated) { if (!response.isAuthenticated) {
throw new NodeOperationError( throw new NodeOperationError(
pollFunctions.getNode(), pollFunctions.getNode(),
'Authentifizierung fehlgeschlagen', 'Authentifizierung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.',
); );
} }
@ -65,6 +65,7 @@ async function authenticateTrigger(
} catch (error: any) { } catch (error: any) {
throw new NodeApiError(pollFunctions.getNode(), error, { throw new NodeApiError(pollFunctions.getNode(), error, {
message: 'Authentifizierung fehlgeschlagen', message: 'Authentifizierung fehlgeschlagen',
description: 'Überprüfen Sie die LibreBooking URL und Ihre Zugangsdaten.',
}); });
} }
} }
@ -113,23 +114,29 @@ async function getReservations(
if (filters.scheduleId) qs.scheduleId = filters.scheduleId; if (filters.scheduleId) qs.scheduleId = filters.scheduleId;
if (filters.userId) qs.userId = filters.userId; if (filters.userId) qs.userId = filters.userId;
const response = await pollFunctions.helpers.httpRequest({ try {
method: 'GET', const response = await pollFunctions.helpers.httpRequest({
url: `${baseUrl}/Web/Services/index.php/Reservations/`, method: 'GET',
headers: { url: `${baseUrl}/Web/Services/index.php/Reservations/`,
'Content-Type': 'application/json', headers: {
'X-Booked-SessionToken': session.sessionToken, 'Content-Type': 'application/json',
'X-Booked-UserId': session.userId.toString(), 'X-Booked-SessionToken': session.sessionToken,
}, 'X-Booked-UserId': session.userId.toString(),
qs, },
json: true, qs,
}); json: true,
});
return response.reservations || []; return response.reservations || [];
} catch (error: any) {
throw new NodeApiError(pollFunctions.getNode(), error, {
message: 'Fehler beim Abrufen der Reservierungen',
});
}
} }
/** /**
* Detaillierte Reservierungsdaten abrufen * Detaillierte Reservierungsdaten abrufen (inkl. Custom Attributes)
*/ */
async function getReservationDetails( async function getReservationDetails(
pollFunctions: IPollFunctions, pollFunctions: IPollFunctions,
@ -137,24 +144,53 @@ async function getReservationDetails(
session: LibreBookingSession, session: LibreBookingSession,
referenceNumber: string, referenceNumber: string,
): Promise<any> { ): Promise<any> {
const response = await pollFunctions.helpers.httpRequest({ try {
method: 'GET', const response = await pollFunctions.helpers.httpRequest({
url: `${baseUrl}/Web/Services/index.php/Reservations/${referenceNumber}`, method: 'GET',
headers: { url: `${baseUrl}/Web/Services/index.php/Reservations/${referenceNumber}`,
'Content-Type': 'application/json', headers: {
'X-Booked-SessionToken': session.sessionToken, 'Content-Type': 'application/json',
'X-Booked-UserId': session.userId.toString(), 'X-Booked-SessionToken': session.sessionToken,
}, 'X-Booked-UserId': session.userId.toString(),
json: true, },
}); json: true,
});
return response; return response;
} catch (error) {
return null;
}
} }
/** /**
* Zeitfenster berechnen * Zeitfenster berechnen für "Get All" Mode
*/ */
function getTimeWindow(timeWindow: string): { start: string; end: string } { 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 now = new Date();
const start = now.toISOString(); const start = now.toISOString();
@ -184,7 +220,6 @@ function getTimeWindow(timeWindow: string): { start: string; end: string } {
/** /**
* Hash für Reservierung generieren (für Änderungserkennung) * Hash für Reservierung generieren (für Änderungserkennung)
* Nur relevante Felder berücksichtigen, die Änderungen anzeigen
*/ */
function getReservationHash(reservation: ReservationData): string { function getReservationHash(reservation: ReservationData): string {
const relevantData = { const relevantData = {
@ -206,11 +241,10 @@ function getReservationHash(reservation: ReservationData): string {
/** /**
* LibreBooking Trigger Node * LibreBooking Trigger Node
* *
* Überwacht neue und geänderte Reservierungen in LibreBooking. * Drei Modi:
* * 1. Get All (One-Time): Alle Reservierungen für einen Zeitraum abrufen
* WICHTIG: Beim ersten Poll werden nur die IDs/Hashes gespeichert, * 2. New Reservations (Poll): Bei neuen Reservierungen triggern
* aber keine Events getriggert. Dies verhindert, dass alle * 3. Updated Reservations (Poll): Bei geänderten Reservierungen triggern
* existierenden Reservierungen als "neu" getriggert werden.
*/ */
export class LibreBookingTrigger implements INodeType { export class LibreBookingTrigger implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -220,7 +254,7 @@ export class LibreBookingTrigger implements INodeType {
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Wird bei neuen oder geänderten Reservierungen in LibreBooking ausgelöst', description: 'Wird bei neuen oder geänderten Reservierungen in LibreBooking ausgelöst',
subtitle: '={{$parameter["event"]}}', subtitle: '={{$parameter["triggerMode"]}}',
defaults: { defaults: {
name: 'LibreBooking Trigger', name: 'LibreBooking Trigger',
}, },
@ -234,57 +268,74 @@ export class LibreBookingTrigger implements INodeType {
], ],
polling: true, polling: true,
properties: [ properties: [
// =====================================================
// TRIGGER MODE SELECTOR
// =====================================================
{ {
displayName: 'Event', displayName: 'Trigger-Modus',
name: 'event', name: 'triggerMode',
type: 'options', type: 'options',
options: [ options: [
{ {
name: 'Neue Reservierung', name: 'Alle Abrufen (Einmalig)',
value: 'newReservation', value: 'getAll',
description: 'Wird bei neuen Reservierungen ausgelöst (nicht beim ersten Poll)' description: 'Alle Reservierungen für einen Zeitraum abrufen (bei jedem Poll)',
}, },
{ {
name: 'Geänderte Reservierung', name: 'Neue Reservierungen (Polling)',
value: 'updatedReservation', value: 'newReservations',
description: 'Wird bei geänderten Reservierungen ausgelöst' description: 'Nur bei neuen Reservierungen triggern',
}, },
{ {
name: 'Alle Reservierungen', name: 'Geänderte Reservierungen (Polling)',
value: 'allReservations', value: 'updatedReservations',
description: 'Wird bei neuen und geänderten Reservierungen ausgelöst' description: 'Nur bei geänderten Reservierungen triggern',
}, },
], ],
default: 'newReservation', default: 'getAll',
description: 'Wählen Sie den Trigger-Modus',
}, },
// =====================================================
// GET ALL MODE - DATE RANGE
// =====================================================
{ {
displayName: 'Hinweis', displayName: 'Startdatum',
name: 'notice', name: 'startDate',
type: 'notice', type: 'dateTime',
default: '',
displayOptions: { displayOptions: {
show: { show: {
event: ['newReservation', 'allReservations'], triggerMode: ['getAll'],
}, },
}, },
description: 'Beim ersten Poll werden existierende Reservierungen gespeichert, aber nicht getriggert. Nur nachfolgende neue Reservierungen lösen den Trigger aus.', default: '',
description: 'Startdatum für den Abruf (leer = heute)',
}, },
{ {
displayName: 'Filter', displayName: 'Enddatum',
name: 'filters', name: 'endDate',
type: 'collection', type: 'dateTime',
placeholder: 'Filter hinzufügen', displayOptions: {
default: {}, show: {
options: [ triggerMode: ['getAll'],
{ displayName: 'Ressourcen-ID', name: 'resourceId', type: 'number', default: '' }, },
{ displayName: 'Zeitplan-ID', name: 'scheduleId', type: 'number', default: '' }, },
{ displayName: 'Benutzer-ID', name: 'userId', type: 'number', default: '' }, default: '',
], description: 'Enddatum für den Abruf (leer = 14 Tage in der Zukunft)',
}, },
// =====================================================
// POLLING MODE - TIME WINDOW
// =====================================================
{ {
displayName: 'Zeitfenster', displayName: 'Zeitfenster',
name: 'timeWindow', name: 'timeWindow',
type: 'options', type: 'options',
displayOptions: {
show: {
triggerMode: ['newReservations', 'updatedReservations'],
},
},
options: [ options: [
{ name: 'Nächste 7 Tage', value: '7days' }, { name: 'Nächste 7 Tage', value: '7days' },
{ name: 'Nächste 14 Tage', value: '14days' }, { name: 'Nächste 14 Tage', value: '14days' },
@ -292,7 +343,58 @@ export class LibreBookingTrigger implements INodeType {
{ name: 'Nächste 90 Tage', value: '90days' }, { name: 'Nächste 90 Tage', value: '90days' },
], ],
default: '14days', 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', displayName: 'Optionen',
name: 'options', name: 'options',
@ -305,7 +407,7 @@ export class LibreBookingTrigger implements INodeType {
name: 'fetchDetails', name: 'fetchDetails',
type: 'boolean', type: 'boolean',
default: false, default: false,
description: 'Ruft vollständige Reservierungsdaten ab (zusätzliche API-Aufrufe)', description: 'Ruft vollständige Reservierungsdaten inkl. Custom Attributes ab (zusätzliche API-Aufrufe)',
}, },
{ {
displayName: 'Debug-Modus', displayName: 'Debug-Modus',
@ -325,16 +427,16 @@ export class LibreBookingTrigger implements INodeType {
const username = credentials.username as string; const username = credentials.username as string;
const password = credentials.password as string; const password = credentials.password as string;
const event = this.getNodeParameter('event') as string; const triggerMode = this.getNodeParameter('triggerMode') as string;
const filters = this.getNodeParameter('filters', {}) as any; const filters = this.getNodeParameter('filters', {}) as any;
const timeWindow = this.getNodeParameter('timeWindow', '14days') as string;
const options = this.getNodeParameter('options', {}) as any; const options = this.getNodeParameter('options', {}) as any;
// Workflow Static Data für State-Management
const webhookData = this.getWorkflowStaticData('node') as WorkflowStaticData;
// Debug-Modus // Debug-Modus
const debugMode = options.debugMode || false; 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; let session: LibreBookingSession;
try { try {
@ -344,23 +446,96 @@ export class LibreBookingTrigger implements INodeType {
} }
try { try {
const { start, end } = getTimeWindow(timeWindow);
const reservations = await getReservations(
this,
baseUrl,
session,
start,
end,
filters,
);
const returnData: INodeExecutionData[] = []; const returnData: INodeExecutionData[] = [];
// ========================================== // ==========================================
// EVENT: Neue Reservierungen // MODE: Get All (One-Time / Every Poll)
// ========================================== // ==========================================
if (event === 'newReservation') { 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 // Initialisiere seenIds beim ersten Poll
if (!webhookData.seenIds) { if (!webhookData.seenIds) {
webhookData.seenIds = []; webhookData.seenIds = [];
@ -369,6 +544,12 @@ export class LibreBookingTrigger implements INodeType {
const currentIds = reservations.map((r: ReservationData) => r.referenceNumber); 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 // Beim ersten Poll: Nur IDs speichern, NICHT triggern
if (webhookData.isFirstPoll) { if (webhookData.isFirstPoll) {
webhookData.seenIds = currentIds; webhookData.seenIds = currentIds;
@ -381,6 +562,7 @@ export class LibreBookingTrigger implements INodeType {
_debug: true, _debug: true,
_message: 'Erster Poll - IDs wurden gespeichert, keine Events getriggert', _message: 'Erster Poll - IDs wurden gespeichert, keine Events getriggert',
_savedIds: currentIds.length, _savedIds: currentIds.length,
_ids: currentIds,
_timestamp: webhookData.lastPollTime, _timestamp: webhookData.lastPollTime,
}, },
}]]; }]];
@ -399,6 +581,9 @@ export class LibreBookingTrigger implements INodeType {
webhookData.lastPollTime = new Date().toISOString(); webhookData.lastPollTime = new Date().toISOString();
if (newReservations.length === 0) { if (newReservations.length === 0) {
if (debugMode) {
console.log(`[LibreBooking Trigger] No new reservations found`);
}
return null; return null;
} }
@ -406,16 +591,19 @@ export class LibreBookingTrigger implements INodeType {
for (const reservation of newReservations) { for (const reservation of newReservations) {
let reservationData = reservation; let reservationData = reservation;
if (options.fetchDetails) { if (fetchDetails) {
try { try {
reservationData = await getReservationDetails( const details = await getReservationDetails(
this, this,
baseUrl, baseUrl,
session, session,
reservation.referenceNumber, reservation.referenceNumber,
); );
if (details) {
reservationData = details;
}
} catch (error) { } catch (error) {
reservationData = reservation; // Fallback auf Basisdaten
} }
} }
@ -427,18 +615,40 @@ export class LibreBookingTrigger implements INodeType {
}, },
}); });
} }
if (debugMode && returnData.length > 0) {
console.log(`[LibreBooking Trigger] Triggering ${returnData.length} new reservations`);
}
} }
// ========================================== // ==========================================
// EVENT: Geänderte Reservierungen // MODE: Updated Reservations (Polling)
// ========================================== // ==========================================
else if (event === 'updatedReservation') { 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 // Initialisiere reservationHashes beim ersten Poll
if (!webhookData.reservationHashes) { if (!webhookData.reservationHashes) {
webhookData.reservationHashes = {}; webhookData.reservationHashes = {};
webhookData.isFirstPoll = true; 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 // Beim ersten Poll: Nur Hashes speichern, NICHT triggern
if (webhookData.isFirstPoll) { if (webhookData.isFirstPoll) {
for (const reservation of reservations) { for (const reservation of reservations) {
@ -483,6 +693,9 @@ export class LibreBookingTrigger implements INodeType {
webhookData.lastPollTime = new Date().toISOString(); webhookData.lastPollTime = new Date().toISOString();
if (updatedReservations.length === 0) { if (updatedReservations.length === 0) {
if (debugMode) {
console.log(`[LibreBooking Trigger] No updated reservations found`);
}
return null; return null;
} }
@ -490,16 +703,19 @@ export class LibreBookingTrigger implements INodeType {
for (const reservation of updatedReservations) { for (const reservation of updatedReservations) {
let reservationData = reservation; let reservationData = reservation;
if (options.fetchDetails) { if (fetchDetails) {
try { try {
reservationData = await getReservationDetails( const details = await getReservationDetails(
this, this,
baseUrl, baseUrl,
session, session,
reservation.referenceNumber, reservation.referenceNumber,
); );
if (details) {
reservationData = details;
}
} catch (error) { } catch (error) {
reservationData = reservation; // Fallback auf Basisdaten
} }
} }
@ -511,87 +727,10 @@ export class LibreBookingTrigger implements INodeType {
}, },
}); });
} }
}
// ========================================== if (debugMode && returnData.length > 0) {
// EVENT: Alle Reservierungen (Neu + Geändert) console.log(`[LibreBooking Trigger] Triggering ${returnData.length} updated reservations`);
// ==========================================
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<string, string> = {};
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) { if (returnData.length === 0) {

View File

@ -1,6 +1,6 @@
{ {
"name": "n8n-nodes-librebooking", "name": "n8n-nodes-librebooking",
"version": "1.2.0", "version": "1.2.1",
"description": "n8n Node für LibreBooking - Ressourcen- und Reservierungsverwaltung", "description": "n8n Node für LibreBooking - Ressourcen- und Reservierungsverwaltung",
"keywords": [ "keywords": [
"n8n-community-node-package", "n8n-community-node-package",

609
test-api.ts Normal file
View File

@ -0,0 +1,609 @@
/**
* LibreBooking API Test Script
*
* Testet alle wichtigen API-Endpunkte mit echten Credentials
*/
import * as https from 'https';
import * as http from 'http';
const BASE_URL = 'https://librebooking.zell-cloud.de';
const USERNAME = 'sebastian.zell@zell-aufmass.de';
const PASSWORD = 'wanUQ4uVqU6lfP';
interface Session {
sessionToken: string;
userId: number;
}
interface TestResult {
name: string;
success: boolean;
data?: any;
error?: string;
}
const results: TestResult[] = [];
/**
* HTTP Request Helper
*/
function makeRequest(
method: string,
path: string,
body?: any,
session?: Session,
qs?: Record<string, string>
): Promise<any> {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
if (qs) {
Object.entries(qs).forEach(([key, value]) => {
if (value) url.searchParams.append(key, value);
});
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (session) {
headers['X-Booked-SessionToken'] = session.sessionToken;
headers['X-Booked-UserId'] = session.userId.toString();
}
const options = {
method,
hostname: url.hostname,
path: url.pathname + url.search,
headers,
};
const httpModule = url.protocol === 'https:' ? https : http;
const req = httpModule.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
if (data) {
resolve(JSON.parse(data));
} else {
resolve({ success: true });
}
} catch (e) {
resolve({ rawData: data });
}
});
});
req.on('error', (error) => {
reject(error);
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
/**
* Test Authentication
*/
async function testAuthentication(): Promise<Session> {
console.log('\n=== 1. Testing Authentication ===');
try {
const response = await makeRequest(
'POST',
'/Web/Services/index.php/Authentication/Authenticate',
{ username: USERNAME, password: PASSWORD }
);
if (response.isAuthenticated) {
console.log('✅ Authentication successful');
console.log(` Session Token: ${response.sessionToken.substring(0, 20)}...`);
console.log(` User ID: ${response.userId}`);
console.log(` Session Expires: ${response.sessionExpires}`);
results.push({ name: 'Authentication', success: true, data: { userId: response.userId } });
return { sessionToken: response.sessionToken, userId: response.userId };
} else {
throw new Error('Authentication failed');
}
} catch (error: any) {
console.log('❌ Authentication failed:', error.message);
results.push({ name: 'Authentication', success: false, error: error.message });
throw error;
}
}
/**
* Test Get Reservations
*/
async function testGetReservations(session: Session): Promise<void> {
console.log('\n=== 2. Testing Get Reservations ===');
try {
// Test without date filters (should return next 2 weeks)
const response = await makeRequest(
'GET',
'/Web/Services/index.php/Reservations/',
undefined,
session
);
console.log(`✅ Reservations fetched: ${response.reservations?.length || 0} found`);
if (response.reservations && response.reservations.length > 0) {
const res = response.reservations[0];
console.log(` Example: ${res.title || 'No title'} (${res.referenceNumber})`);
console.log(` Start: ${res.startDate}`);
console.log(` Resource: ${res.resourceName}`);
}
results.push({ name: 'Get Reservations (no filter)', success: true, data: { count: response.reservations?.length || 0 } });
// Test with date filter (Feb 7-14, 2026)
console.log('\n Testing with date filter (2026-02-07 to 2026-02-14)...');
const responseFiltered = await makeRequest(
'GET',
'/Web/Services/index.php/Reservations/',
undefined,
session,
{
startDateTime: '2026-02-07T00:00:00',
endDateTime: '2026-02-14T23:59:59'
}
);
console.log(`✅ Filtered reservations: ${responseFiltered.reservations?.length || 0} found`);
results.push({ name: 'Get Reservations (date filter)', success: true, data: { count: responseFiltered.reservations?.length || 0 } });
} catch (error: any) {
console.log('❌ Get Reservations failed:', error.message);
results.push({ name: 'Get Reservations', success: false, error: error.message });
}
}
/**
* Test Get Resources
*/
async function testGetResources(session: Session): Promise<number | null> {
console.log('\n=== 3. Testing Get Resources ===');
try {
const response = await makeRequest(
'GET',
'/Web/Services/index.php/Resources/',
undefined,
session
);
console.log(`✅ Resources fetched: ${response.resources?.length || 0} found`);
let firstResourceId: number | null = null;
if (response.resources && response.resources.length > 0) {
const res = response.resources[0];
firstResourceId = res.resourceId;
console.log(` Example: ${res.name} (ID: ${res.resourceId})`);
console.log(` Schedule ID: ${res.scheduleId}`);
console.log(` Custom Attributes: ${res.customAttributes?.length || 0}`);
if (res.customAttributes && res.customAttributes.length > 0) {
res.customAttributes.forEach((attr: any) => {
console.log(` - ${attr.label}: ${attr.value || '(no value)'}`);
});
}
}
results.push({ name: 'Get Resources', success: true, data: { count: response.resources?.length || 0 } });
return firstResourceId;
} catch (error: any) {
console.log('❌ Get Resources failed:', error.message);
results.push({ name: 'Get Resources', success: false, error: error.message });
return null;
}
}
/**
* Test Get Single Resource (with custom attributes)
*/
async function testGetSingleResource(session: Session, resourceId: number): Promise<void> {
console.log('\n=== 4. Testing Get Single Resource (with custom attributes) ===');
try {
const response = await makeRequest(
'GET',
`/Web/Services/index.php/Resources/${resourceId}`,
undefined,
session
);
console.log(`✅ Resource fetched: ${response.name}`);
console.log(` Custom Attributes: ${response.customAttributes?.length || 0}`);
if (response.customAttributes && response.customAttributes.length > 0) {
response.customAttributes.forEach((attr: any) => {
console.log(` - ID: ${attr.id}, Label: ${attr.label}, Value: ${attr.value || '(no value)'}`);
});
}
results.push({ name: 'Get Single Resource', success: true, data: { customAttributes: response.customAttributes?.length || 0 } });
} catch (error: any) {
console.log('❌ Get Single Resource failed:', error.message);
results.push({ name: 'Get Single Resource', success: false, error: error.message });
}
}
/**
* Test Get Users
*/
async function testGetUsers(session: Session): Promise<number | null> {
console.log('\n=== 5. Testing Get Users ===');
try {
const response = await makeRequest(
'GET',
'/Web/Services/index.php/Users/',
undefined,
session
);
console.log(`✅ Users fetched: ${response.users?.length || 0} found`);
let firstUserId: number | null = null;
if (response.users && response.users.length > 0) {
const user = response.users[0];
firstUserId = user.id;
console.log(` Example: ${user.firstName} ${user.lastName} (ID: ${user.id})`);
}
results.push({ name: 'Get Users', success: true, data: { count: response.users?.length || 0 } });
return firstUserId;
} catch (error: any) {
console.log('❌ Get Users failed:', error.message);
results.push({ name: 'Get Users', success: false, error: error.message });
return null;
}
}
/**
* Test Get Single User (with custom attributes)
*/
async function testGetSingleUser(session: Session, userId: number): Promise<void> {
console.log('\n=== 6. Testing Get Single User (with custom attributes) ===');
try {
const response = await makeRequest(
'GET',
`/Web/Services/index.php/Users/${userId}`,
undefined,
session
);
console.log(`✅ User fetched: ${response.firstName} ${response.lastName}`);
console.log(` Custom Attributes: ${response.customAttributes?.length || 0}`);
if (response.customAttributes && response.customAttributes.length > 0) {
response.customAttributes.forEach((attr: any) => {
console.log(` - ID: ${attr.id}, Label: ${attr.label}, Value: ${attr.value || '(no value)'}`);
});
}
results.push({ name: 'Get Single User', success: true, data: { customAttributes: response.customAttributes?.length || 0 } });
} catch (error: any) {
console.log('❌ Get Single User failed:', error.message);
results.push({ name: 'Get Single User', success: false, error: error.message });
}
}
/**
* Test Get Schedules
*/
async function testGetSchedules(session: Session): Promise<void> {
console.log('\n=== 7. Testing Get Schedules ===');
try {
const response = await makeRequest(
'GET',
'/Web/Services/index.php/Schedules/',
undefined,
session
);
console.log(`✅ Schedules fetched: ${response.schedules?.length || 0} found`);
if (response.schedules && response.schedules.length > 0) {
const schedule = response.schedules[0];
console.log(` Example: ${schedule.name} (ID: ${schedule.id})`);
}
results.push({ name: 'Get Schedules', success: true, data: { count: response.schedules?.length || 0 } });
} catch (error: any) {
console.log('❌ Get Schedules failed:', error.message);
results.push({ name: 'Get Schedules', success: false, error: error.message });
}
}
/**
* Test Get Attributes by Category
*/
async function testGetAttributes(session: Session): Promise<void> {
console.log('\n=== 8. Testing Get Attributes by Category ===');
const categories = [
{ id: 1, name: 'Reservation' },
{ id: 2, name: 'User' },
{ id: 4, name: 'Resource' },
{ id: 5, name: 'Resource Type' },
];
for (const cat of categories) {
try {
const response = await makeRequest(
'GET',
`/Web/Services/index.php/Attributes/Category/${cat.id}`,
undefined,
session
);
console.log(`${cat.name} Attributes: ${response.attributes?.length || 0} found`);
if (response.attributes && response.attributes.length > 0) {
response.attributes.forEach((attr: any) => {
console.log(` - ID: ${attr.id}, Label: ${attr.label}, Type: ${attr.type}, Required: ${attr.required}`);
});
}
results.push({ name: `Get Attributes (${cat.name})`, success: true, data: { count: response.attributes?.length || 0 } });
} catch (error: any) {
console.log(`❌ Get ${cat.name} Attributes failed:`, error.message);
results.push({ name: `Get Attributes (${cat.name})`, success: false, error: error.message });
}
}
}
/**
* Test Create, Update, Delete Reservation
*/
async function testReservationCRUD(session: Session, resourceId: number): Promise<void> {
console.log('\n=== 9. Testing Reservation CRUD ===');
// Create
console.log(' Creating test reservation...');
try {
const createResponse = await makeRequest(
'POST',
'/Web/Services/index.php/Reservations/',
{
title: 'API Test Reservation',
description: 'Created by n8n node test script',
resourceId: resourceId,
startDateTime: '2026-02-07T10:00:00',
endDateTime: '2026-02-07T11:00:00',
userId: session.userId,
termsAccepted: true,
allowParticipation: false,
},
session
);
if (createResponse.referenceNumber) {
console.log(`✅ Reservation created: ${createResponse.referenceNumber}`);
results.push({ name: 'Create Reservation', success: true, data: { referenceNumber: createResponse.referenceNumber } });
const refNum = createResponse.referenceNumber;
// Get the created reservation
console.log(' Fetching created reservation...');
const getResponse = await makeRequest(
'GET',
`/Web/Services/index.php/Reservations/${refNum}`,
undefined,
session
);
console.log(`✅ Reservation fetched: ${getResponse.title}`);
console.log(` Custom Attributes: ${getResponse.customAttributes?.length || 0}`);
if (getResponse.customAttributes && getResponse.customAttributes.length > 0) {
getResponse.customAttributes.forEach((attr: any) => {
console.log(` - ID: ${attr.id}, Label: ${attr.label}, Value: ${attr.value || '(no value)'}`);
});
}
results.push({ name: 'Get Created Reservation', success: true });
// Update
console.log(' Updating reservation...');
const updateResponse = await makeRequest(
'POST',
`/Web/Services/index.php/Reservations/${refNum}?updateScope=this`,
{
title: 'API Test Reservation UPDATED',
description: 'Updated by n8n node test script',
resourceId: resourceId,
startDateTime: '2026-02-07T10:00:00',
endDateTime: '2026-02-07T12:00:00',
termsAccepted: true,
allowParticipation: false,
},
session
);
console.log(`✅ Reservation updated`);
results.push({ name: 'Update Reservation', success: true });
// Delete
console.log(' Deleting reservation...');
const deleteResponse = await makeRequest(
'DELETE',
`/Web/Services/index.php/Reservations/${refNum}?updateScope=this`,
undefined,
session
);
console.log(`✅ Reservation deleted`);
results.push({ name: 'Delete Reservation', success: true });
} else {
console.log('❌ Create Reservation failed - no reference number returned');
console.log(' Response:', JSON.stringify(createResponse, null, 2));
results.push({ name: 'Create Reservation', success: false, error: JSON.stringify(createResponse) });
}
} catch (error: any) {
console.log('❌ Reservation CRUD failed:', error.message);
results.push({ name: 'Reservation CRUD', success: false, error: error.message });
}
}
/**
* Test Groups
*/
async function testGetGroups(session: Session): Promise<void> {
console.log('\n=== 10. Testing Get Groups ===');
try {
const response = await makeRequest(
'GET',
'/Web/Services/index.php/Groups/',
undefined,
session
);
console.log(`✅ Groups fetched: ${response.groups?.length || 0} found`);
if (response.groups && response.groups.length > 0) {
const group = response.groups[0];
console.log(` Example: ${group.name} (ID: ${group.id})`);
}
results.push({ name: 'Get Groups', success: true, data: { count: response.groups?.length || 0 } });
} catch (error: any) {
console.log('❌ Get Groups failed:', error.message);
results.push({ name: 'Get Groups', success: false, error: error.message });
}
}
/**
* Test Accessories
*/
async function testGetAccessories(session: Session): Promise<void> {
console.log('\n=== 11. Testing Get Accessories ===');
try {
const response = await makeRequest(
'GET',
'/Web/Services/index.php/Accessories/',
undefined,
session
);
console.log(`✅ Accessories fetched: ${response.accessories?.length || 0} found`);
if (response.accessories && response.accessories.length > 0) {
const acc = response.accessories[0];
console.log(` Example: ${acc.name} (ID: ${acc.id})`);
}
results.push({ name: 'Get Accessories', success: true, data: { count: response.accessories?.length || 0 } });
} catch (error: any) {
console.log('❌ Get Accessories failed:', error.message);
results.push({ name: 'Get Accessories', success: false, error: error.message });
}
}
/**
* Test SignOut
*/
async function testSignOut(session: Session): Promise<void> {
console.log('\n=== 12. Testing Sign Out ===');
try {
await makeRequest(
'POST',
'/Web/Services/index.php/Authentication/SignOut',
{
userId: session.userId,
sessionToken: session.sessionToken,
}
);
console.log('✅ Sign Out successful');
results.push({ name: 'Sign Out', success: true });
} catch (error: any) {
console.log('❌ Sign Out failed:', error.message);
results.push({ name: 'Sign Out', success: false, error: error.message });
}
}
/**
* Print Summary
*/
function printSummary(): void {
console.log('\n========================================');
console.log(' TEST SUMMARY');
console.log('========================================');
const passed = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
console.log(`\nTotal Tests: ${results.length}`);
console.log(`✅ Passed: ${passed}`);
console.log(`❌ Failed: ${failed}`);
if (failed > 0) {
console.log('\nFailed Tests:');
results.filter(r => !r.success).forEach(r => {
console.log(` - ${r.name}: ${r.error}`);
});
}
console.log('\n========================================\n');
}
/**
* Main Test Runner
*/
async function runTests(): Promise<void> {
console.log('========================================');
console.log(' LibreBooking API Test Suite');
console.log(` URL: ${BASE_URL}`);
console.log(` User: ${USERNAME}`);
console.log('========================================');
try {
// Authentication
const session = await testAuthentication();
// Get operations
await testGetReservations(session);
const resourceId = await testGetResources(session);
if (resourceId) {
await testGetSingleResource(session, resourceId);
}
const userId = await testGetUsers(session);
if (userId) {
await testGetSingleUser(session, userId);
}
await testGetSchedules(session);
await testGetAttributes(session);
await testGetGroups(session);
await testGetAccessories(session);
// CRUD operations
if (resourceId) {
await testReservationCRUD(session, resourceId);
}
// Sign out
await testSignOut(session);
} catch (error: any) {
console.log('\n❌ Test suite failed:', error.message);
}
printSummary();
}
// Run tests
runTests().catch(console.error);