commit 142afa6e52328180412dd2bc3c2ae994e43c244c Author: mummert.media Date: Thu Sep 24 17:42:34 2026 +0200 Initial version of the W&K sync bundle for Contao 5.7 Import of W&K seminar exports with preview, booking form via form generator with event-dependent field rules, notification routing, inbox with folders and CSV export for W&K. Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a1e38f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea/ +.vscode/ +vendor/ +composer.lock +var/ +.phpunit.cache/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd603af --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# W&K-Sync Bundle + +Verbindet **Contao 5.7** mit der Seminarverwaltung **W&K** (Windows-Software ohne Schnittstelle) über CSV-Dateien: + +1. **Import**: Die Redaktion exportiert in W&K die Seminarliste als CSV und lädt sie im Contao-Backend hoch. Vor dem Schreiben zeigt eine **Vorschau**, was sich ändert (neu, geändert, storniert, unverändert, übersprungen, Warnungen). Erst nach Bestätigung werden die Veranstaltungen im Kalender angelegt bzw. aktualisiert. +2. **Online-Anmeldung**: Besucher melden sich über ein Formular des **Formulargenerators** zu einer Veranstaltung an. Das Bundle blendet je nach Veranstaltung Felder ein und aus, prüft Plätze und Anmeldeschluss, speichert die Anmeldung, verschickt die passende Benachrichtigung (Notification Center 2) und leitet auf die Bestätigungsseite weiter. +3. **Posteingang**: Anmeldungen und Kontaktanfragen werden in Ordnern bearbeitet (Neu, Download-Liste, Bereits bearbeitet, Ignoriert/Spam, Exportiert). +4. **Export**: Die Download-Liste wird als CSV für den W&K-Import erzeugt (ISO-8859-1, Semikolon, feste Spalten), die Datensätze werden als exportiert markiert und die freien Plätze der Veranstaltungen reduziert. + +- Contao **5.7**, PHP **8.5**, Doctrine DBAL **4** +- Keine Abhängigkeiten außer dem Contao-Core und dem Kalender-Bundle +- **Notification Center 2** wird für Benachrichtigungen empfohlen (Typ „W&K-Anmeldung“), ist aber nicht erforderlich +- Newsletter-Anmeldung (Double-Opt-in) mit dem Newsletter-Bundle, optional + +--- + +## Installation + +```bash +composer require mummert/wk-sync-bundle +``` + +Das Paket liegt nicht auf Packagist; das Repository in der `composer.json` des Projekts eintragen: + +```json +"repositories": [ + { "type": "vcs", "url": "ssh://git@git.mummert.media:3245/mummert/wk-sync-bundle.git" } +] +``` + +Danach die Datenbank aktualisieren: + +```bash +vendor/bin/contao-console contao:migrate +``` + +--- + +## Einrichtung + +1. **Kategorien anlegen** – Backend „W&K-Sync → Kategorien“ oder die Standardkategorien des TPI Moritzburg per Befehl: + ```bash + vendor/bin/contao-console wk-sync:categories:seed + ``` + Jede Kategorie hat eine **Art** (Schule, Evangelische Schulen, Elementarbereich, Gemeinde, Gottesdienst mit Kindern, Tagungen, Ausbildung, Mentoring, Sonstiges) und die **W&K-Bezeichnungen** (Werte des Felds `kbsgebiet`, eine pro Zeile). Beim Import zählen genaue Treffer zuerst (alle Kategorien mit dieser Bezeichnung, z. B. „Alle Schularten“ für Grundschule *und* weiterführende Schule), sonst der erste Teilstring-Treffer in Sortierreihenfolge. Die erste zugeordnete Kategorie wird Hauptkategorie und steuert die Formularregeln und die Benachrichtigung. + +2. **Anmeldeformular anlegen** – im Formulargenerator, am einfachsten per Befehl (legt alle Felder mit passenden Namen, Optionen und CSS-Klassen an und trägt es im Kalender ein): + ```bash + vendor/bin/contao-console wk-sync:form:create --calendar=1 + ``` + Die Feldnamen entsprechen den Spalten von `tl_wk_booking` (`gender`, `firstname`, `lastname`, `street`, `postal`, `city`, `email`, `phone`, `position`, `birthday`, `teacherPosition`, `schoolName`, `schoolCity`, `vegetarian`, `overnightStay`, `addDifferentInvoiceAddress`, `invoice…`, `addNotes`, `notes`, `newsletter`, `privacyAccepted`, `agbAccepted`). Spamschutz über das ALTCHA-Feld des Contao-Kerns. Das Formular braucht **keine** Weiterleitungsseite und **kein** „Daten in Tabelle speichern“ – beides übernimmt das Bundle. + +3. **Kalender konfigurieren** – im Kalender „W&K-Sync aktivieren“ und einstellen: + - Anmeldeformular, Seite mit dem Anmeldeformular, Bestätigungsseite, Seite mit dem Anfrageformular + - Anmeldeschluss (Tage vor Beginn, Standard 1) und angezeigter Anmeldeschluss (Standard 31) + - Eigene Veranstaltungsorte (Standard „TPI Moritzburg“; Orte, die so beginnen, gelten als „im eigenen Haus“) + - Autor importierter Veranstaltungen, Mitgliedergruppen für Kursleitung und Referenten (leer = keine Mitglieder anlegen) + - Newsletter-Verteiler und Aktivierungsseite (optional) + - Benachrichtigungen: Standard sowie je Bedingung 1–12 (siehe unten) + +4. **Seiten und Module** + - Seite **Kurs** (`kurs`): Modul *Event-Reader* des Kerns plus Modul **W&K-Anmeldestatus** (freie Plätze, Anmeldeschluss, Link „Jetzt anmelden“, Hinweis bei externer Anmeldung, geschlossenem Teilnehmerkreis usw.) + - Seite **Anmeldung** (`kurs/anmeldung`): Modul **W&K-Anmeldeformular** – wird wie der Event-Reader mit dem Alias der Veranstaltung aufgerufen (`/kurs/anmeldung/`) + - Seite **Anmeldung eingegangen**: Text; der Link enthält `?booking=`, damit Folgeformulare (z. B. Workshop-Auswahl) die Anmeldung kennen (siehe unten) + - Seite **Anfrage**: Formular des Formulargenerators mit „Daten in Tabelle speichern“ → `tl_wk_inquiry` (Feldnamen `gender`, `academicTitle`, `firstname`, `lastname`, `street`, `postal`, `city`, `email`, `phone`, `subject`, `notes`, `newsletter`, `privacy`). Der Link vom Anmeldestatus hängt `?event=` an; das Bundle trägt die Veranstaltung automatisch ein. + - Kalender-Module des Kerns (Eventliste, Kalender, Eventmenü) erhalten das Feld **W&K-Kategorien**, um nur Veranstaltungen bestimmter Kategorien zu zeigen. + +5. **Benachrichtigungen** (Notification Center 2): Benachrichtigungen vom Typ **W&K-Anmeldung** anlegen und im Kalender zuordnen. Tokens: `##recipient_email##`, `##subscription_*##` (alle Felder der Anmeldung, Auswahlfelder zusätzlich als `##subscription_gender_label##` usw., Ja/Nein-Felder als `##subscription_vegetarian_label##`), `##event_*##` (alle Felder der Veranstaltung, Datumsfelder formatiert), `##event_datetime##`, `##event_link##`, `##event_categories##`, `##event_speakers##`, `##subscription_event_wk_id##`, `##subscription_confirmation_page_url##`, `##calendar_*##`, `##admin_email##`. Die bisherigen Token-Namen `event_organizerCity` und `event_internalEventNumber` bleiben erhalten. + +6. **Benutzergruppen**: Zugriff auf die Module „W&K-Sync“ (Import & Posteingang, Kategorien, Kontaktanfragen, Protokoll) und auf den Kalender (Anmeldungen je Veranstaltung) freigeben. + +--- + +## Import aus W&K + +**Backend → W&K-Sync → Import & Posteingang → Datei hochladen.** Erwartet wird der Seminarexport aus W&K (Semikolon, Anführungszeichen, Windows-1252 oder UTF-8, mehrzeilige Felder). Wichtig: in W&K das größtmögliche Zeitintervall exportieren – Seminare, die nicht in der Datei stehen, werden **storniert und ausgeblendet** (nur Veranstaltungen des Kalenders, die eine W&K-Nummer tragen; manuell angelegte Veranstaltungen ohne W&K-Nummer bleiben unberührt). + +Regeln beim Import: + +| W&K | Contao | Bemerkung | +|---|---|---| +| `kbnr` | `wkId` | Muss mit einer führenden Null beginnen, sonst wird der Datensatz übersprungen | +| `kbvsnr` | `wkInternalEventNumber` | **Leer = nicht veröffentlichen** (übersprungen); „keine“ wird zu leer | +| `kbtitel`, `kbutitel`, `kbrtitel` | `title`, `wkSubTitle`, `wkSubTitleAdditional` | Titel ist Pflicht | +| `kbanreise`, `kbabreise`, `kbuanreise`, `kbuabreise` | `startDate`, `endDate`, `startTime`, `endTime`, `addTime` | Datum `TT.MM.JJJJ`, Uhrzeit `HH.MM`; wie im Contao-Kern (ganztägig bis 23:59:59) | +| `kbstatus1` | `wkState` | 1–5; **5 (Storno) blendet aus**. Manuelles Ausblenden bleibt erhalten, bis sich der W&K-Status ändert | +| `kbsgebiet` | `wkCategoryText`, `wkCategories`, `wkCategoryPrimary` | Keine Zuordnung möglich → Storno + Hinweis | +| `kbpmemo`, `kbzusatz` | `teaser`, `wkTopics` | Text wird HTML-sicher in Absätze bzw. Listen (`- `) umgewandelt | +| `kbwmemo` | `wkTags` | Kommagetrennt, dedupliziert | +| `kbkln1`, `kbmref` | `wkInstructor`, `wkSpeakers` | Mitglieder nach Nachname; werden nur angelegt, wenn im Kalender Mitgliedergruppen gesetzt sind. „N.N.“ wird ignoriert | +| `kbanzmax`, `kbanztat` | `wkMaxPlaces`, `wkPlacesLeft` | Freie Plätze = max − belegt | +| `kbkp1`–`kbkp6`, `kbktxp1`–`kbktxp6` | `wkPrice1`–`6`, `wkPriceType1`–`6` | Dezimalzahlen | +| `kbkd*` | `wkOrganizer*` | Straße und Landeskennzahl richtig zugeordnet (war vertauscht) | +| `kbkdemail` | `wkOrganizerEmail` | E-Mail **oder** URL (Veranstaltungsart 3: Anmeldung beim Veranstalter) | +| `kbvort`, `kbgruppe`, `kbvsart`, `kbztermin` | `location`, `wkAudience`, `wkEventType`, `wkAdditionalDates` | „geschlossener Teilnehmerkreis“ verhindert die Online-Anmeldung | + +Das Euro-Zeichen, das W&K als „Ç“ exportiert, wird im Umfeld von Zahlen korrigiert. Aliase werden nur beim Anlegen erzeugt und bleiben danach stabil. Jeder Import wird protokolliert (W&K-Sync → Protokoll); die Datei liegt unter `var/wk-sync/imports/`. + +Auf der Konsole: + +```bash +vendor/bin/contao-console wk-sync:import files/Import/stva.csv 1 # Vorschau +vendor/bin/contao-console wk-sync:import files/Import/stva.csv 1 --apply # anwenden +``` + +--- + +## Formularregeln + +Welche Felder das Anmeldeformular zeigt, hängt von der Hauptkategorie (Art), dem Veranstaltungsort und der Veranstaltungsnummer ab: + +- **Lehrertätigkeit** (`teacherPosition`, `schoolName`, `schoolCity`): nur bei den Arten Schule und Evangelische Schulen. +- **Abweichende Rechnungsanschrift**: bei Schulkursen nur, wenn „Ich unterrichte an einer Schule in sonstiger Trägerschaft“ gewählt ist; sonst immer. +- **Übernachtung**: nur bei mehrtägigen Veranstaltungen. Bei EXTP-Kursen und Elementarkursen im eigenen Haus erscheint stattdessen der Hinweis, dass Übernachtung und Verpflegung direkt im Gästehaus gebucht werden (Erklärungsfeld mit CSS-Klasse `wk-slot-no-overnight-stay`). Ausnahme: Lehrerkurse außer Haus zeigen das Feld. +- **Vegetarisch**: nicht bei Online-Kursen und nicht bei mehrtägigen EXTP-Kursen oder Elementarkursen (außer Tagungen); Lehrerkurse außer Haus zeigen das Feld. +- Abhängige Felder (Schule, Rechnungsanschrift, Nachricht) werden serverseitig nur validiert und gespeichert, wenn ihr steuerndes Feld sichtbar ist und den passenden Wert hat; ein kleines Skript (`bundles/contaowksync/wk-sync.js`) blendet sie im Browser ein und aus. + +Zusätzlich prüft das Bundle vor dem Anzeigen und noch einmal beim Absenden: veröffentlicht, nicht storniert, kein geschlossener Teilnehmerkreis, keine externe Anmeldung (Veranstaltungsart 3 mit E-Mail/URL), Anmeldeschluss, freie Plätze (W&K-Stand minus offene Anmeldungen, zusätzlich maximale Plätze). + +## Benachrichtigungs-Bedingungen + +Vorrang: Benachrichtigung an der Veranstaltung → erste erfüllte Bedingung des Kalenders → Standard-Benachrichtigung des Kalenders. + +| Nr. | Bedingung | +|---|---| +| 1 | Online-Kurs (Veranstaltungsort „Online“) | +| 2 / 5 | Tages- / Mehrtagesveranstaltung, Art Schule, Nummer beginnt mit EXTP | +| 3 / 7 | Tages- / Mehrtagesveranstaltung, Art Gemeinde, Gottesdienst mit Kindern oder Tagungen | +| 4 / 6 | Tages- / Mehrtagesveranstaltung, Art Elementarbereich | +| 8 / 9 | Tages- / Mehrtagesveranstaltung, Art Evangelische Schulen | +| 10 / 11 | Tages- / Mehrtagesveranstaltung, Art Mentoring | +| 12 | Mehrtägige Ausbildung | + +Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt (Warnung im Systemprotokoll). + +## Folgeformulare (z. B. Workshop-Auswahl) + +Die Bestätigungsseite wird mit `?booking=` aufgerufen. Formulare des Formulargenerators auf dieser Seite erhalten beim Absenden automatisch die Felder `subscription` (ID der Anmeldung), `subscription_email`, `subscription_firstname`, `subscription_lastname`, `event`, `event_title` und `event_wk_id` – in Benachrichtigungen als `##form_subscription_email##` usw. verwendbar. Eine eigene Bestätigungsseite lässt sich je Veranstaltung hinterlegen. + +## Posteingang und Export + +**W&K-Sync → Import & Posteingang → Posteingang.** Neue Anmeldungen und Kontaktanfragen werden gesichtet und einzeln oder gesammelt verschoben. Doppelte Anmeldungen (gleiche E-Mail-Adresse, gleiche Veranstaltung) werden markiert. „Bearbeiten“ öffnet den Datensatz (Anmeldungen unter Kalender → Veranstaltung → Anmeldungen, Anfragen unter W&K-Sync → Kontaktanfragen). + +**Export**: In der Download-Liste erzeugt „Download-Liste für W&K exportieren“ die Datei `JJJJ-MM-TT_website_kontaktanfragen_wk.csv` mit den Spalten `KBNR;KNKDAN;KNKDTITEL;KNKDN2;KNKDN1;KNKDSTR;KNKDPLZ;KNKDORT;KNGEBDAT;KDEMAIL;KDTEL1;KDFREI01;KDFREI02;KDFREI03;KDFREI04;KNUKAT;KNVEG;schoolName;schoolCity`. Kontaktanfragen stehen ohne Kursnummer in der Datei. Die Datensätze werden als „Exportiert“ markiert, die freien Plätze der Veranstaltungen um die Zahl der Anmeldungen reduziert (bis zum nächsten W&K-Import), und die Datei bleibt unter „Exporte für W&K“ zum erneuten Download. + +## Eigene Templates + +- Frontend-Module: `frontend_module/wk_booking_form.html.twig` und `frontend_module/wk_booking_status.html.twig` (Blöcke `booking_header`, `booking_status`, `booking_form` bzw. `status`, `inquiry`). +- Twig-Funktionen für eigene Event-Templates: `wk_booking_info(event.id)` (Verfügbarkeit, freie Plätze, Anmeldeschluss, Links), `wk_event_categories(event)` (Kategorien), `wk_event_people(event)` (Kursleitung, Referenten). + +## Sicherheit und Robustheit + +- Alle schreibenden Backend-Aktionen laufen über POST mit Request-Token; Ausgaben sind escaped. +- Upload nur `.csv`/`.txt`, Dateien liegen außerhalb des Web-Roots (`var/wk-sync/`). +- Import in einer Transaktion; fehlerhafte Zeilen brechen den Import nicht ab, sondern werden gemeldet. +- Verfügbarkeit wird beim Absenden erneut geprüft (Wettlauf um den letzten Platz). +- Anmeldungen und Anfragen werden nur mit bekannten Spalten gespeichert. + +## Entwicklung + +```bash +composer install +vendor/bin/phpunit +``` + +Die Tests decken CSV-Parsing, Zeilen-Zuordnung, Kategorie-Auflösung, Formularregeln, Benachrichtigungs-Routing, Textaufbereitung und den W&K-Export ab. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0697f7b --- /dev/null +++ b/composer.json @@ -0,0 +1,59 @@ +{ + "name": "mummert/wk-sync-bundle", + "description": "W&K-Sync für Contao 5.7: Kursimport aus der Seminarverwaltung W&K (CSV), Online-Anmeldungen mit Posteingang und CSV-Export zurück nach W&K", + "type": "contao-bundle", + "license": "MIT", + "authors": [ + { + "name": "mummert.media", + "email": "juergen@mummert.media" + } + ], + "require": { + "php": "^8.5", + "ext-iconv": "*", + "ext-mbstring": "*", + "contao/core-bundle": "^5.7", + "contao/calendar-bundle": "^5.7", + "doctrine/dbal": "^4.0", + "symfony/config": "^7.4", + "symfony/console": "^7.4", + "symfony/dependency-injection": "^7.4", + "symfony/event-dispatcher": "^7.4", + "symfony/http-foundation": "^7.4", + "symfony/http-kernel": "^7.4", + "twig/twig": "^3.0" + }, + "require-dev": { + "contao/manager-plugin": "^2.13", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "terminal42/notification_center": "Versand der Anmeldebestätigungen über das Notification Center 2 (Benachrichtigungstyp „W&K-Anmeldung“)", + "contao/newsletter-bundle": "Newsletter-Anmeldung (Double-Opt-in) direkt aus dem Anmeldeformular" + }, + "conflict": { + "contao/manager-plugin": "<2.13", + "terminal42/notification_center": "<2.0" + }, + "autoload": { + "psr-4": { + "Mummert\\ContaoWkSyncBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Mummert\\ContaoWkSyncBundle\\Tests\\": "tests/" + } + }, + "extra": { + "contao-manager-plugin": "Mummert\\ContaoWkSyncBundle\\ContaoManager\\Plugin" + }, + "config": { + "allow-plugins": { + "contao-components/installer": false, + "contao/manager-plugin": false, + "php-http/discovery": false + } + } +} diff --git a/config/notification_center.yaml b/config/notification_center.yaml new file mode 100644 index 0000000..aa60228 --- /dev/null +++ b/config/notification_center.yaml @@ -0,0 +1,10 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + Mummert\ContaoWkSyncBundle\EventListener\NotificationCenter\BookingNotificationType: ~ + + Mummert\ContaoWkSyncBundle\Booking\NotificationCenterNotifier: ~ + + Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NotificationCenterNotifier' diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..bd4b9c5 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,48 @@ +services: + _defaults: + autowire: true + autoconfigure: true + bind: + $projectDir: '%kernel.project_dir%' + + Mummert\ContaoWkSyncBundle\: + resource: '../src/' + exclude: + - '../src/ContaoManager/' + - '../src/ContaoWkSyncBundle.php' + - '../src/EventListener/NotificationCenter/' + - '../src/Booking/NotificationCenterNotifier.php' + - '../src/Category/{Category,CategoryKind}.php' + - '../src/Import/{ImportPlan,PlannedEvent,ImportMessage,WkRow}.php' + - '../src/Booking/{Availability,BookingFields}.php' + - '../src/Inbox/{InboxItem,InboxState}.php' + - '../src/Sync/CalendarConfig.php' + + # Ohne Notification Center werden Anmeldungen gespeichert, aber keine Mails verschickt + Mummert\ContaoWkSyncBundle\Booking\BookingNotifierInterface: '@Mummert\ContaoWkSyncBundle\Booking\NullNotifier' + + # Das klassische Backend-Modul wird von Contao ohne Container instanziiert und holt sich diesen Dienst + Mummert\ContaoWkSyncBundle\Backend\WkSyncBackend: + public: true + + Mummert\ContaoWkSyncBundle\Booking\BookingAvailability: + public: true + + Mummert\ContaoWkSyncBundle\Category\CategoryRepository: + public: true + + # Öffentlich, damit Projekt-Skripte und Templates darauf zugreifen können + Mummert\ContaoWkSyncBundle\Booking\EventResolver: + public: true + + Mummert\ContaoWkSyncBundle\Booking\BookingTokens: + public: true + + Mummert\ContaoWkSyncBundle\Booking\NotificationRouter: + public: true + + Mummert\ContaoWkSyncBundle\Sync\CalendarConfigRepository: + public: true + + Mummert\ContaoWkSyncBundle\Import\ImportService: + public: true diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..e3da32c --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,28 @@ + [ + 'wk_sync_tools' => [ + 'callback' => WkSyncModule::class, + 'stylesheet' => 'bundles/contaowksync/wk-sync-backend.css', + ], + 'wk_sync_categories' => [ + 'tables' => ['tl_wk_category'], + ], + 'wk_sync_inquiries' => [ + 'tables' => ['tl_wk_inquiry'], + ], + 'wk_sync_log' => [ + 'tables' => ['tl_wk_sync_log'], + ], + ], +]); + +// Anmeldungen werden als Untertabelle der Veranstaltungen im Kalender-Modul verwaltet +$GLOBALS['BE_MOD']['content']['calendar']['tables'][] = 'tl_wk_booking'; diff --git a/contao/dca/tl_calendar.php b/contao/dca/tl_calendar.php new file mode 100644 index 0000000..c7a9439 --- /dev/null +++ b/contao/dca/tl_calendar.php @@ -0,0 +1,149 @@ +addLegend('wk_legend', 'title_legend', PaletteManipulator::POSITION_AFTER) + ->addField('wkEnable', 'wk_legend', PaletteManipulator::POSITION_APPEND) + ->applyToPalette('default', 'tl_calendar') +; + +$fields = [ + 'wkEnable' => [ + 'filter' => true, + 'inputType' => 'checkbox', + 'eval' => ['submitOnChange' => true, 'tl_class' => 'w50 m12'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'wkBookingForm' => [ + 'inputType' => 'select', + 'foreignKey' => 'tl_form.title', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkBookingPage' => [ + 'inputType' => 'pageTree', + 'foreignKey' => 'tl_page.title', + 'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkConfirmationPage' => [ + 'inputType' => 'pageTree', + 'foreignKey' => 'tl_page.title', + 'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkInquiryPage' => [ + 'inputType' => 'pageTree', + 'foreignKey' => 'tl_page.title', + 'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkDeadlineDays' => [ + 'default' => 1, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'natural', 'maxlength' => 3, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 1], + ], + 'wkDeadlineDisplayDays' => [ + 'default' => 31, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'natural', 'maxlength' => 3, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 31], + ], + 'wkOwnVenues' => [ + 'default' => 'TPI Moritzburg', + 'inputType' => 'textarea', + 'eval' => ['style' => 'height:60px', 'decodeEntities' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'wkAuthor' => [ + 'inputType' => 'select', + 'foreignKey' => 'tl_user.name', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkInstructorGroup' => [ + 'inputType' => 'select', + 'foreignKey' => 'tl_member_group.name', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkSpeakerGroup' => [ + 'inputType' => 'select', + 'foreignKey' => 'tl_member_group.name', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkNewsletterChannels' => [ + 'inputType' => 'checkbox', + 'eval' => ['multiple' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false], + ], + 'wkNewsletterPage' => [ + 'inputType' => 'pageTree', + 'foreignKey' => 'tl_page.title', + 'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkNewsletterText' => [ + 'inputType' => 'textarea', + 'eval' => ['style' => 'height:120px', 'decodeEntities' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'wkNotificationDefault' => [ + 'inputType' => 'select', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], +]; + +foreach (NotificationRouter::CONDITIONS as $condition) { + $fields['wkNotificationExplanation'.$condition] = [ + 'inputType' => 'explanation', + 'eval' => [ + 'class' => 'tl_info', + 'text' => &$GLOBALS['TL_LANG']['tl_calendar']['wkNotificationExplanation'.$condition], + 'tl_class' => 'long clr', + ], + ]; + + $fields['wkNotification'.$condition] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_calendar']['wkNotificationCondition'], + 'inputType' => 'select', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ]; +} + +$dca['fields'] = array_merge($dca['fields'], $fields); diff --git a/contao/dca/tl_calendar_events.php b/contao/dca/tl_calendar_events.php new file mode 100644 index 0000000..60fb127 --- /dev/null +++ b/contao/dca/tl_calendar_events.php @@ -0,0 +1,169 @@ + 'table=tl_wk_booking', + 'icon' => 'mgroup.svg', +]; + +$manipulator = PaletteManipulator::create() + ->addLegend('wk_legend', 'title_legend', PaletteManipulator::POSITION_AFTER) + ->addLegend('wk_content_legend', 'wk_legend', PaletteManipulator::POSITION_AFTER) + ->addLegend('wk_people_legend', 'wk_content_legend', PaletteManipulator::POSITION_AFTER) + ->addLegend('wk_price_legend', 'wk_people_legend', PaletteManipulator::POSITION_AFTER, true) + ->addLegend('wk_organizer_legend', 'wk_price_legend', PaletteManipulator::POSITION_AFTER, true) + ->addLegend('wk_booking_legend', 'wk_organizer_legend', PaletteManipulator::POSITION_AFTER) + ->addField(['wkId', 'wkState', 'wkInternalEventNumber', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft', 'wkImported'], 'wk_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['wkSubTitle', 'wkSubTitleAdditional', 'wkCategories', 'wkCategoryPrimary', 'wkCategoryText', 'wkAudience', 'wkTags', 'wkTopics', 'wkAdditionalDates'], 'wk_content_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['wkInstructor', 'wkSpeakers'], 'wk_people_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['wkPrice1', 'wkPriceType1', 'wkPrice2', 'wkPriceType2', 'wkPrice3', 'wkPriceType3', 'wkPrice4', 'wkPriceType4', 'wkPrice5', 'wkPriceType5', 'wkPrice6', 'wkPriceType6'], 'wk_price_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['wkOrganizer', 'wkOrganizerAdditional', 'wkOrganizerSalutation', 'wkOrganizerAcademicTitle', 'wkOrganizerFirstname', 'wkOrganizerLastname', 'wkOrganizerStreet', 'wkOrganizerPostal', 'wkOrganizerCity', 'wkOrganizerCountryCode', 'wkOrganizerEmail'], 'wk_organizer_legend', PaletteManipulator::POSITION_APPEND) + ->addField(['wkNotification', 'wkConfirmationPage'], 'wk_booking_legend', PaletteManipulator::POSITION_APPEND) +; + +foreach (array_keys($dca['palettes']) as $palette) { + if ('__selector__' === $palette || !\is_string($dca['palettes'][$palette])) { + continue; + } + + $manipulator->applyToPalette($palette, 'tl_calendar_events'); +} + +$text = static fn (int $length, string $class = 'w50', array $eval = []): array => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => $length, 'tl_class' => $class, ...$eval], + 'sql' => ['type' => 'string', 'length' => $length, 'default' => ''], +]; + +$price = static fn (): array => [ + 'inputType' => 'text', + 'eval' => ['rgxp' => 'prcnt', 'maxlength' => 10, 'tl_class' => 'w50'], + 'sql' => "decimal(10,2) NOT NULL default '0.00'", +]; + +$fields = [ + 'wkId' => [ + 'search' => true, + 'filter' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 16, 'readonly' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'wkState' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => WkState::values(), + 'reference' => &$GLOBALS['TL_LANG']['tl_calendar_events']['wkStateRef'], + 'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 2, 'default' => ''], + ], + 'wkInternalEventNumber' => $text(64), + 'wkEventType' => [ + 'filter' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'natural', 'maxlength' => 3, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'wkMaxPlaces' => [ + 'inputType' => 'text', + 'eval' => ['rgxp' => 'natural', 'maxlength' => 5, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'wkPlacesLeft' => [ + 'inputType' => 'text', + 'eval' => ['rgxp' => 'natural', 'maxlength' => 5, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'wkImported' => [ + 'inputType' => 'text', + 'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'wkSubTitle' => $text(255, 'clr long'), + 'wkSubTitleAdditional' => $text(255, 'long'), + 'wkCategories' => [ + 'filter' => true, + 'inputType' => 'checkbox', + 'foreignKey' => 'tl_wk_category.title', + 'eval' => ['multiple' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false], + ], + 'wkCategoryPrimary' => [ + 'filter' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_wk_category.title', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkCategoryText' => $text(255), + 'wkAudience' => $text(255), + 'wkTags' => $text(255), + 'wkTopics' => [ + 'search' => true, + 'inputType' => 'textarea', + 'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'wkAdditionalDates' => $text(512, 'clr long'), + 'wkInstructor' => [ + 'filter' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_member.CONCAT(lastname," ",firstname)', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'wkSpeakers' => [ + 'inputType' => 'select', + 'foreignKey' => 'tl_member.CONCAT(lastname," ",firstname)', + 'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false], + ], + 'wkPrice1' => $price(), + 'wkPriceType1' => $text(64), + 'wkPrice2' => $price(), + 'wkPriceType2' => $text(64), + 'wkPrice3' => $price(), + 'wkPriceType3' => $text(64), + 'wkPrice4' => $price(), + 'wkPriceType4' => $text(64), + 'wkPrice5' => $price(), + 'wkPriceType5' => $text(64), + 'wkPrice6' => $price(), + 'wkPriceType6' => $text(64), + 'wkOrganizer' => $text(128), + 'wkOrganizerAdditional' => $text(128), + 'wkOrganizerSalutation' => $text(64), + 'wkOrganizerAcademicTitle' => $text(64), + 'wkOrganizerFirstname' => $text(64), + 'wkOrganizerLastname' => $text(128), + 'wkOrganizerStreet' => $text(128), + 'wkOrganizerPostal' => $text(16), + 'wkOrganizerCity' => $text(128), + 'wkOrganizerCountryCode' => $text(16), + 'wkOrganizerEmail' => $text(255, 'w50', ['decodeEntities' => true]), + 'wkNotification' => [ + 'inputType' => 'select', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'wkConfirmationPage' => [ + 'inputType' => 'pageTree', + 'foreignKey' => 'tl_page.title', + 'eval' => ['fieldType' => 'radio', 'tl_class' => 'clr'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], +]; + +$dca['fields'] = array_merge($dca['fields'], $fields); diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php new file mode 100644 index 0000000..f29bf0b --- /dev/null +++ b/contao/dca/tl_module.php @@ -0,0 +1,32 @@ + 'checkbox', + 'foreignKey' => 'tl_wk_category.title', + 'eval' => ['multiple' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'blob', 'length' => 65535, 'notnull' => false], +]; + +foreach (['eventlist', 'eventmenu', 'eventmenucal_day', 'calendar'] as $palette) { + if (!isset($dca['palettes'][$palette])) { + continue; + } + + PaletteManipulator::create() + ->addField('wkCategories', 'cal_calendar', PaletteManipulator::POSITION_AFTER) + ->applyToPalette($palette, 'tl_module') + ; +} diff --git a/contao/dca/tl_wk_booking.php b/contao/dca/tl_wk_booking.php new file mode 100644 index 0000000..d72d945 --- /dev/null +++ b/contao/dca/tl_wk_booking.php @@ -0,0 +1,280 @@ + [ + 'dataContainer' => DC_Table::class, + 'ptable' => 'tl_calendar_events', + 'enableVersioning' => true, + 'doNotCopyRecords' => true, + 'notCopyable' => true, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'pid' => 'index', + 'token' => 'unique', + 'state' => 'index', + 'email' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => DataContainer::MODE_PARENT, + 'fields' => ['dateCreated DESC'], + 'headerFields' => ['title', 'wkId', 'startDate', 'endDate', 'wkPlacesLeft'], + 'panelLayout' => 'filter;search,limit', + 'disableGrouping' => true, + ], + 'global_operations' => [ + 'all', + ], + 'operations' => [ + 'edit', + 'delete', + 'show', + ], + ], + 'palettes' => [ + '__selector__' => ['addDifferentInvoiceAddress', 'addNotes', 'teacherPosition'], + 'default' => '{state_legend},state,dateCreated,notification;' + .'{person_legend},gender,academicTitle,firstname,lastname,street,postal,city,email,phone,position,birthday;' + .'{school_legend},teacherPosition;' + .'{stay_legend},vegetarian,overnightStay;' + .'{invoice_legend},addDifferentInvoiceAddress;' + .'{notes_legend},addNotes;' + .'{consent_legend},privacyAccepted,agbAccepted,newsletter', + ], + 'subpalettes' => [ + 'addDifferentInvoiceAddress' => 'invoiceInstitution,invoiceGender,invoiceAcademicTitle,invoiceFirstname,invoiceLastname,invoiceStreet,invoicePostal,invoiceCity', + 'addNotes' => 'notes', + 'teacherPosition_state' => 'schoolName,schoolCity', + 'teacherPosition_other' => 'schoolName,schoolCity', + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + ], + 'pid' => [ + 'foreignKey' => 'tl_calendar_events.title', + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'belongsTo', 'load' => 'lazy'], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'dateCreated' => [ + 'filter' => true, + 'sorting' => true, + 'flag' => DataContainer::SORT_DAY_DESC, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'datim', 'datepicker' => true, 'doNotCopy' => true, 'tl_class' => 'w50 wizard'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'token' => [ + 'eval' => ['doNotCopy' => true], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => '', 'platformOptions' => ['collation' => 'ascii_bin']], + ], + 'state' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => InboxState::values(), + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef'], + 'eval' => ['includeBlankOption' => true, 'blankOptionLabel' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef']['new'], 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'exportedAt' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'notification' => [ + 'inputType' => 'text', + 'eval' => ['readonly' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'gender' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => BookingFields::GENDERS, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['genderRef'], + 'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'academicTitle' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ], + 'firstname' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'lastname' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'street' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'postal' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 32, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => ''], + ], + 'city' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'email' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'rgxp' => 'email', 'maxlength' => 255, 'decodeEntities' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'phone' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'phone', 'maxlength' => 64, 'decodeEntities' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ], + 'position' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => BookingFields::POSITIONS, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['positionRef'], + 'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => ''], + ], + 'birthday' => [ + 'inputType' => 'text', + 'eval' => ['rgxp' => 'date', 'maxlength' => 10, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 10, 'default' => ''], + ], + 'teacherPosition' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => BookingFields::TEACHER_POSITIONS, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['teacherPositionRef'], + 'eval' => ['includeBlankOption' => true, 'submitOnChange' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'schoolName' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'schoolCity' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'vegetarian' => [ + 'filter' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'overnightStay' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => BookingFields::OVERNIGHT_STAYS, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['overnightStayRef'], + 'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'addDifferentInvoiceAddress' => [ + 'filter' => true, + 'inputType' => 'checkbox', + 'eval' => ['submitOnChange' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'invoiceInstitution' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'invoiceGender' => [ + 'inputType' => 'select', + 'options' => BookingFields::GENDERS, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['genderRef'], + 'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'invoiceAcademicTitle' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ], + 'invoiceFirstname' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'invoiceLastname' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'invoiceStreet' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'invoicePostal' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 32, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => ''], + ], + 'invoiceCity' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'addNotes' => [ + 'inputType' => 'checkbox', + 'eval' => ['submitOnChange' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'notes' => [ + 'search' => true, + 'inputType' => 'textarea', + 'eval' => ['tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'privacyAccepted' => [ + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'agbAccepted' => [ + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'newsletter' => [ + 'filter' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + ], +]; diff --git a/contao/dca/tl_wk_category.php b/contao/dca/tl_wk_category.php new file mode 100644 index 0000000..f5cb914 --- /dev/null +++ b/contao/dca/tl_wk_category.php @@ -0,0 +1,82 @@ + [ + 'dataContainer' => DC_Table::class, + 'enableVersioning' => true, + 'markAsCopy' => 'title', + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'alias' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => DataContainer::MODE_SORTED, + 'fields' => ['sorting'], + 'flag' => DataContainer::SORT_ASC, + 'panelLayout' => 'search,limit', + ], + 'label' => [ + 'fields' => ['sorting', 'title', 'kind', 'wkNames'], + 'showColumns' => true, + ], + 'operations' => [ + 'edit', + 'copy', + 'delete', + 'show', + ], + ], + 'palettes' => [ + 'default' => '{title_legend},title,alias,sorting;{wk_legend},kind,wkNames', + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'sorting' => [ + 'inputType' => 'text', + 'sorting' => true, + 'eval' => ['rgxp' => 'natural', 'maxlength' => 5, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'title' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['mandatory' => true, 'basicEntities' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'alias' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'alias', 'unique' => true, 'maxlength' => 255, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => '', 'platformOptions' => ['collation' => 'ascii_bin']], + ], + 'kind' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => array_map(static fn (CategoryKind $kind): string => $kind->value, CategoryKind::cases()), + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_category']['kindRef'], + 'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'tl_class' => 'w50 clr'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => CategoryKind::Other->value], + ], + 'wkNames' => [ + 'search' => true, + 'inputType' => 'textarea', + 'eval' => ['style' => 'height:80px', 'decodeEntities' => true, 'tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + ], +]; diff --git a/contao/dca/tl_wk_inquiry.php b/contao/dca/tl_wk_inquiry.php new file mode 100644 index 0000000..cd37793 --- /dev/null +++ b/contao/dca/tl_wk_inquiry.php @@ -0,0 +1,156 @@ + [ + 'dataContainer' => DC_Table::class, + 'enableVersioning' => true, + 'notCopyable' => true, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'state' => 'index', + 'event' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => DataContainer::MODE_SORTABLE, + 'fields' => ['dateCreated DESC'], + 'panelLayout' => 'filter;sort,search,limit', + ], + 'label' => [ + 'fields' => ['dateCreated', 'lastname', 'firstname', 'subject', 'state'], + 'showColumns' => true, + ], + 'global_operations' => [ + 'all', + ], + 'operations' => [ + 'edit', + 'delete', + 'show', + ], + ], + 'palettes' => [ + 'default' => '{state_legend},state,dateCreated,event;' + .'{person_legend},gender,academicTitle,firstname,lastname,street,postal,city,email,phone;' + .'{message_legend},subject,notes;' + .'{consent_legend},newsletter,privacy', + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + ], + 'tstamp' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'dateCreated' => [ + 'filter' => true, + 'sorting' => true, + 'flag' => DataContainer::SORT_DAY_DESC, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'datim', 'datepicker' => true, 'tl_class' => 'w50 wizard'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'state' => [ + 'filter' => true, + 'inputType' => 'select', + 'options' => InboxState::values(), + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef'], + 'eval' => ['includeBlankOption' => true, 'blankOptionLabel' => &$GLOBALS['TL_LANG']['tl_wk_booking']['stateRef']['new'], 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'exportedAt' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'event' => [ + 'filter' => true, + 'inputType' => 'select', + 'foreignKey' => 'tl_calendar_events.title', + 'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'gender' => [ + 'inputType' => 'select', + 'options' => BookingFields::GENDERS, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_booking']['genderRef'], + 'eval' => ['includeBlankOption' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'academicTitle' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ], + 'firstname' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'lastname' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'street' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'postal' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 32, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => ''], + ], + 'city' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 128, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], + ], + 'email' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'email', 'maxlength' => 255, 'decodeEntities' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'phone' => [ + 'inputType' => 'text', + 'eval' => ['maxlength' => 64, 'decodeEntities' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 64, 'default' => ''], + ], + 'subject' => [ + 'search' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 255, 'tl_class' => 'clr long'], + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'notes' => [ + 'search' => true, + 'inputType' => 'textarea', + 'eval' => ['tl_class' => 'clr'], + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'newsletter' => [ + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + 'privacy' => [ + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50'], + 'sql' => ['type' => 'boolean', 'default' => false], + ], + ], +]; diff --git a/contao/dca/tl_wk_sync_log.php b/contao/dca/tl_wk_sync_log.php new file mode 100644 index 0000000..9fc343f --- /dev/null +++ b/contao/dca/tl_wk_sync_log.php @@ -0,0 +1,82 @@ + [ + 'dataContainer' => DC_Table::class, + 'closed' => true, + 'notEditable' => true, + 'notCopyable' => true, + 'sql' => [ + 'keys' => [ + 'id' => 'primary', + 'type,tstamp' => 'index', + ], + ], + ], + 'list' => [ + 'sorting' => [ + 'mode' => DataContainer::MODE_SORTABLE, + 'fields' => ['tstamp DESC'], + 'panelLayout' => 'filter;limit', + ], + 'label' => [ + 'fields' => ['tstamp', 'type', 'status', 'filename', 'user'], + 'showColumns' => true, + ], + 'operations' => [ + 'show', + 'delete', + ], + ], + 'palettes' => [ + 'default' => 'tstamp,type,status,user,calendar,filename,summary,messages', + ], + 'fields' => [ + 'id' => [ + 'sql' => ['type' => 'integer', 'unsigned' => true, 'autoincrement' => true], + ], + 'tstamp' => [ + 'sorting' => true, + 'flag' => DataContainer::SORT_DAY_DESC, + 'eval' => ['rgxp' => 'datim'], + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + ], + 'type' => [ + 'filter' => true, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_sync_log']['typeRef'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'status' => [ + 'filter' => true, + 'reference' => &$GLOBALS['TL_LANG']['tl_wk_sync_log']['statusRef'], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => ''], + ], + 'user' => [ + 'foreignKey' => 'tl_user.name', + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'calendar' => [ + 'foreignKey' => 'tl_calendar.title', + 'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0], + 'relation' => ['type' => 'hasOne', 'load' => 'lazy'], + ], + 'filename' => [ + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'path' => [ + 'sql' => ['type' => 'string', 'length' => 255, 'default' => ''], + ], + 'summary' => [ + 'sql' => ['type' => 'text', 'notnull' => false], + ], + 'messages' => [ + 'sql' => ['type' => 'text', 'length' => 16777215, 'notnull' => false], + ], + ], +]; diff --git a/contao/languages/de/default.php b/contao/languages/de/default.php new file mode 100644 index 0000000..cabe15f --- /dev/null +++ b/contao/languages/de/default.php @@ -0,0 +1,121 @@ +Bedingung 1: Online-Kurse – alle Kategorien, Veranstaltungsort „Online“.'; +$lang['wkNotificationExplanation2'] = 'Bedingung 2: Tagesveranstaltung Schulfortbildung – Beginn = Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.'; +$lang['wkNotificationExplanation3'] = 'Bedingung 3: Tagesveranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen – Beginn = Ende, Kategorie-Art „Gemeinde“, „Gottesdienst mit Kindern“ oder „Tagungen“.'; +$lang['wkNotificationExplanation4'] = 'Bedingung 4: Tagesveranstaltung Elementarbereich – Beginn = Ende, Kategorie-Art „Elementarbereich“.'; +$lang['wkNotificationExplanation5'] = 'Bedingung 5: Mehrtägige Schulfortbildung – Beginn ≠ Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.'; +$lang['wkNotificationExplanation6'] = 'Bedingung 6: Mehrtägige Fortbildung Elementarbereich – Beginn ≠ Ende, Kategorie-Art „Elementarbereich“.'; +$lang['wkNotificationExplanation7'] = 'Bedingung 7: Mehrtägige Veranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen – Beginn ≠ Ende.'; +$lang['wkNotificationExplanation8'] = 'Bedingung 8: Tagesveranstaltung Evangelische Schulen – Beginn = Ende, Kategorie-Art „Evangelische Schulen“.'; +$lang['wkNotificationExplanation9'] = 'Bedingung 9: Mehrtägige Veranstaltung Evangelische Schulen – Beginn ≠ Ende, Kategorie-Art „Evangelische Schulen“.'; +$lang['wkNotificationExplanation10'] = 'Bedingung 10: Tagesveranstaltung Mentoring – Beginn = Ende, Kategorie-Art „Mentoring“.'; +$lang['wkNotificationExplanation11'] = 'Bedingung 11: Mehrtägige Veranstaltung Mentoring – Beginn ≠ Ende, Kategorie-Art „Mentoring“.'; +$lang['wkNotificationExplanation12'] = 'Bedingung 12: Mehrtägige Ausbildung – Beginn ≠ Ende, Kategorie-Art „Ausbildung“.'; diff --git a/contao/languages/de/tl_calendar_events.php b/contao/languages/de/tl_calendar_events.php new file mode 100644 index 0000000..a43c429 --- /dev/null +++ b/contao/languages/de/tl_calendar_events.php @@ -0,0 +1,64 @@ +Bedingung 1: Online-Kurse – alle Kategorien, Veranstaltungsort „Online“.'; +$lang['wkNotificationExplanation2'] = 'Bedingung 2: Tagesveranstaltung Schulfortbildung – Beginn = Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.'; +$lang['wkNotificationExplanation3'] = 'Bedingung 3: Tagesveranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen – Beginn = Ende, Kategorie-Art „Gemeinde“, „Gottesdienst mit Kindern“ oder „Tagungen“.'; +$lang['wkNotificationExplanation4'] = 'Bedingung 4: Tagesveranstaltung Elementarbereich – Beginn = Ende, Kategorie-Art „Elementarbereich“.'; +$lang['wkNotificationExplanation5'] = 'Bedingung 5: Mehrtägige Schulfortbildung – Beginn ≠ Ende, Kategorie-Art „Schule“, Nummer beginnt mit EXTP.'; +$lang['wkNotificationExplanation6'] = 'Bedingung 6: Mehrtägige Fortbildung Elementarbereich – Beginn ≠ Ende, Kategorie-Art „Elementarbereich“.'; +$lang['wkNotificationExplanation7'] = 'Bedingung 7: Mehrtägige Veranstaltung Gemeinde / Gottesdienst mit Kindern / Tagungen – Beginn ≠ Ende.'; +$lang['wkNotificationExplanation8'] = 'Bedingung 8: Tagesveranstaltung Evangelische Schulen – Beginn = Ende, Kategorie-Art „Evangelische Schulen“.'; +$lang['wkNotificationExplanation9'] = 'Bedingung 9: Mehrtägige Veranstaltung Evangelische Schulen – Beginn ≠ Ende, Kategorie-Art „Evangelische Schulen“.'; +$lang['wkNotificationExplanation10'] = 'Bedingung 10: Tagesveranstaltung Mentoring – Beginn = Ende, Kategorie-Art „Mentoring“.'; +$lang['wkNotificationExplanation11'] = 'Bedingung 11: Mehrtägige Veranstaltung Mentoring – Beginn ≠ Ende, Kategorie-Art „Mentoring“.'; +$lang['wkNotificationExplanation12'] = 'Bedingung 12: Mehrtägige Ausbildung – Beginn ≠ Ende, Kategorie-Art „Ausbildung“.'; diff --git a/contao/languages/en/tl_calendar_events.php b/contao/languages/en/tl_calendar_events.php new file mode 100644 index 0000000..a43c429 --- /dev/null +++ b/contao/languages/en/tl_calendar_events.php @@ -0,0 +1,64 @@ + + {{ 'MSC.wk_sync.nav.import'|trans }} + {{ 'MSC.wk_sync.nav.inbox'|trans }}{% if counts is defined and counts[''] is defined and counts[''] > 0 %} {{ counts[''] }}{% endif %} + {{ 'MSC.wk_sync.nav.exports'|trans }} + diff --git a/contao/templates/backend/wk_sync/dashboard.html.twig b/contao/templates/backend/wk_sync/dashboard.html.twig new file mode 100644 index 0000000..c65bf75 --- /dev/null +++ b/contao/templates/backend/wk_sync/dashboard.html.twig @@ -0,0 +1,80 @@ +{% trans_default_domain 'contao_default' %} +
+ {{ include('@Contao/backend/wk_sync/_nav.html.twig') }} + +

{{ 'MSC.wk_sync.import.headline'|trans }}

+ + {% if not has_categories %} +

{{ 'MSC.wk_sync.import.no_categories'|trans }}

+ {% endif %} + + {% if calendars is empty %} +

{{ 'MSC.wk_sync.import.no_calendar'|trans }}

+ {% else %} +
+ + +
+
+ + +
+
+ + +

{{ 'MSC.wk_sync.import.file_help'|trans([(max_upload / 1048576)|round(1)]) }}

+
+
+

{{ 'MSC.wk_sync.import.hint'|trans }}

+
+
+
+
+ +
+
+
+ {% endif %} + +

{{ 'MSC.wk_sync.import.recent'|trans }}

+ {% if imports is empty %} +

{{ 'MSC.wk_sync.import.none'|trans }}

+ {% else %} + + + + + + + + + + + + + {% for log in imports %} + + + + + + + + + {% endfor %} + +
{{ 'MSC.wk_sync.log.date'|trans }}{{ 'MSC.wk_sync.log.file'|trans }}{{ 'MSC.wk_sync.log.calendar'|trans }}{{ 'MSC.wk_sync.log.user'|trans }}{{ 'MSC.wk_sync.log.status'|trans }}{{ 'MSC.wk_sync.log.summary'|trans }}
{{ log.date }}{{ log.filename }}{{ log.calendarTitle }}{{ log.userName|default('') }}{{ ('MSC.wk_sync.status.' ~ log.status)|trans }} + {% set r = log.summary.result|default(null) %} + {% set p = log.summary.plan|default(null) %} + {% if r %} + {{ 'MSC.wk_sync.log.result'|trans([r.created, r.updated, r.cancelled]) }} + {% elseif p %} + {{ 'MSC.wk_sync.log.planned'|trans([p.create, p.update, p.cancel, p.skip]) }} + {% endif %} +
+ {% endif %} +
diff --git a/contao/templates/backend/wk_sync/exports.html.twig b/contao/templates/backend/wk_sync/exports.html.twig new file mode 100644 index 0000000..f275064 --- /dev/null +++ b/contao/templates/backend/wk_sync/exports.html.twig @@ -0,0 +1,40 @@ +{% trans_default_domain 'contao_default' %} +
+ {{ include('@Contao/backend/wk_sync/_nav.html.twig') }} + +

{{ 'MSC.wk_sync.exports.headline'|trans }}

+

{{ 'MSC.wk_sync.exports.help'|trans }}

+ + {% if exports is empty %} +

{{ 'MSC.wk_sync.exports.none'|trans }}

+ {% else %} + + + + + + + + + + + + {% for log in exports %} + + + + + + + + {% endfor %} + +
{{ 'MSC.wk_sync.log.date'|trans }}{{ 'MSC.wk_sync.log.file'|trans }}{{ 'MSC.wk_sync.log.user'|trans }}{{ 'MSC.wk_sync.exports.content'|trans }}
{{ log.date }}{{ log.filename }}{{ log.userName|default('') }}{{ 'MSC.wk_sync.exports.summary'|trans([log.summary.bookings|default(0), log.summary.inquiries|default(0)]) }} + {% if log.file_exists %} + {{ 'MSC.wk_sync.exports.download'|trans }} + {% else %} + {{ 'MSC.wk_sync.exports.missing'|trans }} + {% endif %} +
+ {% endif %} +
diff --git a/contao/templates/backend/wk_sync/inbox.html.twig b/contao/templates/backend/wk_sync/inbox.html.twig new file mode 100644 index 0000000..a3f0aec --- /dev/null +++ b/contao/templates/backend/wk_sync/inbox.html.twig @@ -0,0 +1,107 @@ +{% trans_default_domain 'contao_default' %} +
+ {{ include('@Contao/backend/wk_sync/_nav.html.twig') }} + +

{{ 'MSC.wk_sync.inbox.headline'|trans }}

+ + + + {% if can_export %} +
+ + + {{ 'MSC.wk_sync.export.help'|trans }} +
+ {% endif %} + + {% if items is empty %} +

{{ 'MSC.wk_sync.inbox.empty'|trans }}

+ {% else %} +
+ + + + + + + + + + + + + + {% for row in items %} + {% set item = row.item %} + + + + + + + + {% endfor %} + +
{% if targets is not empty %}{% endif %}{{ 'MSC.wk_sync.inbox.date'|trans }}{{ 'MSC.wk_sync.inbox.person'|trans }}{{ 'MSC.wk_sync.inbox.data'|trans }}
+ {% if targets is not empty %}{% endif %} + + {{ item.dateCreated|date(contao.datim_format) }}
+ {{ ('MSC.wk_sync.inbox.type_' ~ item.type)|trans }} + {% if item.duplicate %}
{{ 'MSC.wk_sync.inbox.duplicate'|trans }}{% endif %} +
+ {% for line in item.addressLines %} + {% if loop.first %}{{ line }}{% else %}{{ line }}{% endif %}{% if not loop.last %}
{% endif %} + {% endfor %} +
+ {% if item.isBooking %} + {{ 'MSC.wk_sync.inbox.booking_for'|trans([item.eventWkId, item.eventTitle]) }}
+ {% elseif item.eventTitle %} + {{ 'MSC.wk_sync.inbox.inquiry_for'|trans([item.eventWkId, item.eventTitle]) }}
+ {% endif %} + {% for line in item.dataLines %}{{ line|nl2br }}{% if not loop.last %}
{% endif %}{% endfor %} +
+ {{ 'MSC.wk_sync.inbox.edit'|trans }} + {% for target in row.targets %} + + {% endfor %} +
+ {% if targets is not empty %} +
+ + +
+ {% endif %} +
+ {% for row in items %} + {% if row.targets is not empty %} +
+ + + + +
+ {% endif %} + {% endfor %} + {% endif %} +
+ diff --git a/contao/templates/backend/wk_sync/preview.html.twig b/contao/templates/backend/wk_sync/preview.html.twig new file mode 100644 index 0000000..58d2d9b --- /dev/null +++ b/contao/templates/backend/wk_sync/preview.html.twig @@ -0,0 +1,94 @@ +{% trans_default_domain 'contao_default' %} +
+ {{ include('@Contao/backend/wk_sync/_nav.html.twig') }} + +

{{ 'MSC.wk_sync.preview.headline'|trans([log.filename, calendar.title]) }}

+ +

+ {{ 'MSC.wk_sync.log.date'|trans }}: {{ log.date }} · + {{ 'MSC.wk_sync.log.status'|trans }}: {{ ('MSC.wk_sync.status.' ~ log.status)|trans }} +

+ +
    +
  • {{ summary.create }} {{ 'MSC.wk_sync.action.create'|trans }}
  • +
  • {{ summary.update }} {{ 'MSC.wk_sync.action.update'|trans }}
  • +
  • {{ summary.cancel }} {{ 'MSC.wk_sync.action.cancel'|trans }}
  • +
  • {{ summary.unchanged }} {{ 'MSC.wk_sync.action.unchanged'|trans }}
  • +
  • {{ summary.skip }} {{ 'MSC.wk_sync.action.skip'|trans }}
  • +
  • {{ summary.warnings }} {{ 'MSC.wk_sync.preview.warnings'|trans }}
  • +
  • {{ summary.errors }} {{ 'MSC.wk_sync.preview.errors'|trans }}
  • +
+ + {% if result %} +

{{ 'MSC.wk_sync.import.applied'|trans([result.created, result.updated, result.cancelled, result.unchanged]) }}

+ {% endif %} + + {% if is_open %} +
+ {% if plan and plan.hasWrites %} +
+ + + +
+ {% else %} +

{{ 'MSC.wk_sync.preview.nothing'|trans }}

+ {% endif %} +
+ + + +
+
+ {% endif %} + + {% if messages_list is not empty %} +

{{ 'MSC.wk_sync.preview.messages'|trans }}

+
    + {% for message in messages_list %} +
  • {{ message.text }}
  • + {% endfor %} +
+ {% endif %} + + {% for action, entries in groups %} + {% if entries is not empty %} +
+ {{ entries|length }} {{ ('MSC.wk_sync.action.' ~ action)|trans }} + + + {% for entry in entries %} + + + + + + {% endfor %} + +
{{ entry.event.wkId }} + {{ entry.event.title }} + {% if entry.date %}
{{ entry.date }}{% endif %} + {% for message in entry.event.messages %} +
{{ message.text }} + {% endfor %} +
+ {% if entry.changes is not empty %} +
+ {% for change in entry.changes %} +
{{ change.label }}
+
{{ change.old }} → {{ change.new }}
+ {% endfor %} +
+ {% endif %} +
+
+ {% endif %} + {% endfor %} +
+ diff --git a/contao/templates/frontend_module/wk_booking_form.html.twig b/contao/templates/frontend_module/wk_booking_form.html.twig new file mode 100644 index 0000000..9041408 --- /dev/null +++ b/contao/templates/frontend_module/wk_booking_form.html.twig @@ -0,0 +1,43 @@ +{% extends '@Contao/frontend_module/_base.html.twig' %} + +{% block content %} + {% set attributes = attrs().set('data-wk-booking-rules', rules_config) %} +
+ {% block booking_header %} +

{{ 'MSC.wk_booking.course_number'|trans({}, 'contao_default') }}: {{ event.wkId }}

+

{{ event.title }}

+ {% endblock %} + + {% block booking_status %} + {% if availability.isBookable %} +

+ {{ 'MSC.wk_booking.places_left'|trans([places_left], 'contao_default') }} + {% if deadline %} + · {{ 'MSC.wk_booking.deadline'|trans([deadline|date(contao.date_format)], 'contao_default') }} + {% endif %} +

+ {% elseif availability.isExternal %} +

+ {% if availability.externalContactIsEmail %} + {{ 'MSC.wk_booking.status.external_email'|trans({}, 'contao_default') }} + {{ availability.externalContact }} + {% else %} + {{ 'MSC.wk_booking.status.external_url'|trans({}, 'contao_default') }} + {{ availability.externalContact }} + {% endif %} +

+ {% else %} +

{{ ('MSC.wk_booking.status.' ~ availability.reason)|trans({}, 'contao_default') }}

+ {% endif %} + {% endblock %} + + {% block booking_form %} + {% if form_html %} +
+ {{ form_html|raw }} +
+ + {% endif %} + {% endblock %} +
+{% endblock %} diff --git a/contao/templates/frontend_module/wk_booking_status.html.twig b/contao/templates/frontend_module/wk_booking_status.html.twig new file mode 100644 index 0000000..de36909 --- /dev/null +++ b/contao/templates/frontend_module/wk_booking_status.html.twig @@ -0,0 +1,38 @@ +{% extends '@Contao/frontend_module/_base.html.twig' %} + +{% block content %} +
+ {% block status %} + {% if availability.isBookable %} +

{{ 'MSC.wk_booking.places_left'|trans([places_left], 'contao_default') }}

+ {% if deadline %} +

{{ 'MSC.wk_booking.deadline'|trans([deadline|date(contao.date_format)], 'contao_default') }}

+ {% endif %} + {% if booking_url %} + + {% endif %} + {% elseif availability.isExternal %} +

+ {% if availability.externalContactIsEmail %} + {{ 'MSC.wk_booking.status.external_email'|trans({}, 'contao_default') }} + {{ availability.externalContact }} + {% else %} + {{ 'MSC.wk_booking.status.external_url'|trans({}, 'contao_default') }} + {{ availability.externalContact }} + {% endif %} +

+ {% else %} +

{{ ('MSC.wk_booking.status.' ~ availability.reason)|trans({}, 'contao_default') }}

+ {% if inquiry_url and availability.reason in ['deadline', 'full'] %} +

{{ 'MSC.wk_booking.late_inquiry'|trans({}, 'contao_default') }} {{ 'MSC.wk_booking.inquiry_form'|trans({}, 'contao_default') }}

+ {% endif %} + {% endif %} + {% endblock %} + + {% block inquiry %} + {% if inquiry_url %} +

{{ 'MSC.wk_booking.questions'|trans({}, 'contao_default') }} {{ 'MSC.wk_booking.inquiry_form'|trans({}, 'contao_default') }}

+ {% endif %} + {% endblock %} +
+{% endblock %} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bfbcbb5 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests + + + + + src + + + diff --git a/public/wk-sync-backend.css b/public/wk-sync-backend.css new file mode 100644 index 0000000..a126f12 --- /dev/null +++ b/public/wk-sync-backend.css @@ -0,0 +1,46 @@ +.wk-sync { padding: 0 0 1rem; } +.wk-sync h2 { margin: 1.5rem 0 .75rem; font-size: 1.1rem; } +.wk-sync h3 { margin: 1.25rem 0 .5rem; } +.wk-sync-nav, .wk-sync-folders { display: flex; flex-wrap: wrap; gap: .5rem; margin: .5rem 0 1rem; } +.wk-sync-nav .active, .wk-sync-folders .active { background: var(--contao-primary, #f47c00); color: #fff; border-color: transparent; } +.wk-sync-badge { display: inline-block; min-width: 1.4em; padding: 0 .35em; border-radius: 1em; background: rgba(0,0,0,.15); font-size: .8em; text-align: center; } +.wk-sync-upload .widget { margin-bottom: 1rem; } +.wk-sync-table { width: 100%; } +.wk-sync-table td { vertical-align: top; } +.wk-sync-col-id { width: 6rem; white-space: nowrap; } +.wk-sync-col-check { width: 2rem; } +.wk-sync-col-date { width: 9rem; } +.wk-sync-col-person { width: 22%; } +.wk-sync-col-actions { width: 11rem; } +.wk-sync-col-actions .wk-sync-action { display: block; width: 100%; margin-bottom: .25rem; padding: .2rem .4rem; text-align: left; font-size: .85em; border: 1px solid #ccc; border-radius: 3px; background: #f6f6f6; color: inherit; cursor: pointer; text-decoration: none; } +.wk-sync-col-actions .wk-sync-action:hover { background: #e8e8e8; } +.wk-sync-summary { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; margin: 0 0 1rem; padding: 0; list-style: none; } +.wk-sync-summary strong { font-size: 1.2em; } +.wk-sync-summary__errors strong { color: #c33; } +.wk-sync-summary__warnings strong { color: #b7791f; } +.wk-sync-actions { display: flex; gap: .75rem; align-items: center; margin: 1rem 0; } +.wk-sync-inline { display: inline; } +.wk-sync-primary { font-weight: bold; } +.wk-sync-messages { margin: 0 0 1rem; padding-left: 1.25rem; } +.wk-sync-message--error { color: #c33; } +.wk-sync-message--warning { color: #b7791f; } +.wk-sync-message--info { color: #555; } +.wk-sync-group { margin-bottom: 1rem; } +.wk-sync-group summary { cursor: pointer; padding: .5rem 0; } +.wk-sync-changes { margin: 0; } +.wk-sync-changes dt { font-weight: bold; margin-top: .25rem; } +.wk-sync-changes dd { margin: 0 0 .25rem; } +.wk-sync-changes del { color: #c33; text-decoration: line-through; } +.wk-sync-changes ins { color: #2a7; text-decoration: none; } +.wk-sync-muted { color: #888; } +.wk-sync-type { font-size: .85em; color: #888; } +.wk-sync-flag { display: inline-block; padding: 0 .35em; border-radius: 3px; background: #fdecc8; color: #7a4b00; font-size: .8em; } +.wk-sync-status { display: inline-block; padding: 0 .4em; border-radius: 3px; font-size: .85em; background: #eee; } +.wk-sync-status--applied, .wk-sync-status--done { background: #d7f5dd; color: #1f6b34; } +.wk-sync-status--preview { background: #fff2cc; color: #7a4b00; } +.wk-sync-status--failed { background: #fbd9d9; color: #8a1f1f; } +.wk-sync-export { margin: 0 0 1rem; display: flex; gap: .75rem; align-items: center; } +.wk-sync-bulk { display: flex; gap: .75rem; align-items: center; margin: .75rem 0; } +.wk-sync-hidden-form { display: none; } +.wk-sync-highlight td { background: #fff8e1; } +.wk-sync-meta { margin: 0 0 .75rem; } diff --git a/public/wk-sync.js b/public/wk-sync.js new file mode 100644 index 0000000..babea91 --- /dev/null +++ b/public/wk-sync.js @@ -0,0 +1,88 @@ +/* + * W&K-Sync: blendet abhängige Felder des Anmeldeformulars ein und aus (wie Subpaletten im Backend). + * Die Regeln kommen als JSON aus dem Modul (data-wk-booking-rules); serverseitig gelten dieselben Regeln. + */ +(function () { + 'use strict'; + + function widgetOf(form, name) { + var input = form.querySelector('[name="' + name + '"], [name="' + name + '[]"]'); + return input ? input.closest('.widget') || input.parentElement : null; + } + + function valueOf(form, name) { + var inputs = form.querySelectorAll('[name="' + name + '"], [name="' + name + '[]"]'); + var value = ''; + + inputs.forEach(function (input) { + if ((input.type === 'checkbox' || input.type === 'radio')) { + if (input.checked) value = input.value; + } else if (input.tagName === 'SELECT' || input.type !== 'hidden') { + value = input.value; + } + }); + + return value; + } + + function setVisible(widget, visible) { + if (!widget) return; + widget.hidden = !visible; + widget.querySelectorAll('input, select, textarea').forEach(function (field) { + field.disabled = !visible; + }); + } + + function init(container) { + var form = container.querySelector('form'); + var rules; + + try { + rules = JSON.parse(container.getAttribute('data-wk-booking-rules') || '{}'); + } catch (e) { + return; + } + + if (!form || !rules.dependencies) return; + + var dependencies = rules.dependencies; + var controllers = {}; + + Object.keys(dependencies).forEach(function (field) { + var dependency = dependencies[field]; + controllers[dependency.field] = controllers[dependency.field] || []; + controllers[dependency.field].push(field); + }); + + function update() { + Object.keys(dependencies).forEach(function (field) { + var dependency = dependencies[field]; + var controllerWidget = widgetOf(form, dependency.field); + var controllerVisible = controllerWidget ? !controllerWidget.hidden : true; + var value = valueOf(form, dependency.field); + var visible = controllerVisible && dependency.values.indexOf(String(value)) !== -1; + setVisible(widgetOf(form, field), visible); + }); + } + + Object.keys(controllers).forEach(function (name) { + form.querySelectorAll('[name="' + name + '"], [name="' + name + '[]"]').forEach(function (input) { + input.addEventListener('change', update); + }); + }); + + // Zweimal, damit verschachtelte Abhängigkeiten (Feld hängt von verstecktem Feld ab) stimmen + update(); + update(); + } + + function boot() { + document.querySelectorAll('[data-wk-booking-rules]').forEach(init); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/src/Backend/WkSyncBackend.php b/src/Backend/WkSyncBackend.php new file mode 100644 index 0000000..a8b81ed --- /dev/null +++ b/src/Backend/WkSyncBackend.php @@ -0,0 +1,517 @@ +requestStack->getCurrentRequest(); + + if (null === $request) { + throw new \LogicException('No request.'); + } + + $this->framework->initialize(); + $key = (string) $request->query->get('key', ''); + + try { + return match ($key) { + '' => $this->dashboard($request), + 'upload' => $this->upload($request), + 'preview' => $this->preview($request), + 'apply' => $this->apply($request), + 'discard' => $this->discard($request), + 'inbox' => $this->inboxView($request), + 'move' => $this->move($request), + 'export' => $this->export($request), + 'exports' => $this->exports($request), + 'download' => $this->download($request), + default => throw new PageNotFoundException('Unknown action "'.$key.'"'), + }; + } catch (ImportException $e) { + $this->message()->addError($e->getMessage()); + + throw new RedirectResponseException($this->url([])); + } + } + + private function dashboard(Request $request): string + { + return $this->render('dashboard', [ + 'calendars' => $this->calendars->findEnabled(), + 'imports' => array_map($this->decorateLog(...), $this->log->findLatest(SyncLog::TYPE_IMPORT, 10)), + 'counts' => $this->inbox->countByState(), + 'has_categories' => !$this->categories->isEmpty(), + 'max_upload' => $this->maxUploadBytes(), + ]); + } + + private function upload(Request $request): string + { + $this->requirePost($request); + + $file = $request->files->get('file'); + $calendarId = (int) $request->request->get('calendar', 0); + + if (!$file instanceof UploadedFile || !$file->isValid()) { + throw new ImportException($this->trans('upload.missing')); + } + + if ($file->getSize() > $this->maxUploadBytes()) { + throw new ImportException($this->trans('upload.too_large')); + } + + if (!\in_array(strtolower((string) $file->getClientOriginalExtension()), ['csv', 'txt'], true)) { + throw new ImportException($this->trans('upload.wrong_type')); + } + + $filename = (string) $file->getClientOriginalName(); + $path = $this->storage->storeUpload($file); + + $prepared = $this->importService->prepare($path, $filename, $calendarId, $this->userId()); + + throw new RedirectResponseException($this->url(['key' => 'preview', 'id' => $prepared['logId']])); + } + + private function preview(Request $request): string + { + $id = (int) $request->query->get('id', 0); + $log = $this->log->find($id); + + if (null === $log || SyncLog::TYPE_IMPORT !== $log['type']) { + throw new ImportException($this->trans('import.not_found')); + } + + // Bereits angewendete oder verworfene Importe: gespeicherten Stand zeigen, nichts neu berechnen + if (SyncLog::STATUS_PREVIEW !== $log['status']) { + $summary = $log['summary']['plan'] ?? []; + + return $this->render('preview', [ + 'log' => $this->decorateLog($log), + 'calendar' => $this->calendars->find((int) $log['calendar']), + 'plan' => null, + 'summary' => [...array_fill_keys([PlannedEvent::CREATE, PlannedEvent::UPDATE, PlannedEvent::CANCEL, PlannedEvent::UNCHANGED, PlannedEvent::SKIP, 'errors', 'warnings'], 0), ...$summary], + 'groups' => [], + 'messages_list' => array_map(ImportMessage::fromArray(...), $log['messages'] ?? []), + 'is_open' => false, + 'result' => $log['summary']['result'] ?? null, + ]); + } + + $preview = $this->importService->preview($id); + $plan = $preview['plan']; + + return $this->render('preview', [ + 'log' => $this->decorateLog($log), + 'calendar' => $preview['calendar'], + 'plan' => $plan, + 'summary' => $plan->summary(), + 'groups' => $this->groupPlan($plan), + 'messages_list' => $plan->allMessages(), + 'is_open' => true, + 'result' => null, + ]); + } + + private function apply(Request $request): string + { + $this->requirePost($request); + $id = (int) $request->request->get('id', 0); + + $applied = $this->importService->apply($id, $this->userId()); + $result = $applied['result']; + + $this->message()->addConfirmation($this->trans('import.applied', [$result['created'], $result['updated'], $result['cancelled'], $result['unchanged']])); + + throw new RedirectResponseException($this->url(['key' => 'preview', 'id' => $id])); + } + + private function discard(Request $request): string + { + $this->requirePost($request); + $id = (int) $request->request->get('id', 0); + $log = $this->log->find($id); + + if (null !== $log && SyncLog::STATUS_PREVIEW === $log['status']) { + $this->log->update($id, ['status' => SyncLog::STATUS_FAILED, 'summary' => ['discarded' => true]]); + $this->message()->addInfo($this->trans('import.discarded')); + } + + throw new RedirectResponseException($this->url([])); + } + + private function inboxView(Request $request): string + { + $state = InboxState::tryFrom((string) $request->query->get('list', '')) ?? InboxState::New; + $items = $this->inbox->findByState($state, $state->isFinal() ? 300 : 0); + $referer = (string) $request->attributes->get('_contao_referer_id', ''); + + $decorated = []; + + foreach ($items as $item) { + $decorated[] = [ + 'item' => $item, + 'edit_url' => $this->editUrl($item, $referer), + 'targets' => $state->moveTargets(), + ]; + } + + return $this->render('inbox', [ + 'state' => $state, + 'states' => InboxState::cases(), + 'counts' => $this->inbox->countByState(), + 'items' => $decorated, + 'targets' => $state->moveTargets(), + 'can_export' => InboxState::DownloadList === $state && [] !== $items, + ]); + } + + private function move(Request $request): string + { + $this->requirePost($request); + + $target = InboxState::tryFrom((string) $request->request->get('target', '-')); + $list = (string) $request->request->get('list', ''); + + if (null === $target || InboxState::Exported === $target) { + throw new ImportException($this->trans('inbox.invalid_target')); + } + + $moved = 0; + + foreach ([InboxItem::TYPE_BOOKING, InboxItem::TYPE_INQUIRY] as $type) { + $ids = $request->request->all($type); + $moved += $this->inbox->move($type, \is_array($ids) ? $ids : [], $target); + } + + // Einzelaktion: type + id + $single = (string) $request->request->get('type', ''); + $singleId = (int) $request->request->get('id', 0); + + if ('' !== $single && $singleId > 0) { + $moved += $this->inbox->move($single, [$singleId], $target); + } + + $this->message()->addConfirmation($this->trans('inbox.moved', [$moved, $this->trans('state.'.$target->translationKey())])); + + throw new RedirectResponseException($this->url(['key' => 'inbox', 'list' => $list])); + } + + private function export(Request $request): string + { + $this->requirePost($request); + + $result = $this->exporter->export($this->userId()); + + if (null === $result) { + $this->message()->addInfo($this->trans('export.empty')); + + throw new RedirectResponseException($this->url(['key' => 'inbox', 'list' => InboxState::DownloadList->value])); + } + + $this->message()->addConfirmation($this->trans('export.done', [$result['bookings'], $result['inquiries']])); + + throw new RedirectResponseException($this->url(['key' => 'exports', 'highlight' => $result['logId']])); + } + + private function exports(Request $request): string + { + return $this->render('exports', [ + 'exports' => array_map($this->decorateLog(...), $this->log->findLatest(SyncLog::TYPE_EXPORT, 50)), + 'highlight' => (int) $request->query->get('highlight', 0), + ]); + } + + private function download(Request $request): string + { + $log = $this->log->find((int) $request->query->get('id', 0)); + + if (null === $log || SyncLog::TYPE_EXPORT !== $log['type'] || !$this->storage->exists((string) $log['path'])) { + throw new PageNotFoundException('Export file not found.'); + } + + $response = new BinaryFileResponse($this->storage->absolutePath((string) $log['path'])); + $response->headers->set('Content-Type', 'text/csv; charset=ISO-8859-1'); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, (string) $log['filename']); + $response->headers->addCacheControlDirective('no-store'); + + throw new ResponseException($response); + } + + /** + * @return array>> + */ + private function groupPlan(ImportPlan $plan): array + { + $groups = []; + + foreach ([PlannedEvent::CREATE, PlannedEvent::UPDATE, PlannedEvent::CANCEL, PlannedEvent::SKIP, PlannedEvent::UNCHANGED] as $action) { + $groups[$action] = array_map(fn (PlannedEvent $e): array => [ + 'event' => $e, + 'changes' => $this->formatChanges($e), + 'date' => isset($e->data['startDate']) ? $this->formatDate((int) $e->data['startDate']) : '', + ], $plan->byAction($action)); + } + + return $groups; + } + + /** + * @return list + */ + private function formatChanges(PlannedEvent $event): array + { + $changes = []; + + foreach ($event->changes as $field => [$old, $new]) { + $changes[] = [ + 'field' => $field, + 'label' => $this->fieldLabel($field), + 'old' => $this->formatValue($field, $old), + 'new' => $this->formatValue($field, $new), + ]; + } + + return $changes; + } + + private function formatValue(string $field, mixed $value): string + { + if (\in_array($field, ['startDate', 'endDate'], true)) { + return (int) $value > 0 ? $this->formatDate((int) $value) : ''; + } + + if (\in_array($field, ['startTime', 'endTime'], true)) { + return (int) $value > 0 ? $this->formatDate((int) $value, 'datimFormat') : ''; + } + + if (\in_array($field, ['addTime', 'published'], true)) { + return $this->translator->trans($value ? 'MSC.yes' : 'MSC.no', [], 'contao_default'); + } + + if ('wkCategories' === $field) { + $titles = $this->categories->titles(); + + return implode(', ', array_map(static fn ($id): string => $titles[(int) $id] ?? (string) $id, \is_array($value) ? $value : StringUtil::deserialize($value, true))); + } + + if ('wkCategoryPrimary' === $field) { + return $this->categories->titles()[(int) $value] ?? ((int) $value > 0 ? (string) $value : ''); + } + + if (str_starts_with($field, 'wkPrice') && !str_starts_with($field, 'wkPriceType')) { + return number_format((float) $value, 2, ',', '.'); + } + + if ('wkState' === $field) { + $value = (string) $value; + + return '' === $value ? '' : $this->translator->trans('tl_calendar_events.wkStateRef.'.$value, [], 'contao_tl_calendar_events'); + } + + $text = trim(strip_tags((string) $value)); + + return mb_strlen($text) > 160 ? mb_substr($text, 0, 157).'…' : $text; + } + + private function fieldLabel(string $field): string + { + $label = $this->translator->trans('tl_calendar_events.'.$field.'.0', [], 'contao_tl_calendar_events'); + + return $label === 'tl_calendar_events.'.$field.'.0' ? $field : $label; + } + + private function formatDate(int $timestamp, string $format = 'dateFormat'): string + { + $config = $this->framework->getAdapter(Config::class); + + return $this->framework->getAdapter(Date::class)->parse((string) $config->get($format), $timestamp); + } + + /** + * @param array $log + * + * @return array + */ + private function decorateLog(array $log): array + { + $log['date'] = $this->formatDate((int) $log['tstamp'], 'datimFormat'); + $log['calendarTitle'] = $this->calendars->find((int) $log['calendar'])?->title ?? ''; + $log['preview_url'] = $this->url(['key' => 'preview', 'id' => $log['id']]); + $log['download_url'] = $this->url(['key' => 'download', 'id' => $log['id']]); + $log['file_exists'] = $this->storage->exists((string) ($log['path'] ?? '')); + + return $log; + } + + private function editUrl(InboxItem $item, string $referer): string + { + $params = $item->isBooking() + ? ['do' => 'calendar', 'table' => 'tl_wk_booking', 'act' => 'edit', 'id' => $item->id] + : ['do' => 'wk_sync_inquiries', 'table' => 'tl_wk_inquiry', 'act' => 'edit', 'id' => $item->id]; + + if ('' !== $referer) { + $params['ref'] = $referer; + } + + return $this->router->generate('contao_backend', $params); + } + + /** + * @param array $context + */ + private function render(string $template, array $context): string + { + $request = $this->requestStack->getCurrentRequest(); + + return $this->twig->render('@Contao/backend/wk_sync/'.$template.'.html.twig', [ + ...$context, + 'messages' => $this->message()->generate(), + 'request_token' => $this->csrfTokenManager->getDefaultTokenValue(), + 'urls' => [ + 'dashboard' => $this->url([]), + 'upload' => $this->url(['key' => 'upload']), + 'apply' => $this->url(['key' => 'apply']), + 'discard' => $this->url(['key' => 'discard']), + 'inbox' => $this->url(['key' => 'inbox']), + 'move' => $this->url(['key' => 'move']), + 'export' => $this->url(['key' => 'export']), + 'exports' => $this->url(['key' => 'exports']), + ], + 'inbox_urls' => array_combine( + array_map(static fn (InboxState $s): string => $s->translationKey(), InboxState::cases()), + array_map(fn (InboxState $s): string => $this->url(['key' => 'inbox', 'list' => $s->value]), InboxState::cases()), + ), + 'current_key' => (string) $request?->query->get('key', ''), + ]); + } + + /** + * @param array $params + */ + private function url(array $params): string + { + return $this->router->generate('contao_backend', ['do' => self::MODULE, ...$params]); + } + + private function requirePost(Request $request): void + { + if (!$request->isMethod('POST')) { + throw new AccessDeniedException('POST required.'); + } + } + + private function userId(): int + { + $user = $this->security->getUser(); + + return $user instanceof BackendUser ? (int) $user->id : 0; + } + + /** + * @return Adapter + */ + private function message(): Adapter + { + return $this->framework->getAdapter(Message::class); + } + + private function maxUploadBytes(): int + { + $limits = [self::MAX_UPLOAD_BYTES]; + + foreach (['upload_max_filesize', 'post_max_size'] as $ini) { + $value = (string) \ini_get($ini); + + if ('' !== $value && '0' !== $value && '-1' !== $value) { + $limits[] = $this->iniBytes($value); + } + } + + return (int) min($limits); + } + + private function iniBytes(string $value): int + { + $unit = strtolower(substr($value, -1)); + $number = (int) $value; + + return match ($unit) { + 'g' => $number * 1024 ** 3, + 'm' => $number * 1024 ** 2, + 'k' => $number * 1024, + default => (int) $value, + }; + } + + /** + * @param array $params + */ + private function trans(string $key, array $params = []): string + { + return $this->translator->trans('MSC.wk_sync.'.$key, $params, 'contao_default'); + } +} diff --git a/src/Backend/WkSyncModule.php b/src/Backend/WkSyncModule.php new file mode 100644 index 0000000..e78a338 --- /dev/null +++ b/src/Backend/WkSyncModule.php @@ -0,0 +1,23 @@ +get(WkSyncBackend::class)->handle(); + } +} diff --git a/src/Booking/Availability.php b/src/Booking/Availability.php new file mode 100644 index 0000000..fe8f775 --- /dev/null +++ b/src/Booking/Availability.php @@ -0,0 +1,47 @@ +reason; + } + + public function isExternal(): bool + { + return self::EXTERNAL === $this->reason; + } + + public function externalContactIsEmail(): bool + { + return str_contains($this->externalContact, '@') && !str_contains($this->externalContact, '/'); + } +} diff --git a/src/Booking/BookingAvailability.php b/src/Booking/BookingAvailability.php new file mode 100644 index 0000000..356fb64 --- /dev/null +++ b/src/Booking/BookingAvailability.php @@ -0,0 +1,70 @@ +bookings->countOpenByEvent($event->id); + $placesLeft = max(0, $event->placesLeft - $open); + $deadline = $event->startTime - $calendar->deadlineDays * 86400; + $displayDeadline = $calendar->deadlineDisplayDays > 0 ? $event->startTime - $calendar->deadlineDisplayDays * 86400 : $deadline; + + $result = fn (string $reason, string $contact = ''): Availability => new Availability($reason, $placesLeft, $open, $deadline, $displayDeadline, $contact); + + if (!$calendar->enabled) { + return $result(Availability::NOT_ENABLED); + } + + if (!$event->published) { + return $result(Availability::UNPUBLISHED); + } + + if (WkState::isCancelled($event->wkState)) { + return $result(Availability::CANCELLED); + } + + if ($event->isClosedAudience()) { + return $result(Availability::CLOSED_AUDIENCE); + } + + $contact = trim($event->organizerEmail); + + if (3 === $event->eventType && '' !== $contact && (Validator::isEmail($contact) || Validator::isUrl($contact))) { + return $result(Availability::EXTERNAL, $contact); + } + + if ($calendar->bookingForm < 1) { + return $result(Availability::NO_FORM); + } + + if ($event->startTime <= $now) { + return $result(Availability::PAST); + } + + if ($deadline <= $now) { + return $result(Availability::DEADLINE); + } + + if ($placesLeft < 1 || ($event->maxPlaces > 0 && $open >= $event->maxPlaces)) { + return $result(Availability::FULL); + } + + return $result(Availability::OK); + } +} diff --git a/src/Booking/BookingContext.php b/src/Booking/BookingContext.php new file mode 100644 index 0000000..e3bfdb2 --- /dev/null +++ b/src/Booking/BookingContext.php @@ -0,0 +1,44 @@ +|null die angelegte Anmeldung nach dem Speichern */ + public array|null $booking = null; + + public function __construct( + public readonly BookingEvent $event, + public readonly CalendarConfig $calendar, + public readonly FieldRules $rules, + public readonly Availability $availability, + ) { + } + + public function attachTo(Request $request): void + { + $request->attributes->set(self::ATTRIBUTE, $this); + } + + public static function fromRequest(Request|null $request): self|null + { + $context = $request?->attributes->get(self::ATTRIBUTE); + + return $context instanceof self ? $context : null; + } + + public function isBookingForm(int $formId): bool + { + return $formId > 0 && $formId === $this->calendar->bookingForm; + } +} diff --git a/src/Booking/BookingCreator.php b/src/Booking/BookingCreator.php new file mode 100644 index 0000000..1620568 --- /dev/null +++ b/src/Booking/BookingCreator.php @@ -0,0 +1,101 @@ + $submitted + * + * @return array Zeile ohne ID + */ + public function prepare(BookingEvent $event, array $submitted, FieldRules $rules): array + { + $now = time(); + + $row = [ + 'pid' => $event->id, + 'tstamp' => $now, + 'dateCreated' => $now, + 'token' => bin2hex(random_bytes(20)), + 'state' => InboxState::New->value, + 'exportedAt' => 0, + 'notification' => 0, + ]; + + foreach (BookingFields::COLUMNS as $column) { + $visible = $rules->isVisible($column, $submitted); + $value = $visible ? ($submitted[$column] ?? null) : null; + + $row[$column] = match (true) { + \in_array($column, BookingFields::BOOLEAN_COLUMNS, true) => (int) self::toBool($value), + 'birthday' === $column => self::normalizeDate(self::toString($value)), + default => self::toString($value), + }; + } + + return $row; + } + + /** + * @param array $submitted + * + * @return array die gespeicherte Zeile inklusive ID + */ + public function create(BookingEvent $event, array $submitted, FieldRules $rules): array + { + $row = $this->prepare($event, $submitted, $rules); + $row['id'] = $this->bookings->insert($row); + + return $row; + } + + /** + * W&K erwartet Geburtstage als TT.MM.JJJJ, unabhängig vom Datumsformat der Website. + */ + public static function normalizeDate(string $value): string + { + if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value, $m)) { + return \sprintf('%02d.%02d.%04d', (int) $m[3], (int) $m[2], (int) $m[1]); + } + + if (preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/', $value, $m)) { + return \sprintf('%02d.%02d.%04d', (int) $m[1], (int) $m[2], (int) $m[3]); + } + + if (preg_match('/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/', $value, $m)) { + return \sprintf('%02d.%02d.%04d', (int) $m[2], (int) $m[1], (int) $m[3]); + } + + return $value; + } + + public static function toBool(mixed $value): bool + { + if (\is_array($value)) { + return [] !== array_filter($value, static fn ($v): bool => '' !== (string) $v); + } + + return \in_array(mb_strtolower(trim((string) $value)), ['1', 'on', 'true', 'ja', 'yes'], true); + } + + public static function toString(mixed $value): string + { + if (\is_array($value)) { + $value = implode(', ', array_map(static fn ($v): string => trim((string) $v), $value)); + } + + return mb_substr(trim((string) $value), 0, 65535); + } +} diff --git a/src/Booking/BookingEvent.php b/src/Booking/BookingEvent.php new file mode 100644 index 0000000..4343bc8 --- /dev/null +++ b/src/Booking/BookingEvent.php @@ -0,0 +1,101 @@ + $row vollständige Zeile aus tl_calendar_events + * @param list $categories + */ + public function __construct( + public int $id, + public int $calendarId, + public string $title, + public string $alias, + public bool $published, + public int $startDate, + public int $endDate, + public int $startTime, + public int $endTime, + public string $location, + public string $wkId, + public string $wkState, + public string $internalNumber, + public int $eventType, + public int $placesLeft, + public int $maxPlaces, + public string $audience, + public string $organizerEmail, + public int $confirmationPage, + public int $notification, + public int $primaryCategory, + public array $categories, + public CategoryKind $kind, + public array $row, + ) { + } + + /** + * @param array $row + */ + public static function fromRow(array $row, CategoryKind $kind): self + { + return new self( + (int) $row['id'], + (int) $row['pid'], + (string) $row['title'], + (string) ($row['alias'] ?? ''), + (bool) ($row['published'] ?? false), + (int) ($row['startDate'] ?? 0), + (int) ($row['endDate'] ?? 0), + (int) ($row['startTime'] ?? 0), + (int) ($row['endTime'] ?? 0), + (string) ($row['location'] ?? ''), + (string) ($row['wkId'] ?? ''), + (string) ($row['wkState'] ?? ''), + (string) ($row['wkInternalEventNumber'] ?? ''), + (int) ($row['wkEventType'] ?? 0), + (int) ($row['wkPlacesLeft'] ?? 0), + (int) ($row['wkMaxPlaces'] ?? 0), + (string) ($row['wkAudience'] ?? ''), + (string) ($row['wkOrganizerEmail'] ?? ''), + (int) ($row['wkConfirmationPage'] ?? 0), + (int) ($row['wkNotification'] ?? 0), + (int) ($row['wkCategoryPrimary'] ?? 0), + array_map(intval(...), StringUtil::deserialize($row['wkCategories'] ?? null, true)), + $kind, + $row, + ); + } + + public function isSingleDay(): bool + { + return date('Y-m-d', $this->startDate) === date('Y-m-d', $this->endDate ?: $this->startDate); + } + + public function isOnline(): bool + { + return 'online' === mb_strtolower(trim($this->location)); + } + + public function hasExtpNumber(): bool + { + return str_starts_with(mb_strtoupper(trim($this->internalNumber)), 'EXTP'); + } + + public function isClosedAudience(): bool + { + return self::CLOSED_AUDIENCE === mb_strtolower(trim($this->audience)); + } +} diff --git a/src/Booking/BookingFields.php b/src/Booking/BookingFields.php new file mode 100644 index 0000000..76f82e7 --- /dev/null +++ b/src/Booking/BookingFields.php @@ -0,0 +1,57 @@ +}> + */ + public const array DEPENDENCIES = [ + 'schoolName' => ['field' => 'teacherPosition', 'values' => ['state', 'other']], + 'schoolCity' => ['field' => 'teacherPosition', 'values' => ['state', 'other']], + 'invoiceInstitution' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoiceGender' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoiceAcademicTitle' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoiceFirstname' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoiceLastname' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoiceStreet' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoicePostal' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'invoiceCity' => ['field' => 'addDifferentInvoiceAddress', 'values' => ['1']], + 'notes' => ['field' => 'addNotes', 'values' => ['1']], + ]; + + /** + * CSS-Klasse, mit der ein Formularfeld (z. B. ein Erklärungstext) an einen Regel-Platzhalter gebunden wird. + */ + public const string SLOT_CLASS_PREFIX = 'wk-slot-'; + + public const string SLOT_NO_OVERNIGHT_STAY = 'no-overnight-stay'; +} diff --git a/src/Booking/BookingNotifierInterface.php b/src/Booking/BookingNotifierInterface.php new file mode 100644 index 0000000..696904e --- /dev/null +++ b/src/Booking/BookingNotifierInterface.php @@ -0,0 +1,20 @@ + $tokens + * + * @return bool ob die Benachrichtigung verschickt wurde + */ + public function notify(int $notificationId, array $tokens): bool; + + /** + * @return array ID => Titel der verfügbaren Benachrichtigungen + */ + public function availableNotifications(): array; +} diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php new file mode 100644 index 0000000..f32a0eb --- /dev/null +++ b/src/Booking/BookingRepository.php @@ -0,0 +1,81 @@ +connection->fetchOne( + 'SELECT COUNT(*) FROM tl_wk_booking WHERE pid = ? AND state IN (?)', + [$eventId, [InboxState::New->value, InboxState::DownloadList->value]], + [null, ArrayParameterType::STRING], + ); + } + + /** + * @param array $data + */ + public function insert(array $data): int + { + $this->connection->insert('tl_wk_booking', $data); + + return (int) $this->connection->lastInsertId(); + } + + /** + * @return array|null + */ + public function find(int $id): array|null + { + $row = $this->connection->fetchAssociative('SELECT * FROM tl_wk_booking WHERE id = ?', [$id]); + + return false === $row ? null : $row; + } + + /** + * @return array|null + */ + public function findByToken(string $token): array|null + { + if ('' === $token || !preg_match('/^[a-f0-9]{32,64}$/', $token)) { + return null; + } + + $row = $this->connection->fetchAssociative('SELECT * FROM tl_wk_booking WHERE token = ?', [$token]); + + return false === $row ? null : $row; + } + + /** + * Gibt es zu dieser E-Mail-Adresse bereits eine offene Anmeldung für die Veranstaltung? + */ + public function hasOpenBookingForEmail(int $eventId, string $email, int $excludeId = 0): bool + { + return (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM tl_wk_booking WHERE pid = ? AND email = ? AND id != ? AND state != ?', + [$eventId, $email, $excludeId, InboxState::Spam->value], + ) > 0; + } + + /** + * @param array $data + */ + public function update(int $id, array $data): void + { + $this->connection->update('tl_wk_booking', ['tstamp' => time(), ...$data], ['id' => $id]); + } +} diff --git a/src/Booking/BookingRules.php b/src/Booking/BookingRules.php new file mode 100644 index 0000000..1e9eb99 --- /dev/null +++ b/src/Booking/BookingRules.php @@ -0,0 +1,58 @@ +kind; + $single = $event->isSingleDay(); + $ownVenue = $calendar->isOwnVenue($event->location); + $teacherCourseAway = $kind->isTeacherCourse() && !$ownVenue; + $conference = CategoryKind::Conference === $kind; + $elementary = CategoryKind::Elementary === $kind; + + $hidden = []; + $slots = []; + $dependencies = BookingFields::DEPENDENCIES; + + if ($kind->asksTeacherPosition()) { + $dependencies['addDifferentInvoiceAddress'] = ['field' => 'teacherPosition', 'values' => ['other']]; + } else { + $hidden[] = 'teacherPosition'; + $hidden[] = 'schoolName'; + $hidden[] = 'schoolCity'; + } + + if ($single) { + $hidden[] = 'overnightStay'; + } elseif (!$teacherCourseAway && ($event->hasExtpNumber() || $elementary) && !$conference) { + $hidden[] = 'overnightStay'; + $slots[] = BookingFields::SLOT_NO_OVERNIGHT_STAY; + } + + $noCatering = ((!$single && $event->hasExtpNumber()) || $elementary) && !$conference; + + if (($noCatering || $event->isOnline()) && !$teacherCourseAway) { + $hidden[] = 'vegetarian'; + } + + return new FieldRules($hidden, $slots, $dependencies); + } +} diff --git a/src/Booking/BookingTokens.php b/src/Booking/BookingTokens.php new file mode 100644 index 0000000..755071f --- /dev/null +++ b/src/Booking/BookingTokens.php @@ -0,0 +1,169 @@ + 'genderRef', + 'invoiceGender' => 'genderRef', + 'position' => 'positionRef', + 'teacherPosition' => 'teacherPositionRef', + 'overnightStay' => 'overnightStayRef', + ]; + + public function __construct( + private readonly ContaoFramework $framework, + private readonly Connection $connection, + private readonly ContentUrlGenerator $urlGenerator, + private readonly TranslatorInterface $translator, + private readonly CategoryRepository $categories, + private readonly MemberResolver $members, + ) { + } + + /** + * @param array $booking + * @param array{notification: int, conditions: list, source: string} $routing + * + * @return array + */ + public function build(array $booking, BookingEvent $event, CalendarConfig $calendar, string $confirmationUrl, array $routing = []): array + { + $this->framework->initialize(); + $config = $this->framework->getAdapter(Config::class); + $date = $this->framework->getAdapter(Date::class); + + $dateFormat = (string) $config->get('dateFormat'); + $timeFormat = (string) $config->get('timeFormat'); + $datimFormat = (string) $config->get('datimFormat'); + + $tokens = [ + 'admin_email' => (string) ($GLOBALS['TL_ADMIN_EMAIL'] ?? $config->get('adminEmail')), + ]; + + // Veranstaltung + foreach ($event->row as $key => $value) { + $tokens['event_'.$key] = $this->scalar($value); + } + + $tokens['event_startDate'] = $date->parse($dateFormat, $event->startDate); + $tokens['event_endDate'] = $date->parse($dateFormat, $event->endDate); + $tokens['event_startTime'] = $date->parse($timeFormat, $event->startTime); + $tokens['event_endTime'] = $date->parse($timeFormat, $event->endTime); + $tokens['event_datetime'] = $this->formatDateTime($event, $dateFormat, $timeFormat); + $tokens['event_link'] = $this->eventUrl($event); + $tokens['event_categories'] = implode(', ', array_map(static fn ($c): string => $c->title, $this->categories->findByIds($event->categories))); + $tokens['event_category_primary'] = $this->categories->find($event->primaryCategory)?->title ?? ''; + $tokens['event_instructor'] = $this->members->namesByIds([(int) ($event->row['wkInstructor'] ?? 0)])[(int) ($event->row['wkInstructor'] ?? 0)] ?? ''; + $tokens['event_speakers'] = implode(', ', $this->members->namesByIds(array_map(intval(...), StringUtil::deserialize($event->row['wkSpeakers'] ?? null, true)))); + + // Bisherige Token-Namen + $tokens['event_organizerCity'] = (string) ($event->row['wkOrganizerCity'] ?? ''); + $tokens['event_internalEventNumber'] = $event->internalNumber; + $tokens['subscription_event_wk_id'] = $event->wkId; + + // Kalender + $calendarRow = $this->connection->fetchAssociative('SELECT * FROM tl_calendar WHERE id = ?', [$calendar->id]) ?: []; + + foreach ($calendarRow as $key => $value) { + $tokens['calendar_'.$key] = $this->scalar($value); + } + + // Anmeldung + foreach ($booking as $key => $value) { + if ('token' === $key) { + continue; + } + + $tokens['subscription_'.$key] = \is_bool($value) ? ($value ? '1' : '') : $this->scalar($value); + } + + foreach (self::SELECT_FIELDS as $field => $reference) { + $value = (string) ($booking[$field] ?? ''); + $tokens['subscription_'.$field.'_label'] = '' === $value ? '' : $this->translator->trans('tl_wk_booking.'.$reference.'.'.$value, [], 'contao_tl_wk_booking'); + } + + foreach (BookingFields::BOOLEAN_COLUMNS as $field) { + $tokens['subscription_'.$field.'_label'] = $this->translator->trans(!empty($booking[$field]) ? 'MSC.yes' : 'MSC.no', [], 'contao_default'); + } + + $tokens['subscription_dateCreated'] = $date->parse($datimFormat, (int) ($booking['dateCreated'] ?? time())); + $tokens['subscription_confirmation_page_url'] = $confirmationUrl; + $tokens['recipient_email'] = (string) ($booking['email'] ?? ''); + $tokens['wk_notification_conditions'] = implode(', ', $routing['conditions'] ?? []); + + return $tokens; + } + + private function formatDateTime(BookingEvent $event, string $dateFormat, string $timeFormat): string + { + $date = $this->framework->getAdapter(Date::class); + $text = $date->parse($dateFormat, $event->startDate); + + if (!$event->isSingleDay()) { + $text .= ' - '.$date->parse($dateFormat, $event->endDate); + } + + if (!empty($event->row['addTime'])) { + $text .= ', '.$date->parse($timeFormat, $event->startTime).' - '.$date->parse($timeFormat, $event->endTime); + } + + return $text; + } + + private function eventUrl(BookingEvent $event): string + { + $model = $this->framework->getAdapter(CalendarEventsModel::class)->findById($event->id); + + if (null === $model) { + return ''; + } + + try { + return $this->urlGenerator->generate($model, [], UrlGeneratorInterface::ABSOLUTE_URL); + } catch (\Throwable) { + return ''; + } + } + + private function scalar(mixed $value): string + { + if (null === $value) { + return ''; + } + + if (\is_string($value) && str_starts_with($value, 'a:')) { + $unserialized = StringUtil::deserialize($value); + + if (\is_array($unserialized)) { + return implode(', ', array_map(static fn ($v): string => (string) (\is_array($v) ? implode(' ', $v) : $v), $unserialized)); + } + } + + if (\is_string($value) && !mb_check_encoding($value, 'UTF-8')) { + return ''; + } + + return (string) $value; + } +} diff --git a/src/Booking/BookingUrlBuilder.php b/src/Booking/BookingUrlBuilder.php new file mode 100644 index 0000000..e05be59 --- /dev/null +++ b/src/Booking/BookingUrlBuilder.php @@ -0,0 +1,64 @@ +pageUrl($calendar->bookingPage); + + if ('' === $url) { + return ''; + } + + return rtrim($url, '/').'/'.($event->alias ?: $event->id); + } + + public function inquiryUrl(BookingEvent $event, CalendarConfig $calendar): string + { + $url = $this->pageUrl($calendar->inquiryPage); + + if ('' === $url) { + return ''; + } + + return $url.(str_contains($url, '?') ? '&' : '?').'event='.$event->id; + } + + public function pageUrl(int $pageId): string + { + if ($pageId < 1) { + return ''; + } + + $this->framework->initialize(); + $page = $this->framework->getAdapter(PageModel::class)->findPublishedById($pageId); + + if (null === $page) { + return ''; + } + + try { + return $this->urlGenerator->generate($page); + } catch (\Throwable) { + return ''; + } + } +} diff --git a/src/Booking/EventResolver.php b/src/Booking/EventResolver.php new file mode 100644 index 0000000..27f2095 --- /dev/null +++ b/src/Booking/EventResolver.php @@ -0,0 +1,83 @@ + $calendarIds leere Liste = alle Kalender + */ + public function resolveFromRequest(Request $request, array $calendarIds = []): BookingEvent|null + { + $this->framework->initialize(); + $input = $this->framework->getAdapter(Input::class); + + $identifier = (string) ($input->get('auto_item') ?? $input->get('events') ?? $request->attributes->get('auto_item') ?? ''); + + if ('' === $identifier) { + return null; + } + + return $this->findByIdOrAlias($identifier, $calendarIds); + } + + /** + * @param list $calendarIds + */ + public function findByIdOrAlias(string $identifier, array $calendarIds = []): BookingEvent|null + { + $sql = 'SELECT * FROM tl_calendar_events WHERE (alias = ? OR id = ?)'; + $params = [$identifier, (int) $identifier]; + $types = [null, null]; + + if ([] !== $calendarIds) { + $sql .= ' AND pid IN (?)'; + $params[] = array_map(intval(...), $calendarIds); + $types[] = ArrayParameterType::INTEGER; + } + + $row = $this->connection->fetchAssociative($sql.' LIMIT 1', $params, $types); + + return false === $row ? null : $this->fromRow($row); + } + + public function find(int $id): BookingEvent|null + { + $row = $this->connection->fetchAssociative('SELECT * FROM tl_calendar_events WHERE id = ?', [$id]); + + return false === $row ? null : $this->fromRow($row); + } + + /** + * @param array $row + */ + public function fromRow(array $row): BookingEvent + { + return BookingEvent::fromRow($row, $this->kindFor((int) ($row['wkCategoryPrimary'] ?? 0))); + } + + public function kindFor(int $categoryId): CategoryKind + { + return $this->categories->find($categoryId)?->kind ?? CategoryKind::Other; + } +} diff --git a/src/Booking/FieldRules.php b/src/Booking/FieldRules.php new file mode 100644 index 0000000..381335d --- /dev/null +++ b/src/Booking/FieldRules.php @@ -0,0 +1,67 @@ + $hiddenFields + * @param list $activeSlots + * @param array}> $dependencies + */ + public function __construct( + public array $hiddenFields, + public array $activeSlots, + public array $dependencies, + ) { + } + + public function isHidden(string $field): bool + { + return \in_array($field, $this->hiddenFields, true); + } + + /** + * Ist ein Feld bei den übermittelten Werten sichtbar? Berücksichtigt Regeln und Abhängigkeiten, + * auch über mehrere Stufen (z. B. Rechnungsfelder -> Rechnungsanschrift-Checkbox -> Lehrertätigkeit). + * + * @param array $values + */ + public function isVisible(string $field, array $values, int $depth = 0): bool + { + if ($this->isHidden($field) || $depth > 10) { + return false; + } + + if (!isset($this->dependencies[$field])) { + return true; + } + + $dependency = $this->dependencies[$field]; + + if (!$this->isVisible($dependency['field'], $values, $depth + 1)) { + return false; + } + + $value = $values[$dependency['field']] ?? ''; + + if (\is_array($value)) { + $value = (string) reset($value); + } + + return \in_array((string) $value, $dependency['values'], true); + } + + /** + * @param array $values + */ + public function isDependencyMet(string $field, array $values): bool + { + return !isset($this->dependencies[$field]) || $this->isVisible($field, $values); + } +} diff --git a/src/Booking/FormFieldFilter.php b/src/Booking/FormFieldFilter.php new file mode 100644 index 0000000..d2008cc --- /dev/null +++ b/src/Booking/FormFieldFilter.php @@ -0,0 +1,67 @@ + $fields + * @param array|null $submitted null = Formular noch nicht abgeschickt + * + * @return array + */ + public function filter(array $fields, FieldRules $rules, array|null $submitted): array + { + foreach ($fields as $key => $field) { + $slots = $this->slotsOf((string) $field->class); + + if ([] !== $slots && [] === array_intersect($slots, $rules->activeSlots)) { + unset($fields[$key]); + continue; + } + + $name = (string) $field->name; + + if ('' === $name) { + continue; + } + + if ($rules->isHidden($name)) { + unset($fields[$key]); + continue; + } + + if (null !== $submitted && !$rules->isVisible($name, $submitted)) { + unset($fields[$key]); + } + } + + return $fields; + } + + /** + * @return list + */ + public function slotsOf(string $classes): array + { + $slots = []; + + foreach (preg_split('/\s+/', trim($classes)) ?: [] as $class) { + if (str_starts_with($class, BookingFields::SLOT_CLASS_PREFIX)) { + $slots[] = substr($class, \strlen(BookingFields::SLOT_CLASS_PREFIX)); + } + } + + return $slots; + } +} diff --git a/src/Booking/NewsletterSubscriber.php b/src/Booking/NewsletterSubscriber.php new file mode 100644 index 0000000..d2303d0 --- /dev/null +++ b/src/Booking/NewsletterSubscriber.php @@ -0,0 +1,136 @@ +isAvailable() || [] === $calendar->newsletterChannels || '' === trim($email)) { + return false; + } + + try { + return $this->doSubscribe(mb_strtolower(trim($email)), $calendar); + } catch (\Throwable $e) { + $this->contaoErrorLogger?->error('W&K-Anmeldung: Newsletter-Anmeldung fehlgeschlagen: '.$e->getMessage()); + + return false; + } + } + + private function doSubscribe(string $email, CalendarConfig $calendar): bool + { + $this->framework->initialize(); + + $recipients = $this->framework->getAdapter(NewsletterRecipientsModel::class); + $channels = $this->framework->getAdapter(NewsletterChannelModel::class); + $denyList = $this->framework->getAdapter(NewsletterDenyListModel::class); + $pages = $this->framework->getAdapter(PageModel::class); + + // Nicht aktivierte Altanmeldungen entfernen + if (null !== ($old = $recipients->findOldSubscriptionsByEmailAndPids($email, $calendar->newsletterChannels))) { + foreach ($old as $recipient) { + $recipient->delete(); + } + } + + $now = time(); + $related = []; + + foreach ($calendar->newsletterChannels as $channelId) { + // Bereits (aktiv) eingetragen + if (null !== $recipients->findOneBy(['pid=?', 'email=?'], [$channelId, $email])) { + continue; + } + + $recipient = new NewsletterRecipientsModel(); + $recipient->pid = $channelId; + $recipient->tstamp = $now; + $recipient->email = $email; + $recipient->active = false; + $recipient->addedOn = $now; + $recipient->save(); + + if (null !== ($denied = $denyList->findByHashAndPid(md5($email), $channelId))) { + $denied->delete(); + } + + $related['tl_newsletter_recipients'][] = $recipient->id; + } + + if ([] === $related) { + return false; + } + + $token = $this->optIn->create('nl', $email, $related); + $channelTitles = $channels->findByIds($calendar->newsletterChannels)?->fetchEach('title') ?? []; + + $link = ''; + + if ($calendar->newsletterPage > 0 && null !== ($page = $pages->findPublishedById($calendar->newsletterPage))) { + $link = $this->urlGenerator->generate($page, [], UrlGeneratorInterface::ABSOLUTE_URL).'?token='.$token->getIdentifier(); + } elseif (null !== ($request = $this->requestStack->getCurrentRequest())) { + $link = $request->getSchemeAndHttpHost().$request->getBaseUrl().'/?token='.$token->getIdentifier(); + } + + $host = null !== ($request = $this->requestStack->getCurrentRequest()) ? Idna::decode($request->getHost()) : ''; + + $data = [ + 'token' => $token->getIdentifier(), + 'domain' => $host, + 'link' => $link, + 'channel' => implode("\n", $channelTitles), + 'channels' => implode("\n", $channelTitles), + ]; + + $this->framework->getAdapter(System::class)->loadLanguageFile('default'); + + $text = '' !== trim($calendar->newsletterText) ? $calendar->newsletterText : $this->translator->trans('MSC.wk_newsletter_optin_text', [], 'contao_default'); + $subject = \sprintf($GLOBALS['TL_LANG']['MSC']['nl_subject'] ?? 'Ihr Newsletter-Abonnement auf %s', $host); + + $token->send($subject, $this->tokenParser->parse($text, $data)); + + return true; + } +} diff --git a/src/Booking/NotificationCenterNotifier.php b/src/Booking/NotificationCenterNotifier.php new file mode 100644 index 0000000..59c9194 --- /dev/null +++ b/src/Booking/NotificationCenterNotifier.php @@ -0,0 +1,46 @@ +notificationCenter->sendNotification($notificationId, $tokens); + } catch (\Throwable $e) { + $this->contaoErrorLogger?->error(\sprintf('W&K-Anmeldung: Benachrichtigung %d konnte nicht verschickt werden: %s', $notificationId, $e->getMessage())); + + return false; + } + + $delivered = false; + + foreach ($receipts as $receipt) { + if ($receipt->wasDelivered()) { + $delivered = true; + } elseif (null !== ($exception = $receipt->getException())) { + $this->contaoErrorLogger?->error(\sprintf('W&K-Anmeldung: Benachrichtigung %d: %s', $notificationId, $exception->getMessage())); + } + } + + return $delivered; + } + + public function availableNotifications(): array + { + return $this->notificationCenter->getNotificationsForNotificationType(BookingNotificationType::NAME); + } +} diff --git a/src/Booking/NotificationRouter.php b/src/Booking/NotificationRouter.php new file mode 100644 index 0000000..ecf2a2b --- /dev/null +++ b/src/Booking/NotificationRouter.php @@ -0,0 +1,110 @@ + erfüllte Bedingungen in Prüfreihenfolge + */ + public function matchingConditions(BookingEvent $event): array + { + $conditions = []; + + if ($event->isOnline()) { + $conditions[] = 1; + } + + $kind = $event->kind; + $extp = $event->hasExtpNumber(); + + if ($event->isSingleDay()) { + if (CategoryKind::School === $kind && $extp) { + $conditions[] = 2; + } + + if ($kind->isCommunityGroup()) { + $conditions[] = 3; + } + + if (CategoryKind::Elementary === $kind) { + $conditions[] = 4; + } + + if (CategoryKind::EvangelicalSchool === $kind) { + $conditions[] = 8; + } + + if (CategoryKind::Mentoring === $kind) { + $conditions[] = 10; + } + } else { + if (CategoryKind::School === $kind && $extp) { + $conditions[] = 5; + } + + if (CategoryKind::Elementary === $kind) { + $conditions[] = 6; + } + + if ($kind->isCommunityGroup()) { + $conditions[] = 7; + } + + if (CategoryKind::EvangelicalSchool === $kind) { + $conditions[] = 9; + } + + if (CategoryKind::Mentoring === $kind) { + $conditions[] = 11; + } + + if (CategoryKind::Training === $kind) { + $conditions[] = 12; + } + } + + return $conditions; + } + + /** + * @return array{notification: int, conditions: list, source: string} + */ + public function resolve(BookingEvent $event, CalendarConfig $calendar): array + { + if ($event->notification > 0) { + return ['notification' => $event->notification, 'conditions' => [], 'source' => 'event']; + } + + $conditions = $this->matchingConditions($event); + + foreach ($conditions as $condition) { + $id = $calendar->conditionNotifications[$condition] ?? 0; + + if ($id > 0) { + return ['notification' => $id, 'conditions' => $conditions, 'source' => 'condition']; + } + } + + return ['notification' => $calendar->defaultNotification, 'conditions' => $conditions, 'source' => $calendar->defaultNotification > 0 ? 'default' : 'none']; + } +} diff --git a/src/Booking/NullNotifier.php b/src/Booking/NullNotifier.php new file mode 100644 index 0000000..cd63cba --- /dev/null +++ b/src/Booking/NullNotifier.php @@ -0,0 +1,29 @@ +contaoErrorLogger?->warning(\sprintf('W&K-Anmeldung: Benachrichtigung %d kann nicht verschickt werden, das Notification Center ist nicht installiert.', $notificationId)); + + return false; + } + + public function availableNotifications(): array + { + return []; + } +} diff --git a/src/Category/Category.php b/src/Category/Category.php new file mode 100644 index 0000000..535d6c2 --- /dev/null +++ b/src/Category/Category.php @@ -0,0 +1,39 @@ + $wkNames W&K-Bezeichnungen (kbsgebiet), die auf diese Kategorie abgebildet werden + */ + public function __construct( + public int $id, + public string $title, + public string $alias, + public CategoryKind $kind, + public array $wkNames, + public int $sorting, + ) { + } + + /** + * @param array $row + */ + public static function fromRow(array $row): self + { + $names = preg_split('/\r?\n/', (string) ($row['wkNames'] ?? '')) ?: []; + $names = array_values(array_filter(array_map(trim(...), $names), static fn (string $n): bool => '' !== $n)); + + return new self( + (int) $row['id'], + (string) $row['title'], + (string) ($row['alias'] ?? ''), + CategoryKind::tryFromValue($row['kind'] ?? null), + $names, + (int) ($row['sorting'] ?? 0), + ); + } +} diff --git a/src/Category/CategoryKind.php b/src/Category/CategoryKind.php new file mode 100644 index 0000000..a030e1c --- /dev/null +++ b/src/Category/CategoryKind.php @@ -0,0 +1,50 @@ +|null */ + private array|null $cache = null; + + public function __construct(private readonly Connection $connection) + { + } + + /** + * @return list nach Sortierung + */ + public function findAll(): array + { + if (null === $this->cache) { + $rows = $this->connection->fetchAllAssociative('SELECT * FROM tl_wk_category ORDER BY sorting, title'); + $this->cache = array_map(Category::fromRow(...), $rows); + } + + return $this->cache; + } + + public function find(int $id): Category|null + { + return array_find($this->findAll(), static fn (Category $c): bool => $c->id === $id); + } + + /** + * @param list $ids + * + * @return list in der Reihenfolge der Sortierung + */ + public function findByIds(array $ids): array + { + $ids = array_map(intval(...), $ids); + + return array_values(array_filter($this->findAll(), static fn (Category $c): bool => \in_array($c->id, $ids, true))); + } + + /** + * @return array ID => Titel + */ + public function titles(): array + { + $titles = []; + + foreach ($this->findAll() as $category) { + $titles[$category->id] = $category->title; + } + + return $titles; + } + + public function isEmpty(): bool + { + return [] === $this->findAll(); + } + + /** + * @param array $data + */ + public function insert(array $data): int + { + $this->connection->insert('tl_wk_category', ['tstamp' => time(), ...$data]); + $this->cache = null; + + return (int) $this->connection->lastInsertId(); + } + + /** + * @param list $ids + * + * @return list IDs, die tatsächlich existieren + */ + public function existingIds(array $ids): array + { + if ([] === $ids) { + return []; + } + + return array_map(intval(...), $this->connection->fetchFirstColumn('SELECT id FROM tl_wk_category WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER])); + } + + public function reset(): void + { + $this->cache = null; + } +} diff --git a/src/Category/CategoryResolver.php b/src/Category/CategoryResolver.php new file mode 100644 index 0000000..633decb --- /dev/null +++ b/src/Category/CategoryResolver.php @@ -0,0 +1,60 @@ +|null $categories + * + * @return list + */ + public function resolve(string $wkText, array|null $categories = null): array + { + $needle = $this->normalize($wkText); + + if ('' === $needle) { + return []; + } + + $categories ??= $this->repository->findAll(); + + $exact = array_values(array_filter( + $categories, + fn (Category $c): bool => array_any($c->wkNames, fn (string $n): bool => $this->normalize($n) === $needle), + )); + + if ([] !== $exact) { + return $exact; + } + + foreach ($categories as $category) { + foreach ($category->wkNames as $name) { + $name = $this->normalize($name); + + if ('' !== $name && str_contains($needle, $name)) { + return [$category]; + } + } + } + + return []; + } + + private function normalize(string $text): string + { + return trim(preg_replace('/\s+/u', ' ', mb_strtolower($text)) ?? ''); + } +} diff --git a/src/Command/CreateBookingFormCommand.php b/src/Command/CreateBookingFormCommand.php new file mode 100644 index 0000000..e92a004 --- /dev/null +++ b/src/Command/CreateBookingFormCommand.php @@ -0,0 +1,146 @@ +addOption('title', null, InputOption::VALUE_REQUIRED, 'Titel des Formulars', 'W&K-Anmeldung') + ->addOption('calendar', null, InputOption::VALUE_REQUIRED, 'Formular diesem Kalender (ID) als Anmeldeformular zuweisen') + ->addOption('privacy-url', null, InputOption::VALUE_REQUIRED, 'Link zur Datenschutzseite', '/datenschutz') + ->addOption('terms-url', null, InputOption::VALUE_REQUIRED, 'Link zu den AGB', '/agb') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $now = time(); + $title = (string) $input->getOption('title'); + + $formId = $this->insert('tl_form', [ + 'tstamp' => $now, + 'title' => $title, + 'alias' => $this->uniqueAlias('wk-anmeldung'), + 'method' => 'POST', + 'sendViaEmail' => 0, + 'storeValues' => 0, + 'targetTable' => '', + 'novalidate' => 0, + ]); + + $sorting = 0; + + foreach ($this->fields((string) $input->getOption('privacy-url'), (string) $input->getOption('terms-url')) as $field) { + $sorting += 128; + + if (isset($field['options'])) { + $field['options'] = serialize(array_map(static fn (array $o): array => ['value' => $o[0], 'label' => $o[1], 'default' => $o[2] ?? '', 'group' => ''], $field['options'])); + } + + $this->insert('tl_form_field', ['pid' => $formId, 'sorting' => $sorting, 'tstamp' => $now, ...$field]); + } + + $io->success(\sprintf('Formular „%s“ (ID %d) mit %d Feldern angelegt.', $title, $formId, $sorting / 128)); + + $calendarId = (int) $input->getOption('calendar'); + + if ($calendarId > 0) { + $this->connection->update('tl_calendar', ['wkBookingForm' => $formId, 'tstamp' => $now], ['id' => $calendarId]); + $io->writeln(\sprintf('Kalender %d verwendet das Formular jetzt als Anmeldeformular.', $calendarId)); + } else { + $io->note('Das Formular jetzt im Kalender unter „W&K-Sync → Anmeldeformular“ auswählen.'); + } + + return Command::SUCCESS; + } + + /** + * @return list> + */ + private function fields(string $privacyUrl, string $termsUrl): array + { + $genders = [['', 'bitte wählen'], ['male', 'Herr'], ['female', 'Frau'], ['divers', 'divers'], ['none', 'keine Angabe']]; + + return [ + ['type' => 'select', 'name' => 'gender', 'label' => 'Anrede', 'mandatory' => 1, 'options' => $genders], + ['type' => 'text', 'name' => 'academicTitle', 'label' => 'Titel', 'maxlength' => 64], + ['type' => 'text', 'name' => 'firstname', 'label' => 'Vorname', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'lastname', 'label' => 'Nachname', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'street', 'label' => 'Straße & Hausnummer', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'postal', 'label' => 'PLZ', 'mandatory' => 1, 'maxlength' => 32], + ['type' => 'text', 'name' => 'city', 'label' => 'Ort', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'email', 'label' => 'E-Mail-Adresse', 'mandatory' => 1, 'rgxp' => 'email', 'maxlength' => 255], + ['type' => 'text', 'name' => 'phone', 'label' => 'Telefonnummer/Handy', 'mandatory' => 1, 'rgxp' => 'phone', 'maxlength' => 64], + ['type' => 'select', 'name' => 'position', 'label' => 'Ihre Tätigkeit', 'options' => [['', 'bitte wählen'], ['church_employee', 'Kirchlicher Mitarbeiter'], ['teacher', 'Lehrer'], ['pastor', 'Pfarrer'], ['volunteer', 'Ehrenamtlich']]], + ['type' => 'text', 'name' => 'birthday', 'label' => 'Geburtstag', 'rgxp' => 'date', 'placeholder' => 'TT.MM.JJJJ', 'maxlength' => 10], + ['type' => 'select', 'name' => 'teacherPosition', 'label' => 'Angaben zur Lehrertätigkeit', 'mandatory' => 1, 'options' => [['', 'bitte wählen'], ['state', 'Ich unterrichte an einer staatlichen Schule'], ['other', 'Ich unterrichte an einer Schule in sonstiger Trägerschaft'], ['none', 'Ich unterrichte nicht']]], + ['type' => 'text', 'name' => 'schoolName', 'label' => 'Name der Schule', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'schoolCity', 'label' => 'Ort der Schule', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'checkbox', 'name' => 'vegetarian', 'label' => '', 'options' => [['1', 'Ich möchte vegetarisch essen']]], + ['type' => 'select', 'name' => 'overnightStay', 'label' => 'Übernachtung', 'mandatory' => 1, 'options' => [['', 'bitte wählen'], ['none', 'keine Übernachtung benötigt'], ['single_room', 'Einzelzimmer'], ['double_room', 'Doppelzimmer']]], + ['type' => 'explanation', 'text' => '

Die Buchung von Übernachtung und/oder Verpflegung nehmen Sie direkt im Gästehaus oder in einem ortsansässigen Beherbergungsbetrieb vor. Mit der Anmeldebestätigung erhalten Sie per Mail weitere Informationen.

', 'class' => BookingFields::SLOT_CLASS_PREFIX.BookingFields::SLOT_NO_OVERNIGHT_STAY], + ['type' => 'checkbox', 'name' => 'addDifferentInvoiceAddress', 'label' => '', 'options' => [['1', 'Rechnungen bitte an eine andere Anschrift schicken']]], + ['type' => 'text', 'name' => 'invoiceInstitution', 'label' => 'Institution (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'select', 'name' => 'invoiceGender', 'label' => 'Anrede (Rechnungsempfänger)', 'mandatory' => 1, 'options' => $genders], + ['type' => 'text', 'name' => 'invoiceAcademicTitle', 'label' => 'Titel (Rechnungsempfänger)', 'maxlength' => 64], + ['type' => 'text', 'name' => 'invoiceFirstname', 'label' => 'Vorname (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'invoiceLastname', 'label' => 'Nachname (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'invoiceStreet', 'label' => 'Straße & Hausnummer (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'text', 'name' => 'invoicePostal', 'label' => 'PLZ (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 32], + ['type' => 'text', 'name' => 'invoiceCity', 'label' => 'Ort (Rechnungsempfänger)', 'mandatory' => 1, 'maxlength' => 128], + ['type' => 'checkbox', 'name' => 'addNotes', 'label' => '', 'options' => [['1', 'Ich habe eine zusätzliche Frage oder Nachricht']]], + ['type' => 'textarea', 'name' => 'notes', 'label' => 'Ihre Nachricht', 'mandatory' => 1], + ['type' => 'checkbox', 'name' => 'newsletter', 'label' => '', 'options' => [['1', 'Ich möchte den Newsletter abonnieren']]], + ['type' => 'checkbox', 'name' => 'privacyAccepted', 'label' => '', 'mandatory' => 1, 'options' => [['1', 'Ich bin mit der Verarbeitung meiner Daten gemäß den Datenschutzhinweisen einverstanden.']]], + ['type' => 'checkbox', 'name' => 'agbAccepted', 'label' => '', 'mandatory' => 1, 'options' => [['1', 'Ich habe die AGB gelesen und stimme diesen zu.']]], + ['type' => 'altcha', 'name' => 'altcha', 'label' => 'Spamschutz', 'mandatory' => 1], + ['type' => 'submit', 'slabel' => 'Verbindlich anmelden'], + ]; + } + + /** + * @param array $data + */ + private function insert(string $table, array $data): int + { + $columns = array_map(static fn ($c): string => $c->getName(), $this->connection->createSchemaManager()->listTableColumns($table)); + $this->connection->insert($table, array_intersect_key($data, array_flip($columns))); + + return (int) $this->connection->lastInsertId(); + } + + private function uniqueAlias(string $alias): string + { + $base = $alias; + + for ($i = 2; (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_form WHERE alias = ?', [$alias]) > 0; ++$i) { + $alias = $base.'-'.$i; + } + + return $alias; + } +} diff --git a/src/Command/ImportCommand.php b/src/Command/ImportCommand.php new file mode 100644 index 0000000..fcf5061 --- /dev/null +++ b/src/Command/ImportCommand.php @@ -0,0 +1,137 @@ +addArgument('file', InputArgument::REQUIRED, 'Pfad zur W&K-Datei (absolut oder relativ zum Projektverzeichnis)') + ->addArgument('calendar', InputArgument::REQUIRED, 'ID des Kalenders (W&K-Sync muss dort aktiviert sein)') + ->addOption('apply', null, InputOption::VALUE_NONE, 'Änderungen anwenden (sonst nur Vorschau)') + ->addOption('user', null, InputOption::VALUE_REQUIRED, 'ID des Backend-Benutzers für das Protokoll', '0') + ->addOption('details', null, InputOption::VALUE_NONE, 'Alle geplanten Änderungen einzeln auflisten') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $file = (string) $input->getArgument('file'); + + if (!str_starts_with($file, '/')) { + $file = $this->projectDir.'/'.$file; + } + + if (!is_file($file)) { + $io->error(\sprintf('Die Datei "%s" wurde nicht gefunden.', $file)); + + return Command::FAILURE; + } + + $userId = (int) $input->getOption('user'); + + try { + $path = $this->storage->store((string) file_get_contents($file), SyncFileStorage::KIND_IMPORT, basename($file)); + $prepared = $this->importService->prepare($path, basename($file), (int) $input->getArgument('calendar'), $userId); + $plan = $prepared['plan']; + + $this->printPlan($io, $plan, (bool) $input->getOption('details')); + + if (!$input->getOption('apply')) { + $io->note(\sprintf('Vorschau gespeichert als Import #%d. Zum Anwenden --apply angeben oder den Import im Backend bestätigen.', $prepared['logId'])); + + return Command::SUCCESS; + } + + $applied = $this->importService->apply($prepared['logId'], $userId); + $result = $applied['result']; + + $io->success(\sprintf( + 'Import #%d angewendet: %d neu, %d geändert, %d storniert, %d unverändert, %d Mitglieder angelegt.', + $prepared['logId'], $result['created'], $result['updated'], $result['cancelled'], $result['unchanged'], $result['members'], + )); + } catch (ImportException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + private function printPlan(SymfonyStyle $io, ImportPlan $plan, bool $details): void + { + $summary = $plan->summary(); + + $io->table(['Neu', 'Geändert', 'Storniert', 'Unverändert', 'Übersprungen', 'Warnungen', 'Fehler'], [[ + $summary[PlannedEvent::CREATE], $summary[PlannedEvent::UPDATE], $summary[PlannedEvent::CANCEL], + $summary[PlannedEvent::UNCHANGED], $summary[PlannedEvent::SKIP], $summary['warnings'], $summary['errors'], + ]]); + + foreach ($plan->allMessages() as $message) { + $line = ('' !== $message->wkId ? '['.$message->wkId.'] ' : '').$message->text; + + match ($message->type) { + ImportMessage::ERROR => $io->writeln('Fehler: '.$line), + ImportMessage::WARNING => $io->writeln('Warnung: '.$line), + default => $io->writeln('Hinweis: '.$line), + }; + } + + if (!$details) { + return; + } + + foreach ([PlannedEvent::CREATE, PlannedEvent::UPDATE, PlannedEvent::CANCEL] as $action) { + $events = $plan->byAction($action); + + if ([] === $events) { + continue; + } + + $io->section(ucfirst($action).' ('.\count($events).')'); + + foreach ($events as $event) { + $io->writeln(\sprintf(' - %s %s', $event->wkId, $event->title)); + + foreach ($event->changes as $field => [$old, $new]) { + $io->writeln(\sprintf(' %s: %s -> %s', $field, $this->short($old), $this->short($new))); + } + } + } + } + + private function short(mixed $value): string + { + $text = \is_array($value) ? implode(',', array_map(strval(...), $value)) : trim(strip_tags((string) (\is_bool($value) ? (int) $value : $value))); + + return mb_strlen($text) > 60 ? mb_substr($text, 0, 57).'...' : $text; + } +} diff --git a/src/Command/SeedCategoriesCommand.php b/src/Command/SeedCategoriesCommand.php new file mode 100644 index 0000000..db97bb6 --- /dev/null +++ b/src/Command/SeedCategoriesCommand.php @@ -0,0 +1,72 @@ +}> + */ + public const array DEFAULTS = [ + ['title' => 'Elementarbereich', 'alias' => 'elementarbereich', 'kind' => CategoryKind::Elementary, 'names' => ['Elementarbereich']], + ['title' => 'Grundschule/Förderschule', 'alias' => 'grundschule-foerderschule', 'kind' => CategoryKind::School, 'names' => ['Grundschule', 'Förderschule', 'Grund- und Förderschule', 'Alle Schularten']], + ['title' => 'Weiterführende Schule', 'alias' => 'weiterfuehrende-schule', 'kind' => CategoryKind::School, 'names' => ['Weiterführende Schule', 'Weiterführende', 'Oberschule', 'Gymnasium', 'Berufsbildende', 'Alle Schularten']], + ['title' => 'Evang. Schulen', 'alias' => 'evang-schulen', 'kind' => CategoryKind::EvangelicalSchool, 'names' => ['Evang. Schulen', 'Evangelische Schulen', 'evang']], + ['title' => 'Gemeinde', 'alias' => 'gemeinde', 'kind' => CategoryKind::Community, 'names' => ['Gemeinde', 'Gemeindepädagogische Arbeit', 'Gemeindepädagogik']], + ['title' => 'Gottesdienst mit Kindern', 'alias' => 'gottesdienst-mit-kindern', 'kind' => CategoryKind::ChildrenService, 'names' => ['Gottesdienst mit Kindern']], + ['title' => 'Ausbildung', 'alias' => 'ausbildung', 'kind' => CategoryKind::Training, 'names' => ['Ausbildung']], + ['title' => 'Mentoring', 'alias' => 'mentoring', 'kind' => CategoryKind::Mentoring, 'names' => ['Mentoring', 'Mentoren', 'Mentor']], + ['title' => 'Tagungen', 'alias' => 'tagungen', 'kind' => CategoryKind::Conference, 'names' => ['Tagungen', 'Tagung']], + ['title' => 'AusZEIT', 'alias' => 'auszeit', 'kind' => CategoryKind::Other, 'names' => ['AusZEIT']], + ['title' => 'Angebote anderer Träger', 'alias' => 'angebote-anderer-traeger', 'kind' => CategoryKind::Other, 'names' => ['Angebote anderer Träger']], + ['title' => 'Schule', 'alias' => 'schule', 'kind' => CategoryKind::School, 'names' => ['Schule']], + ]; + + public function __construct(private readonly Connection $connection) + { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $existing = $this->connection->fetchFirstColumn('SELECT alias FROM tl_wk_category'); + $sorting = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(sorting), 0) FROM tl_wk_category'); + $created = 0; + + foreach (self::DEFAULTS as $category) { + if (\in_array($category['alias'], $existing, true)) { + continue; + } + + $sorting += 16; + + $this->connection->insert('tl_wk_category', [ + 'tstamp' => time(), + 'sorting' => $sorting, + 'title' => $category['title'], + 'alias' => $category['alias'], + 'kind' => $category['kind']->value, + 'wkNames' => implode("\n", $category['names']), + ]); + + ++$created; + $io->writeln(\sprintf(' + %s (%s)', $category['title'], $category['kind']->value)); + } + + $io->success(\sprintf('%d Kategorien angelegt, %d bereits vorhanden.', $created, \count(self::DEFAULTS) - $created)); + + return Command::SUCCESS; + } +} diff --git a/src/ContaoManager/Plugin.php b/src/ContaoManager/Plugin.php new file mode 100644 index 0000000..9711113 --- /dev/null +++ b/src/ContaoManager/Plugin.php @@ -0,0 +1,30 @@ +setLoadAfter([ + ContaoCoreBundle::class, + ContaoCalendarBundle::class, + ContaoNewsletterBundle::class, + Terminal42NotificationCenterBundle::class, + ]), + ]; + } +} diff --git a/src/ContaoWkSyncBundle.php b/src/ContaoWkSyncBundle.php new file mode 100644 index 0000000..1bf637b --- /dev/null +++ b/src/ContaoWkSyncBundle.php @@ -0,0 +1,23 @@ +import('../config/services.yaml'); + + // Benachrichtigungen nur anbinden, wenn das Notification Center 2 installiert ist + if (class_exists(Terminal42NotificationCenterBundle::class)) { + $container->import('../config/notification_center.yaml'); + } + } +} diff --git a/src/Controller/FrontendModule/BookingFormController.php b/src/Controller/FrontendModule/BookingFormController.php new file mode 100644 index 0000000..89b30c7 --- /dev/null +++ b/src/Controller/FrontendModule/BookingFormController.php @@ -0,0 +1,88 @@ +cal_calendar, true)); + $event = $this->events->resolveFromRequest($request, $calendarIds); + + if (null === $event || (!$event->published && !$request->attributes->get('_preview', false))) { + throw new PageNotFoundException('Event not found: '.($request->attributes->get('auto_item') ?? '')); + } + + $calendar = $this->calendars->find($event->calendarId); + + if (null === $calendar) { + throw new PageNotFoundException('Calendar not found for event ID '.$event->id); + } + + $availability = $this->availability->check($event, $calendar); + $rules = $this->rules->rulesFor($event, $calendar); + + $context = new BookingContext($event, $calendar, $rules, $availability); + $context->attachTo($request); + + $formHtml = ''; + + if ($availability->isBookable()) { + // Die Hooks des Formulargenerators greifen über den BookingContext auf die Veranstaltung zu + $formHtml = $this->getContaoAdapter(Controller::class)->getForm($calendar->bookingForm); + } + + $template->set('event', $event); + $template->set('calendar', $calendar); + $template->set('availability', $availability); + $template->set('form_html', $formHtml); + $template->set('places_left', $availability->placesLeft); + $template->set('deadline', $availability->displayDeadline); + $template->set('categories', $this->categories->findByIds($event->categories)); + $template->set('rules_config', json_encode([ + 'hidden' => $rules->hiddenFields, + 'dependencies' => $rules->dependencies, + ], JSON_THROW_ON_ERROR)); + + $this->tagResponse(['contao.db.tl_calendar_events.'.$event->id, 'contao.db.tl_wk_booking']); + + $response = $template->getResponse(); + $response->setPrivate(); + $response->headers->addCacheControlDirective('no-store'); + + return $response; + } +} diff --git a/src/Controller/FrontendModule/BookingStatusController.php b/src/Controller/FrontendModule/BookingStatusController.php new file mode 100644 index 0000000..528d7a5 --- /dev/null +++ b/src/Controller/FrontendModule/BookingStatusController.php @@ -0,0 +1,63 @@ +cal_calendar, true)); + $event = $this->events->resolveFromRequest($request, $calendarIds); + + if (null === $event || null === ($calendar = $this->calendars->find($event->calendarId))) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + $availability = $this->availability->check($event, $calendar); + + $template->set('event', $event); + $template->set('calendar', $calendar); + $template->set('availability', $availability); + $template->set('places_left', $availability->placesLeft); + $template->set('deadline', $availability->displayDeadline); + $template->set('booking_url', $this->urls->bookingUrl($event, $calendar)); + $template->set('inquiry_url', $this->urls->inquiryUrl($event, $calendar)); + + $this->tagResponse(['contao.db.tl_calendar_events.'.$event->id, 'contao.db.tl_wk_booking']); + + $response = $template->getResponse(); + $response->setPrivate(); + $response->headers->addCacheControlDirective('no-store'); + + return $response; + } +} diff --git a/src/EventListener/DataContainer/BookingLabelListener.php b/src/EventListener/DataContainer/BookingLabelListener.php new file mode 100644 index 0000000..60c7191 --- /dev/null +++ b/src/EventListener/DataContainer/BookingLabelListener.php @@ -0,0 +1,47 @@ + $row + */ + public function __invoke(array $row): string + { + $config = $this->framework->getAdapter(Config::class); + $date = $this->framework->getAdapter(Date::class)->parse((string) $config->get('datimFormat'), (int) $row['dateCreated']); + $state = InboxState::tryFrom((string) $row['state']) ?? InboxState::New; + $stateLabel = $this->translator->trans('MSC.wk_sync.state.'.$state->translationKey(), [], 'contao_default'); + + $name = trim(($row['academicTitle'] ? $row['academicTitle'].' ' : '').$row['firstname'].' '.$row['lastname']); + + return \sprintf( + '
%s [%s] [%s] [%s]
', + StringUtil::specialchars($name), + StringUtil::specialchars((string) $row['email']), + StringUtil::specialchars($date), + StringUtil::specialchars($stateLabel), + ); + } +} diff --git a/src/EventListener/DataContainer/CategoryAliasListener.php b/src/EventListener/DataContainer/CategoryAliasListener.php new file mode 100644 index 0000000..6f81662 --- /dev/null +++ b/src/EventListener/DataContainer/CategoryAliasListener.php @@ -0,0 +1,36 @@ + (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_wk_category WHERE alias = ? AND id != ?', [$alias, (int) $dc->id]) > 0; + + if ('' === $value) { + return $this->slug->generate((string) $dc->getCurrentRecord()['title'], [], $exists); + } + + if ($exists($value)) { + throw new \RuntimeException(\sprintf($GLOBALS['TL_LANG']['ERR']['aliasExists'] ?? 'Alias "%s" already exists.', $value)); + } + + return $value; + } +} diff --git a/src/EventListener/DataContainer/NewsletterChannelOptionsListener.php b/src/EventListener/DataContainer/NewsletterChannelOptionsListener.php new file mode 100644 index 0000000..7281ab2 --- /dev/null +++ b/src/EventListener/DataContainer/NewsletterChannelOptionsListener.php @@ -0,0 +1,37 @@ + + */ + public function __invoke(): array + { + if (!$this->connection->createSchemaManager()->tablesExist(['tl_newsletter_channel'])) { + return []; + } + + $options = []; + + foreach ($this->connection->fetchAllKeyValue('SELECT id, title FROM tl_newsletter_channel ORDER BY title') as $id => $title) { + $options[(int) $id] = (string) $title; + } + + return $options; + } +} diff --git a/src/EventListener/DataContainer/NotificationOptionsListener.php b/src/EventListener/DataContainer/NotificationOptionsListener.php new file mode 100644 index 0000000..dffba5a --- /dev/null +++ b/src/EventListener/DataContainer/NotificationOptionsListener.php @@ -0,0 +1,40 @@ + + */ + public function __invoke(): array + { + return $this->notifier->availableNotifications(); + } +} diff --git a/src/EventListener/Hook/CompileFormFieldsListener.php b/src/EventListener/Hook/CompileFormFieldsListener.php new file mode 100644 index 0000000..326b20f --- /dev/null +++ b/src/EventListener/Hook/CompileFormFieldsListener.php @@ -0,0 +1,38 @@ +requestStack->getCurrentRequest(); + $context = BookingContext::fromRequest($request); + + if (null === $context || null === $request || !$context->isBookingForm((int) $form->id)) { + return $fields; + } + + $submitted = $request->request->get('FORM_SUBMIT') === $formId ? $request->request->all() : null; + + return $this->filter->filter($fields, $context->rules, $submitted); + } +} diff --git a/src/EventListener/Hook/GetAllEventsListener.php b/src/EventListener/Hook/GetAllEventsListener.php new file mode 100644 index 0000000..1804480 --- /dev/null +++ b/src/EventListener/Hook/GetAllEventsListener.php @@ -0,0 +1,47 @@ +wkCategories ?? null, true)); + + if ([] === $wanted) { + return $events; + } + + foreach ($events as $day => $eventsPerDay) { + foreach ($eventsPerDay as $time => $eventsPerTime) { + foreach ($eventsPerTime as $key => $event) { + $categories = array_map(intval(...), StringUtil::deserialize($event['wkCategories'] ?? null, true)); + + if ([] === array_intersect($categories, $wanted)) { + unset($events[$day][$time][$key]); + } + } + + if ([] === $events[$day][$time]) { + unset($events[$day][$time]); + } + } + + if ([] === $events[$day]) { + unset($events[$day]); + } + } + + return $events; + } +} diff --git a/src/EventListener/Hook/PrepareFormDataListener.php b/src/EventListener/Hook/PrepareFormDataListener.php new file mode 100644 index 0000000..fea4a2f --- /dev/null +++ b/src/EventListener/Hook/PrepareFormDataListener.php @@ -0,0 +1,73 @@ + in die übermittelten Daten übernehmen. + */ +#[AsHook('prepareFormData')] +class PrepareFormDataListener +{ + public function __construct( + private readonly RequestStack $requestStack, + private readonly BookingAvailability $availability, + private readonly BookingRepository $bookings, + private readonly EventResolver $events, + private readonly TranslatorInterface $translator, + ) { + } + + /** + * @param array $submittedData + */ + public function __invoke(array &$submittedData, array $labels, array $fields, Form $form): void + { + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + return; + } + + $context = BookingContext::fromRequest($request); + + if (null !== $context && $context->isBookingForm((int) $form->id)) { + $availability = $this->availability->check($context->event, $context->calendar); + + if (!$availability->isBookable()) { + $form->addError($this->translator->trans('MSC.wk_booking.error.'.$availability->reason, [], 'contao_default')); + } + + return; + } + + $token = (string) ($request->query->get('booking') ?? $submittedData['booking'] ?? ''); + + if ('' === $token || null === ($booking = $this->bookings->findByToken($token))) { + return; + } + + $submittedData['subscription'] = (int) $booking['id']; + $submittedData['subscription_email'] = (string) $booking['email']; + $submittedData['subscription_firstname'] = (string) $booking['firstname']; + $submittedData['subscription_lastname'] = (string) $booking['lastname']; + + if (null !== ($event = $this->events->find((int) $booking['pid']))) { + $submittedData['event'] = $event->id; + $submittedData['event_title'] = $event->title; + $submittedData['event_wk_id'] = $event->wkId; + } + } +} diff --git a/src/EventListener/Hook/ProcessFormDataListener.php b/src/EventListener/Hook/ProcessFormDataListener.php new file mode 100644 index 0000000..2a2e91f --- /dev/null +++ b/src/EventListener/Hook/ProcessFormDataListener.php @@ -0,0 +1,114 @@ + $submittedData + * @param array $formData + */ + public function __invoke(array $submittedData, array $formData, array|null $files, array $labels, Form $form): void + { + $request = $this->requestStack->getCurrentRequest(); + $context = BookingContext::fromRequest($request); + + if (null === $context || !$context->isBookingForm((int) $form->id) || $form->hasErrors()) { + return; + } + + // Wurde die Zeile bereits vom Formulargenerator gespeichert („Daten in Tabelle speichern“)? + if (null !== $context->booking && !isset($context->booking['id'])) { + $stored = $this->bookings->findByToken((string) $context->booking['token']); + $booking = $stored ?? $this->creator->create($context->event, $submittedData, $context->rules); + } else { + $booking = $context->booking ?? $this->creator->create($context->event, $submittedData, $context->rules); + } + + $context->booking = $booking; + + $url = $this->confirmationUrl($context, (string) $booking['token'], (int) $form->jumpTo); + $routing = $this->router->resolve($context->event, $context->calendar); + + if ($routing['notification'] > 0) { + $tokens = $this->tokens->build($booking, $context->event, $context->calendar, $url, $routing); + $sent = $this->notifier->notify($routing['notification'], $tokens); + $this->bookings->update((int) $booking['id'], ['notification' => $sent ? $routing['notification'] : 0]); + + if (!$sent) { + $this->contaoErrorLogger?->error(\sprintf('W&K-Anmeldung #%d zu „%s“: Benachrichtigung %d wurde nicht zugestellt.', $booking['id'], $context->event->title, $routing['notification'])); + } + } else { + $this->contaoErrorLogger?->warning(\sprintf('W&K-Anmeldung #%d zu „%s“: Es ist keine Benachrichtigung konfiguriert (Bedingungen: %s).', $booking['id'], $context->event->title, implode(', ', $routing['conditions']) ?: 'keine')); + } + + if (!empty($booking['newsletter'])) { + $this->newsletter->subscribe((string) $booking['email'], $context->calendar); + } + + $this->contaoGeneralLogger?->info(\sprintf('Neue W&K-Anmeldung #%d von %s %s zu „%s“ (W&K %s).', $booking['id'], $booking['firstname'], $booking['lastname'], $context->event->title, $context->event->wkId)); + + if ('' !== $url) { + throw new RedirectResponseException($url); + } + } + + private function confirmationUrl(BookingContext $context, string $token, int $formJumpTo): string + { + $this->framework->initialize(); + $pages = $this->framework->getAdapter(PageModel::class); + + foreach ([$context->event->confirmationPage, $context->calendar->confirmationPage, $formJumpTo] as $pageId) { + if ($pageId < 1 || null === ($page = $pages->findPublishedById($pageId))) { + continue; + } + + try { + return $this->urlGenerator->generate($page, [], UrlGeneratorInterface::ABSOLUTE_URL).'?booking='.$token; + } catch (\Throwable) { + continue; + } + } + + return ''; + } +} diff --git a/src/EventListener/Hook/StoreFormDataListener.php b/src/EventListener/Hook/StoreFormDataListener.php new file mode 100644 index 0000000..e0d745f --- /dev/null +++ b/src/EventListener/Hook/StoreFormDataListener.php @@ -0,0 +1,117 @@ +> */ + private array $columns = []; + + public function __construct( + private readonly RequestStack $requestStack, + private readonly Connection $connection, + private readonly BookingCreator $creator, + ) { + } + + /** + * @param array $data + * + * @return array + */ + public function __invoke(array $data, Form $form): array + { + return match ((string) $form->targetTable) { + 'tl_wk_inquiry' => $this->prepareInquiry($data), + 'tl_wk_booking' => $this->prepareBooking($data, $form), + default => $data, + }; + } + + /** + * @param array $data + * + * @return array + */ + private function prepareInquiry(array $data): array + { + $request = $this->requestStack->getCurrentRequest(); + $now = time(); + + $eventId = (int) ($data['event'] ?? 0); + + if ($eventId < 1 && null !== $request) { + $eventId = (int) $request->query->get('event', 0); + } + + $data['dateCreated'] = $now; + $data['tstamp'] = $now; + $data['state'] = InboxState::New->value; + $data['event'] = $eventId; + + foreach (['newsletter', 'privacy'] as $bool) { + $data[$bool] = (int) BookingCreator::toBool($data[$bool] ?? null); + } + + foreach ($data as $key => $value) { + if (\is_array($value)) { + $data[$key] = BookingCreator::toString($value); + } + } + + return $this->onlyKnownColumns('tl_wk_inquiry', $data); + } + + /** + * @param array $data + * + * @return array + */ + private function prepareBooking(array $data, Form $form): array + { + $request = $this->requestStack->getCurrentRequest(); + $context = BookingContext::fromRequest($request); + + if (null === $context || !$context->isBookingForm((int) $form->id)) { + return $this->onlyKnownColumns('tl_wk_booking', $data); + } + + $row = $this->creator->prepare($context->event, $data, $context->rules); + $context->booking = $row; + + return $row; + } + + /** + * @param array $data + * + * @return array + */ + private function onlyKnownColumns(string $table, array $data): array + { + if (!isset($this->columns[$table])) { + $this->columns[$table] = array_map( + static fn ($column): string => $column->getName(), + $this->connection->createSchemaManager()->listTableColumns($table), + ); + } + + return array_intersect_key($data, array_flip($this->columns[$table])); + } +} diff --git a/src/EventListener/NotificationCenter/BookingNotificationType.php b/src/EventListener/NotificationCenter/BookingNotificationType.php new file mode 100644 index 0000000..4259352 --- /dev/null +++ b/src/EventListener/NotificationCenter/BookingNotificationType.php @@ -0,0 +1,48 @@ +factory->create(EmailTokenDefinition::class, 'recipient_email', 'wk_booking.recipient_email'), + $this->factory->create(EmailTokenDefinition::class, 'admin_email', 'wk_booking.admin_email'), + $this->factory->create(EmailTokenDefinition::class, 'subscription_email', 'wk_booking.subscription_email'), + $this->factory->create(EmailTokenDefinition::class, 'event_wkOrganizerEmail', 'wk_booking.event_wkOrganizerEmail'), + $this->factory->create(TextTokenDefinition::class, 'subscription_confirmation_page_url', 'wk_booking.subscription_confirmation_page_url'), + $this->factory->create(TextTokenDefinition::class, 'subscription_event_wk_id', 'wk_booking.subscription_event_wk_id'), + $this->factory->create(TextTokenDefinition::class, 'event_link', 'wk_booking.event_link'), + $this->factory->create(TextTokenDefinition::class, 'event_datetime', 'wk_booking.event_datetime'), + $this->factory->create(TextTokenDefinition::class, 'event_categories', 'wk_booking.event_categories'), + $this->factory->create(TextTokenDefinition::class, 'event_speakers', 'wk_booking.event_speakers'), + $this->factory->create(TextTokenDefinition::class, 'wk_notification_conditions', 'wk_booking.wk_notification_conditions'), + $this->factory->create(AnythingTokenDefinition::class, 'subscription_*', 'wk_booking.subscription_*'), + $this->factory->create(AnythingTokenDefinition::class, 'event_*', 'wk_booking.event_*'), + $this->factory->create(AnythingTokenDefinition::class, 'calendar_*', 'wk_booking.calendar_*'), + ]; + } +} diff --git a/src/Export/BookingExporter.php b/src/Export/BookingExporter.php new file mode 100644 index 0000000..812b315 --- /dev/null +++ b/src/Export/BookingExporter.php @@ -0,0 +1,160 @@ + 2, 'single_room' => 3, 'double_room' => 4]; + + public function __construct( + private readonly Connection $connection, + private readonly WkCsvWriter $writer, + private readonly NameFormatter $names, + private readonly SyncFileStorage $storage, + private readonly SyncLog $log, + private readonly TranslatorInterface $translator, + private readonly LoggerInterface|null $contaoGeneralLogger = null, + ) { + } + + /** + * Baut die CSV-Zeilen für die aktuelle Download-Liste (ohne etwas zu verändern). + * + * @return array{rows: list>, bookingIds: list, inquiryIds: list, places: array} + */ + public function collect(): array + { + $rows = []; + $bookingIds = []; + $inquiryIds = []; + $places = []; + + $bookings = $this->connection->fetchAllAssociative( + 'SELECT b.*, e.wkId AS eventWkId FROM tl_wk_booking b INNER JOIN tl_calendar_events e ON e.id = b.pid WHERE b.state = ? ORDER BY b.dateCreated, b.id', + [InboxState::DownloadList->value], + ); + + foreach ($bookings as $row) { + $rows[] = $this->row($row, (string) $row['eventWkId']); + $bookingIds[] = (int) $row['id']; + $places[(int) $row['pid']] = ($places[(int) $row['pid']] ?? 0) + 1; + } + + $inquiries = $this->connection->fetchAllAssociative( + 'SELECT * FROM tl_wk_inquiry WHERE state = ? ORDER BY dateCreated, id', + [InboxState::DownloadList->value], + ); + + foreach ($inquiries as $row) { + $rows[] = $this->row($row, ''); + $inquiryIds[] = (int) $row['id']; + } + + return ['rows' => $rows, 'bookingIds' => $bookingIds, 'inquiryIds' => $inquiryIds, 'places' => $places]; + } + + /** + * @return array{logId: int, filename: string, bookings: int, inquiries: int}|null null, wenn die Download-Liste leer ist + */ + public function export(int $userId): array|null + { + $data = $this->collect(); + + if ([] === $data['rows']) { + return null; + } + + $csv = $this->writer->write(self::HEADER, $data['rows']); + $filename = date('Y-m-d').'_website_kontaktanfragen_wk.csv'; + $now = time(); + + $path = $this->storage->store($csv, SyncFileStorage::KIND_EXPORT, $filename); + + $logId = $this->connection->transactional(function () use ($data, $now, $userId, $filename, $path): int { + if ([] !== $data['bookingIds']) { + $this->connection->executeStatement( + 'UPDATE tl_wk_booking SET state = ?, exportedAt = ?, tstamp = ? WHERE id IN (?)', + [InboxState::Exported->value, $now, $now, $data['bookingIds']], + [null, null, null, ArrayParameterType::INTEGER], + ); + } + + if ([] !== $data['inquiryIds']) { + $this->connection->executeStatement( + 'UPDATE tl_wk_inquiry SET state = ?, exportedAt = ?, tstamp = ? WHERE id IN (?)', + [InboxState::Exported->value, $now, $now, $data['inquiryIds']], + [null, null, null, ArrayParameterType::INTEGER], + ); + } + + foreach ($data['places'] as $eventId => $amount) { + $this->connection->executeStatement( + 'UPDATE tl_calendar_events SET wkPlacesLeft = GREATEST(0, wkPlacesLeft - ?), tstamp = ? WHERE id = ?', + [$amount, $now, $eventId], + ); + } + + return $this->log->create(SyncLog::TYPE_EXPORT, SyncLog::STATUS_DONE, $userId, 0, $filename, $path, [ + 'bookings' => \count($data['bookingIds']), + 'inquiries' => \count($data['inquiryIds']), + 'events' => \count($data['places']), + ]); + }); + + $this->contaoGeneralLogger?->info(\sprintf('W&K-Export #%d: %d Anmeldungen und %d Anfragen als %s exportiert.', $logId, \count($data['bookingIds']), \count($data['inquiryIds']), $filename)); + + return ['logId' => $logId, 'filename' => $filename, 'bookings' => \count($data['bookingIds']), 'inquiries' => \count($data['inquiryIds'])]; + } + + /** + * @param array $row + * + * @return list + */ + private function row(array $row, string $wkId): array + { + $gender = (string) ($row['gender'] ?? ''); + $position = (string) ($row['position'] ?? ''); + + return [ + $wkId, + '' === $gender ? '' : $this->translator->trans('tl_wk_booking.genderRef.'.$gender, [], 'contao_tl_wk_booking'), + (string) ($row['academicTitle'] ?? ''), + $this->names->fix((string) ($row['firstname'] ?? '')), + $this->names->fix((string) ($row['lastname'] ?? '')), + (string) ($row['street'] ?? ''), + (string) ($row['postal'] ?? ''), + (string) ($row['city'] ?? ''), + (string) ($row['birthday'] ?? ''), + (string) ($row['email'] ?? ''), + (string) ($row['phone'] ?? ''), + 'pastor' === $position ? 1 : 0, + 'church_employee' === $position ? 1 : 0, + 'volunteer' === $position ? 1 : 0, + 'teacher' === $position ? 1 : 0, + self::OVERNIGHT_CODES[(string) ($row['overnightStay'] ?? '')] ?? 2, + !empty($row['vegetarian']) ? 1 : 0, + (string) ($row['schoolName'] ?? ''), + (string) ($row['schoolCity'] ?? ''), + ]; + } +} diff --git a/src/Export/NameFormatter.php b/src/Export/NameFormatter.php new file mode 100644 index 0000000..56e43bf --- /dev/null +++ b/src/Export/NameFormatter.php @@ -0,0 +1,24 @@ + $header + * @param list> $rows + */ + public function write(array $header, array $rows): string + { + $lines = [$this->line($header)]; + + foreach ($rows as $row) { + $lines[] = $this->line($row); + } + + return $this->toLatin1(implode(self::LINE_END, $lines).self::LINE_END); + } + + /** + * @param list $values + */ + private function line(array $values): string + { + return implode(self::DELIMITER, array_map( + static fn (mixed $value): string => '"'.str_replace('"', '""', str_replace(["\r\n", "\r", "\n"], ' ', (string) $value)).'"', + $values, + )); + } + + /** + * Zeichen außerhalb von ISO-8859-1 werden auf ihr Grundzeichen reduziert (ă → a), sonst durch „?“ ersetzt. + */ + public function toLatin1(string $text): string + { + $text = str_replace(['€', '„', '“', '”', '‚', '‘', '’', '–', '—', '…'], ['EUR', '"', '"', '"', "'", "'", "'", '-', '-', '...'], $text); + + $result = ''; + + foreach (mb_str_split($text, 1, 'UTF-8') as $char) { + $converted = mb_convert_encoding($char, 'ISO-8859-1', 'UTF-8'); + + if ('?' !== $converted || '?' === $char) { + $result .= $converted; + continue; + } + + $base = class_exists(\Normalizer::class) ? preg_replace('/\p{Mn}+/u', '', \Normalizer::normalize($char, \Normalizer::FORM_D) ?: $char) : $char; + $converted = mb_convert_encoding((string) $base, 'ISO-8859-1', 'UTF-8'); + $result .= '' === $converted ? '?' : $converted; + } + + return $result; + } +} diff --git a/src/Import/CsvReader.php b/src/Import/CsvReader.php new file mode 100644 index 0000000..8ba3b4c --- /dev/null +++ b/src/Import/CsvReader.php @@ -0,0 +1,120 @@ +readString($content); + } + + public function readString(string $content): CsvTable + { + [$content, $encoding] = $this->toUtf8($content); + + if ('Windows-1252' === $encoding) { + $content = $this->fixEuroSign($content); + } + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $content); + rewind($stream); + + $header = null; + $rows = []; + $warnings = []; + $record = 0; + + try { + while (false !== ($fields = fgetcsv($stream, null, self::DELIMITER, self::ENCLOSURE, ''))) { + ++$record; + + // Leere Zeile + if ([null] === $fields || [] === $fields || (1 === \count($fields) && '' === trim((string) $fields[0]))) { + continue; + } + + $fields = array_map(static fn (string|null $v): string => trim(str_replace("\r\n", "\n", (string) $v)), $fields); + + if (null === $header) { + $header = array_map(static fn (string $h): string => mb_strtolower(trim($h, " \t\"\xEF\xBB\xBF")), $fields); + + if (\count(array_unique($header)) !== \count($header)) { + $warnings[] = ImportMessage::warning('Die Kopfzeile enthält doppelte Spaltennamen; es gilt jeweils die letzte Spalte.'); + } + + continue; + } + + $expected = \count($header); + $actual = \count($fields); + + if ($actual < $expected) { + $fields = array_pad($fields, $expected, ''); + $warnings[] = ImportMessage::warning(\sprintf('Datensatz %d hat weniger Spalten (%d) als die Kopfzeile (%d); fehlende Werte wurden als leer übernommen.', $record, $actual, $expected), '', $record); + } elseif ($actual > $expected) { + $fields = \array_slice($fields, 0, $expected); + $warnings[] = ImportMessage::warning(\sprintf('Datensatz %d hat mehr Spalten (%d) als die Kopfzeile (%d); überzählige Werte wurden ignoriert.', $record, $actual, $expected), '', $record); + } + + $row = array_combine($header, $fields); + $row['__record'] = (string) $record; + $rows[] = $row; + } + } finally { + fclose($stream); + } + + if (null === $header) { + throw new ImportException('Die Datei enthält keine Kopfzeile.'); + } + + return new CsvTable($header, $rows, $encoding, $warnings); + } + + /** + * @return array{0: string, 1: string} Inhalt in UTF-8 und erkannte Quellkodierung + */ + private function toUtf8(string $content): array + { + if (str_starts_with($content, "\xEF\xBB\xBF")) { + return [substr($content, 3), 'UTF-8']; + } + + if (mb_check_encoding($content, 'UTF-8')) { + return [$content, 'UTF-8']; + } + + return [mb_convert_encoding($content, 'UTF-8', 'Windows-1252'), 'Windows-1252']; + } + + /** + * W&K exportiert das Euro-Zeichen als „Ç“ (DOS-Zeichensatz). Nur im Umfeld von Zahlen ersetzen, + * damit Namen wie „Çelik“ erhalten bleiben. + */ + private function fixEuroSign(string $content): string + { + $content = preg_replace('/(?<=\d)(\s*)Ç/u', '$1€', $content) ?? $content; + + return preg_replace('/Ç(?=\s*\d)/u', '€', $content) ?? $content; + } +} diff --git a/src/Import/CsvTable.php b/src/Import/CsvTable.php new file mode 100644 index 0000000..21c8df4 --- /dev/null +++ b/src/Import/CsvTable.php @@ -0,0 +1,39 @@ + $header + * @param list> $rows Datensätze, Schlüssel = Spaltenname in Kleinbuchstaben + * @param list $warnings + */ + public function __construct( + public array $header, + public array $rows, + public string $encoding, + public array $warnings = [], + ) { + } + + public function hasColumn(string $name): bool + { + return \in_array(mb_strtolower($name), $this->header, true); + } + + /** + * @param list $names + * + * @return list fehlende Spalten + */ + public function missingColumns(array $names): array + { + return array_values(array_filter($names, fn (string $n): bool => !$this->hasColumn($n))); + } +} diff --git a/src/Import/ImportException.php b/src/Import/ImportException.php new file mode 100644 index 0000000..230b80f --- /dev/null +++ b/src/Import/ImportException.php @@ -0,0 +1,9 @@ +framework->initialize(); + + $result = ['created' => 0, 'updated' => 0, 'cancelled' => 0, 'unchanged' => 0, 'members' => 0]; + $now = time(); + $touchedIds = []; + + $this->connection->transactional(function () use ($plan, $calendar, $userId, $now, &$result, &$touchedIds): void { + foreach ($plan->events as $event) { + switch ($event->action) { + case PlannedEvent::CREATE: + $set = $this->buildSet($event, $calendar, $now, $result); + $set['pid'] = $calendar->id; + $set['author'] = $calendar->author > 0 ? $calendar->author : $userId; + $set['alias'] = $this->generateAlias($event->title, $calendar); + $set['source'] = 'default'; + + $this->connection->insert('tl_calendar_events', $set); + $touchedIds[] = (int) $this->connection->lastInsertId(); + ++$result['created']; + break; + + case PlannedEvent::UPDATE: + $set = $this->buildSet($event, $calendar, $now, $result); + $this->connection->update('tl_calendar_events', $set, ['id' => $event->existingId]); + $touchedIds[] = $event->existingId; + ++$result['updated']; + break; + + case PlannedEvent::CANCEL: + $this->connection->update('tl_calendar_events', [ + 'wkState' => WkState::CANCELLED, + 'published' => 0, + 'tstamp' => $now, + 'wkImported' => $now, + ], ['id' => $event->existingId]); + $touchedIds[] = $event->existingId; + ++$result['cancelled']; + break; + + case PlannedEvent::UNCHANGED: + $this->connection->update('tl_calendar_events', ['wkImported' => $now], ['id' => $event->existingId]); + ++$result['unchanged']; + break; + } + } + }); + + $tags = ['contao.db.tl_calendar_events', 'contao.db.tl_calendar.'.$calendar->id]; + + foreach (array_unique($touchedIds) as $id) { + $tags[] = 'contao.db.tl_calendar_events.'.$id; + } + + $this->cacheTagManager->invalidateTags($tags); + + return $result; + } + + /** + * @param array{created: int, updated: int, cancelled: int, unchanged: int, members: int} $result + * + * @return array + */ + private function buildSet(PlannedEvent $event, CalendarConfig $calendar, int $now, array &$result): array + { + $set = array_map(static fn (mixed $v): mixed => \is_bool($v) ? (int) $v : $v, $event->data); + $set['tstamp'] = $now; + $set['wkImported'] = $now; + $set['wkCategories'] = serialize(array_map(strval(...), $event->categories)); + $set['wkCategoryPrimary'] = $event->primaryCategory; + + $instructorId = 0; + + if ('' !== $event->instructorName) { + [$instructorId, $created] = $calendar->instructorGroup > 0 + ? $this->memberResolver->resolve($event->instructorName, $calendar->instructorGroup) + : [$this->memberResolver->findId($event->instructorName), false]; + + if ($created) { + ++$result['members']; + } + } + + $set['wkInstructor'] = (int) $instructorId; + + $speakerIds = []; + + foreach ($event->speakerNames as $name) { + [$id, $created] = $calendar->speakerGroup > 0 + ? $this->memberResolver->resolve($name, $calendar->speakerGroup) + : [$this->memberResolver->findId($name), false]; + + if ($created) { + ++$result['members']; + } + + if (null !== $id && $id > 0) { + $speakerIds[] = (string) $id; + } + } + + $set['wkSpeakers'] = serialize($speakerIds); + + return $set; + } + + private function generateAlias(string $title, CalendarConfig $calendar): string + { + return $this->slug->generate( + $title, + $calendar->jumpTo, + fn (string $alias): bool => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_calendar_events WHERE alias = ?', [$alias]) > 0, + ); + } + + /** + * @param list $ids + */ + public function eventsExist(array $ids): bool + { + if ([] === $ids) { + return true; + } + + return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM tl_calendar_events WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]) === \count(array_unique($ids)); + } +} diff --git a/src/Import/ImportMessage.php b/src/Import/ImportMessage.php new file mode 100644 index 0000000..005dc7c --- /dev/null +++ b/src/Import/ImportMessage.php @@ -0,0 +1,51 @@ + $this->type, 'text' => $this->text, 'wkId' => $this->wkId, 'line' => $this->line]; + } + + /** + * @param array{type?: string, text?: string, wkId?: string, line?: int} $data + */ + public static function fromArray(array $data): self + { + return new self((string) ($data['type'] ?? self::INFO), (string) ($data['text'] ?? ''), (string) ($data['wkId'] ?? ''), (int) ($data['line'] ?? 0)); + } +} diff --git a/src/Import/ImportPlan.php b/src/Import/ImportPlan.php new file mode 100644 index 0000000..6ea668e --- /dev/null +++ b/src/Import/ImportPlan.php @@ -0,0 +1,89 @@ + $events + * @param list $messages Meldungen, die keiner Zeile zugeordnet sind + */ + public function __construct( + public readonly int $calendarId, + public readonly array $events, + public readonly array $messages = [], + ) { + } + + /** + * @return list + */ + public function byAction(string $action): array + { + return array_values(array_filter($this->events, static fn (PlannedEvent $e): bool => $e->action === $action)); + } + + /** + * @return array + */ + public function summary(): array + { + $summary = [ + PlannedEvent::CREATE => 0, + PlannedEvent::UPDATE => 0, + PlannedEvent::UNCHANGED => 0, + PlannedEvent::SKIP => 0, + PlannedEvent::CANCEL => 0, + 'errors' => 0, + 'warnings' => 0, + ]; + + foreach ($this->events as $event) { + ++$summary[$event->action]; + + foreach ($event->messages as $message) { + if (ImportMessage::ERROR === $message->type) { + ++$summary['errors']; + } elseif (ImportMessage::WARNING === $message->type) { + ++$summary['warnings']; + } + } + } + + foreach ($this->messages as $message) { + if (ImportMessage::ERROR === $message->type) { + ++$summary['errors']; + } elseif (ImportMessage::WARNING === $message->type) { + ++$summary['warnings']; + } + } + + return $summary; + } + + /** + * @return list + */ + public function allMessages(): array + { + $messages = $this->messages; + + foreach ($this->events as $event) { + foreach ($event->messages as $message) { + $messages[] = $message; + } + } + + return $messages; + } + + public function hasWrites(): bool + { + return array_any($this->events, static fn (PlannedEvent $e): bool => $e->isWrite()); + } +} diff --git a/src/Import/ImportPlanner.php b/src/Import/ImportPlanner.php new file mode 100644 index 0000000..35face8 --- /dev/null +++ b/src/Import/ImportPlanner.php @@ -0,0 +1,247 @@ +missingColumns(RowMapper::REQUIRED_COLUMNS); + + if ([] !== $missing) { + throw new ImportException(\sprintf('Die Datei ist kein W&K-Seminarexport: Die Spalten %s fehlen.', implode(', ', $missing))); + } + + $categories = $this->categoryRepository->findAll(); + $existing = $this->loadExistingEvents($calendar->id); + $messages = $table->warnings; + $events = []; + $seen = []; + + if ([] === $categories) { + $messages[] = ImportMessage::warning('Es sind noch keine W&K-Kategorien angelegt. Alle Seminare würden mangels Kategorie storniert; bitte zuerst die Kategorien pflegen.'); + } + + foreach ($table->rows as $row) { + $record = (int) ($row['__record'] ?? 0); + unset($row['__record']); + + $wkRow = $this->rowMapper->map($row, $record); + + if ($wkRow->skipped) { + $events[] = new PlannedEvent(PlannedEvent::SKIP, $wkRow->wkId, (string) ($row['kbtitel'] ?? ''), 0, [], [], [], 0, '', [], $wkRow->messages, $record); + $seen[$wkRow->wkId] = true; + continue; + } + + if (isset($seen[$wkRow->wkId])) { + $events[] = new PlannedEvent(PlannedEvent::SKIP, $wkRow->wkId, $wkRow->data['title'], 0, [], [], [], 0, '', [], [ + ImportMessage::error(\sprintf('Seminar %s „%s“: Die W&K-Nummer kommt in der Datei mehrfach vor; nur der erste Datensatz wird verwendet.', $wkRow->wkId, $wkRow->data['title']), $wkRow->wkId, $record), + ], $record); + continue; + } + + $seen[$wkRow->wkId] = true; + $events[] = $this->planRow($wkRow, $existing[$wkRow->wkId] ?? null, $categories, $calendar); + } + + // Seminare, die nicht mehr in der Datei stehen, werden storniert + foreach ($existing as $wkId => $row) { + if (isset($seen[$wkId])) { + continue; + } + + if (WkState::isCancelled($row['wkState']) && !$row['published']) { + continue; + } + + $events[] = new PlannedEvent(PlannedEvent::CANCEL, (string) $wkId, (string) $row['title'], (int) $row['id'], [], [ + 'wkState' => [(string) $row['wkState'], WkState::CANCELLED], + 'published' => [(bool) $row['published'], false], + ], [], 0, '', [], [ + ImportMessage::info(\sprintf('Seminar %s „%s“ steht nicht mehr in der W&K-Datei und wird storniert und ausgeblendet.', $wkId, $row['title']), (string) $wkId), + ]); + } + + return new ImportPlan($calendar->id, $events, $messages); + } + + /** + * @param array|null $existing + * @param list $categories + */ + private function planRow(WkRow $row, array|null $existing, array $categories, CalendarConfig $calendar): PlannedEvent + { + $data = $row->data; + $messages = $row->messages; + $wkId = $row->wkId; + $title = (string) $data['title']; + + // Kategorien + $resolved = $this->categoryResolver->resolve($row->categoryText, $categories); + $categoryIds = array_map(static fn (Category $c): int => $c->id, $resolved); + $primary = $categoryIds[0] ?? 0; + + if ([] === $resolved) { + $messages[] = ImportMessage::error('' === trim($row->categoryText) + ? \sprintf('Seminar %s „%s“: Das Feld kbsgebiet enthält keinen Kategorienamen. Das Seminar wird storniert und ausgeblendet.', $wkId, $title) + : \sprintf('Seminar %s „%s“: Für „%s“ (kbsgebiet) gibt es keine W&K-Kategorie. Das Seminar wird storniert und ausgeblendet.', $wkId, $title, $row->categoryText), $wkId, $row->line); + $data['wkState'] = WkState::CANCELLED; + } + + $cancelled = WkState::isCancelled($data['wkState']); + + // Personen (nur nachschlagen; angelegt wird beim Anwenden) + $instructorId = '' !== $row->instructorName ? $this->memberResolver->findId($row->instructorName) : null; + $speakerIds = []; + + foreach ($row->speakerNames as $name) { + $speakerIds[] = $this->memberResolver->findId($name); + } + + if (null === $existing) { + $data['published'] = !$cancelled; + + if ('' !== $row->instructorName && null === $instructorId && $calendar->instructorGroup < 1) { + $messages[] = ImportMessage::info(\sprintf('Seminar %s „%s“: Kursleitung „%s“ ist kein Mitglied; ohne Mitgliedergruppe im Kalender wird kein Mitglied angelegt.', $wkId, $title, $row->instructorName), $wkId, $row->line); + } + + return new PlannedEvent(PlannedEvent::CREATE, $wkId, $title, 0, $data, [], $categoryIds, $primary, $row->instructorName, $row->speakerNames, $messages, $row->line); + } + + $changes = []; + + foreach (self::COMPARED_FIELDS as $field) { + if (!\array_key_exists($field, $data)) { + continue; + } + + $old = $this->normalize($field, $existing[$field] ?? null); + $new = $this->normalize($field, $data[$field]); + + if ($old !== $new) { + $changes[$field] = [$existing[$field] ?? null, $data[$field]]; + } + } + + // Veröffentlichung folgt nur einem Statuswechsel, damit manuelles Ausblenden erhalten bleibt + $wasCancelled = WkState::isCancelled($existing['wkState']); + $published = (bool) $existing['published']; + + if ($cancelled && $published) { + $data['published'] = false; + $changes['published'] = [true, false]; + } elseif (!$cancelled && $wasCancelled && !$published) { + $data['published'] = true; + $changes['published'] = [false, true]; + } + + $existingCategories = array_map(intval(...), StringUtil::deserialize($existing['wkCategories'] ?? null, true)); + sort($existingCategories); + $sortedNew = $categoryIds; + sort($sortedNew); + + if ($existingCategories !== $sortedNew) { + $changes['wkCategories'] = [$existingCategories, $categoryIds]; + } + + if ((int) $existing['wkCategoryPrimary'] !== $primary) { + $changes['wkCategoryPrimary'] = [(int) $existing['wkCategoryPrimary'], $primary]; + } + + $existingInstructor = (int) $existing['wkInstructor']; + + if (('' !== $row->instructorName || $existingInstructor > 0) && $existingInstructor !== (int) $instructorId) { + $changes['wkInstructor'] = [$this->memberName($existingInstructor), $row->instructorName]; + } + + $existingSpeakers = array_map(intval(...), StringUtil::deserialize($existing['wkSpeakers'] ?? null, true)); + + if ($existingSpeakers !== array_map(static fn (int|null $id): int => (int) $id, $speakerIds) && ([] !== $existingSpeakers || [] !== $row->speakerNames)) { + $changes['wkSpeakers'] = [implode(', ', array_values($this->memberResolver->namesByIds($existingSpeakers))), implode(', ', $row->speakerNames)]; + } + + $action = [] === $changes ? PlannedEvent::UNCHANGED : PlannedEvent::UPDATE; + + return new PlannedEvent($action, $wkId, $title, (int) $existing['id'], $data, $changes, $categoryIds, $primary, $row->instructorName, $row->speakerNames, $messages, $row->line); + } + + /** + * @return array> W&K-Nummer => Zeile + */ + private function loadExistingEvents(int $calendarId): array + { + $columns = array_unique(['id', 'wkId', 'published', 'wkCategories', 'wkCategoryPrimary', 'wkInstructor', 'wkSpeakers', ...self::COMPARED_FIELDS]); + $sql = \sprintf('SELECT %s FROM tl_calendar_events WHERE pid = ? AND wkId != "" ORDER BY id', implode(', ', array_map(static fn (string $c): string => "`$c`", $columns))); + + $events = []; + + foreach ($this->connection->fetchAllAssociative($sql, [$calendarId]) as $row) { + // Bei Dubletten im Bestand gewinnt der älteste Datensatz + $events[(string) $row['wkId']] ??= $row; + } + + return $events; + } + + private function normalize(string $field, mixed $value): string + { + if (str_starts_with($field, 'wkPrice') && !str_starts_with($field, 'wkPriceType')) { + return number_format((float) $value, 2, '.', ''); + } + + if (\in_array($field, ['startDate', 'endDate', 'startTime', 'endTime', 'wkEventType', 'wkMaxPlaces', 'wkPlacesLeft'], true)) { + return (string) (int) $value; + } + + if ('addTime' === $field) { + return $value ? '1' : '0'; + } + + return trim(str_replace("\r\n", "\n", (string) $value)); + } + + private function memberName(int $id): string + { + if ($id < 1) { + return ''; + } + + return $this->memberResolver->namesByIds([$id])[$id] ?? (string) $id; + } +} diff --git a/src/Import/ImportService.php b/src/Import/ImportService.php new file mode 100644 index 0000000..3736df2 --- /dev/null +++ b/src/Import/ImportService.php @@ -0,0 +1,144 @@ +requireCalendar($calendarId); + $plan = $this->planFile($relativePath, $calendar); + + $logId = $this->log->create( + SyncLog::TYPE_IMPORT, + SyncLog::STATUS_PREVIEW, + $userId, + $calendarId, + $filename, + $relativePath, + ['plan' => $plan->summary(), 'rows' => \count($plan->events)], + array_map(static fn (ImportMessage $m): array => $m->toArray(), $plan->allMessages()), + ); + + return ['logId' => $logId, 'plan' => $plan]; + } + + /** + * Vorschau eines Protokolleintrags neu berechnen. + * + * @return array{log: array, plan: ImportPlan, calendar: CalendarConfig} + */ + public function preview(int $logId): array + { + $log = $this->requireLog($logId); + $calendar = $this->requireCalendar((int) $log['calendar']); + + return ['log' => $log, 'plan' => $this->planFile((string) $log['path'], $calendar), 'calendar' => $calendar]; + } + + /** + * @return array{plan: ImportPlan, result: array{created: int, updated: int, cancelled: int, unchanged: int, members: int}} + */ + public function apply(int $logId, int $userId): array + { + $log = $this->requireLog($logId); + + if (SyncLog::STATUS_PREVIEW !== $log['status']) { + throw new ImportException('Dieser Import wurde bereits angewendet oder ist fehlgeschlagen.'); + } + + $calendar = $this->requireCalendar((int) $log['calendar']); + $plan = $this->planFile((string) $log['path'], $calendar); + + try { + $result = $this->executor->execute($plan, $calendar, $userId); + } catch (\Throwable $e) { + $this->log->update($logId, [ + 'status' => SyncLog::STATUS_FAILED, + 'user' => $userId, + 'messages' => [ImportMessage::error('Der Import ist fehlgeschlagen: '.$e->getMessage())->toArray()], + ]); + + $this->contaoGeneralLogger?->error(\sprintf('W&K-Import #%d fehlgeschlagen: %s', $logId, $e->getMessage())); + + throw new ImportException('Der Import ist fehlgeschlagen. Es wurden keine Änderungen gespeichert. ('.$e->getMessage().')', 0, $e); + } + + $this->log->update($logId, [ + 'status' => SyncLog::STATUS_APPLIED, + 'user' => $userId, + 'summary' => ['plan' => $plan->summary(), 'result' => $result, 'rows' => \count($plan->events)], + 'messages' => array_map(static fn (ImportMessage $m): array => $m->toArray(), $plan->allMessages()), + ]); + + $this->contaoGeneralLogger?->info(\sprintf( + 'W&K-Import #%d in Kalender "%s": %d neu, %d geändert, %d storniert, %d unverändert.', + $logId, $calendar->title, $result['created'], $result['updated'], $result['cancelled'], $result['unchanged'], + )); + + return ['plan' => $plan, 'result' => $result]; + } + + public function planFile(string $relativePath, CalendarConfig $calendar): ImportPlan + { + if (!$this->storage->exists($relativePath)) { + throw new ImportException('Die Importdatei ist nicht mehr vorhanden. Bitte die Datei erneut hochladen.'); + } + + $table = $this->csvReader->read($this->storage->absolutePath($relativePath)); + + return $this->planner->plan($table, $calendar); + } + + private function requireCalendar(int $calendarId): CalendarConfig + { + $calendar = $this->calendars->find($calendarId); + + if (null === $calendar || !$calendar->enabled) { + throw new ImportException('Der Kalender ist nicht für den W&K-Sync freigegeben.'); + } + + return $calendar; + } + + /** + * @return array + */ + private function requireLog(int $logId): array + { + $log = $this->log->find($logId); + + if (null === $log || SyncLog::TYPE_IMPORT !== $log['type']) { + throw new ImportException('Der Importvorgang wurde nicht gefunden.'); + } + + return $log; + } +} diff --git a/src/Import/MemberResolver.php b/src/Import/MemberResolver.php new file mode 100644 index 0000000..7630ab2 --- /dev/null +++ b/src/Import/MemberResolver.php @@ -0,0 +1,110 @@ + */ + private array $lookup = []; + + public function __construct(private readonly Connection $connection) + { + } + + /** + * ID eines vorhandenen Mitglieds mit diesem Namen, ohne es anzulegen. + */ + public function findId(string $name): int|null + { + $name = trim($name); + + if ('' === $name) { + return null; + } + + $key = mb_strtolower($name); + + if (!\array_key_exists($key, $this->lookup)) { + $id = $this->connection->fetchOne('SELECT id FROM tl_member WHERE lastname = ? ORDER BY id LIMIT 1', [$name]); + $this->lookup[$key] = false === $id ? null : (int) $id; + } + + return $this->lookup[$key]; + } + + /** + * ID des Mitglieds; legt es bei Bedarf an und ergänzt die Mitgliedergruppe. + * + * @return array{0: int|null, 1: bool} ID und ob neu angelegt + */ + public function resolve(string $name, int $groupId): array + { + $name = trim($name); + + if ('' === $name) { + return [null, false]; + } + + $id = $this->findId($name); + $created = false; + + if (null === $id) { + $this->connection->insert('tl_member', [ + 'tstamp' => time(), + 'dateAdded' => time(), + 'firstname' => '', + 'lastname' => $name, + 'groups' => serialize($groupId > 0 ? [(string) $groupId] : []), + ]); + + $id = (int) $this->connection->lastInsertId(); + $this->lookup[mb_strtolower($name)] = $id; + $created = true; + } elseif ($groupId > 0) { + $this->ensureGroup($id, $groupId); + } + + return [$id, $created]; + } + + /** + * @param list $ids + * + * @return array ID => Name + */ + public function namesByIds(array $ids): array + { + $ids = array_values(array_filter(array_map(intval(...), $ids))); + + if ([] === $ids) { + return []; + } + + $rows = $this->connection->fetchAllKeyValue('SELECT id, TRIM(CONCAT(lastname, " ", firstname)) FROM tl_member WHERE id IN (?)', [$ids], [ArrayParameterType::INTEGER]); + + return array_map(strval(...), array_combine(array_map(intval(...), array_keys($rows)), array_values($rows))); + } + + private function ensureGroup(int $memberId, int $groupId): void + { + $groups = StringUtil::deserialize($this->connection->fetchOne('SELECT `groups` FROM tl_member WHERE id = ?', [$memberId]), true); + $groups = array_map(strval(...), $groups); + + if (\in_array((string) $groupId, $groups, true)) { + return; + } + + $groups[] = (string) $groupId; + $this->connection->update('tl_member', ['groups' => serialize(array_values($groups)), 'tstamp' => time()], ['id' => $memberId]); + } +} diff --git a/src/Import/PlannedEvent.php b/src/Import/PlannedEvent.php new file mode 100644 index 0000000..b8e0594 --- /dev/null +++ b/src/Import/PlannedEvent.php @@ -0,0 +1,45 @@ + $data Zielwerte (Spalten von tl_calendar_events) + * @param array $changes Geänderte Felder: Name => [alt, neu] + * @param list $categories IDs der zugeordneten Kategorien + * @param list $speakerNames + * @param list $messages + */ + public function __construct( + public readonly string $action, + public readonly string $wkId, + public readonly string $title, + public readonly int $existingId = 0, + public readonly array $data = [], + public readonly array $changes = [], + public readonly array $categories = [], + public readonly int $primaryCategory = 0, + public readonly string $instructorName = '', + public readonly array $speakerNames = [], + public array $messages = [], + public readonly int $line = 0, + ) { + } + + public function isWrite(): bool + { + return \in_array($this->action, [self::CREATE, self::UPDATE, self::CANCEL], true); + } +} diff --git a/src/Import/RowMapper.php b/src/Import/RowMapper.php new file mode 100644 index 0000000..092e343 --- /dev/null +++ b/src/Import/RowMapper.php @@ -0,0 +1,292 @@ + Feld in tl_calendar_events (einfache Textfelder). + */ + public const array TEXT_COLUMNS = [ + 'kbkdab' => 'wkOrganizer', + 'kbkdab2' => 'wkOrganizerAdditional', + 'kbkdan' => 'wkOrganizerSalutation', + 'kbkdtitel' => 'wkOrganizerAcademicTitle', + 'kbkdn1' => 'wkOrganizerLastname', + 'kbkdn2' => 'wkOrganizerFirstname', + 'kbkdstr' => 'wkOrganizerStreet', + 'kbkdlkz' => 'wkOrganizerCountryCode', + 'kbkdplz' => 'wkOrganizerPostal', + 'kbkdort' => 'wkOrganizerCity', + 'kbtitel' => 'title', + 'kbutitel' => 'wkSubTitle', + 'kbrtitel' => 'wkSubTitleAdditional', + 'kbztermin' => 'wkAdditionalDates', + 'kbktxp1' => 'wkPriceType1', + 'kbktxp2' => 'wkPriceType2', + 'kbktxp3' => 'wkPriceType3', + 'kbktxp4' => 'wkPriceType4', + 'kbktxp5' => 'wkPriceType5', + 'kbktxp6' => 'wkPriceType6', + 'kbvort' => 'location', + 'kbgruppe' => 'wkAudience', + 'kbsgebiet' => 'wkCategoryText', + ]; + + public const array PRICE_COLUMNS = [ + 'kbkp1' => 'wkPrice1', + 'kbkp2' => 'wkPrice2', + 'kbkp3' => 'wkPrice3', + 'kbkp4' => 'wkPrice4', + 'kbkp5' => 'wkPrice5', + 'kbkp6' => 'wkPrice6', + ]; + + /** + * Spalten, ohne die die Datei nicht als W&K-Export erkannt wird. + */ + public const array REQUIRED_COLUMNS = ['kbnr', 'kbtitel', 'kbanreise', 'kbabreise', 'kbstatus1', 'kbsgebiet', 'kbvsnr']; + + public const string COLUMN_ID = 'kbnr'; + public const string COLUMN_START_DATE = 'kbanreise'; + public const string COLUMN_END_DATE = 'kbabreise'; + public const string COLUMN_START_TIME = 'kbuanreise'; + public const string COLUMN_END_TIME = 'kbuabreise'; + public const string COLUMN_STATE = 'kbstatus1'; + public const string COLUMN_TAGS = 'kbwmemo'; + public const string COLUMN_TEASER = 'kbpmemo'; + public const string COLUMN_TOPICS = 'kbzusatz'; + public const string COLUMN_INSTRUCTOR = 'kbkln1'; + public const string COLUMN_MAX_PLACES = 'kbanzmax'; + public const string COLUMN_RESERVED = 'kbanztat'; + public const string COLUMN_INTERNAL_NUMBER = 'kbvsnr'; + public const string COLUMN_SPEAKERS = 'kbmref'; + public const string COLUMN_EMAIL = 'kbkdemail'; + public const string COLUMN_EVENT_TYPE = 'kbvsart'; + + public function __construct(private readonly TextFormatter $textFormatter = new TextFormatter()) + { + } + + /** + * @param array $row + */ + public function map(array $row, int $record): WkRow + { + $get = static fn (string $column): string => trim((string) ($row[$column] ?? '')); + + $wkId = $get(self::COLUMN_ID); + $title = $get('kbtitel'); + $messages = []; + + $skip = function (string $text, string $type = ImportMessage::ERROR) use ($wkId, $record, &$messages): WkRow { + $messages[] = new ImportMessage($type, $text, $wkId, $record); + + return new WkRow($record, $wkId, [], '', '', [], $messages, true); + }; + + if ('' === $wkId || !preg_match('/^0\d+$/', $wkId)) { + return $skip(\sprintf('Datensatz %d: Die W&K-Nummer „%s“ fehlt oder beginnt nicht mit einer führenden Null. Das Seminar wird nicht importiert.', $record, $wkId)); + } + + if ('' === $get(self::COLUMN_INTERNAL_NUMBER)) { + return $skip(\sprintf('Seminar %s „%s“: Das Feld kbvsnr ist leer, das Seminar wird nicht veröffentlicht (übersprungen).', $wkId, $title), ImportMessage::INFO); + } + + if ('' === $title) { + return $skip(\sprintf('Seminar %s: Der Titel (kbtitel) ist leer. Das Seminar wird nicht importiert.', $wkId)); + } + + $startDate = $this->parseDate($get(self::COLUMN_START_DATE)); + + if (null === $startDate) { + return $skip(\sprintf('Seminar %s „%s“: Das Beginndatum „%s“ ist ungültig. Das Seminar wird nicht importiert.', $wkId, $title, $get(self::COLUMN_START_DATE))); + } + + $endDate = $this->parseDate($get(self::COLUMN_END_DATE)); + + if (null === $endDate) { + if ('' !== $this->digitsOnly($get(self::COLUMN_END_DATE))) { + $messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: Das Enddatum „%s“ ist ungültig; das Beginndatum wird verwendet.', $wkId, $title, $get(self::COLUMN_END_DATE)), $wkId, $record); + } + + $endDate = $startDate; + } elseif ($endDate < $startDate) { + $messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: Das Enddatum liegt vor dem Beginndatum; das Beginndatum wird verwendet.', $wkId, $title), $wkId, $record); + $endDate = $startDate; + } + + $startTime = $this->parseTime($get(self::COLUMN_START_TIME)); + $endTime = $this->parseTime($get(self::COLUMN_END_TIME)); + + $data = [ + 'wkId' => $wkId, + 'title' => $title, + ]; + + foreach (self::TEXT_COLUMNS as $column => $field) { + $data[$field] = $get($column); + } + + // Datum und Uhrzeit wie Contao selbst (tl_calendar_events::adjustTime) + $data['startDate'] = $startDate->getTimestamp(); + $data['endDate'] = $endDate->getTimestamp(); + $data['addTime'] = null !== $startTime; + + if (null !== $startTime) { + $data['startTime'] = $startDate->setTime($startTime[0], $startTime[1])->getTimestamp(); + $data['endTime'] = null !== $endTime + ? $endDate->setTime($endTime[0], $endTime[1])->getTimestamp() + : $endDate->setTime($startTime[0], $startTime[1])->getTimestamp(); + + if ($data['endTime'] < $data['startTime']) { + $data['endTime'] = $data['startTime']; + } + } else { + $data['startTime'] = $startDate->getTimestamp(); + $data['endTime'] = $endDate->modify('+1 day')->getTimestamp() - 1; + } + + // Status + $state = $get(self::COLUMN_STATE); + + if (!\in_array($state, WkState::values(), true)) { + $messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: Unbekannter Status „%s“.', $wkId, $title, $state), $wkId, $record); + $state = ''; + } + + $data['wkState'] = $state; + + // Interne Veranstaltungsnummer: „keine“ bedeutet keine Nummer + $internal = $get(self::COLUMN_INTERNAL_NUMBER); + $data['wkInternalEventNumber'] = 'keine' === mb_strtolower($internal) ? '' : $internal; + + // Veranstaltungsart und Plätze + $data['wkEventType'] = (int) $this->digitsOnly($get(self::COLUMN_EVENT_TYPE)); + $maxPlaces = (int) $this->digitsOnly($get(self::COLUMN_MAX_PLACES)); + $reserved = (int) $this->digitsOnly($get(self::COLUMN_RESERVED)); + $data['wkMaxPlaces'] = $maxPlaces; + $data['wkPlacesLeft'] = max(0, $maxPlaces - $reserved); + + // E-Mail des Hauptverantwortlichen + $email = $get(self::COLUMN_EMAIL); + + if ('' !== $email && !Validator::isEmail($email) && !self::isAbsoluteUrl($email)) { + $messages[] = ImportMessage::warning(\sprintf('Seminar %s „%s“: „%s“ ist weder eine gültige E-Mail-Adresse noch eine URL; das Feld bleibt leer.', $wkId, $title, $email), $wkId, $record); + $email = ''; + } + + $data['wkOrganizerEmail'] = $email; + + // Preise + foreach (self::PRICE_COLUMNS as $column => $field) { + $data[$field] = $this->parsePrice($get($column)); + } + + // Texte + $data['teaser'] = $this->textFormatter->paragraphs($get(self::COLUMN_TEASER)); + $data['wkTopics'] = $this->textFormatter->topics($get(self::COLUMN_TOPICS)); + $data['wkTags'] = $this->normalizeList($get(self::COLUMN_TAGS)); + + // Personen + $instructor = $this->cleanName($get(self::COLUMN_INSTRUCTOR)); + $speakers = []; + + foreach (preg_split('/\r?\n/', $get(self::COLUMN_SPEAKERS)) ?: [] as $speaker) { + $speaker = $this->cleanName($speaker); + + if ('' !== $speaker && !\in_array($speaker, $speakers, true)) { + $speakers[] = $speaker; + } + } + + return new WkRow($record, $wkId, $data, $get('kbsgebiet'), $instructor, $speakers, $messages); + } + + /** + * Veranstaltungsart 3 verweist über dieses Feld auch auf externe Anmeldeseiten. + */ + public static function isAbsoluteUrl(string $value): bool + { + return (bool) preg_match('#^https?://#i', $value) && Validator::isUrl($value); + } + + private function parseDate(string $value): \DateTimeImmutable|null + { + if (!preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/', $value, $m)) { + return null; + } + + if (!checkdate((int) $m[2], (int) $m[1], (int) $m[3])) { + return null; + } + + return (new \DateTimeImmutable())->setDate((int) $m[3], (int) $m[2], (int) $m[1])->setTime(0, 0); + } + + /** + * @return array{0: int, 1: int}|null Stunde und Minute + */ + private function parseTime(string $value): array|null + { + if (!preg_match('/^(\d{1,2})[.:](\d{2})$/', $value, $m)) { + return null; + } + + $hour = (int) $m[1]; + $minute = (int) $m[2]; + + if ($hour > 23 || $minute > 59) { + return null; + } + + return [$hour, $minute]; + } + + private function parsePrice(string $value): float + { + $value = str_replace(['€', ' '], '', $value); + + if ('' === $value) { + return 0.0; + } + + // „1.250,00“ oder „12,50“ oder „12.50“ + if (str_contains($value, ',')) { + $value = str_replace('.', '', $value); + $value = str_replace(',', '.', $value); + } + + return is_numeric($value) ? round((float) $value, 2) : 0.0; + } + + private function normalizeList(string $value): string + { + $items = array_filter(array_map(trim(...), preg_split('/[,\n]/', $value) ?: []), static fn (string $i): bool => '' !== $i); + + return implode(', ', array_values(array_unique($items))); + } + + private function cleanName(string $name): string + { + $name = trim(preg_replace('/\s+/u', ' ', $name) ?? $name); + + if ('' === $name || preg_match('/^n\.\s*n\.?$/iu', $name)) { + return ''; + } + + return $name; + } + + private function digitsOnly(string $value): string + { + return preg_replace('/\D+/', '', $value) ?? ''; + } +} diff --git a/src/Import/SyncFileStorage.php b/src/Import/SyncFileStorage.php new file mode 100644 index 0000000..8d0b7ee --- /dev/null +++ b/src/Import/SyncFileStorage.php @@ -0,0 +1,109 @@ +buildName($file->getClientOriginalName(), 'csv'); + $dir = $this->directory($kind); + $file->move($dir, $name); + + return $kind.'/'.$name; + } + + public function store(string $content, string $kind, string $originalName): string + { + $name = $this->buildName($originalName, 'csv'); + $this->filesystem->dumpFile($this->directory($kind).'/'.$name, $content); + + return $kind.'/'.$name; + } + + public function absolutePath(string $relativePath): string + { + $path = Path::canonicalize($this->baseDir().'/'.$relativePath); + + if (!Path::isBasePath($this->baseDir(), $path)) { + throw new \InvalidArgumentException('Ungültiger Dateipfad.'); + } + + return $path; + } + + public function exists(string $relativePath): bool + { + return '' !== $relativePath && is_file($this->absolutePath($relativePath)); + } + + /** + * Löscht Dateien, die älter als die angegebene Anzahl Tage sind. + */ + public function purge(int $days = 90): int + { + $deleted = 0; + $limit = time() - $days * 86400; + + foreach ([self::KIND_IMPORT, self::KIND_EXPORT] as $kind) { + $dir = $this->baseDir().'/'.$kind; + + if (!is_dir($dir)) { + continue; + } + + foreach (new \DirectoryIterator($dir) as $file) { + if ($file->isFile() && $file->getMTime() < $limit) { + $this->filesystem->remove($file->getPathname()); + ++$deleted; + } + } + } + + return $deleted; + } + + private function directory(string $kind): string + { + if (!\in_array($kind, [self::KIND_IMPORT, self::KIND_EXPORT], true)) { + throw new \InvalidArgumentException(\sprintf('Unbekannte Ablage "%s".', $kind)); + } + + $dir = $this->baseDir().'/'.$kind; + $this->filesystem->mkdir($dir, 0o775); + + return $dir; + } + + private function baseDir(): string + { + return Path::canonicalize($this->projectDir.'/var/wk-sync'); + } + + private function buildName(string $originalName, string $extension): string + { + $base = pathinfo($originalName, PATHINFO_FILENAME); + $base = preg_replace('/[^A-Za-z0-9_-]+/', '_', $base) ?? 'datei'; + $base = trim(substr($base, 0, 60), '_') ?: 'datei'; + + return date('Ymd-His').'_'.$base.'.'.$extension; + } +} diff --git a/src/Import/TextFormatter.php b/src/Import/TextFormatter.php new file mode 100644 index 0000000..ff56ad7 --- /dev/null +++ b/src/Import/TextFormatter.php @@ -0,0 +1,80 @@ +, einfache Zeilenumbrüche zu
. + */ + public function paragraphs(string $text): string + { + $text = trim(str_replace("\r\n", "\n", $text)); + + if ('' === $text) { + return ''; + } + + $blocks = preg_split('/\n{2,}/', $text) ?: []; + $html = []; + + foreach ($blocks as $block) { + $block = trim($block); + + if ('' === $block) { + continue; + } + + $lines = array_map(static fn (string $l): string => self::escape(trim($l)), explode("\n", $block)); + $html[] = '

'.implode('
', $lines).'

'; + } + + return implode("\n", $html); + } + + /** + * Schwerpunkte: Zeilen, die mit „-“ beginnen, werden zu einer Liste, sonst Absätze. + */ + public function topics(string $text): string + { + $text = trim(str_replace("\r\n", "\n", $text)); + + if ('' === $text) { + return ''; + } + + $lines = array_values(array_filter(array_map(trim(...), explode("\n", $text)), static fn (string $l): bool => '' !== $l)); + $bullets = array_filter($lines, static fn (string $l): bool => str_starts_with($l, '-') || str_starts_with($l, '•')); + + if ([] !== $lines && \count($bullets) >= max(1, (int) ceil(\count($lines) / 2))) { + $items = []; + $intro = []; + + foreach ($lines as $line) { + if (str_starts_with($line, '-') || str_starts_with($line, '•')) { + $items[] = '
  • '.self::escape(trim(ltrim($line, '-• '))).'
  • '; + } elseif ([] === $items) { + $intro[] = '

    '.self::escape($line).'

    '; + } else { + // Fortsetzungszeile eines Listenpunkts + $last = array_key_last($items); + $items[$last] = substr($items[$last], 0, -5).'
    '.self::escape($line).''; + } + } + + return implode("\n", [...$intro, '
      '.implode('', $items).'
    ']); + } + + return $this->paragraphs($text); + } + + public static function escape(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); + } +} diff --git a/src/Import/WkRow.php b/src/Import/WkRow.php new file mode 100644 index 0000000..29fab72 --- /dev/null +++ b/src/Import/WkRow.php @@ -0,0 +1,33 @@ + $data Spalten von tl_calendar_events mit normalisierten Werten + * @param list $messages + * @param list $speakerNames + */ + public function __construct( + public int $line, + public string $wkId, + public array $data, + public string $categoryText, + public string $instructorName, + public array $speakerNames, + public array $messages = [], + public bool $skipped = false, + ) { + } + + public function hasErrors(): bool + { + return array_any($this->messages, static fn (ImportMessage $m): bool => ImportMessage::ERROR === $m->type); + } +} diff --git a/src/Import/WkState.php b/src/Import/WkState.php new file mode 100644 index 0000000..f85f26d --- /dev/null +++ b/src/Import/WkState.php @@ -0,0 +1,30 @@ + + */ + public static function values(): array + { + return [self::INQUIRY, self::BOOKING, self::CHECK_IN, self::CHECK_OUT, self::CANCELLED]; + } + + public static function isCancelled(string|int|null $state): bool + { + return self::CANCELLED === (string) $state; + } +} diff --git a/src/Inbox/InboxItem.php b/src/Inbox/InboxItem.php new file mode 100644 index 0000000..da50f96 --- /dev/null +++ b/src/Inbox/InboxItem.php @@ -0,0 +1,51 @@ + $addressLines + * @param list $dataLines + * @param array $row + */ + public function __construct( + public string $type, + public int $id, + public int $dateCreated, + public InboxState $state, + public string $name, + public array $addressLines, + public array $dataLines, + public string $email, + public int $eventId, + public string $eventTitle, + public string $eventWkId, + public bool $duplicate, + public array $row, + ) { + } + + public function isBooking(): bool + { + return self::TYPE_BOOKING === $this->type; + } + + public function table(): string + { + return $this->isBooking() ? 'tl_wk_booking' : 'tl_wk_inquiry'; + } + + public function key(): string + { + return $this->type.':'.$this->id; + } +} diff --git a/src/Inbox/InboxRepository.php b/src/Inbox/InboxRepository.php new file mode 100644 index 0000000..0e6d59c --- /dev/null +++ b/src/Inbox/InboxRepository.php @@ -0,0 +1,206 @@ + älteste zuerst + */ + public function findByState(InboxState $state, int $limit = 0): array + { + $items = []; + + $bookings = $this->connection->fetchAllAssociative( + 'SELECT b.*, e.title AS eventTitle, e.wkId AS eventWkId, + (SELECT COUNT(*) FROM tl_wk_booking d WHERE d.pid = b.pid AND d.email = b.email AND d.id != b.id AND d.state != ?) AS duplicates + FROM tl_wk_booking b LEFT JOIN tl_calendar_events e ON e.id = b.pid + WHERE b.state = ? ORDER BY b.dateCreated DESC, b.id DESC'.($limit > 0 ? ' LIMIT '.$limit : ''), + [InboxState::Spam->value, $state->value], + ); + + foreach ($bookings as $row) { + $items[] = $this->bookingItem($row); + } + + $inquiries = $this->connection->fetchAllAssociative( + 'SELECT i.*, e.title AS eventTitle, e.wkId AS eventWkId + FROM tl_wk_inquiry i LEFT JOIN tl_calendar_events e ON e.id = i.event + WHERE i.state = ? ORDER BY i.dateCreated DESC, i.id DESC'.($limit > 0 ? ' LIMIT '.$limit : ''), + [$state->value], + ); + + foreach ($inquiries as $row) { + $items[] = $this->inquiryItem($row); + } + + usort($items, static fn (InboxItem $a, InboxItem $b): int => [$a->dateCreated, $a->id] <=> [$b->dateCreated, $b->id]); + + if ($limit > 0) { + $items = \array_slice($items, -$limit); + } + + return $items; + } + + /** + * @return array Status => Anzahl (Anmeldungen und Anfragen zusammen) + */ + public function countByState(): array + { + $counts = []; + + foreach (InboxState::cases() as $state) { + $counts[$state->value] = 0; + } + + foreach (['tl_wk_booking', 'tl_wk_inquiry'] as $table) { + foreach ($this->connection->fetchAllKeyValue("SELECT state, COUNT(*) FROM $table GROUP BY state") as $state => $count) { + $counts[(string) $state] = ($counts[(string) $state] ?? 0) + (int) $count; + } + } + + return $counts; + } + + /** + * @param list $ids + * + * @return int Anzahl verschobener Datensätze + */ + public function move(string $type, array $ids, InboxState $target): int + { + $ids = array_values(array_filter(array_map(intval(...), $ids))); + + if ([] === $ids) { + return 0; + } + + $table = InboxItem::TYPE_BOOKING === $type ? 'tl_wk_booking' : 'tl_wk_inquiry'; + + // Exportierte Datensätze bleiben exportiert, exportiert wird nur über den Export selbst + return (int) $this->connection->executeStatement( + "UPDATE $table SET state = ?, tstamp = ? WHERE id IN (?) AND state != ?", + [$target->value, time(), $ids, InboxState::Exported->value], + [null, null, ArrayParameterType::INTEGER, null], + ); + } + + /** + * @param array $row + */ + private function bookingItem(array $row): InboxItem + { + $t = fn (string $key): string => $this->translator->trans($key, [], 'contao_tl_wk_booking'); + $ref = fn (string $reference, string $value): string => '' === $value ? '-' : $this->translator->trans('tl_wk_booking.'.$reference.'.'.$value, [], 'contao_tl_wk_booking'); + + $address = array_values(array_filter([ + trim(($row['academicTitle'] ? $row['academicTitle'].' ' : '').$row['firstname'].' '.$row['lastname']), + (string) $row['street'], + trim($row['postal'].' '.$row['city']), + '' !== (string) $row['phone'] ? $t('tl_wk_booking.phone.0').': '.$row['phone'] : '', + (string) $row['email'], + ])); + + $data = [ + $t('tl_wk_booking.birthday.0').': '.($row['birthday'] ?: '-'), + $t('tl_wk_booking.position.0').': '.$ref('positionRef', (string) $row['position']), + $t('tl_wk_booking.overnightStay.0').': '.$ref('overnightStayRef', (string) $row['overnightStay']), + $t('tl_wk_booking.vegetarian.0').': '.$this->translator->trans($row['vegetarian'] ? 'MSC.yes' : 'MSC.no', [], 'contao_default'), + ]; + + if ('' !== (string) $row['teacherPosition']) { + $data[] = $t('tl_wk_booking.teacherPosition.0').': '.$ref('teacherPositionRef', (string) $row['teacherPosition']); + + if ('' !== trim((string) $row['schoolName'].(string) $row['schoolCity'])) { + $data[] = $t('tl_wk_booking.schoolName.0').': '.trim($row['schoolName'].', '.$row['schoolCity'], ', '); + } + } + + if ($row['addDifferentInvoiceAddress']) { + $data[] = $t('tl_wk_booking.addDifferentInvoiceAddress.0').': '.trim(implode(', ', array_filter([ + $row['invoiceInstitution'], + trim($row['invoiceFirstname'].' '.$row['invoiceLastname']), + $row['invoiceStreet'], + trim($row['invoicePostal'].' '.$row['invoiceCity']), + ]))); + } + + if ($row['addNotes'] && '' !== trim((string) $row['notes'])) { + $data[] = $t('tl_wk_booking.notes.0').': '.trim((string) $row['notes']); + } + + if ($row['newsletter']) { + $data[] = $t('tl_wk_booking.newsletter.0'); + } + + return new InboxItem( + InboxItem::TYPE_BOOKING, + (int) $row['id'], + (int) $row['dateCreated'], + InboxState::tryFrom((string) $row['state']) ?? InboxState::New, + trim($row['firstname'].' '.$row['lastname']), + $address, + $data, + (string) $row['email'], + (int) $row['pid'], + (string) ($row['eventTitle'] ?? ''), + (string) ($row['eventWkId'] ?? ''), + (int) ($row['duplicates'] ?? 0) > 0, + $row, + ); + } + + /** + * @param array $row + */ + private function inquiryItem(array $row): InboxItem + { + $t = fn (string $key): string => $this->translator->trans($key, [], 'contao_tl_wk_booking'); + + $address = array_values(array_filter([ + trim(($row['academicTitle'] ? $row['academicTitle'].' ' : '').$row['firstname'].' '.$row['lastname']), + (string) $row['street'], + trim($row['postal'].' '.$row['city']), + '' !== (string) $row['phone'] ? $t('tl_wk_booking.phone.0').': '.$row['phone'] : '', + (string) $row['email'], + ])); + + $data = array_values(array_filter([ + (string) $row['subject'], + trim((string) $row['notes']), + $row['newsletter'] ? $t('tl_wk_booking.newsletter.0') : '', + ])); + + return new InboxItem( + InboxItem::TYPE_INQUIRY, + (int) $row['id'], + (int) $row['dateCreated'], + InboxState::tryFrom((string) $row['state']) ?? InboxState::New, + trim($row['firstname'].' '.$row['lastname']), + $address, + $data, + (string) $row['email'], + (int) $row['event'], + (string) ($row['eventTitle'] ?? ''), + (string) ($row['eventWkId'] ?? ''), + false, + $row, + ); + } +} diff --git a/src/Inbox/InboxState.php b/src/Inbox/InboxState.php new file mode 100644 index 0000000..505c17c --- /dev/null +++ b/src/Inbox/InboxState.php @@ -0,0 +1,62 @@ + + */ + public static function values(): array + { + return array_values(array_filter( + array_map(static fn (self $s): string => $s->value, self::cases()), + static fn (string $v): bool => '' !== $v, + )); + } + + public function isOpen(): bool + { + return \in_array($this, [self::New, self::DownloadList], true); + } + + public function isFinal(): bool + { + return \in_array($this, [self::Processed, self::Spam, self::Exported], true); + } + + /** + * Ordner, in die ein Datensatz aus dem aktuellen Ordner verschoben werden darf. + * + * @return list + */ + public function moveTargets(): array + { + if (self::Exported === $this) { + return []; + } + + return array_values(array_filter( + [self::New, self::DownloadList, self::Processed, self::Spam], + fn (self $s): bool => $s !== $this, + )); + } + + public function translationKey(): string + { + return '' === $this->value ? 'new' : $this->value; + } +} diff --git a/src/Sync/CalendarConfig.php b/src/Sync/CalendarConfig.php new file mode 100644 index 0000000..4f05976 --- /dev/null +++ b/src/Sync/CalendarConfig.php @@ -0,0 +1,94 @@ + $ownVenues + * @param list $newsletterChannels + * @param array $conditionNotifications Bedingung => Benachrichtigungs-ID + */ + public function __construct( + public int $id, + public string $title, + public int $jumpTo, + public bool $enabled, + public int $bookingForm, + public int $bookingPage, + public int $confirmationPage, + public int $inquiryPage, + public int $deadlineDays, + public int $deadlineDisplayDays, + public array $ownVenues, + public int $author, + public int $instructorGroup, + public int $speakerGroup, + public array $newsletterChannels, + public int $newsletterPage, + public string $newsletterText, + public int $defaultNotification, + public array $conditionNotifications, + ) { + } + + /** + * @param array $row + */ + public static function fromRow(array $row): self + { + $venues = preg_split('/\r?\n/', (string) ($row['wkOwnVenues'] ?? '')) ?: []; + $venues = array_values(array_filter(array_map(trim(...), $venues), static fn (string $v): bool => '' !== $v)); + + $conditions = []; + + foreach ($row as $key => $value) { + if (preg_match('/^wkNotification(\d+)$/', (string) $key, $m)) { + $conditions[(int) $m[1]] = (int) $value; + } + } + + return new self( + (int) $row['id'], + (string) ($row['title'] ?? ''), + (int) ($row['jumpTo'] ?? 0), + (bool) ($row['wkEnable'] ?? false), + (int) ($row['wkBookingForm'] ?? 0), + (int) ($row['wkBookingPage'] ?? 0), + (int) ($row['wkConfirmationPage'] ?? 0), + (int) ($row['wkInquiryPage'] ?? 0), + (int) ($row['wkDeadlineDays'] ?? 1), + (int) ($row['wkDeadlineDisplayDays'] ?? 0), + $venues, + (int) ($row['wkAuthor'] ?? 0), + (int) ($row['wkInstructorGroup'] ?? 0), + (int) ($row['wkSpeakerGroup'] ?? 0), + array_map(intval(...), StringUtil::deserialize($row['wkNewsletterChannels'] ?? null, true)), + (int) ($row['wkNewsletterPage'] ?? 0), + (string) ($row['wkNewsletterText'] ?? ''), + (int) ($row['wkNotificationDefault'] ?? 0), + $conditions, + ); + } + + /** + * Ist der Veranstaltungsort einer der eigenen Orte (z. B. „TPI Moritzburg (barrierefrei)“)? + */ + public function isOwnVenue(string $location): bool + { + $location = mb_strtolower(trim($location)); + + if ('' === $location) { + return false; + } + + return array_any($this->ownVenues, static fn (string $venue): bool => str_starts_with($location, mb_strtolower($venue))); + } +} diff --git a/src/Sync/CalendarConfigRepository.php b/src/Sync/CalendarConfigRepository.php new file mode 100644 index 0000000..9408332 --- /dev/null +++ b/src/Sync/CalendarConfigRepository.php @@ -0,0 +1,58 @@ + */ + private array $cache = []; + + public function __construct(private readonly Connection $connection) + { + } + + public function find(int $id): CalendarConfig|null + { + if (!\array_key_exists($id, $this->cache)) { + $row = $this->connection->fetchAssociative('SELECT * FROM tl_calendar WHERE id = ?', [$id]); + $this->cache[$id] = false === $row ? null : CalendarConfig::fromRow($row); + } + + return $this->cache[$id]; + } + + /** + * @return list + */ + public function findEnabled(): array + { + $rows = $this->connection->fetchAllAssociative('SELECT * FROM tl_calendar WHERE wkEnable = 1 ORDER BY title'); + + return array_map(CalendarConfig::fromRow(...), $rows); + } + + public function findForEvent(int $eventId): CalendarConfig|null + { + $calendarId = $this->connection->fetchOne('SELECT pid FROM tl_calendar_events WHERE id = ?', [$eventId]); + + return false === $calendarId ? null : $this->find((int) $calendarId); + } + + /** + * @return list + */ + public function findByBookingForm(int $formId): array + { + if ($formId < 1) { + return []; + } + + $rows = $this->connection->fetchAllAssociative('SELECT * FROM tl_calendar WHERE wkEnable = 1 AND wkBookingForm = ?', [$formId]); + + return array_map(CalendarConfig::fromRow(...), $rows); + } +} diff --git a/src/Sync/SyncLog.php b/src/Sync/SyncLog.php new file mode 100644 index 0000000..89c0b30 --- /dev/null +++ b/src/Sync/SyncLog.php @@ -0,0 +1,106 @@ + $summary + * @param list> $messages + */ + public function create(string $type, string $status, int $user, int $calendar, string $filename, string $path, array $summary = [], array $messages = []): int + { + $this->connection->insert('tl_wk_sync_log', [ + 'tstamp' => time(), + 'type' => $type, + 'status' => $status, + 'user' => $user, + 'calendar' => $calendar, + 'filename' => mb_substr($filename, 0, 255), + 'path' => $path, + 'summary' => json_encode($summary, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + 'messages' => json_encode($messages, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), + ]); + + return (int) $this->connection->lastInsertId(); + } + + /** + * @param array $fields + */ + public function update(int $id, array $fields): void + { + foreach (['summary', 'messages'] as $json) { + if (isset($fields[$json]) && \is_array($fields[$json])) { + $fields[$json] = json_encode($fields[$json], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } + } + + $this->connection->update('tl_wk_sync_log', ['tstamp' => time(), ...$fields], ['id' => $id]); + } + + /** + * @return array|null + */ + public function find(int $id): array|null + { + $row = $this->connection->fetchAssociative('SELECT * FROM tl_wk_sync_log WHERE id = ?', [$id]); + + return false === $row ? null : $this->decode($row); + } + + /** + * @return list> + */ + public function findLatest(string|null $type = null, int $limit = 20): array + { + $sql = 'SELECT l.*, u.name AS userName FROM tl_wk_sync_log l LEFT JOIN tl_user u ON u.id = l.user'; + $params = []; + + if (null !== $type) { + $sql .= ' WHERE l.type = ?'; + $params[] = $type; + } + + $sql .= ' ORDER BY l.tstamp DESC, l.id DESC LIMIT '.max(1, $limit); + + return array_map($this->decode(...), $this->connection->fetchAllAssociative($sql, $params)); + } + + /** + * @param array $row + * + * @return array + */ + private function decode(array $row): array + { + foreach (['summary', 'messages'] as $json) { + try { + $row[$json] = \is_string($row[$json]) && '' !== $row[$json] ? json_decode($row[$json], true, 512, JSON_THROW_ON_ERROR) : []; + } catch (\JsonException) { + $row[$json] = []; + } + } + + return $row; + } +} diff --git a/src/Twig/WkSyncExtension.php b/src/Twig/WkSyncExtension.php new file mode 100644 index 0000000..8baa428 --- /dev/null +++ b/src/Twig/WkSyncExtension.php @@ -0,0 +1,26 @@ + Verfügbarkeit, Plätze, Links + * wk_event_categories(row) -> Kategorietitel + * wk_event_people(row) -> Kursleitung und Referenten + */ +class WkSyncExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('wk_booking_info', [WkSyncRuntime::class, 'bookingInfo']), + new TwigFunction('wk_event_categories', [WkSyncRuntime::class, 'eventCategories']), + new TwigFunction('wk_event_people', [WkSyncRuntime::class, 'eventPeople']), + ]; + } +} diff --git a/src/Twig/WkSyncRuntime.php b/src/Twig/WkSyncRuntime.php new file mode 100644 index 0000000..f3ed936 --- /dev/null +++ b/src/Twig/WkSyncRuntime.php @@ -0,0 +1,84 @@ +|null + */ + public function bookingInfo(int|string $eventId): array|null + { + $event = $this->events->find((int) $eventId); + + if (null === $event || null === ($calendar = $this->calendars->find($event->calendarId))) { + return null; + } + + $availability = $this->availability->check($event, $calendar); + + return [ + 'event' => $event, + 'availability' => $availability, + 'bookable' => $availability->isBookable(), + 'reason' => $availability->reason, + 'places_left' => $availability->placesLeft, + 'deadline' => $availability->displayDeadline, + 'booking_url' => $this->urls->bookingUrl($event, $calendar), + 'inquiry_url' => $this->urls->inquiryUrl($event, $calendar), + 'external_contact' => $availability->externalContact, + ]; + } + + /** + * @param array|object $row Zeile aus tl_calendar_events (oder Objekt mit wkCategories) + * + * @return list + */ + public function eventCategories(array|object $row): array + { + $ids = \is_array($row) ? ($row['wkCategories'] ?? null) : ($row->wkCategories ?? null); + + return $this->categories->findByIds(array_map(intval(...), StringUtil::deserialize($ids, true))); + } + + /** + * @param array|object $row + * + * @return array{instructor: string, speakers: list} + */ + public function eventPeople(array|object $row): array + { + $instructorId = (int) (\is_array($row) ? ($row['wkInstructor'] ?? 0) : ($row->wkInstructor ?? 0)); + $speakerIds = array_map(intval(...), StringUtil::deserialize(\is_array($row) ? ($row['wkSpeakers'] ?? null) : ($row->wkSpeakers ?? null), true)); + + $names = $this->members->namesByIds([...$speakerIds, $instructorId]); + + return [ + 'instructor' => $names[$instructorId] ?? '', + 'speakers' => array_values(array_filter(array_map(static fn (int $id): string => $names[$id] ?? '', $speakerIds))), + ]; + } +} diff --git a/tests/Booking/BookingRulesTest.php b/tests/Booking/BookingRulesTest.php new file mode 100644 index 0000000..f26ff5a --- /dev/null +++ b/tests/Booking/BookingRulesTest.php @@ -0,0 +1,115 @@ + 1, 'wkOwnVenues' => "TPI Moritzburg\nMoritzburg"]); + } + + /** + * @param array $overrides + */ + private function event(CategoryKind $kind, array $overrides = []): BookingEvent + { + $row = [ + 'id' => 1, 'pid' => 1, 'title' => 'Test', 'alias' => 'test', 'published' => 1, + 'startDate' => mktime(0, 0, 0, 9, 29, 2026), 'endDate' => mktime(0, 0, 0, 9, 30, 2026), + 'startTime' => mktime(9, 0, 0, 9, 29, 2026), 'endTime' => mktime(16, 0, 0, 9, 30, 2026), + 'location' => 'TPI Moritzburg (barrierefrei)', 'wkInternalEventNumber' => 'EXTP00557', + ...$overrides, + ]; + + return BookingEvent::fromRow($row, $kind); + } + + public function testTeacherFieldsOnlyForSchoolKinds(): void + { + $rules = new BookingRules(); + + self::assertFalse($rules->rulesFor($this->event(CategoryKind::School), $this->calendar())->isHidden('teacherPosition')); + self::assertFalse($rules->rulesFor($this->event(CategoryKind::EvangelicalSchool), $this->calendar())->isHidden('teacherPosition')); + self::assertTrue($rules->rulesFor($this->event(CategoryKind::Community), $this->calendar())->isHidden('teacherPosition')); + self::assertTrue($rules->rulesFor($this->event(CategoryKind::Community), $this->calendar())->isHidden('schoolName')); + } + + public function testInvoiceAddressDependsOnTeacherPositionForSchoolCourses(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::School), $this->calendar()); + + self::assertFalse($rules->isDependencyMet('addDifferentInvoiceAddress', ['teacherPosition' => 'state'])); + self::assertTrue($rules->isDependencyMet('addDifferentInvoiceAddress', ['teacherPosition' => 'other'])); + + $community = (new BookingRules())->rulesFor($this->event(CategoryKind::Community), $this->calendar()); + self::assertTrue($community->isDependencyMet('addDifferentInvoiceAddress', [])); + } + + public function testMultiDayExtpCourseAtOwnVenueShowsGuesthouseNoteInsteadOfOvernightStay(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::School), $this->calendar()); + + self::assertTrue($rules->isHidden('overnightStay')); + self::assertTrue($rules->isHidden('vegetarian')); + self::assertContains(BookingFields::SLOT_NO_OVERNIGHT_STAY, $rules->activeSlots); + } + + public function testMultiDayTeacherCourseAwayShowsOvernightStayAndVegetarian(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::School, ['location' => 'Klosterhof St. Afra Meißen']), $this->calendar()); + + self::assertFalse($rules->isHidden('overnightStay')); + self::assertFalse($rules->isHidden('vegetarian')); + self::assertSame([], $rules->activeSlots); + } + + public function testSingleDayHidesOvernightStayWithoutNote(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::Community, ['endDate' => mktime(0, 0, 0, 9, 29, 2026)]), $this->calendar()); + + self::assertTrue($rules->isHidden('overnightStay')); + self::assertSame([], $rules->activeSlots); + self::assertFalse($rules->isHidden('vegetarian')); + } + + public function testOnlineCourseHidesVegetarian(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::Community, ['location' => 'Online', 'wkInternalEventNumber' => 'keine']), $this->calendar()); + + self::assertTrue($rules->isHidden('vegetarian')); + } + + public function testConferenceKeepsOvernightStayAndVegetarian(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::Conference), $this->calendar()); + + self::assertFalse($rules->isHidden('overnightStay')); + self::assertFalse($rules->isHidden('vegetarian')); + } + + public function testElementaryMultiDayHidesVegetarianAndShowsNote(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::Elementary, ['wkInternalEventNumber' => '']), $this->calendar()); + + self::assertTrue($rules->isHidden('vegetarian')); + self::assertTrue($rules->isHidden('overnightStay')); + self::assertContains(BookingFields::SLOT_NO_OVERNIGHT_STAY, $rules->activeSlots); + } + + public function testDependentFieldIsHiddenWhenControllerIsHidden(): void + { + $rules = (new BookingRules())->rulesFor($this->event(CategoryKind::Community), $this->calendar()); + + self::assertFalse($rules->isDependencyMet('schoolName', ['teacherPosition' => 'state'])); + } +} diff --git a/tests/Booking/NotificationRouterTest.php b/tests/Booking/NotificationRouterTest.php new file mode 100644 index 0000000..82d8b94 --- /dev/null +++ b/tests/Booking/NotificationRouterTest.php @@ -0,0 +1,92 @@ + 1, 'wkNotificationDefault' => 99]; + + foreach (NotificationRouter::CONDITIONS as $c) { + $row['wkNotification'.$c] = 100 + $c; + } + + return CalendarConfig::fromRow($row); + } + + /** + * @param array $overrides + */ + private function event(CategoryKind $kind, bool $singleDay, array $overrides = []): BookingEvent + { + $start = mktime(0, 0, 0, 9, 29, 2026); + + return BookingEvent::fromRow([ + 'id' => 1, 'pid' => 1, 'title' => 'Test', 'published' => 1, + 'startDate' => $start, 'endDate' => $singleDay ? $start : $start + 86400, + 'startTime' => $start, 'endTime' => $start + 3600, + 'location' => 'TPI Moritzburg', 'wkInternalEventNumber' => 'EXTP00001', 'wkNotification' => 0, + ...$overrides, + ], $kind); + } + + /** + * @return iterable, int}> + */ + public static function conditions(): iterable + { + yield 'online' => [CategoryKind::Elementary, true, ['location' => 'Online'], 1]; + yield 'single school extp' => [CategoryKind::School, true, [], 2]; + yield 'single school without extp falls back to default' => [CategoryKind::School, true, ['wkInternalEventNumber' => ''], 99]; + yield 'single community' => [CategoryKind::Community, true, [], 3]; + yield 'single conference' => [CategoryKind::Conference, true, [], 3]; + yield 'single children service' => [CategoryKind::ChildrenService, true, [], 3]; + yield 'single elementary' => [CategoryKind::Elementary, true, [], 4]; + yield 'multi school extp' => [CategoryKind::School, false, [], 5]; + yield 'multi elementary' => [CategoryKind::Elementary, false, [], 6]; + yield 'multi community' => [CategoryKind::Community, false, [], 7]; + yield 'single evangelical' => [CategoryKind::EvangelicalSchool, true, [], 8]; + yield 'multi evangelical' => [CategoryKind::EvangelicalSchool, false, [], 9]; + yield 'single mentoring' => [CategoryKind::Mentoring, true, [], 10]; + yield 'multi mentoring' => [CategoryKind::Mentoring, false, [], 11]; + yield 'multi training' => [CategoryKind::Training, false, [], 12]; + yield 'other kind uses default' => [CategoryKind::Other, false, [], 99]; + yield 'event override wins' => [CategoryKind::Community, true, ['wkNotification' => 7], 7]; + } + + /** + * @param array $overrides + */ + #[\PHPUnit\Framework\Attributes\DataProvider('conditions')] + public function testRouting(CategoryKind $kind, bool $singleDay, array $overrides, int $expected): void + { + $result = (new NotificationRouter())->resolve($this->event($kind, $singleDay, $overrides), $this->calendar()); + $expectedId = $expected < 13 && 7 !== $expected ? 100 + $expected : $expected; + + if (7 === $expected && isset($overrides['wkNotification'])) { + $expectedId = 7; + } elseif (7 === $expected) { + $expectedId = 107; + } + + self::assertSame($expectedId, $result['notification']); + } + + public function testFallsThroughToNextConditionWhenNotificationMissing(): void + { + $calendar = CalendarConfig::fromRow(['id' => 1, 'wkNotification1' => 0, 'wkNotification4' => 104]); + $result = (new NotificationRouter())->resolve($this->event(CategoryKind::Elementary, true, ['location' => 'Online']), $calendar); + + self::assertSame([1, 4], $result['conditions']); + self::assertSame(104, $result['notification']); + } +} diff --git a/tests/Category/CategoryResolverTest.php b/tests/Category/CategoryResolverTest.php new file mode 100644 index 0000000..994316b --- /dev/null +++ b/tests/Category/CategoryResolverTest.php @@ -0,0 +1,58 @@ + + */ + private function categories(): array + { + return [ + new Category(11, 'Grundschule/Förderschule', 'gs', CategoryKind::School, ['Grundschule', 'Förderschule', 'Alle Schularten'], 32), + new Category(12, 'Weiterführende Schule', 'ws', CategoryKind::School, ['Weiterführende Schule', 'Gymnasium', 'Oberschule', 'Alle Schularten'], 48), + new Category(5, 'Gemeinde', 'gemeinde', CategoryKind::Community, ['Gemeinde', 'Gemeindepädagogische Arbeit'], 80), + new Category(16, 'Mentoring', 'mentoring', CategoryKind::Mentoring, ['Mentor'], 128), + ]; + } + + private function resolver(): CategoryResolver + { + return new CategoryResolver($this->createStub(CategoryRepository::class)); + } + + public function testExactMatchReturnsAllCategoriesWithThatName(): void + { + $result = $this->resolver()->resolve('Alle Schularten', $this->categories()); + + self::assertSame([11, 12], array_map(static fn (Category $c): int => $c->id, $result)); + } + + public function testExactMatchIsCaseInsensitive(): void + { + $result = $this->resolver()->resolve('gemeindepädagogische arbeit', $this->categories()); + + self::assertSame([5], array_map(static fn (Category $c): int => $c->id, $result)); + } + + public function testSubstringFallback(): void + { + self::assertSame(12, $this->resolver()->resolve('Gymnasium/Oberschule', $this->categories())[0]->id); + self::assertSame(16, $this->resolver()->resolve('Mentoren', $this->categories())[0]->id); + } + + public function testUnknownAndEmptyTextsResolveToNothing(): void + { + self::assertSame([], $this->resolver()->resolve('Unbekannt', $this->categories())); + self::assertSame([], $this->resolver()->resolve(' ', $this->categories())); + } +} diff --git a/tests/Export/WkCsvWriterTest.php b/tests/Export/WkCsvWriterTest.php new file mode 100644 index 0000000..651e130 --- /dev/null +++ b/tests/Export/WkCsvWriterTest.php @@ -0,0 +1,30 @@ +write(['A', 'B'], [['Müller "Max"', "mehr\nzeilig"], ['Ionescu Ștefan ă', '€ 5']]); + + $expected = "\"A\";\"B\"\r\n\"M\xFCller \"\"Max\"\"\";\"mehr zeilig\"\r\n\"Ionescu Stefan a\";\"EUR 5\"\r\n"; + + self::assertSame($expected, $csv); + } + + public function testNameFormatter(): void + { + $formatter = new NameFormatter(); + + self::assertSame('Müller', $formatter->fix('MÜLLER')); + self::assertSame('Anna-Lena Van Der Berg', $formatter->fix(' anna - lena van der BERG ')); + self::assertSame('', $formatter->fix(' ')); + } +} diff --git a/tests/Import/CsvReaderTest.php b/tests/Import/CsvReaderTest.php new file mode 100644 index 0000000..6cb11eb --- /dev/null +++ b/tests/Import/CsvReaderTest.php @@ -0,0 +1,65 @@ +readString($csv); + + self::assertSame('Windows-1252', $table->encoding); + self::assertSame(['kbnr', 'kbtitel', 'kbpmemo', 'kbkp1'], $table->header); + self::assertCount(2, $table->rows); + self::assertSame('Pädagogik', $table->rows[0]['kbtitel']); + self::assertSame("Zeile 1\nZeile 2", $table->rows[0]['kbpmemo']); + self::assertSame('240,00 €', $table->rows[0]['kbkp1']); + self::assertSame('Titel "mit" Anführung', $table->rows[1]['kbtitel']); + self::assertSame('2', $table->rows[0]['__record']); + } + + public function testKeepsUtf8AndNamesWithCedilla(): void + { + $csv = "\xEF\xBB\xBFkbnr;kbkln1\n\"0000001\";\"Çelik\"\n"; + + $table = (new CsvReader())->readString($csv); + + self::assertSame('UTF-8', $table->encoding); + self::assertSame('Çelik', $table->rows[0]['kbkln1']); + } + + public function testDoesNotReplaceCedillaInNamesForWindows1252(): void + { + $csv = "kbnr;kbkln1;kbkp1\n\"0000001\";\"\xC7elik\";\"12,50 \xC7\"\n"; + + $table = (new CsvReader())->readString($csv); + + self::assertSame('Çelik', $table->rows[0]['kbkln1']); + self::assertSame('12,50 €', $table->rows[0]['kbkp1']); + } + + public function testPadsShortRowsWithWarning(): void + { + $table = (new CsvReader())->readString("a;b;c\n1;2\n"); + + self::assertSame(['a' => '1', 'b' => '2', 'c' => '', '__record' => '2'], $table->rows[0]); + self::assertCount(1, $table->warnings); + } + + public function testRejectsEmptyFile(): void + { + $this->expectException(ImportException::class); + + (new CsvReader())->readString("\n\n"); + } +} diff --git a/tests/Import/RowMapperTest.php b/tests/Import/RowMapperTest.php new file mode 100644 index 0000000..c6058bd --- /dev/null +++ b/tests/Import/RowMapperTest.php @@ -0,0 +1,133 @@ + $overrides + * + * @return array + */ + private function row(array $overrides = []): array + { + return [ + 'kbnr' => '0003259', + 'kbtitel' => 'Pause für den Kopf', + 'kbanreise' => '29.09.2026', + 'kbabreise' => '30.09.2026', + 'kbuanreise' => '09.00', + 'kbuabreise' => '16.00', + 'kbstatus1' => '2', + 'kbsgebiet' => 'Alle Schularten', + 'kbvsnr' => 'EXTP00557', + 'kbvort' => 'TPI Moritzburg', + 'kbkp1' => '240,00', + 'kbkp2' => '1.250,50', + 'kbanzmax' => '20', + 'kbanztat' => '6', + 'kbkdemail' => 'info@example.org', + 'kbkln1' => 'Tobias Richter', + 'kbmref' => "Johanna Fabel\nN.N.\nUwe Hahn\nJohanna Fabel", + 'kbwmemo' => 'Lehrkräfte, alle Schularten, Lehrkräfte', + 'kbpmemo' => "Absatz eins\nZeile zwei\n\nAbsatz zwei", + 'kbzusatz' => "- Punkt 1\n- Punkt 2", + 'kbvsart' => '2', + ...$overrides, + ]; + } + + public function testMapsAValidRow(): void + { + $mapped = (new RowMapper())->map($this->row(), 2); + + self::assertFalse($mapped->skipped); + self::assertSame('0003259', $mapped->wkId); + self::assertSame('Pause für den Kopf', $mapped->data['title']); + self::assertSame(mktime(0, 0, 0, 9, 29, 2026), $mapped->data['startDate']); + self::assertSame(mktime(0, 0, 0, 9, 30, 2026), $mapped->data['endDate']); + self::assertTrue($mapped->data['addTime']); + self::assertSame(mktime(9, 0, 0, 9, 29, 2026), $mapped->data['startTime']); + self::assertSame(mktime(16, 0, 0, 9, 30, 2026), $mapped->data['endTime']); + self::assertSame('2', $mapped->data['wkState']); + self::assertSame('EXTP00557', $mapped->data['wkInternalEventNumber']); + self::assertSame(240.0, $mapped->data['wkPrice1']); + self::assertSame(1250.5, $mapped->data['wkPrice2']); + self::assertSame(20, $mapped->data['wkMaxPlaces']); + self::assertSame(14, $mapped->data['wkPlacesLeft']); + self::assertSame('Tobias Richter', $mapped->instructorName); + self::assertSame(['Johanna Fabel', 'Uwe Hahn'], $mapped->speakerNames); + self::assertSame('Lehrkräfte, alle Schularten', $mapped->data['wkTags']); + self::assertSame('

    Absatz eins
    Zeile zwei

    '."\n".'

    Absatz zwei

    ', $mapped->data['teaser']); + self::assertSame('
    • Punkt 1
    • Punkt 2
    ', $mapped->data['wkTopics']); + self::assertSame('Alle Schularten', $mapped->categoryText); + self::assertSame(2, $mapped->data['wkEventType']); + self::assertSame([], $mapped->messages); + } + + public function testAllDayEventGetsMidnightAndEndOfDay(): void + { + $mapped = (new RowMapper())->map($this->row(['kbuanreise' => '', 'kbuabreise' => '']), 2); + + self::assertFalse($mapped->data['addTime']); + self::assertSame(mktime(0, 0, 0, 9, 29, 2026), $mapped->data['startTime']); + self::assertSame(mktime(0, 0, 0, 10, 1, 2026) - 1, $mapped->data['endTime']); + } + + public function testSkipsRowsWithoutLeadingZeroOrInternalNumberOrTitle(): void + { + $mapper = new RowMapper(); + + self::assertTrue($mapper->map($this->row(['kbnr' => '3259']), 2)->skipped); + self::assertTrue($mapper->map($this->row(['kbvsnr' => '']), 2)->skipped); + self::assertTrue($mapper->map($this->row(['kbtitel' => '']), 2)->skipped); + self::assertTrue($mapper->map($this->row(['kbanreise' => '. .']), 2)->skipped); + } + + public function testKeineBecomesEmptyInternalNumberCaseInsensitive(): void + { + $mapper = new RowMapper(); + + self::assertSame('', $mapper->map($this->row(['kbvsnr' => 'Keine']), 2)->data['wkInternalEventNumber']); + self::assertSame('', $mapper->map($this->row(['kbvsnr' => 'keine']), 2)->data['wkInternalEventNumber']); + } + + public function testInvalidEmailBecomesWarningAndEmptyField(): void + { + $mapped = (new RowMapper())->map($this->row(['kbkdemail' => 'kaputt']), 2); + + self::assertSame('', $mapped->data['wkOrganizerEmail']); + self::assertCount(1, $mapped->messages); + self::assertSame(ImportMessage::WARNING, $mapped->messages[0]->type); + } + + public function testExternalRegistrationUrlIsKept(): void + { + $mapped = (new RowMapper())->map($this->row(['kbkdemail' => 'https://example.org/anmeldung?x=1']), 2); + + self::assertSame('https://example.org/anmeldung?x=1', $mapped->data['wkOrganizerEmail']); + self::assertSame([], $mapped->messages); + } + + public function testEndDateBeforeStartFallsBackToStart(): void + { + $mapped = (new RowMapper())->map($this->row(['kbabreise' => '01.01.2020']), 2); + + self::assertSame($mapped->data['startDate'], $mapped->data['endDate']); + self::assertSame(ImportMessage::WARNING, $mapped->messages[0]->type); + } + + public function testStreetAndCountryAreMappedCorrectly(): void + { + $mapped = (new RowMapper())->map($this->row(['kbkdstr' => 'Bahnhofstraße 9', 'kbkdlkz' => 'D']), 2); + + self::assertSame('Bahnhofstraße 9', $mapped->data['wkOrganizerStreet']); + self::assertSame('D', $mapped->data['wkOrganizerCountryCode']); + } +} diff --git a/tests/Import/TextFormatterTest.php b/tests/Import/TextFormatterTest.php new file mode 100644 index 0000000..7d3f0da --- /dev/null +++ b/tests/Import/TextFormatterTest.php @@ -0,0 +1,31 @@ +paragraphs("fett & mehr\r\n\r\nzweiter"); + + self::assertSame("

    <b>fett</b> & mehr

    \n

    zweiter

    ", $html); + } + + public function testTopicsBecomeList(): void + { + $html = (new TextFormatter())->topics("Einleitung\n- Punkt A\n- Punkt B\nFortsetzung B\n- Punkt C"); + + self::assertSame("

    Einleitung

    \n
    • Punkt A
    • Punkt B
      Fortsetzung B
    • Punkt C
    ", $html); + } + + public function testTopicsWithoutDashesAreParagraphs(): void + { + self::assertSame('

    Nur Text

    ', (new TextFormatter())->topics('Nur Text')); + self::assertSame('', (new TextFormatter())->topics(" \n ")); + } +}