From fe272f2139fd562bbfab0c41038fccc0fa4f3fef Mon Sep 17 00:00:00 2001 From: Stefan Bechtold Date: Wed, 9 Sep 2026 15:13:14 +0200 Subject: [PATCH 01/16] feat(cxdevforms): dynamic forms extension for sap commerce cloud --- .../cxdevforms/DynamicFormsBTP.spec | 693 ++++++++++++++++++ .../custom/cxdevtools/cxdevforms/LICENSE.md | 201 +++++ .../custom/cxdevtools/cxdevforms/README.md | 335 +++++++++ .../DynamicFormSubmissionRenderer.java | 58 ++ .../cxdevtools/cxdevforms/extensioninfo.xml | 11 + .../cxdevforms/external-dependencies.xml | 9 + .../cxdevtools/cxdevforms/lib/.lastupdate | 0 .../cxdevtools/cxdevforms/project.properties | 9 + .../cxdevforms-backoffice-config.xml | 521 +++++++++++++ .../labels_de.properties | 19 + .../labels_en.properties | 19 + .../cxdevforms-backoffice-spring.xml | 12 + .../cxdevforms/resources/cxdevforms-beans.xml | 87 +++ .../cxdevforms/resources/cxdevforms-items.xml | 257 +++++++ .../resources/cxdevforms-spring.xml | 7 + .../cxdevforms/cxdevforms-facades-spring.xml | 51 ++ .../cxdevforms/cxdevforms-services-spring.xml | 14 + .../cxdevforms-submission-email-spring.xml | 15 + .../cxdevforms-submissions-spring.xml | 43 ++ .../essentialdata/dynamic-form-types.impex | 2 + .../html/cxdevforms-submission.html | 17 + .../cxdevforms-locales_de.properties | 63 ++ .../cxdevforms-locales_en.properties | 63 ++ .../cxdevforms-submissions_de.properties | 24 + .../cxdevforms-submissions_en.properties | 24 + .../cxdevforms-dto-level-mappings-spring.xml | 36 + .../config/cxdevforms-dto-mappings-spring.xml | 16 + .../web/spring/cxdevforms-web-spring.xml | 10 + .../forms/constants/CxFormsConstants.java | 10 + .../controller/CxDynamicFormsController.java | 52 ++ .../CxDynamicFormsSubmissionsController.java | 88 +++ .../DynamicFormSubmissionCreatedEvent.java | 52 ++ .../DynamicFormSubmissionEmailListener.java | 78 ++ .../forms/facade/DynamicFormFacade.java | 26 + .../facade/DynamicFormSubmissionFacade.java | 10 + .../facade/impl/DefaultDynamicFormFacade.java | 37 + .../DefaultDynamicFormSubmissionFacade.java | 33 + .../populator/DynamicFormFieldPopulator.java | 58 ++ .../DynamicFormFieldValuePopulator.java | 44 ++ .../populator/DynamicFormPopulator.java | 53 ++ .../forms/service/DynamicFormService.java | 28 + .../service/DynamicFormSubmissionService.java | 16 + .../impl/DefaultDynamicFormService.java | 48 ++ .../DefaultDynamicFormSubmissionService.java | 76 ++ .../DynamicFormSubmissionRequest.java | 15 + .../DynamicFormSubmissionValidator.java | 216 ++++++ .../RejectingSubmissionFileValidator.java | 11 + .../submission/SubmissionFileValidator.java | 12 + .../forms/submission/SubmissionMetadata.java | 5 + .../SubmissionPresentationService.java | 73 ++ .../forms/submission/SubmissionReceipt.java | 7 + .../forms/submission/SubmissionSnapshot.java | 11 + .../submission/SubmissionSnapshotService.java | 79 ++ .../SubmissionValidationException.java | 16 + ...ormSubmissionSpringConfigurationTests.java | 39 + .../DynamicFormsSpringConfigurationTest.java | 58 ++ .../CxDynamicFormsControllerTest.java | 67 ++ ...ynamicFormsSubmissionsControllerTests.java | 84 +++ ...namicFormSubmissionEmailListenerTests.java | 65 ++ .../impl/DefaultDynamicFormFacadeTest.java | 74 ++ ...faultDynamicFormSubmissionFacadeTests.java | 44 ++ .../DynamicFormFieldPopulatorTest.java | 87 +++ .../DynamicFormFieldValuePopulatorTest.java | 72 ++ .../populator/DynamicFormPopulatorTest.java | 79 ++ .../impl/DefaultDynamicFormServiceTest.java | 76 ++ ...aultDynamicFormSubmissionServiceTests.java | 79 ++ ...DynamicFormSubmissionIntegrationTests.java | 142 ++++ .../DynamicFormSubmissionValidatorTests.java | 128 ++++ .../SubmissionPresentationTests.java | 61 ++ .../hybris/config/localextensions.xml | 1 + 70 files changed, 4926 insertions(+) create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/DynamicFormsBTP.spec create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/LICENSE.md create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/README.md create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/backoffice/src/me/cxdev/commerce/forms/backoffice/renderers/DynamicFormSubmissionRenderer.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/extensioninfo.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/external-dependencies.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/lib/.lastupdate create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/project.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-config.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_de.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_en.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-beans.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-items.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-facades-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-services-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submission-email-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submissions-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/essentialdata/dynamic-form-types.impex create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/email-templates/html/cxdevforms-submission.html create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_de.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_en.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_de.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_en.properties create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-level-mappings-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-mappings-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/cxdevforms-web-spring.xml create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/constants/CxFormsConstants.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsController.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsController.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionCreatedEvent.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListener.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormFacade.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormSubmissionFacade.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacade.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacade.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulator.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulator.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulator.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormSubmissionService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionRequest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidator.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/RejectingSubmissionFileValidator.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionFileValidator.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionMetadata.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionPresentationService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionReceipt.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshot.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshotService.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionValidationException.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormSubmissionSpringConfigurationTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormsSpringConfigurationTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsControllerTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsControllerTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListenerTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacadeTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacadeTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulatorTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulatorTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulatorTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormServiceTest.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionServiceTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DynamicFormSubmissionIntegrationTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidatorTests.java create mode 100644 core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/SubmissionPresentationTests.java diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/DynamicFormsBTP.spec b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/DynamicFormsBTP.spec new file mode 100644 index 00000000..e060dfa1 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/DynamicFormsBTP.spec @@ -0,0 +1,693 @@ +# Dynamic Forms – Fachliche und technische Spezifikation für SAP BTP + +Version: 1.1, 09.09.2026 +Status: Konzeption; keine implementierte BTP-Anwendung +Referenz: parallele Commerce-Extraktion in cxdevforms +Normative Begriffe: MUSS = Abnahmeanforderung; SOLL = begründet abweichbar; +KANN = optionaler Ausbau. Neue Zielanforderungen sind nicht als Funktionen der +extrahierten Commerce-Extension zu verstehen. + +## 1. Ziel, Systemgrenze und Lieferumfang + +DF-01 Die Anwendung MUSS fachlichen Administratoren erlauben, Formulare ohne +Programmänderung anzulegen, zu lokalisieren, zu konfigurieren, zu prüfen und zu +veröffentlichen. Externe Frontends MUSS sie über eine stabile Formular-ID mit +maschinenlesbaren Definitionen versorgen. Die Anwendung MUSS eigenständig auf +SAP BTP betreibbar sein und DARF kein SAP-Commerce-Laufzeitsystem voraussetzen. + +DF-02 Ein Modul MUSS Domänenlogik, Administration und externe API bereitstellen. +Die technische Aufteilung interner Packages darf keine fachlich unvollständigen +Deployments erzwingen. Eine Admin-Weboberfläche kann als eigenes statisches +Deployment ausgeliefert werden, bleibt aber Teil desselben Produkts und Vertrags. + +DF-03 Die relationale Speicherung der Definitionen einschließlich Beziehungen, +Reihenfolgen, Übersetzungen und Regeln ist verpflichtend. PostgreSQL ist die +bevorzugte Zielvariante. MySQL ist eine zulässige Alternative nach dem in Abschnitt +10 beschriebenen Bereitstellungsnachweis. Ein JSON-Dokument als einzige Datenquelle +erfüllt die Anforderung nicht. + +DF-04 Der erste Lieferumfang umfasst Modell, Admin-Oberfläche, Lese-API, +Validierungs- und Submission-API, Versionierung/Veröffentlichung, Import/Export, +Identität und Betrieb. Die validierte, dauerhafte Speicherung ausgefüllter Formulare +und ihre lesbare Administration sind verpflichtend (Abschnitt 9). Ein optional +aktivierbarer Event-Mesh-Adapter und ein standardmäßig deaktivierter E-Mail-Listener +mit Template-Fallback gehören ebenfalls zum Lieferumfang. + +## 2. Nachgewiesener Commerce-Bestand und Abweichungen im BTP-Ziel + +| Thema | Extrahierter Ist-Stand | BTP-Ziel | +| --- | --- | --- | +| Formular-ID | Global eindeutige DynamicForm.id | Stabil pro Mandant und Site; Version separat | +| Formulare | Typ, Titel, Beschreibung, Empfänger | Zusätzlich Status, Revisionen, UI-Konfiguration | +| Schritte | Optionale Feldgruppierung über stepId/stepTitle; kein eigener Schritttyp | Geordnete, lokalisierte Schritte mit Bedingungen | +| Felder | Geordnete Wurzelfelder, rekursive Unterfelder | Geordnete Platzierungen und zentrale Regeln | +| Auswahlwerte | ID, Label, Liste am Feld, n:m-Unterfelder | Lokale Options-IDs, Reihenfolge, geprüfte Referenzen | +| Aktiv | Nur Wurzelfelder werden beim Lesen gefiltert | Gilt für alle Felder, Optionen und Schritte | +| Bedingungen | Auswahlwert.childFields | Feld-/Schrittbedingungen mit definierter Semantik | +| Darstellung | Feldtyp; | Form-/Schritt-/Feld-Layout | +| Lesen | Alle Formulare / eine ID; keine Pagination | Publizierte Revisionen, Scope, Pagination, ETag | +| Nicht gefunden | HTTP 200, unbefülltes DTO | HTTP 404 | +| Lokalisierung | Commerce-Session und lokalisierte Attribute | Explizite Sprache und definierter Fallback | +| Schreiben | Backoffice / ImpEx | Gesicherte Admin-API und Admin-Oberfläche | +| Antworten | Separater Submission-Controller, Facade, Service, Itemtype und Commit-ClusterEvent | Persistente Annahme und Administration verpflichtend; Event Mesh optional aktivierbar | +| Empfänger | In DEFAULT/FULL öffentlich enthalten | Nur Admin-/Backend-Konfiguration | +| Datenhaltung | Commerce-Items und Relationen | Normalisierte relationale Datenbank | + +Die Bestandsartefakte sind resources/cxdevforms-items.xml, +resources/cxdevforms-beans.xml, service, facade, populator, +controller/CxDynamicFormsController und CxDynamicFormsSubmissionsController und die zugehörigen Spring-/Backoffice-Dateien. +`DynamicFormType` ist dynamisch; `DynamicFormFieldType` ist ein festes Enum. Eine Backoffice-Wizard-Seite ist kein Frontend-Formularschritt. +`value` existiert nur im bisherigen Data-/DTO-Vertrag, nicht als persistierte +Attribute der Formulardefinition. Die BTP-Lese-API übernimmt sie nicht als fachliche +Konfigurationsattribute. + +## 3. Rollen, Scope und Lebenszyklus + +ADM-01 Die Rollen Leser, Redakteur, Freigeber und Administrator MUSS es getrennt +geben. Leser sehen Definitionen im Admin-Bereich; Redakteure bearbeiten Entwürfe; +Freigeber publizieren und archivieren; Administratoren verwalten Rollen, Sites, +Formulararten und Integrationskonfiguration. Berechtigungen sind im Backend zu +prüfen. Ein ausgeblendeter UI-Button reicht nicht aus. + +ADM-02 Alle Zugriffe MUSS das Backend auf einen aus der vertrauenswürdigen +Identität abgeleiteten Mandanten beschränken. Site-Zugriff ist zusätzlich zu +prüfen. Eine Mandanten-ID aus einem Request allein gewährt keine Berechtigung. +Eine Installation KANN zunächst nur einen Mandanten konfigurieren; Tabellen und +API-Zugriff müssen dessen Trennung trotzdem durchgängig durchsetzen. + +ADM-03 Eine Formulardefinition hat eine stabile fachliche ID. Änderungen erfolgen +in Revisionen mit Zuständen DRAFT, PUBLISHED, ARCHIVED. Pro Formular ist höchstens +eine Revision die aktuell veröffentlichte. Mehrere historische veröffentlichte +Revisionen dürfen für reproduzierbare Verarbeitung erhalten bleiben. + +ADM-04 Veröffentlichte Revisionen sind unveränderlich. Bearbeiten erzeugt einen +neuen Entwurf aus einer Revision. Publizieren MUSS vollständig validieren und den +Zeiger auf die aktuelle Revision atomar umschalten. Fehlgeschlagene Validierung +lässt die bisher publizierte Revision unverändert. Wiederherstellen einer älteren +Revision erfolgt als neuer Entwurf mit neuer Versionsnummer und erneuter Freigabe. + +ADM-05 Ein Entwurf darf unvollständig zwischengespeichert werden; kaputte +Referenzen und ungültige Datenbankzustände dürfen nicht gespeichert werden. +Fachliche Unvollständigkeit MUSS die Oberfläche anzeigen. Archivierung entfernt +Formulare aus der öffentlichen Liste. Ein harter Löschvorgang ist nur für +unveröffentlichte, unreferenzierte Entwürfe zulässig. + +ADM-06 Änderungen verwenden optimistische Sperren. Admin-GET liefert ETag; +PATCH, DELETE, Publizieren und Umordnen benötigen If-Match. Fehlender Header +führt zu 428, veralteter Stand zu 412. Die UI MUSS Konflikte erklären und einen +Vergleich bzw. erneutes Laden anbieten, ohne lokale Eingaben still zu verwerfen. + +## 4. Fachliches Modell + +MOD-01 Formular: ID, Formularart, Standard-/unterstützte Sprachen, Titel, +Beschreibung, Site, Revision, Status, Darstellungsmodus und geordnete Schritte. +Titel und Beschreibungen sind übersetzbar. Empfängerlisten und dynamische +Empfängerstrategien sind separate private Backend-Konfiguration. + +MOD-02 Formularart entspricht DynamicFormType: stabiler Code, lokalisierter Name, +Beschreibung und Aktivstatus. Neue Arten dürfen Administratoren ohne Deployment +anlegen. Deaktivierung verbietet neue Publikationen mit dieser Art, verändert +aber keine bereits publizierte Revision. + +MOD-03 Schritt: innerhalb der Revision eindeutige ID, Position, lokalisierter +Titel und Beschreibung, Aktivstatus, optional eine Sichtbarkeitsbedingung, +Layout und geordnete Felder. Ein Formular MUSS mindestens einen aktiven Schritt +besitzen. Ein fachlich einschrittiges Formular benutzt denselben Datentyp. + +MOD-04 Felddefinition: revisionslokal eindeutige ID, Feldtyp, Label, +Beschreibung, Platzhalter, Standardwert, required, hidden, active, Constraints +und gegebenenfalls Auswahlwerte. Formularübergreifende Wiederverwendung erfolgt +im ersten Lieferumfang durch Kopieren einer Vorlage; nachträgliche Änderungen +an der Vorlage dürfen keine publizierte Revision verändern. + +MOD-05 Feldplatzierung: Referenz auf eine Felddefinition, Schritt, Position und +optional übergeordnete Feldplatzierung. Im ersten Lieferumfang darf eine +Felddefinition nur einmal in einer Revision platziert werden. Eine Platzierung +gehört genau einem Schritt. Verschachtelte Unterfelder bleiben im selben Schritt +wie ihr Elternfeld. Schrittübergreifende Steuerung geschieht durch Regeln auf dem +Zielschritt, nicht durch mehrfaches Einhängen desselben Feldes. + +MOD-06 Auswahlwert: pro Feld eindeutige, stabile ID, lokalisierte Bezeichnung, +Position und Aktivstatus. Die ID ist der technische Wert; das Label darf sich +sprachabhängig ändern. Auswahlbedingungen referenzieren IDs, niemals Labels. +RADIO/SELECT speichern genau eine Options-ID, CHECKBOXES eine Liste eindeutiger +Options-IDs. CHECKBOX ist ein einzelner Boolean. + +MOD-07 Typen und Antwortwerte: + +| Feldtyp | Antwortdatentyp | Regeln / Darstellung | +| --- | --- | --- | +| TEXT | String | einzeilig, Längengrenzen | +| EMAIL | String | E-Mail-Format, Längengrenzen; kein Versandnachweis | +| HIDDEN | typisierter String | keine sichtbare Eingabe; kein Speicher für Geheimnisse | +| PASSWORD | String | maskiert; nie als persistierter Standardwert oder in Logs | +| COLOR | String | #RRGGBB, normalisierte Großschreibung | +| TEXTAREA | String | mehrzeilig, Längengrenzen | +| NUMBER | Dezimalzahl als JSON-String | dezimale, locale-unabhängige Syntax; Min/Max | +| RADIO | String | eine aktive Options-ID | +| CHECKBOX | Boolean | bei required MUSS true sein | +| CHECKBOXES | String[] | eindeutige aktive Options-IDs, deterministische Optionsreihenfolge | +| SELECT | String | eine aktive Options-ID; Mehrfachwahl über CHECKBOXES | +| FILE | String[] | Referenzen auf geprüfte Uploads, keine Binärwerte in Definitionen | +| DATE | String | ISO-Kalenderdatum YYYY-MM-DD ohne Zeitzone | +| WEEK | String | ISO-Woche YYYY-Www mit Prüfung gültiger Wochenzahl | + +Null oder ein fehlender Eintrag bedeutet keine Antwort; leere Strings und leere +Listen gelten für required als leer. Leerzeichen allein gelten bei Text als leer. +0 ist bei NUMBER ein gültiger Wert. NUMBER verarbeitet der Server mit +Dezimalarithmetik, nicht als binären Gleitkommawert. Die deklarative Typprüfung +MUSS identisch in Validierungs- und Submit-API gelten. + +MOD-08 Numerische Grenzen sind inklusiv; minValue <= maxValue. Längengrenzen sind +nichtnegative Ganzzahlen und zählen Unicode-Codepoints. minLength <= maxLength. +Constraints müssen zum Feldtyp passen. Labels und Platzhalter sind Plaintext. +Beschreibungen dürfen nur einen festgelegten, serverseitig bereinigten +Rich-Text-Umfang enthalten. Beliebiges HTML, JavaScript, CSS oder auszuführende +Ausdrücke dürfen nicht als Layoutkonfiguration angenommen werden. + +MOD-09 Standardwerte müssen dem Antwortdatentyp entsprechen und im +Publikationscheck validiert werden. Versteckte Werte sind für den Browser +sichtbare Daten. Geheimnisse und private Integrationsparameter gehören niemals +in Definitionen. Defaults für PASSWORD und FILE sind unzulässig. + +MOD-10 Deklarative UI-Einstellungen: ein- oder zweispaltiges Layout für große +Ansichten, immer eine Spalte auf kleinen Ansichten; Feldbreite 1 oder 2; +Schrittanzeige als fortlaufend oder Assistent; lokalisierte Weiter-/Zurück-/ +Abschlussbeschriftung. Die Reihenfolge im Datenmodell bestimmt zugleich die +Lese- und Tastaturreihenfolge. Kein freier CSS-Klassenname als funktionale API. +Unbekannte Layoutoptionen MUSS ein Renderer mit einer dokumentierten, zugänglichen +Standarddarstellung behandeln; unbekannte Feldtypen müssen sichtbar als +inkompatibel gemeldet werden und dürfen keinen unvollständigen Submit erlauben. + +## 5. Abhängigkeiten, Auswertung und Formularschritte + +RULE-01 Bedingungen sind typisierte Daten. Die Sprache besteht aus Gruppen +ALL bzw. ANY und Prädikaten EQ, NEQ, IN, CONTAINS_ANY, IS_EMPTY, IS_NOT_EMPTY. +Ein Prädikat referenziert die ID eines Quellfeldes und gegebenenfalls Literale oder +Options-IDs. ALL verlangt alle, ANY mindestens eines der Prädikate; leere Gruppen +sind ungültig. Eine fehlende Bedingung bedeutet true. Kein eval, SQL oder Skript. + +RULE-02 EQ/NEQ vergleichen einzelne typkompatible Werte; IN prüft einen skalaren +Wert gegen eine Literalliste; CONTAINS_ANY prüft CHECKBOXES gegen Options-IDs. +Ein nicht beantwortetes oder nicht effektives Quellfeld erfüllt nur IS_EMPTY; +alle anderen Prädikate einschließlich NEQ sind dann false. Vergleich ist +sprachunabhängig, ID-/String-Vergleich exakt und Groß-/Kleinschreibung beachtend. + +RULE-03 Eine Platzierung kann visibleWhen und requiredWhen besitzen. Ein Schritt +kann visibleWhen besitzen. Effektive Pflicht = statisches required ODER +requiredWhen, jedoch nur für effektive Felder. Ein Feld ist effektiv, wenn sein +Schritt aktiv und sichtbar ist, das Feld aktiv ist, alle Elternplatzierungen +effektiv sind und seine Sichtbarkeitsbedingung erfüllt ist. hidden beeinflusst +nur die Darstellung, nicht die Effektivität; erforderliche HIDDEN-Felder müssen +durch einen zulässigen Default oder eine deklarierte Backend-Kontextquelle +versorgt werden. Fehlt eine solche Quelle, ist die Veröffentlichung abzulehnen. + +RULE-04 Auswahlwert.childFields des Bestands wird durch sichtbare +Feldplatzierungen mit Bedingungen auf die Quelloption übersetzt. Mehrere Optionen, +die dasselbe Unterfeld aktivieren, werden durch ANY verbunden. Fachliches UND +zwischen unterschiedlichen Feldern benötigt eine ausdrückliche ALL-Gruppe. +Bei CHECKBOXES werden die wirksamen Unterfelder aller ausgewählten Optionen +vereinigt; gemeinsame Zielplatzierungen erscheinen einmal in ihrer Position. + +RULE-05 Das Abhängigkeitsnetz MUSS azyklisch sein. Selbstbezüge, direkte und +indirekte Zyklen sind beim Publizieren mit einem Pfad der beteiligten Felder/ +Schritte zurückzuweisen. Der Prüfgraph enthält sowohl explizite Regelkanten als +auch Schritt-/Elternfeld-Effektivität. So ist etwa ein Schritt unzulässig, dessen +Sichtbarkeit von einem Feld in diesem Schritt abhängt. Referenzen auf spätere +Schritte sind im ersten Lieferumfang unzulässig; innerhalb eines Schritts gilt +eine topologische Auswertung. + +RULE-06 Bei jeder Antwortänderung MUSS die Engine in topologischer Reihenfolge +Effektivität und Pflicht neu bestimmen. Nicht effektive Antworten werden aus dem +aktiven Antwortzustand entfernt; sie dürfen keine weiteren Regeln auslösen und +nicht validiert oder übermittelt werden. Wird ein Feld wieder effektiv, beginnt +es mit seinem gültigen Default oder leer. Dieses Verhalten gilt identisch beim +Frontend und bei serverseitiger Validierung. Der Server vertraut keinem vom +Client gemeldeten Sichtbarkeitszustand. + +RULE-07 Weiter führt zum nächsten sichtbaren Schritt und prüft alle effektiven +Felder des aktuellen Schritts. Zurück führt ohne Pflichtprüfung zum vorherigen +sichtbaren Schritt. Übersprungene Schritte zählen nicht zur sichtbaren +Fortschrittsanzeige. Entfällt der aktuelle Schritt nach einer Änderung, wird der +nächste noch sichtbare Schritt gewählt, sonst der letzte sichtbare Vorgänger. +Falls keine Schritte sichtbar sind, zeigt die UI einen lokalisierten Leerzustand +und deaktiviert den Abschluss. Ein Abschluss validiert alle effektiven Felder. + +RULE-08 Als anfängliche Produktgrenzen gelten 50 Schritte, 500 Feldplatzierungen, +100 Optionen pro Feld, 10 Verschachtelungsebenen, 10 Regelgruppenebenen und 1 MiB +unkomprimierte Definition pro Revision. Das Backend MUSS Überschreitungen +verständlich zurückweisen. Diese Grenzen sind konfigurierbar und bei Änderung +mit passenden Lasttests zu belegen. + +## 6. Verwaltung und Admin-Oberfläche + +UI-01 Die Navigation MUSS mindestens Formulare, Formulararten, Einbettungen und +Administration enthalten. Ein Formular öffnet Übersicht, Schritte/Felder, +Bedingungen, Darstellung, Übersetzungen, Vorschau und Revisionen. Die Oberfläche +MUSS deutsche und englische Bedienbeschriftungen anbieten. + +UI-02 Die Formularliste MUSS suchen, nach ID/Titel/Art/Status/Site filtern, +sortieren und paginieren. Nutzer können anlegen, kopieren, bearbeiten, +validieren, veröffentlichen, archivieren und Versionen vergleichen, jeweils +entsprechend ihrer Rolle. Die Formular-ID einer publizierten Definition ist +nicht umbenennbar; eine neue fachliche ID entsteht durch Kopieren. + +UI-03 Schritte, Felder und Optionen MUSS man anlegen, bearbeiten, entfernen und +umordnen können. Umordnen braucht neben Drag-and-drop eine Tastaturalternative. +Entfernen referenzierter Elemente MUSS die betroffenen Regeln anzeigen und +entweder gemeinsam konsistent ändern oder den Vorgang blockieren. Es darf keine +stille kaskadierende Änderung einer anderen publizierten Revision geben. + +UI-04 Die Bedingungsverwaltung MUSS passende Quellfelder, Operatoren und +Optionswerte anbieten, nur typkompatible Vergleiche zulassen und eine lesbare +Zusammenfassung anzeigen. Fehler zeigen die genaue Schritt-/Feld-/Regelstelle. +Die Ansicht MUSS erklären können, warum ein Feld oder Schritt in der Vorschau +sichtbar, unsichtbar oder erforderlich ist. + +UI-05 Vorschau MUSS Entwürfe mit demselben Regelvertrag wie die externe API +rendern und Sprache, schmale/breite Ansicht sowie Testantworten wechseln können. +Vorschau darf weder Formulare publizieren noch echte Antworten verschicken. + +UI-06 Übersetzungsansicht MUSS Vollständigkeit je Sprache zeigen. Fallbackfolge: +explizit angefragte unterstützte Sprache, deren konfigurierte Basissprache, +Formular-Standardsprache. Die Antwort nennt requestedLocale und resolvedLocale; +pro Attribut greift derselbe Fallback. Fehlende Pflichtübersetzungen der +Standardsprache blockieren Publikation. Stabile IDs werden niemals übersetzt. + +UI-07 Lade-, Leer-, Fehler- und Berechtigungszustände sind für Listen und Editoren +zu implementieren. Nicht gespeicherte Änderungen brauchen eine Verlassensabfrage. +Tastaturbedienung, zugeordnete Labels, sichtbarer Fokus, Fehlerzusammenfassung +und Fokusführung bei Schrittwechsel sind Abnahmekriterien. Modale Darstellung +benötigt Fokusbegrenzung und Fokusrückgabe an das auslösende Element. + +UI-08 Import/Export verwendet einen dokumentierten JSON-Vertrag mit +schemaVersion. Import bietet zunächst einen Dry-run mit Referenzprüfung, +Konfliktbericht und Zählung der Änderungen. Erst ein separater Commit schreibt +einen neuen Entwurf atomar. Vorhandene publizierte Revisionen werden nie ersetzt. +Unbekannte Schema-Versionen sind abzulehnen. Ein Export umfasst alle Sprachen, +Reihenfolgen und Regeln; private Integrationsdaten sind separat berechtigt. + +## 7. Relationales Persistenzmodell + +DB-01 Technische Primärschlüssel sind UUIDs; fachliche IDs bleiben eigene +Zeichenketten. Alle mandantenabhängigen Tabellen enthalten tenant_id. +Referenzen MUSS die Datenbank mit zusammengesetzten Fremdschlüsseln gegen +Mandanten-/Revisionsvermischung absichern. Ausnahmen sind ausschließlich +absichtlich mandantenweite Referenzen wie eine Formularart. + +| Tabelle | Wesentliche Spalten und Beziehungen | +| --- | --- | +| tenant | id, code | +| site | id, tenant_id, code, default_locale | +| form_type | id, tenant_id, code, active | +| form_type_i18n | tenant_id, form_type_id, locale, name, description | +| form_definition | id, tenant_id, site_id, code, current_published_revision_id | +| form_revision | id, tenant_id, form_id, revision_no, status, type_id, default_locale, lock_version, created/updated/published_at/by | +| form_revision_locale | tenant_id, revision_id, locale | +| form_revision_i18n | tenant_id, revision_id, locale, title, description, next/back/finish_label | +| form_step | id, tenant_id, revision_id, code, position, active, layout_columns | +| form_step_i18n | tenant_id, revision_id, step_id, locale, title, description | +| field_definition | id, tenant_id, revision_id, code, field_type, active, hidden, required, min/max_value, min/max_length | +| field_i18n | tenant_id, revision_id, field_id, locale, label, description, placeholder | +| field_placement | id, tenant_id, revision_id, step_id, field_id, parent_placement_id nullable, position, column_span | +| field_option | id, tenant_id, revision_id, field_id, code, position, active | +| field_option_i18n | tenant_id, revision_id, option_id, locale, label | +| field_default | tenant_id, revision_id, field_id, scalar_string/decimal/boolean/date/week nullable | +| field_default_option | tenant_id, revision_id, field_id, option_id, position | +| rule | id, tenant_id, revision_id, target_placement_id OR target_step_id, effect VISIBLE/REQUIRED | +| rule_group | id, tenant_id, revision_id, rule_id, parent_group_id nullable, operator ALL/ANY, position | +| rule_predicate | id, tenant_id, revision_id, group_id, source_field_id, operator, position | +| rule_operand | id, tenant_id, revision_id, predicate_id, position, option_id OR typisiertes Literal | +| embedding | id, tenant_id, site_id, code, view_mode, lock_version | +| embedding_form | tenant_id, embedding_id, form_id, position | +| embedding_i18n | tenant_id, embedding_id, locale, headline, description, toggle_text | +| recipient_config | id, tenant_id, revision_id, strategy_code, private integration reference | +| recipient_address | tenant_id, recipient_config_id, position, address | +| audit_event | id, tenant_id, actor, action, object_id, revision_id, timestamp, change_summary | + +DB-02 Unique Constraints: Site-Code pro Mandant; Formular-Code pro Mandant/Site; +Revisionsnummer pro Formular; Schritt-/Feld-Code pro Revision; Options-Code pro +Feld; eine Feldplatzierung pro Feld/Revision; eine Position pro Schritt für +Feldplatzierungen; eine Optionsposition pro Feld; eine Schrittposition pro +Revision; eine Übersetzung je Objekt/Locale. Eltern-/Kindfelder werden in der +Feldpositionsfolge des Schritts geführt; parent_placement_id bestimmt die +Gruppierung. Der Publikationscheck sichert eine zusammenhängende, konsistente +Elternreihenfolge ab. + +DB-03 Regelziele müssen genau eine Platzierung oder einen Schritt referenzieren; +REQUIRED ist nur für Felder zulässig. Pro Ziel/Effekt gibt es höchstens eine +Regel. Pro Regel gibt es genau eine Wurzelgruppe. Fremdschlüssel, Check Constraints +und Backendvalidierung erzwingen diese Form. Vergleichsoperanden enthalten genau +einen zum Quelltyp passenden Wert; Mehrfachwerte stehen in mehreren Zeilen. + +DB-04 Dezimale Grenzen/Defaults verwenden DECIMAL(30,10), Zeiten UTC, +Kalenderdaten DATE, Sprachen normalisierte Sprachcodes. Flags verwenden Boolean +bzw. die entsprechende MySQL-Abbildung. Case-sensitive fachliche Codes brauchen +unter MySQL eine passende explizite Collation. Konfigurationsrelationen dürfen +nicht durch kommaseparierte ID-Strings ersetzt werden. + +DB-05 JSON/Text darf exportierte Snapshots, nichtkritische Erweiterungsmetadaten +oder Audit-Diffs aufnehmen. Die verbindlichen Felder, Übersetzungen, Beziehungen +und Bedingungen bleiben relational. Laufzeitdefinitionen dürfen aus einem +unveränderlichen, validierten Snapshot gecacht werden, der jederzeit aus der +relationalen Revision reproduzierbar ist. + +DB-06 Speichern, Kopieren, Umordnen, Import und Veröffentlichung sind jeweils +transaktional. Der current_published_revision_id muss auf eine Revision desselben +Formulars/Mandanten zeigen. Mandanten-/Site-/Code-Suche, Fremdschlüssel und +Statuslisten benötigen Indizes. Listen und Definitionen dürfen keine unbeschränkten +N+1-Abfragen verursachen. Datenbankschemata werden versioniert migriert; +Produktionsstart darf kein destruktives automatisches Schema-Recreate ausführen. + +## 8. HTTP-API und Frontend-Vertrag + +API-01 Alle Endpunkte nutzen HTTPS und JSON mit versioniertem Präfix. API-Version +und Formularrevision sind unterschiedliche Größen. Die vollständigen Schemas, +Beispiele, Statuscodes und Authentisierung MUSS eine OpenAPI-Beschreibung liefern. +Die BTP-API ist ein neuer Vertrag; es gibt keine stillschweigende OCC-Kompatibilität. + +Öffentliche, je nach Formularzugriffspolitik anonym oder authentisiert lesbare API: + +| Methode / Pfad | Verhalten | +| --- | --- | +| GET /api/v1/sites/{siteId}/forms | Nur aktuell publizierte Formulare; type, locale, limit, cursor | +| GET /api/v1/sites/{siteId}/forms/{formId} | Vollständige publizierte Definition mit Schritten, Feldern und Regeln | +| GET /api/v1/sites/{siteId}/forms/{formId}/revisions/{revision} | Noch freigegebene unveränderliche Revision | +| POST /api/v1/sites/{siteId}/forms/{formId}/validate | Antworten gegen explizite Revision prüfen; keine Persistenz | +| POST /api/v1/sites/{siteId}/forms/{formId}/submissions | Validierte Antworten speichern; 201 bzw. 422 | +| GET /api/v1/sites/{siteId}/embeddings/{embeddingId} | Öffentliche Einbettung mit ViewMode und Formular-IDs | + +API-02 Listen verwenden eine stabile Code-/ID-Sortierung und opake Cursor, +Standardlimit 20, Maximum 100. Sie liefern items und nextCursor. Einzeldefinitionen +enthalten alle zum Rendern nötigen Daten, keine zyklischen Objektreferenzen und +keine Empfänger, internen Benutzerinformationen oder Integrationsgeheimnisse. + +API-03 Beispiel einer Definition (gekürzt um zusätzliche Metadaten): + +```json +{ + "schemaVersion": "1.0", + "id": "contact", + "revision": 3, + "type": "CONTACT", + "requestedLocale": "de", + "resolvedLocale": "de", + "title": "Kontakt", + "presentation": {"stepMode": "WIZARD", "columns": 1}, + "steps": [ + {"id": "topic", "title": "Anliegen", "position": 0, "fields": ["reason"]}, + {"id": "details", "title": "Technische Angaben", "position": 1, + "fields": ["serial"], "visibleWhen": "technical-details"} + ], + "fields": [ + {"id": "reason", "fieldType": "SELECT", "label": "Grund", "required": true, + "active": true, "hidden": false, + "options": [{"id": "general", "label": "Allgemein", "position": 0}, + {"id": "technical", "label": "Technik", "position": 1}]}, + {"id": "serial", "fieldType": "TEXT", "label": "Seriennummer", + "active": true, "hidden": false, "required": true, "maxLength": 80} + ], + "rules": [ + {"id": "technical-details", "expression": {"op": "ALL", "children": [ + {"fieldId": "reason", "op": "EQ", "value": "technical"} + ]}} + ] +} +``` + +Felder stehen in einer flachen Liste mit stabilen IDs; steps[].fields definiert +Wurzelfelder in Reihenfolge. Für Unterfelder enthält eine Feldplatzierung +parentFieldId, position und visibleWhen. Ein öffentliches Feldobjekt kombiniert +Felddefinition und genau eine Platzierung. Regeln referenzieren ausschließlich +IDs desselben Dokuments. Die öffentliche Regel-ID ist ein stabiler Bezeichner +innerhalb der Revision, getrennt von der technischen Datenbank-UUID. + +API-04 Validierungsrequest: + +```json +{"revision": 3, "answers": {"reason": "technical", "serial": "ABC-123"}} +``` + +Erfolg: HTTP 200 mit valid=true, effectiveStepIds, effectiveFieldIds und errors=[]. +Fachlich ungültige Antworten: HTTP 200, valid=false, feldbezogene Fehler mit code, +fieldId, stepId und lokalisierter message. Unbekannte Antwortfelder, +nicht effektive Felder oder unzulässige Options-IDs MUSS die API als Fehler +melden; sie dürfen nicht stillschweigend in eine Verarbeitung gelangen. +Die API darf keine Passwort-/Dateiinhalte oder kompletten Antworten spiegeln. + +API-05 Transportfehler: 400 für syntaktisch ungültige Requests/Parameter, 401 bei +fehlender erforderlicher Identität, 403 für fehlende Rechte, 404 für nicht +sichtbare/unbekannte Formulare oder Revisionen, 409 für Lebenszykluskonflikte, +412/428 für Sperrbedingungen, 413 für Größenlimits, 422 für fachlich ungültige +Admin-Konfiguration, 429 für Rate-Limits mit Retry-After. Fehlerkörper: +code, message, correlationId, optional violations mit path/code/message; +keine Stacktraces oder internen SQL-/Identitätsdaten. + +API-06 Definitions-GET liefert ETag und unterstützt If-None-Match/304. Der ETag +berücksichtigt Revision, Sprache und öffentliche Darstellung. Mandant, Site, +Zugriffspolitik und Sprache gehören in Cache-Schlüssel. Private Inhalte sind +nicht öffentlich cachebar. Publizieren/Archivieren invalidiert den Latest-Zeiger; +eine archivierte Revision darf nicht über einen noch gültigen öffentlichen Cache +weiter verfügbar bleiben. Cache-Laufzeiten und Invalidierung sind zu testen. + +API-07 Admin-Endpunkte stehen unter /admin/api/v1/sites/{siteId} und verlangen +eine Admin-Identität. Minimaler Vertrag: + +| Operation | Endpunkt | +| --- | --- | +| Liste / Neuanlage | GET /forms, POST /forms | +| Metadaten / Entwurf / Löschen | GET /forms/{id}, POST /forms/{id}/revisions, DELETE /forms/{id}/revisions/{rev} | +| Entwurf lesen / atomar ändern | GET/PATCH /forms/{id}/revisions/{rev} | +| Prüfen / Veröffentlichen | POST /forms/{id}/revisions/{rev}/validate, POST .../publish | +| Archivieren | POST /forms/{id}/archive | +| Vergleich / Export | GET /forms/{id}/revisions/{rev}/diff?against=..., GET .../export | +| Import | POST /imports/validate, POST /imports/{validatedImportId}/commit | +| Formulararten | GET/POST /form-types, GET/PATCH/DELETE /form-types/{code} | +| Einbettungen | GET/POST /embeddings, GET/PATCH/DELETE /embeddings/{id} | +| Anfragen | GET /submissions, GET /submissions/{id}, GET /forms/{id}/submissions; getrennte Antwortrechte | + +PATCH einer Revision enthält eine vollständige, validierbare Änderungseinheit +für Schritte/Felder/Optionen/Regeln/Übersetzungen. JSON Merge Patch ersetzt Arrays +vollständig; kein implizites Mischen nach Array-Index. Der Dienst MUSS Größenlimits, +Referenzen und If-Match prüfen. Import-IDs sind kurzlebig und an Nutzer, Mandant, +Site und geprüften Dateninhalt gebunden; Commit prüft Konflikte erneut. +Formulararten sind mandantenweit und ihre Rechte gelten unabhängig vom Site-Pfad. + +API-08 Ein optionaler OCC-Kompatibilitätsadapter KANN die bisherigen /forms-Routen, +Array-Liste und BASIC/DEFAULT/FULL-Selektion abbilden. Er MUSS separat versioniert +und getestet werden. Echte Schritte und allgemeine Regeln lassen sich nicht +verlustfrei in childFields abbilden; inkompatible Definitionen müssen eindeutig +abgelehnt werden. Keine stillschweigende Abflachung der fachlichen Steuerung. + +FE-01 Das Frontend lädt eine Definition per ID, bindet seinen Antwortzustand an +die gelieferte Revision, erzeugt Eingabeelemente über eine Feldtyp-Registry und +verwendet die in Abschnitt 5 definierte Regelauswertung. Frontend-Validierung +unterstützt Nutzer; Backendvalidierung bleibt verbindlich. Bei Abschluss wird +die ursprüngliche Revision mitgegeben. Ein zwischenzeitliches Publishing darf +die bereits angezeigte Definition nicht unbemerkt austauschen. + +FE-02 Ladefehler zeigen eine wiederholbare Fehlermeldung, unbekannte/archivierte +Formulare einen Nicht-verfügbar-Zustand. Bei einer nicht mehr zulässigen Revision +muss ein bewusstes Neuladen erfolgen. Antworten dürfen nicht automatisch auf +anders typisierte Felder einer neuen Revision übertragen werden. + +## 9. Verpflichtender Scope: Antworten und Verarbeitung + +SUB-01 Ein separater Submissions-Controller MUSS POST /api/v1/sites/{siteId}/ +forms/{formId}/submissions bereitstellen. Der Vertrag enthält revision, answers und +Idempotency-Key. Erfolg liefert 201, submissionId und status. Derselbe Schlüssel +mit gleichem Request darf keine Doppelverarbeitung auslösen; anderer Inhalt mit +demselben Schlüssel liefert 409. Die Bindung umfasst Mandant, Site, Identität und +Formular. Die Aufbewahrungszeit des Schlüssels ist im API-Vertrag zu definieren. + +SUB-02 Der Server MUSS vor Persistenz die freigegebene Revision, Typen, +Pflichtfelder, Regeln, Constraints und Dateireferenzen prüfen. Empfänger werden +serverseitig aus privaten Strategien bestimmt. Eine dynamische Empfängerauswahl +darf nur freigegebene fachliche Zielcodes annehmen, keine beliebige Adresse. + +SUB-03 Antworten benötigen eigene relationale Tabellen submission, answer, +answer_option und attachment_reference mit Revisionsbezug. Ein normalisiertes +Antwort-JSON und ein unveränderlicher Snapshot mit Feld-/Options-IDs, Schritten, +Reihenfolge und allen Übersetzungen MUSS zusätzlich für Darstellung und +Integrationen verfügbar sein. Alle Daten werden über die Service-Schicht atomar +in der persistenten Datenbank gespeichert; In-Memory-Speicherung genügt nicht. Verarbeitung an +Ticket-/Mail-/Integrationsdienste erfolgt über eine transaktionale Outbox mit +Retry, fachlichem Status und Idempotenz. Ein Fehler beim Versand darf keine +zweite Antwort anlegen. Passwortwerte dürfen nicht standardmäßig dauerhaft +gespeichert werden; die konkrete fachliche Nutzung bedarf eines eigenen Vertrags. + +SUB-04 FILE braucht einen separaten Uploadvertrag mit Größen-/Typgrenzen, +Berechtigungs-/Besitzprüfung, Ablauf nicht verwendeter Uploads und Prüfung des +Inhalts vor Verarbeitung. Die Definition enthält nur erlaubte Typen/Grenzen. +Ohne konfigurierten Uploadanbieter MUSS Publizieren eines FILE-Felds im +annahmefähigen Formular blockieren. Reine Definitions-Renderer müssen die +fehlende Uploadfähigkeit explizit behandeln. + +SUB-05 Aufbewahrung, Löschung, Zugriff und Export von Antworten werden pro +Formular festgelegt. Audit- und Betriebslogs dürfen keine vollständigen Antworten, +Passwörter oder Uploadinhalte aufnehmen. Löschregeln berücksichtigen Datenbank, +Dateispeicher, Outbox und Backups gemäß dem beschlossenen Betriebskonzept. + +SUB-06 Der Controller MUSS Formulardaten an eine Facade bzw. Anwendungsschicht +übergeben. Diese MUSS die Validierung nach MOD/RULE vor dem Service-Aufruf +verbindlich ausführen. Antworten sind anhand der konfigurierten Feld-IDs zu +übermitteln; lokalisierte Labels sind keine Schlüssel. Auswahlwerte referenzieren +Options-IDs. Ein fachlich ungültiger POST liefert 422 und persistiert weder +Submission noch Outbox-Ereignis. Fehler werden intern bei Spring über Errors / +BindingResult repräsentiert und extern gemäß API-05 mit stabilen Fehlercodes, +fieldId, stepId und lokalisierter message ausgegeben; keine rejectedValue-Daten. +Syntaktisch fehlerhafte Requests liefern 400, unbekannte/unzugängliche Formulare +404. Ein erfolgreicher POST liefert erst nach DB-Commit 201 CREATED mit +submissionId, formId, revision, submittedAt und status. Die reine Validate-API +behält den 200/valid=false-Vertrag aus API-04. + +SUB-07 Metadaten umfassen serverseitigen UTC-Zeitpunkt, Mandant, Site, +Formular-ID und Revision, authentisierten Benutzer bzw. expliziten anonymen +Status, aufgelöste Sprache, begrenzten Browser-User-Agent und correlationId. +Eine Absender-IP ist gemäß projektseitiger Datenerfassungsrichtlinie optional; +Forwarded-Header dürfen nur über konfigurierte vertrauenswürdige Proxies +aufgelöst werden. Identität, Zeit und Scope dürfen nicht aus dem Antwort-JSON +übernommen werden. Metadaten dürfen keine Tokens oder Session-Cookies enthalten. + +SUB-08 Die Administration MUSS unter Formulare eine separate Kategorie „Anfragen“ +mit paginierter Liste und Filtern für Formular, Revision, Zeitraum, Site und Status +anbieten. Formulardetails verlinken auf die zugehörigen Anfragen. Eine getrennte +Berechtigung für Servicemitarbeiter steuert Lesen und Export von Antworten; +Definitionsleserechte implizieren keinen Zugriff auf personenbezogene Antworten. +Die Detailansicht zeigt zuerst einen schreibgeschützten Renderer mit Feldlabel und +Antwort untereinander, lokalisierte Options-/Enum-Bezeichnungen sowie getrennte +Sektionen pro Schritt in ursprünglicher Reihenfolge. Boolean, fehlende Antworten +und Metadaten werden lokalisiert; technische IDs dienen als letzter Fallback. +Der unveränderliche Snapshot verhindert eine nachträgliche Umdeutung alter Antworten. +Roh-JSON und Snapshot gehören ausschließlich in einen separaten technischen Tab. +Alle Antwortwerte werden als Text escaped; Leer-, Lade- und Fehlerzustände sowie +lange Texte, Tastaturbedienung und Sprachwechsel sind abzudecken. + +SUB-09 Nach erfolgreichem Commit MUSS ein internes Ereignis +DynamicFormSubmissionCreated bereitstehen. Da es in BTP kein Commerce-ClusterEvent +gibt, MUSS ein konfigurierbarer Adapter dieses Ereignis über SAP BTP Event Mesh +publizieren können. Der Adapter ist standardmäßig deaktiviert. Seine transaktionale +Outbox wird zusammen mit der Submission geschrieben; Veröffentlichung erfolgt +nach Commit mit Retry/Backoff, Dead-Letter-Behandlung und Monitoring. Ein Brokerfehler +ändert keine bereits erfolgreiche 201-Antwort und erzeugt keine zweite Submission. +Das versionierte Ereignis enthält eventId, eventType, occurredAt, tenantId, siteId, +formId, revision, submissionId und correlationId; vollständige Antworten und +Benutzerdaten gehören nicht in die Standardnachricht. Projektspezifische Consumer +laden autorisiert über die Submission-Referenz nach. Topic, Service Binding, +Berechtigungen und Schema werden dokumentiert. Zustellung ist mindestens einmal; +Consumer müssen anhand eventId idempotent reagieren. Rollback erzeugt kein Ereignis. + +SUB-10 Ein vorbereiteter, standardmäßig deaktivierter E-Mail-Consumer MUSS +mitgeliefert werden. Aktivierung und Empfängerauflösung erfolgen ausschließlich +serverseitig. Pro Formularrevision wird ein privates lokalisiertes Thymeleaf-HTML- +Template konfiguriert; nur berechtigte Administratoren dürfen es ändern. Auswahl: +angeforderte Sprache, Formular-Standardsprache, statisches mitgeliefertes Fallback. +Ungültige konfigurierte Templates führen zu einem nachvollziehbaren Versandfehler, +nicht stillschweigend zu einem anderen Template. Templates erhalten einen typisierten +answers-Zugriff auf das gesamte JSON, answersJson, den lokalisierten View mit Schritten +und freigegebene Metadaten. Das Fallback stellt alle Antworten lesbar und escaped dar. +Antwortwerte dürfen nie selbst als Template ausgewertet werden. Die Commerce- +Vorbereitung verwendet cxdevtools HtmlEmailGenerator/HtmlEmailService; die BTP- +Implementierung MUSS gleichwertige abstrahierte Generator-/Versanddienste ohne +Commerce-Laufzeitabhängigkeit bereitstellen. Versandfehler ändern den Speichererfolg +nicht; Retry, Versandstatus und Idempotenz müssen Mehrfachversand soweit technisch +möglich vermeiden und die Ungewissheit nach SMTP-Annahme explizit behandeln. + +## 10. BTP-Bereitstellung und Betrieb + +OPS-01 Zielarchitektur: stateless Backend und Admin-Webanwendung auf SAP BTP mit +relationalem, persistentem Datenbankdienst. Cloud Foundry ist die bevorzugte +Deployment-Variante dieser Konzeption; Kyma ist eine mögliche alternative +Betriebsentscheidung. Anwendung und Datenbank benötigen getrennte DEV/TEST/PROD- +Instanzen. Eine PostgreSQL- oder MySQL-Instanz im flüchtigen App-Dateisystem ist +nicht zulässig. + +OPS-02 Für PostgreSQL ist eine Instanz von „PostgreSQL on SAP BTP, Hyperscaler +Option“ im vorgesehenen Subaccount/Space zu provisionieren und per Service Binding +an das Backend anzubinden. SAP dokumentiert Entitlements, Anlage über das BTP +Cockpit und Application Binding. Die konkrete Region und der Serviceplan müssen +vor Projektstart im Zielaccount verifiziert werden. Quelle: +https://help.sap.com/docs/postgresql-on-sap-btp/postgresql-on-sap-btp-hyperscaler-option/provision-postgresql-using-cockpit +(abgerufen 09.09.2026). + +OPS-03 Diese Spezifikation verspricht keinen allgemein verfügbaren nativen +MySQL-Serviceplan in jedem BTP-Account. MySQL ist erst wählbar, wenn der Betreiber +einen im BTP-Betriebskonzept provisionierten Dienst einschließlich privater +Konnektivität, Binding/Secret-Verwaltung, Backup und Support nachgewiesen hat. +Eine angebundene Hyperscaler-Datenbank benötigt eine ausdrückliche Entscheidung, +ob sie die Anforderung „in der BTP bereitgestellt“ im Projekt erfüllt. Andernfalls +ist PostgreSQL nach OPS-02 verbindlich. SAP dokumentiert beispielsweise die +private Anbindung von Azure-Diensten einschließlich Azure Database for MySQL: +https://help.sap.com/docs/private-link/private-link1/consume-azure-services-in-sap-btp +(abgerufen 09.09.2026). + +OPS-04 Authentisierung MUSS an den vorgesehenen BTP-Identitätsdienst angebunden +werden; Admin- und externe API benötigen getrennte Scopes/Audiences. Tokens sind +serverseitig auf Signatur, Aussteller, Zielgruppe, Ablauf und Rollen zu prüfen. +CORS erlaubt nur konfigurierte Frontend-Ursprünge. Browser erhalten keine +Datenbankcredentials. Secrets kommen aus Service Bindings/Secret-Verwaltung, +nicht aus Repository oder öffentlichen Konfigurationsendpunkten. + +OPS-05 Pflichtartefakte: reproduzierbarer Build, Deployment-Descriptor, deklarierte +Serviceabhängigkeiten, Umgebungsparameter, Schema-Migrationen und Runbook für +Installation, Update, Rollback, Restore und Credential-Rotation. Migrationen +müssen mit der gewählten Datenbank in CI getestet werden. Wird Unterstützung für +beide Datenbanken ausgeliefert, sind dieselben Akzeptanztests für beide Pflicht. + +OPS-06 Logs sind strukturiert, enthalten correlationId und technische Ereignisse, +keine Antwortinhalte. Metriken umfassen API-Latenz/-Fehler, Publikationsfehler, +Cachetreffer, DB-Poolauslastung und gegebenenfalls Outbox-Rückstände. Liveness darf +keinen Datenbankzugriff erzwingen; Readiness MUSS die Betriebsfähigkeit prüfen. +Health-Endpunkte dürfen keine Credentials oder internen Details offenlegen. + +OPS-07 Vorgeschlagene Abnahmeziele: p95 < 500 ms für eine gecachte vollständige +Definition bei 100 parallelen Lesern; 99,9 % monatliche Verfügbarkeit; RPO <= 24 h +und RTO <= 4 h. Dies sind Produktziele, keine behaupteten Zusagen eines BTP-Plans. +Sie sind anhand repräsentativer maximaler Definitionen, gewähltem Plan und +Restore-Probe zu bestätigen oder vor Implementierung begründet anzupassen. + +## 11. Abnahmekriterien und Tests + +| ID | Szenario / erwartetes Ergebnis | +| --- | --- | +| A01 | Admin erstellt zweisprachiges Formular mit drei Schritten; Speicherung und erneutes Laden erhalten Daten und Reihenfolge vollständig. | +| A02 | Auswahl general blendet Technikschritt aus; technical blendet ihn mit Pflicht-Seriennummer ein. Zurück-/Weiter-Navigation bleibt konsistent. | +| A03 | Mehrfachauswahl aktiviert die Vereinigung der Zielunterfelder ohne doppelte Platzierungen. Abwahl entfernt unzulässige Antworten. | +| A04 | Inaktive Felder, Eltern oder Schritte unterdrücken auch abhängige Felder; hidden allein ändert Effektivität nicht. | +| A05 | Direkter, indirekter und über Schritte laufender Zyklus verhindern Publikation mit verständlichem Fehlerpfad. | +| A06 | Ungültige Zahlen-/Längengrenzen, nicht passende Defaults und fremde Options-IDs werden abgelehnt. 0 und false bleiben typgerecht unterscheidbar. | +| A07 | Neue Publikation beeinflusst keine laufende, revisionsgebundene Formularinstanz; alte freigegebene Revision bleibt eindeutig abrufbar. | +| A08 | Zwei parallele Bearbeitungen verursachen 412 statt Datenverlust; eine fehlgeschlagene Publikation ändert die öffentliche Definition nicht. | +| A09 | Fremder Mandant/Site, fehlende Rollen und Entwurfsabruf über öffentliche API liefern keine privaten Daten. Empfänger fehlen in allen öffentlichen Projektionen. | +| A10 | Sprachfallback, Unicode-Längenzählung, DATE/WEEK und Dezimalgrenzen sind zwischen Frontend und Backend durch gemeinsame Testvektoren belegt. | +| A11 | ETag/304 und Cursor-Pagination funktionieren; Publish-/Archivierung invalidiert Latest-Caches ohne Scope-Leak. | +| A12 | Import/Export-Rundlauf erhält alle Sprachen und Regeln; fehlerhafter Import schreibt keine Teilobjekte. | +| A13 | Definitionen nach Prozessneustart und Restore sind identisch; DB-Constraints verhindern mandantenfremde Referenzen. | +| A14 | Vorschau, schmale Ansicht, Tastaturbedienung, Fokusführung und Fehlerzustände werden in Browser-E2E-Tests geprüft. | +| A15 | Last-/Größenlimits, Rate-Limits und definierte Betriebsziele sind mit repräsentativen Daten gemessen. | +| A16 | doppelte Requests erzeugen genau eine Antwort/Outbox-Verarbeitung; manipulierte Antworten/Empfänger/Dateireferenzen werden abgelehnt. | + +| A17 | Ungültiger Submission-POST liefert 422 mit deutschen/englischen Feldfehlern; keine Daten und kein Ereignis werden gespeichert. Gültiger POST liefert 201 erst nach DB-Commit. | +| A18 | Reload nach Prozessneustart erhält Antworten und serverseitige Metadaten. Nach Änderungen an Definition/Labels bleiben alte Anfragen durch Snapshot korrekt lesbar. | +| A19 | Servicemitarbeiter sieht Schrittsektionen, lokalisierte Auswahlwerte und einen separaten JSON-Tab; fehlende Antwortrechte verweigern Liste, Details und Export. | +| A20 | DB-Rollback veröffentlicht kein Ereignis. Broker-Ausfall nach Commit erhält Submission und Outbox; Retry und doppelte Zustellung erzeugen keine doppelte fachliche Verarbeitung. | +| A21 | Mail-Consumer ist zunächst deaktiviert. Aktivierung verwendet das konfigurierte lokalisierte Template bzw. Fallback; HTML in Antworten bleibt escaped. Versandfehler verändern die gespeicherte Anfrage nicht. | + +Zusätzlich verpflichtend: Unit-Tests für Regelengine/Validierung, +API-Vertragstests, Integrationstests gegen die reale gewählte relationale Datenbank, +Transaktions-/Concurrency-Tests, Berechtigungstests und Restore-Probe. Ein +In-Memory-Datenbanktest ersetzt keinen PostgreSQL-/MySQL-Integrationstest. + +## 12. Vor Implementierungsbeginn festzulegende Entscheidungen + +1. BTP-Zielaccount, Region, Runtime, Datenbankplan und Entitlements bestätigen; + PostgreSQL nach OPS-02 bleibt bis zu einer anderen genehmigten Entscheidung Ziel. +2. Anonyme versus authentisierte Formulare und Site-/Mandantenmodell konkretisieren. +3. Produktgrenzen und Betriebsziele bestätigen; unterstützte Sprachen bestimmen. +4. Uploadanbieter, Event-Mesh-Topic/Binding und Aktivierung der mitzuliefernden + Integrationsadapter festlegen; Speicherung und Anfragen-Administration sind Pflichtscope. +5. Identitätsdienst, Integrationsziele und Aufbewahrungsregeln festlegen. +6. Migrierbare Bestandsfälle und erforderlichen OCC-Kompatibilitätsumfang abnehmen. + +Diese Entscheidungen sind Umsetzungsparameter der späteren BTP-Anwendung. Sie +blockieren nicht die parallele Extraktion der bestehenden cxdevforms-Extension. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/LICENSE.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/LICENSE.md new file mode 100644 index 00000000..348e6f88 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2025] [CX DEV Tools] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/README.md b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/README.md new file mode 100644 index 00000000..573a4bab --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/README.md @@ -0,0 +1,335 @@ +# CX DEV Forms + +`cxdevforms` provides configurable form definitions, Backoffice administration, +services, a facade and an OCC API in a single SAP Commerce extension. Frontends +retrieve a form by its ID and use its field definitions, validation metadata and +conditional child fields to build the user interface. + +Java package: `me.cxdev.commerce.forms`. + +## Features + +- Manage forms, fields, selectable values and form types in Backoffice. +- Localize form titles, descriptions, field labels, placeholders and option labels. +- Configure field order, input types, defaults, visibility and validation metadata. +- Associate selectable values with conditional child fields, including nested dependencies. +- Retrieve all form definitions or a single definition by ID through services and OCC. +- Select response properties through OCC BASIC, DEFAULT, FULL or custom field sets. +- Use German and English Backoffice labels and type localization. + +## Installation + +The extension requires `cxdevtoolkit`, `cxdevbackoffice` and +`commercewebservices`, as declared in [extensioninfo.xml](extensioninfo.xml). + +Add the extension to `localextensions.xml`: + +```xml + +``` + +Build with `ant all` using the JDK and Ant environment required by your Commerce +installation, then perform a System Update for `cxdevforms`. Create form types +and definitions in Backoffice or through ImpEx. + +The global Spring context is configured in [project.properties](project.properties): + +```properties +cxdevforms.application-context=cxdevforms-spring.xml +``` + +OCC loads the web context from +`resources/occ/v2/cxdevformsocc/web/spring/cxdevforms-web-spring.xml` through the +standard `classpath*:/occ/v2/*occ/web/spring/*-web-spring.xml` resource pattern. + +## Data model + +The `DynamicForms` type group contains four item types, supported by two enums, +three relations and the `DynamicFormFieldValueList` collection type. + +| Type | Configuration | +| --- | --- | +| `DynamicForm` | Unique ID, localized title and description, form type, recipients, dynamic-recipient flag and ordered fields | +| `DynamicFormField` | Unique ID, localized label, description and placeholder, input type, active/hidden/required flags, constraints, default value and selectable values | +| `DynamicFormFieldValue` | Unique ID, localized label and conditional child fields | + +IDs are globally unique within each item type. Definitions are independent of +catalog versions. `DynamicFormType` is a dynamic enum for business categories +such as `CONTACT`; additional categories can be configured in Backoffice. +`DynamicFormFieldType` defines the supported input types. + +### Deployment + +| Type / Relation | Table | Typecode | +| --- | --- | --- | +| DynamicForm | cxdevformsform | 31175 | +| DynamicFormField | cxdevformsfield | 31176 | +| DynamicFormFieldValue | cxdevformsfieldvalue | 31177 | +| DynamicFormFieldValues2DynamicFormFields | cxdevformsfield2value | 31178 | +| DynamicFormSubmission | cxdevformsubmission | 31179 | + +### Field configuration + +Supported field types: + +`TEXT`, `EMAIL`, `HIDDEN`, `PASSWORD`, `COLOR`, `TEXTAREA`, `NUMBER`, `RADIO`, +`CHECKBOX`, `CHECKBOXES`, `SELECT`, `FILE`, `DATE`, `WEEK`. + +| Property | Purpose | +| --- | --- | +| `active` | Controls inclusion of a form's top-level field; defaults to `true` | +| `hidden` | Supplies visibility metadata to the frontend; defaults to `false` | +| `required` | Marks a field as mandatory; defaults to `false` | +| `fieldType` | Selects the input type; defaults to `TEXT` | +| `minValue`, `maxValue` | Numeric validation bounds | +| `minLength`, `maxLength` | Text-length validation bounds | +| `defaultValue` | Initial value; for option-based inputs, use the selectable value's ID | +| `placeholder` | Localized input hint | +| `formFieldValues` | Ordered list of selectable values | + +The frontend applies these rendering and validation settings. `recipients` and +`dynamicRecipient` describe recipient configuration; submission notifications can use the optional listener described below. The default +listener uses only configured recipients; client-supplied email routing is not supported. + +### Relationships and conditional fields + +`DynamicForm2DynamicFormFields` associates a form with its ordered `formFields`. +A field can belong to one form through its `form` reference. + +`DynamicFormFieldValues2DynamicFormFields` connects selectable values to their +`childFields`. The reverse reference on a field is `parentFieldValues`. This is +a many-to-many relationship without a defined child-field order. + +A frontend uses the selected option's ID to determine which child fields to +activate. Child fields can themselves contain options and further child fields, +allowing nested conditional sections. Configure these dependencies without cycles. +The form structure consists of fields and conditional child fields; the renderer +controls their presentation and navigation. + +## Backoffice administration + +Navigate to **CX DEV Tools → Forms**: + +- **Form definitions**: forms, fields and selectable values. +- **Configuration**: form types and field types. + +Backoffice provides search, list views, editors and creation wizards for managing +definitions and their relationships. + +1. Create a form type with a business code such as `CONTACT`. +2. Create a form with a unique ID, type and localized title. +3. Create fields with unique IDs, labels, input types and validation settings. +4. Assign top-level fields to the form and arrange their order. +5. Create selectable values and add them to each relevant field's ordered value list. +6. Assign conditional child fields to the values that activate them. Fields that + should appear only conditionally should be configured as child fields rather + than also being assigned as top-level fields. + +## Services and Spring configuration + +| Bean / alias | Purpose | +| --- | --- | +| `cxDynamicFormService` / `dynamicFormService` | Retrieve all form models or find a model by ID | +| `cxDynamicFormFacade` / `dynamicFormFacade` | Convert form models to `DynamicFormData` | +| `cxDynamicFormDataConverter` | Convert form metadata and active top-level fields | +| `cxDynamicFormFieldConverter` | Convert field metadata and selectable values | +| `cxDynamicFormFieldValueConverter` | Convert selectable values and nested child fields | + +`DynamicFormService.getAllDynamicForms()` returns an immutable copy of the model +list. `getDynamicFormForId(id)` returns an `Optional`. +The facade exposes the corresponding data objects. + +The field and value converters resolve their mutual references through +`ObjectFactoryCreatingFactoryBean`. Populators are located in +`me.cxdev.commerce.forms.facade.populator`. + +Three data classes in `me.cxdev.commerce.forms.data` and three corresponding +DTOs in `me.cxdev.commerce.forms.dto` are generated from +[resources/cxdevforms-beans.xml](resources/cxdevforms-beans.xml). + +## OCC API + +Controller: `me.cxdev.commerce.forms.controller.CxDynamicFormsController`. +Swagger tag: **Dynamic Forms**. + +| Request | Response | +| --- | --- | +| `GET /occ/v2/{baseSiteId}/forms` | Array of all form definitions | +| `GET /occ/v2/{baseSiteId}/forms/{id}` | One form definition identified by ID | + +Both endpoints accept the optional `fields` parameter, defaulting to `DEFAULT`. + +| Field set | Form response | +| --- | --- | +| `BASIC` | `id`, `type`, `title`, `description` | +| `DEFAULT` | BASIC properties plus `recipients`, `dynamicRecipient` and `formFields(DEFAULT)` | +| `FULL` | DEFAULT properties with `formFields(FULL)` | + +Field responses include input metadata and selectable values. Each value can +include `childFields`, producing the nested definition structure. Custom OCC +field selections are passed to `DataMapper`. Platform field-set size and recursion +limits apply to nested responses. + +The mapping configuration is located in +`resources/occ/v2/cxdevformsocc/web/spring/config/`: + +- `cxdevforms-dto-mappings-spring.xml`: data-to-DTO class mappings. +- `cxdevforms-dto-level-mappings-spring.xml`: BASIC, DEFAULT and FULL field sets. + +### Frontend usage + +```javascript +const response = await fetch( + `/occ/v2/${encodeURIComponent(baseSiteId)}/forms/${encodeURIComponent(formId)}?fields=DEFAULT&lang=en` +); +if (!response.ok) throw new Error(`Unable to load form: ${response.status}`); + +const form = await response.json(); +if (!form.id) throw new Error('Form not found'); +``` + +Render `form.formFields` using each field's `fieldType`, labels, defaults and +constraints. Populate choice inputs from `field.formFieldValues`. When a selection +changes, evaluate the selected value's `childFields` and update the active input +controls. The consuming frontend submits ID-keyed answers to the submission endpoint below. + +### Response behavior + +- An unknown form ID produces an empty data object and an HTTP 200 response with + an unpopulated DTO. Null-property serialization follows the OCC configuration. +- Form lookup is global. The service does not filter definitions by `baseSiteId`; + the list response is unpaginated and has no guaranteed ordering. +- Only active top-level fields are included. Nested child fields are converted + independently of their `active` flag. +- `hidden` is returned as metadata; hidden fields remain part of the definition. +- Field data and DTOs contain a `value` property, which the definition populators + leave unset. +- Localization and access control follow the installed OCC configuration. + + +## Form submissions + +`CxDynamicFormsSubmissionsController` accepts +`POST /occ/v2/{baseSiteId}/forms/{id}/submissions?lang=de`: + +```json +{ + "answers": { + "name": "Ada Lovelace", + "reason": "technical", + "serial": "ABC-123", + "consent": true, + "products": ["product-a", "product-b"], + "quantity": "2.5" + } +} +``` + +Use field IDs as keys and option IDs as values. TEXT/EMAIL/HIDDEN/TEXTAREA/COLOR, +DATE/WEEK, SELECT/RADIO and NUMBER use strings (decimal point for NUMBER). +CHECKBOX uses a JSON boolean; CHECKBOXES uses an array of distinct option IDs. +Missing/null and blank text mean unanswered; required CHECKBOX must be `true`. +Zero is a valid number. Constraints count Unicode codepoints and use decimal arithmetic. +Active hidden fields remain subject to validation. Only selected options activate +conditional child fields; unknown, inactive and unselected fields are rejected. +No implicit defaults are inserted by the submission service. + +The controller delegates to `dynamicFormSubmissionFacade`. The facade validates +against the stored definition and delegates valid answers to +`dynamicFormSubmissionService`; it throws `SubmissionValidationException` containing +standard Spring `Errors` on failure. HTTP responses: + +- `201 CREATED`: `{ "submissionId": "UUID", "formId": "contact", "submittedAt": ... }` after commit. +- `422 Unprocessable Entity`: localized `errors` with stable `code`, `field` + (for example `answers[email]`) and `message`; no rejected values are reflected. +- `400 Bad Request`: unreadable JSON; `404 Not Found`: unknown form ID. + +Language uses the OCC-resolved session language, including `lang`. Definitions +remain global as in the existing GET API; base site is recorded from server context. +OCC authentication/access control remains platform-owned. There is no public +submission GET endpoint. Configure Backoffice type permissions for authorized +service staff. No new broad permissions are granted by this extension. + +`DynamicFormSubmission` stores answers as JSON, a definition/translation snapshot, +server time, current user (including the platform anonymous user), current base site, +resolved language, bounded User-Agent and servlet remote address. Forwarded headers +are not trusted by this controller. Apply project retention and proxy policies in +integration. The form relation is not part-of: deleting a definition must not silently +cascade to historical submissions. Do not delete definitions referenced by submissions. + +PASSWORD answers are rejected to avoid durable plaintext secrets. FILE accepts a +list of references only when a project replaces `cxDynamicFormSubmissionFileValidator` +with a `SubmissionFileValidator` checking ownership, allowed type/size and scan status. +The supplied validator rejects every file reference. This extension provides no upload API. +Requests allow at most 1000 fields, text values at most 65536 codepoints and conditional +depth at most 64; configure HTTP request size/rate limits at the OCC edge as well. + +### Backoffice answers + +Navigate to **CX DEV Tools → Forms → Submissions** (German: **Anfragen**), or open +`submissions` on a form. Filter by form, time, user or site. The initial Answers tab +renders snapshot labels and translated option values as plain text. Metadata and +raw JSON have separate tabs. The editor is read-only; authorization still requires +platform type permissions. Optional `DynamicFormField.stepId` and localized +`stepTitle` group answers into sections in first-occurrence order. These are +presentation groups, not a new navigation/conditional-step engine. The definition +API exposes both properties. Ungrouped fields appear in a general answer section. +Localization falls back from requested locale to language, submitted language, +English, then technical ID. Snapshots preserve translations across definition edits. + +### Events and optional email notifications + +`DynamicFormSubmissionCreatedEvent` implements `ClusterAwareEvent` and +`TransactionAwareEvent`. The service publishes after saving inside a Commerce +transaction; `publishOnCommitOnly()` prevents delivery for rolled-back transactions. +The event references the submission PK/UUID, form ID and originating cluster node; +it carries no answer data. Project listeners can extend `AbstractEventListener` +and load the committed submission via `ModelService`. + +The prepared `DynamicFormSubmissionEmailListener` is **not registered by default**. +To enable it, import this resource in project Spring configuration: + +```xml + +``` + +The listener uses `htmlEmailGenerator` and `htmlEmailService` from `cxdevtoolkit`. +Only the originating node sends mail, avoiding a notification per cluster node. +It reads the form's configured `recipients`; `dynamicRecipient` does not enable +arbitrary client email addresses. Missing recipients skip mail. Exceptions are logged +without template or answer content and do not reverse successful persistence. +This prepared listener is best-effort: the Commerce cluster event bus and SMTP do +not guarantee durable or exactly-once delivery. Projects requiring retry/failover +should persist an outbox and implement idempotent listeners rather than retry POSTs. +The current Commerce POST has no idempotency key; each successful POST creates a submission. + +Administrators may set localized `DynamicForm.submissionEmailTemplate` to trusted +Thymeleaf HTML. The listener requests the submission locale (Commerce's configured +localized-attribute fallback applies); a blank template uses +`email-templates/html/cxdevforms-submission.html`. Invalid nonblank templates fail +notification and are not silently replaced. Context variables: + +| Variable | Contents | +| --- | --- | +| `submission` | Persisted model and metadata | +| `answers` | Entire typed answer map, keyed by field ID | +| `answersJson` | Entire JSON string | +| `view` | Localized title and ordered sections, each with label/value answer rows | + +The fallback renders every effective field using `th:text`. Treat templates as +trusted administrator code and submitted answers as text, never as executable +Thymeleaf or unescaped HTML. + +### Upgrade and validation + +Run `ant all`, then a System Update for `cxdevforms` to create typecode 31179 and +new attributes/relations. Existing definitions need no data migration; step grouping +and per-form email templates are optional. This change also repairs the definition +controller's bean reference restores generated type constants, and removes stale Solr-provider beans whose implementation +was absent from the extracted extension. + +Unit tests cover conditional validation, HTTP contracts/localization, persistence +failure handling, snapshots/presentation and optional email behavior. The service-layer +integration test requires an initialized Commerce test tenant with this type system. +SAP's transaction-aware event contract is documented in +[TransactionAwareEvent](https://help.sap.com/doc/02d5152884b34821a06408495ba0b771/1905/en-US/de/hybris/platform/servicelayer/event/TransactionAwareEvent.html). diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/backoffice/src/me/cxdev/commerce/forms/backoffice/renderers/DynamicFormSubmissionRenderer.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/backoffice/src/me/cxdev/commerce/forms/backoffice/renderers/DynamicFormSubmissionRenderer.java new file mode 100644 index 00000000..9adc8b05 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/backoffice/src/me/cxdev/commerce/forms/backoffice/renderers/DynamicFormSubmissionRenderer.java @@ -0,0 +1,58 @@ +package me.cxdev.commerce.forms.backoffice.renderers; + +import com.hybris.cockpitng.core.config.impl.jaxb.editorarea.AbstractSection; +import com.hybris.cockpitng.dataaccess.facades.type.DataType; +import com.hybris.cockpitng.engine.WidgetInstanceManager; +import com.hybris.cockpitng.widgets.common.WidgetComponentRenderer; + +import org.zkoss.util.Locales; +import org.zkoss.util.resource.Labels; +import org.zkoss.zk.ui.Component; +import org.zkoss.zul.Caption; +import org.zkoss.zul.Div; +import org.zkoss.zul.Groupbox; +import org.zkoss.zul.Label; + +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.submission.SubmissionPresentationService; + +/** Read-only, escaped labels and values grouped by configured form step. @since 5.0.2 */ +public class DynamicFormSubmissionRenderer implements WidgetComponentRenderer { + private final SubmissionPresentationService presentationService; + public DynamicFormSubmissionRenderer(final SubmissionPresentationService presentationService) { + this.presentationService = presentationService; + } + + @Override + public void render(final Component parent, final AbstractSection configuration, final Object object, + final DataType dataType, final WidgetInstanceManager widgetInstanceManager) { + if (!(object instanceof DynamicFormSubmissionModel submission)) { + return; + } + try { + final var view = presentationService.present(submission, Locales.getCurrent()); + if (view.sections().isEmpty()) { + parent.appendChild(new Label(Labels.getLabel("cxdevforms.submission.empty"))); + } + for (final var section : view.sections()) { + final Groupbox group = new Groupbox(); + group.setClosable(false); + group.appendChild(new Caption(section.title())); + for (final var answer : section.answers()) { + final Div row = new Div(); + final Div heading = new Div(); + heading.appendChild(new Label(answer.label())); + row.appendChild(heading); + final Label value = new Label(answer.value()); + value.setPre(true); + value.setStyle("white-space: pre-wrap; overflow-wrap: anywhere;"); + row.appendChild(value); + group.appendChild(row); + } + parent.appendChild(group); + } + } catch (final RuntimeException e) { + parent.appendChild(new Label(Labels.getLabel("cxdevforms.submission.unreadable"))); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/extensioninfo.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/extensioninfo.xml new file mode 100644 index 00000000..88274526 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/extensioninfo.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/external-dependencies.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/external-dependencies.xml new file mode 100644 index 00000000..47656513 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/external-dependencies.xml @@ -0,0 +1,9 @@ + + 4.0.0 + me.cxdev + cxdevforms + 5.0.3-snapshot + jar + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/lib/.lastupdate b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/lib/.lastupdate new file mode 100644 index 00000000..e69de29b diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/project.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/project.properties new file mode 100644 index 00000000..a4dffe21 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/project.properties @@ -0,0 +1,9 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2026 CX DEV Tools +# ---------------------------------------------------------------------------- + +# Location of the file for the global platform spring application context +cxdevforms.application-context=cxdevforms-spring.xml + +# Essential data +cxdevtoolkit.impeximport.essentialdata.0100.dynamicformtypes=/cxdevforms/essentialdata/dynamic-form-types.impex diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-config.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-config.xml new file mode 100644 index 00000000..980aaf13 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-config.xml @@ -0,0 +1,521 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + id + (title != null ? ' : '+ title : '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + forbidSubtypesCreation + true + + + + + + + + + actionName + label.export.csv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + (label != null ? ' : '+ label : '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + forbidSubtypesCreation + true + + + + + + + + + actionName + label.export.csv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + (label != null ? ' : '+ label : '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + forbidSubtypesCreation + true + + + + + + + + + actionName + label.export.csv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + code + (name != null ? ' : '+ name : '') + + + + + + + + + + + + + + + + + + + + + + + + + + + + + forbidSubtypesCreation + true + + + + + + + + + actionName + label.export.csv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_de.properties new file mode 100644 index 00000000..0c9f2b01 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_de.properties @@ -0,0 +1,19 @@ +cxdevtools_treenode_forms=Formulare +cxdevforms_treenode_definitions=Formulardefinitionen +cxdevforms_treenode_configuration=Konfiguration +cxdevforms.tab.common=Formular +cxdevforms.tab.general=Allgemein +cxdevforms.section.general=Allgemein +cxdevforms.tab.form=Formular +cxdevforms.section.form=Formularfelder +cxdevforms.tab.notification=Benachrichtigung +cxdevforms.section.notification=Benachrichtigung +cxdevforms.tab.submissions=Anfragen +cxdevforms.section.submissions=Anfragen + +cxdevforms_typenode_DynamicFormSubmission=Anfragen +cxdevforms.submission.answers=Antworten +cxdevforms.submission.metadata=Angaben zur Anfrage +cxdevforms.submission.raw=Technische Daten (JSON) +cxdevforms.submission.empty=Es wurden keine Antworten übermittelt. +cxdevforms.submission.unreadable=Die Antworten konnten nicht angezeigt werden. Bitte die technischen Daten prüfen. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_en.properties new file mode 100644 index 00000000..44c9d99a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-labels/labels_en.properties @@ -0,0 +1,19 @@ +cxdevtools_treenode_forms=Forms +cxdevforms_treenode_definitions=Form definitions +cxdevforms_treenode_configuration=Configuration +cxdevforms.tab.common=Form +cxdevforms.tab.general=General +cxdevforms.section.general=General +cxdevforms.tab.form=Form +cxdevforms.section.form=Form Fields +cxdevforms.tab.notification=Notification +cxdevforms.section.notification=Notification +cxdevforms.tab.submissions=Submissions +cxdevforms.section.submissions=Submissions + +cxdevforms_typenode_DynamicFormSubmission=Submissions +cxdevforms.submission.answers=Answers +cxdevforms.submission.metadata=Submission details +cxdevforms.submission.raw=Technical data (JSON) +cxdevforms.submission.empty=No answers were submitted. +cxdevforms.submission.unreadable=Answers could not be displayed. Please check the technical data. diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-spring.xml new file mode 100644 index 00000000..0ed7a080 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-backoffice-spring.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-beans.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-beans.xml new file mode 100644 index 00000000..201d1fe1 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-beans.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Representations of a dynamic form + + + DynamicFormWsDTO + + + + + + + + + + + + Representations of a dynamic form field + + + DynamicFormFieldWsDTO + + + + + + + + + + + + + + + + + + + + + Representations of a dynamic form field value + + + DynamicFormFieldValueWsDTO + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-items.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-items.xml new file mode 100644 index 00000000..c0073964 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-items.xml @@ -0,0 +1,257 @@ + + + + + + + + + Type of Dynamic Form + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ID of the dynamic form. + + + + + Title of the dynamic form. + + + + Type of the dynamic form. + + + + + Description of the dynamic form. + + + HYBRIS.LONG_STRING + + + + + Trusted administrator-authored Thymeleaf HTML; blank uses the classpath fallback. + HYBRIS.LONG_STRING + + + String list for email-addresses to send the dynamic form to. + + + + If true adds a hidden email field, that can be filled by frontend as an input. + + + false + + + + + + + + + + + + + The id of the dynamic form field. + + + + + Label of the dynamic form field. + + + + Description of the dynamic form field. + + + HYBRIS.LONG_STRING + + + + + Is the dynamic form field required. + + + false + + + Is the dynamic form field should be hidden. + + + false + + + Is the dynamic form field active. + + + true + + + Type of the dynamic form field. + + em().getEnumerationValue("DynamicFormFieldType","TEXT") + + + + Validation constraint for fieldtype number. + + + + Validation constraint for fieldtype number. + + + + Validation constraint for fieldtype string. + + + + Validation constraint for fieldtype string. + + + + Default value of the dynamic form field. + + + + Optional presentation group ID; does not change conditional validation. + + + + + + + Placeholder of the dynamic form field. + + + + List of values for the dynamic form field. + + + + + + + + + + + + + + The id of the value field. + + + + + Value of the field. + + + + + + + + + + + + + + + + + + + HYBRIS.LONG_STRING + + + + HYBRIS.LONG_STRING + + + + + + + + + + + + + + + + + + + + HYBRIS.LONG_STRING + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-spring.xml new file mode 100644 index 00000000..6881f7f9 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms-spring.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-facades-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-facades-spring.xml new file mode 100644 index 00000000..faef1d6b --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-facades-spring.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-services-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-services-spring.xml new file mode 100644 index 00000000..12672779 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-services-spring.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submission-email-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submission-email-spring.xml new file mode 100644 index 00000000..9bf19181 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submission-email-spring.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submissions-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submissions-spring.xml new file mode 100644 index 00000000..cd608f80 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/cxdevforms-submissions-spring.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/essentialdata/dynamic-form-types.impex b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/essentialdata/dynamic-form-types.impex new file mode 100644 index 00000000..9a0d21c5 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/cxdevforms/essentialdata/dynamic-form-types.impex @@ -0,0 +1,2 @@ +INSERT_UPDATE EnumerationValue; code[unique = true]; enumtype(code)[unique = true]; name[lang = de]; name[lang = en] + ; default ; DynamicFormType ; Standard ; Default diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/email-templates/html/cxdevforms-submission.html b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/email-templates/html/cxdevforms-submission.html new file mode 100644 index 00000000..5e2c221e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/email-templates/html/cxdevforms-submission.html @@ -0,0 +1,17 @@ + + +Form submission + +

Form submission

+

+
+

+
+
+
+
+
+ + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_de.properties new file mode 100644 index 00000000..1033747e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_de.properties @@ -0,0 +1,63 @@ +# Dynamic Forms +type.DynamicFormType.name=Formulartyp +type.DynamicForm.name=Formular +type.DynamicForm.id.name=ID +type.DynamicForm.title.name=Titel +type.DynamicForm.type.name=Typ +type.DynamicForm.description.name=Beschreibung +type.DynamicForm.recipients.name=Empfänger +type.DynamicForm.dynamicRecipient.name=Empfänger (dynamisch) +type.DynamicForm.formFields.name=Formularfelder +type.DynamicFormField.name=Formularfeld +type.DynamicFormField.id.name=ID +type.DynamicFormField.label.name=Bezeichnung +type.DynamicFormField.description.name=Beschreibung +type.DynamicFormField.required.name=Ist erforderlich +type.DynamicFormField.hidden.name=Ist versteckt +type.DynamicFormField.active.name=Ist aktiv +type.DynamicFormField.fieldType.name=Feldtyp +type.DynamicFormField.minValue.name=Minimaler Wert +type.DynamicFormField.maxValue.name=Maximaler Wert +type.DynamicFormField.minLength.name=Minimale Länge +type.DynamicFormField.maxLength.name=Maximale Länge +type.DynamicFormField.defaultValue.name=Standardwert +type.DynamicFormField.defaultValue.description=Wenn Radio, Select oder Checkbox ausgewählt ist, verwenden Sie die ID aus dem Formularfeldwert +type.DynamicFormField.placeholder.name=Platzhalter +type.DynamicFormField.formFieldValues.name=Formularfeldwerte +type.DynamicFormField.parentFieldValues.name=Formular übergeordnete Feldwerte +type.DynamicFormField.form.name=Formular +type.DynamicFormFieldValue.name=Formularfeldwert +type.DynamicFormFieldValue.id.name=Id +type.DynamicFormFieldValue.label.name=Bezeichnung +type.DynamicFormFieldValue.childFields.name=Unterfelder +type.DynamicFormFieldType.name=Formularfeldtyp +type.DynamicFormFieldType.TEXT.name=Text +type.DynamicFormFieldType.EMAIL.name=E-Mail +type.DynamicFormFieldType.HIDDEN.name=Versteckt +type.DynamicFormFieldType.PASSWORD.name=Passwort +type.DynamicFormFieldType.COLOR.name=Farbe +type.DynamicFormFieldType.TEXTAREA.name=Textbereich +type.DynamicFormFieldType.NUMBER.name=Nummer +type.DynamicFormFieldType.RADIO.name=Radio +type.DynamicFormFieldType.CHECKBOX.name=Kontrollkästchen +type.DynamicFormFieldType.CHECKBOXES.name=Kontrollkästchen +type.DynamicFormFieldType.SELECT.name=Auswahlbox +type.DynamicFormFieldType.FILE.name=Datei +type.DynamicFormFieldType.DATE.name=Datum +type.DynamicFormFieldType.WEEK.name=Woche + +type.DynamicFormSubmission.name=Formularanfrage +type.DynamicForm.submissionEmailTemplate.name=E-Mail-Vorlage für Anfragen +type.DynamicForm.submissions.name=Anfragen +type.DynamicFormField.stepId.name=Schritt-ID +type.DynamicFormField.stepTitle.name=Schritttitel +type.DynamicFormSubmission.id.name=Anfrage-ID +type.DynamicFormSubmission.form.name=Formular +type.DynamicFormSubmission.answersJson.name=Antwort-JSON +type.DynamicFormSubmission.definitionSnapshotJson.name=Snapshot der Definition +type.DynamicFormSubmission.submittedAt.name=Eingegangen am +type.DynamicFormSubmission.user.name=Benutzer +type.DynamicFormSubmission.baseSite.name=Website +type.DynamicFormSubmission.language.name=Sprache +type.DynamicFormSubmission.userAgent.name=Browser-Agent +type.DynamicFormSubmission.remoteAddress.name=Absenderadresse diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_en.properties new file mode 100644 index 00000000..6266645a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-locales_en.properties @@ -0,0 +1,63 @@ +# Dynamic Forms +type.DynamicFormType.name=Form Type +type.DynamicForm.name=Form +type.DynamicForm.id.name=ID +type.DynamicForm.title.name=Title +type.DynamicForm.type.name=Type +type.DynamicForm.description.name=Description +type.DynamicForm.recipients.name=Recipients +type.DynamicForm.dynamicRecipient.name=Recipient (dynamic) +type.DynamicForm.formFields.name=Form Fields +type.DynamicFormField.name=Form Field +type.DynamicFormField.id.name=ID +type.DynamicFormField.label.name=Label +type.DynamicFormField.description.name=Description +type.DynamicFormField.required.name=Is required +type.DynamicFormField.hidden.name=Is hidden +type.DynamicFormField.active.name=Is active +type.DynamicFormField.fieldType.name=Field Type +type.DynamicFormField.minValue.name=Min Value +type.DynamicFormField.maxValue.name=Max Value +type.DynamicFormField.minLength.name=Min Length +type.DynamicFormField.maxLength.name=Max Length +type.DynamicFormField.defaultValue.name=Default Value +type.DynamicFormField.defaultValue.description=If radio, select, or checkbox type is selected, use the ID from the form field value +type.DynamicFormField.placeholder.name=Placeholder +type.DynamicFormField.formFieldValues.name=Form Field Values +type.DynamicFormField.parentFieldValues.name=Form Parent Field Values +type.DynamicFormField.form.name=Form +type.DynamicFormFieldValue.name=Form Field Value +type.DynamicFormFieldValue.id.name=Id +type.DynamicFormFieldValue.label.name=Label +type.DynamicFormFieldValue.childFields.name=Child Fields +type.DynamicFormFieldType.name=Form Field Type +type.DynamicFormFieldType.TEXT.name=Text +type.DynamicFormFieldType.EMAIL.name=E-Mail +type.DynamicFormFieldType.HIDDEN.name=Hidden +type.DynamicFormFieldType.PASSWORD.name=Password +type.DynamicFormFieldType.COLOR.name=Color +type.DynamicFormFieldType.TEXTAREA.name=Text Area +type.DynamicFormFieldType.NUMBER.name=Number +type.DynamicFormFieldType.RADIO.name=Radio +type.DynamicFormFieldType.CHECKBOX.name=Checkbox +type.DynamicFormFieldType.CHECKBOXES.name=Checkboxes +type.DynamicFormFieldType.SELECT.name=Select +type.DynamicFormFieldType.FILE.name=File +type.DynamicFormFieldType.DATE.name=Date +type.DynamicFormFieldType.WEEK.name=Week + +type.DynamicFormSubmission.name=Form submission +type.DynamicForm.submissionEmailTemplate.name=Submission email template +type.DynamicForm.submissions.name=Submissions +type.DynamicFormField.stepId.name=Step ID +type.DynamicFormField.stepTitle.name=Step title +type.DynamicFormSubmission.id.name=Submission ID +type.DynamicFormSubmission.form.name=Form +type.DynamicFormSubmission.answersJson.name=Answer JSON +type.DynamicFormSubmission.definitionSnapshotJson.name=Definition snapshot +type.DynamicFormSubmission.submittedAt.name=Submitted at +type.DynamicFormSubmission.user.name=User +type.DynamicFormSubmission.baseSite.name=Site +type.DynamicFormSubmission.language.name=Language +type.DynamicFormSubmission.userAgent.name=Browser agent +type.DynamicFormSubmission.remoteAddress.name=Remote address diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_de.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_de.properties new file mode 100644 index 00000000..dc4382b4 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_de.properties @@ -0,0 +1,24 @@ +cxdevforms.submission.answers=Bitte ein Antwortobjekt mit höchstens 1000 Feldern übermitteln. +cxdevforms.submission.configuration=Die Formularkonfiguration kann nicht verarbeitet werden. +cxdevforms.submission.unknown=Dieses Feld ist unbekannt oder für die gewählten Antworten nicht aktiv. +cxdevforms.submission.required=Bitte dieses Pflichtfeld ausfüllen. +cxdevforms.submission.password=Passwortwerte können über diesen Endpunkt nicht gespeichert werden. +cxdevforms.submission.file=Die Upload-Referenz konnte nicht geprüft werden. +cxdevforms.submission.option=Bitte gültige, unterschiedliche Auswahl-IDs angeben. +cxdevforms.submission.type=Bitte den konfigurierten Antwortdatentyp verwenden. +cxdevforms.submission.minLength=Die Eingabe ist zu kurz. +cxdevforms.submission.maxLength=Die Eingabe ist zu lang. +cxdevforms.submission.number=Bitte eine Dezimalzahl als Zeichenkette mit Dezimalpunkt angeben. +cxdevforms.submission.minValue=Der Wert unterschreitet das erlaubte Minimum. +cxdevforms.submission.maxValue=Der Wert überschreitet das erlaubte Maximum. +cxdevforms.submission.email=Bitte eine gültige E-Mail-Adresse eingeben. +cxdevforms.submission.color=Bitte das Farbformat #RRGGBB verwenden. +cxdevforms.submission.date=Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben. +cxdevforms.submission.week=Bitte eine gültige ISO-Woche im Format JJJJ-Www eingeben. +cxdevforms.submission.notFound=Das Formular wurde nicht gefunden. +cxdevforms.submission.malformed=Der Anfrageinhalt muss gültiges JSON enthalten. +cxdevforms.submission.details=Formulareingaben +cxdevforms.submission.unanswered=Nicht angegeben +cxdevforms.submission.yes=Ja +cxdevforms.submission.no=Nein +cxdevforms.submission.subject=Neue Formularanfrage: {0} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_en.properties b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_en.properties new file mode 100644 index 00000000..a7669177 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/localization/cxdevforms-submissions_en.properties @@ -0,0 +1,24 @@ +cxdevforms.submission.answers=Provide an answers object containing at most 1000 fields. +cxdevforms.submission.configuration=The form configuration cannot be processed. +cxdevforms.submission.unknown=This field is unknown or not active for the selected answers. +cxdevforms.submission.required=Complete this required field. +cxdevforms.submission.password=Password values cannot be stored by this endpoint. +cxdevforms.submission.file=The upload reference could not be verified. +cxdevforms.submission.option=Select valid, distinct option IDs. +cxdevforms.submission.type=Use the configured answer type. +cxdevforms.submission.minLength=The value is too short. +cxdevforms.submission.maxLength=The value is too long. +cxdevforms.submission.number=Enter a decimal number as a string using a decimal point. +cxdevforms.submission.minValue=The value is below the allowed minimum. +cxdevforms.submission.maxValue=The value exceeds the allowed maximum. +cxdevforms.submission.email=Enter a valid email address. +cxdevforms.submission.color=Use the color format #RRGGBB. +cxdevforms.submission.date=Enter a valid date in YYYY-MM-DD format. +cxdevforms.submission.week=Enter a valid ISO week in YYYY-Www format. +cxdevforms.submission.notFound=The form was not found. +cxdevforms.submission.malformed=The request body must contain valid JSON. +cxdevforms.submission.details=Form answers +cxdevforms.submission.unanswered=Not provided +cxdevforms.submission.yes=Yes +cxdevforms.submission.no=No +cxdevforms.submission.subject=New form submission: {0} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-level-mappings-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-level-mappings-spring.xml new file mode 100644 index 00000000..53c8320f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-level-mappings-spring.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-mappings-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-mappings-spring.xml new file mode 100644 index 00000000..5bb3d3b4 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/config/cxdevforms-dto-mappings-spring.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/cxdevforms-web-spring.xml b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/cxdevforms-web-spring.xml new file mode 100644 index 00000000..c6783d30 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/resources/occ/v2/cxdevformsocc/web/spring/cxdevforms-web-spring.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/constants/CxFormsConstants.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/constants/CxFormsConstants.java new file mode 100644 index 00000000..d2f1fbd5 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/constants/CxFormsConstants.java @@ -0,0 +1,10 @@ +package me.cxdev.commerce.forms.constants; + +/** Platform extension identifier. */ +public final class CxFormsConstants extends GeneratedCxFormsConstants { + public static final String EXTENSIONNAME = "cxdevforms"; + + private CxFormsConstants() { + // Utility class. + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsController.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsController.java new file mode 100644 index 00000000..c1a2ab53 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsController.java @@ -0,0 +1,52 @@ +package me.cxdev.commerce.forms.controller; + +import java.util.List; + +import de.hybris.platform.commerceservices.request.mapping.annotation.ApiVersion; +import de.hybris.platform.webservicescommons.mapping.DataMapper; +import de.hybris.platform.webservicescommons.swagger.ApiFieldsParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.annotation.Resource; +import jakarta.ws.rs.core.MediaType; +import me.cxdev.commerce.forms.data.DynamicFormData; +import me.cxdev.commerce.forms.dto.DynamicFormWsDTO; +import me.cxdev.commerce.forms.facade.DynamicFormFacade; + +@RestController +@RequestMapping(value = "/{baseSiteId}/forms") +@Tag(name = "Dynamic Forms") +@ApiVersion("v2") +public class CxDynamicFormsController { + protected static final String DEFAULT_FIELD_SET = "DEFAULT"; + + @Resource(name = "dataMapper") + private DataMapper dataMapper; + + @Resource(name = "cxDynamicFormFacade") + private DynamicFormFacade dynamicFormFacade; + + @GetMapping() + @Operation(operationId = "getAllDynamicForms", summary = "Get all dynamic forms", description = "Returns all dynamic forms with detailed information") + public List getAllDynamicForms( + @ApiFieldsParam @RequestParam(defaultValue = DEFAULT_FIELD_SET) final String fields) { + final List requestForms = dynamicFormFacade.getAllDynamicForms(); + return dataMapper.mapAsList(requestForms, DynamicFormWsDTO.class, fields); + } + + @GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON) + @Operation(operationId = "getDynamicFormForId", summary = "Returns dynamic form of given id.", description = "Returns dynamic form of given id.") + public DynamicFormWsDTO getDynamicFormForId(@PathVariable("id") final String id, + @ApiFieldsParam @RequestParam(defaultValue = DEFAULT_FIELD_SET) final String fields) { + final DynamicFormData requestForm = dynamicFormFacade.getDynamicFormForId(id); + return dataMapper.map(requestForm, DynamicFormWsDTO.class, fields); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsController.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsController.java new file mode 100644 index 00000000..0d232a1a --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsController.java @@ -0,0 +1,88 @@ +package me.cxdev.commerce.forms.controller; + +import java.util.List; +import java.util.Locale; + +import de.hybris.platform.commerceservices.request.mapping.annotation.ApiVersion; +import de.hybris.platform.servicelayer.exceptions.UnknownIdentifierException; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import me.cxdev.commerce.forms.facade.DynamicFormSubmissionFacade; +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionRequest; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; +import me.cxdev.commerce.forms.submission.SubmissionReceipt; +import me.cxdev.commerce.forms.submission.SubmissionValidationException; + +/** OCC creation endpoint; validation is delegated to the facade. @since 5.0.2 */ +@RestController +@RequestMapping("/{baseSiteId}/forms/{id}/submissions") +@ApiVersion("v2") +@Tag(name = "Dynamic Forms Submissions") +public class CxDynamicFormsSubmissionsController { + @Resource(name = "cxDynamicFormSubmissionFacade") + private DynamicFormSubmissionFacade facade; + @Resource(name = "cxDynamicFormSubmissionMessageSource") + private MessageSource messageSource; + @Resource(name = "commonI18NService") + private CommonI18NService commonI18NService; + + @PostMapping(consumes = "application/json", produces = "application/json") + @Operation(operationId = "createDynamicFormSubmission", summary = "Validate and store a form submission") + public ResponseEntity submit(@PathVariable("id") final String id, + @RequestBody final DynamicFormSubmissionRequest body, final HttpServletRequest request) { + final var receipt = facade.submit(id, body, new SubmissionMetadata(request.getHeader("User-Agent"), request.getRemoteAddr())); + return ResponseEntity.status(HttpStatus.CREATED).body(receipt); + } + + @ExceptionHandler(SubmissionValidationException.class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + public SubmissionErrors validation(final SubmissionValidationException exception) { + final Locale locale = locale(); + return new SubmissionErrors(exception.getErrors().getAllErrors().stream() + .map(error -> new SubmissionError(error.getCode(), error instanceof FieldError field ? field.getField() : null, + messageSource.getMessage(error, locale))) + .toList()); + } + + @ExceptionHandler(UnknownIdentifierException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public SubmissionErrors notFound() { + return error("cxdevforms.submission.notFound"); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public SubmissionErrors malformed() { + return error("cxdevforms.submission.malformed"); + } + + private SubmissionErrors error(final String code) { + return new SubmissionErrors(List.of(new SubmissionError(code, null, messageSource.getMessage(code, null, locale())))); + } + + private Locale locale() { + return commonI18NService.getLocaleForLanguage(commonI18NService.getCurrentLanguage()); + } + public record SubmissionError(String code, String field, String message) { + } + public record SubmissionErrors(List errors) { + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionCreatedEvent.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionCreatedEvent.java new file mode 100644 index 00000000..f7fab717 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionCreatedEvent.java @@ -0,0 +1,52 @@ +package me.cxdev.commerce.forms.event; + +import de.hybris.platform.core.PK; +import de.hybris.platform.servicelayer.event.ClusterAwareEvent; +import de.hybris.platform.servicelayer.event.TransactionAwareEvent; +import de.hybris.platform.servicelayer.event.events.AbstractEvent; + +/** Broadcast after commit; listeners load the submission by PK. @since 5.0.2 */ +public class DynamicFormSubmissionCreatedEvent extends AbstractEvent implements ClusterAwareEvent, TransactionAwareEvent { + private static final long serialVersionUID = 1L; + private final PK submissionPk; + private final String submissionId; + private final String formId; + private final int originNodeId; + public DynamicFormSubmissionCreatedEvent(final PK submissionPk, final String submissionId, final String formId, final int originNodeId) { + this.submissionPk = submissionPk; + this.submissionId = submissionId; + this.formId = formId; + this.originNodeId = originNodeId; + } + + public PK getSubmissionPk() { + return submissionPk; + } + + public String getSubmissionId() { + return submissionId; + } + + public String getFormId() { + return formId; + } + + public int getOriginNodeId() { + return originNodeId; + } + + @Override + public boolean publish(final int sourceNodeId, final int targetNodeId) { + return true; + } + + @Override + public boolean publishOnCommitOnly() { + return true; + } + + @Override + public Object getId() { + return getClass().getName() + ":" + submissionId; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListener.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListener.java new file mode 100644 index 00000000..3be757ff --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListener.java @@ -0,0 +1,78 @@ +package me.cxdev.commerce.forms.event; + +import java.util.Locale; +import java.util.Map; + +import de.hybris.platform.core.Registry; +import de.hybris.platform.servicelayer.event.impl.AbstractEventListener; +import de.hybris.platform.servicelayer.model.ModelService; + +import org.apache.commons.mail2.core.EmailException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSource; + +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.submission.SubmissionPresentationService; +import me.cxdev.commerce.forms.submission.SubmissionSnapshotService; +import me.cxdev.commerce.toolkit.email.HtmlEmailGenerator; +import me.cxdev.commerce.toolkit.email.HtmlEmailService; + +/** + * Optional best-effort notification listener, instantiated only by explicit import. + * Only the originating node sends mail to avoid one email per cluster node. + * Durable retries and delivery deduplication belong in a project outbox listener. + * @since 5.0.2 + */ +public class DynamicFormSubmissionEmailListener extends AbstractEventListener { + private static final Logger LOG = LoggerFactory.getLogger(DynamicFormSubmissionEmailListener.class); + private final ModelService modelService; + private final HtmlEmailGenerator emailGenerator; + private final HtmlEmailService emailService; + private final SubmissionSnapshotService snapshotService; + private final SubmissionPresentationService presentationService; + private final MessageSource messageSource; + public DynamicFormSubmissionEmailListener(final ModelService modelService, final HtmlEmailGenerator emailGenerator, + final HtmlEmailService emailService, final SubmissionSnapshotService snapshotService, + final SubmissionPresentationService presentationService, final MessageSource messageSource) { + this.modelService = modelService; + this.emailGenerator = emailGenerator; + this.emailService = emailService; + this.snapshotService = snapshotService; + this.presentationService = presentationService; + this.messageSource = messageSource; + } + + @Override + protected void onEvent(final DynamicFormSubmissionCreatedEvent event) { + if (event.getOriginNodeId() != getNodeId()) { + return; + } + try { + final DynamicFormSubmissionModel submission = modelService.get(event.getSubmissionPk()); + final var form = submission.getForm(); + if (form.getRecipients() == null || form.getRecipients().isEmpty()) { + return; + } + final Locale locale = Locale.forLanguageTag(submission.getLanguage()); + final var view = presentationService.present(submission, locale); + final String configured = form.getSubmissionEmailTemplate(locale); + final String template = configured == null || configured.isBlank() ? "html/cxdevforms-submission" : configured; + final var builder = emailGenerator.newHtmlEmail() + .subject(messageSource.getMessage("cxdevforms.submission.subject", new Object[] { view.title() }, locale)) + .locale(locale).template(template) + .templateParameter(Map.of("submission", submission, "view", view, + "answers", snapshotService.readAnswers(submission.getAnswersJson()), + "answersJson", submission.getAnswersJson())); + form.getRecipients().forEach(builder::to); + emailService.sendEmail(builder.build()); + } catch (final EmailException | RuntimeException e) { + // Template exceptions may contain HTML or answers; never log their message/stack. + LOG.warn("Could not send notification for submission {} ({})", event.getSubmissionId(), e.getClass().getSimpleName()); + } + } + + protected int getNodeId() { + return Registry.getClusterID(); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormFacade.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormFacade.java new file mode 100644 index 00000000..2dc28687 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormFacade.java @@ -0,0 +1,26 @@ +package me.cxdev.commerce.forms.facade; + +import java.util.List; + +import me.cxdev.commerce.forms.data.DynamicFormData; + +/** + * This facade summarizes functions around the service request forms. + */ +public interface DynamicFormFacade { + /** + * Returns a list of all service request forms. + * + * @return list data of all service request forms + */ + List getAllDynamicForms(); + + /** + * Find service request form for given id. + * + * @param id id of the service request form + * @return service request form data + */ + DynamicFormData getDynamicFormForId(String id); + +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormSubmissionFacade.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormSubmissionFacade.java new file mode 100644 index 00000000..af5e6a8b --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/DynamicFormSubmissionFacade.java @@ -0,0 +1,10 @@ +package me.cxdev.commerce.forms.facade; + +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionRequest; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; +import me.cxdev.commerce.forms.submission.SubmissionReceipt; + +/** Validates form answers before delegating to the service layer. @since 5.0.2 */ +public interface DynamicFormSubmissionFacade { + SubmissionReceipt submit(String formId, DynamicFormSubmissionRequest request, SubmissionMetadata metadata); +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacade.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacade.java new file mode 100644 index 00000000..c22e69c7 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacade.java @@ -0,0 +1,37 @@ +package me.cxdev.commerce.forms.facade.impl; + +import java.util.List; + +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.service.DynamicFormService; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.data.DynamicFormData; +import me.cxdev.commerce.forms.facade.DynamicFormFacade; + +/** + * Default facade implementation for dynamic forms. + */ +public class DefaultDynamicFormFacade implements DynamicFormFacade { + private final DynamicFormService dynamicFormService; + private final Converter dynamicFormDataConverter; + + public DefaultDynamicFormFacade( + final DynamicFormService dynamicFormService, + final Converter dynamicFormDataConverter) { + this.dynamicFormService = dynamicFormService; + this.dynamicFormDataConverter = dynamicFormDataConverter; + } + + @Override + public List getAllDynamicForms() { + return dynamicFormDataConverter.convertAll(dynamicFormService.getAllDynamicForms()); + } + + @Override + public DynamicFormData getDynamicFormForId(final String id) { + return dynamicFormService.getDynamicFormForId(id) + .map(dynamicFormDataConverter::convert) + .orElseGet(DynamicFormData::new); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacade.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacade.java new file mode 100644 index 00000000..61b09ce4 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacade.java @@ -0,0 +1,33 @@ +package me.cxdev.commerce.forms.facade.impl; + +import de.hybris.platform.servicelayer.exceptions.UnknownIdentifierException; + +import me.cxdev.commerce.forms.facade.DynamicFormSubmissionFacade; +import me.cxdev.commerce.forms.service.DynamicFormService; +import me.cxdev.commerce.forms.service.DynamicFormSubmissionService; +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionRequest; +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionValidator; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; +import me.cxdev.commerce.forms.submission.SubmissionReceipt; + +/** Implements the validation and persistence workflow. @since 5.0.2 */ +public class DefaultDynamicFormSubmissionFacade implements DynamicFormSubmissionFacade { + private final DynamicFormService formService; + private final DynamicFormSubmissionService submissionService; + private final DynamicFormSubmissionValidator validator; + public DefaultDynamicFormSubmissionFacade(final DynamicFormService formService, + final DynamicFormSubmissionService submissionService, final DynamicFormSubmissionValidator validator) { + this.formService = formService; + this.submissionService = submissionService; + this.validator = validator; + } + + @Override + public SubmissionReceipt submit(final String formId, final DynamicFormSubmissionRequest request, final SubmissionMetadata metadata) { + final var form = formService.getDynamicFormForId(formId).orElseThrow(() -> new UnknownIdentifierException("Form not found")); + final var answers = request == null ? null : request.getAnswers(); + final var fields = validator.validate(form, answers); + final var submission = submissionService.create(form, answers, fields, metadata); + return new SubmissionReceipt(submission.getId(), form.getId(), submission.getSubmittedAt()); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulator.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulator.java new file mode 100644 index 00000000..8b0f3f5f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulator.java @@ -0,0 +1,58 @@ +package me.cxdev.commerce.forms.facade.populator; + +import de.hybris.platform.converters.Populator; +import de.hybris.platform.servicelayer.dto.converter.ConversionException; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.ObjectFactory; + +import me.cxdev.commerce.forms.data.DynamicFormFieldData; +import me.cxdev.commerce.forms.data.DynamicFormFieldValueData; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; + +/** + * Populates {@link DynamicFormFieldData} from {@link DynamicFormFieldModel}. + */ +public class DynamicFormFieldPopulator implements Populator { + private final ObjectFactory> dynamicFormFieldValueConverterFactory; + + public DynamicFormFieldPopulator( + final ObjectFactory> dynamicFormFieldValueConverterFactory) { + this.dynamicFormFieldValueConverterFactory = dynamicFormFieldValueConverterFactory; + } + + /** + * Copies all relevant field attributes including selectable values. + * + * @param source source model + * @param target target data object + * @throws ConversionException if conversion fails + */ + @Override + public void populate(final DynamicFormFieldModel source, final DynamicFormFieldData target) + throws ConversionException { + target.setId(source.getId()); + target.setLabel(source.getLabel()); + target.setDescription(source.getDescription()); + target.setHidden(source.isHidden()); + target.setRequired(BooleanUtils.isTrue(source.isRequired())); + if (source.getFieldType() != null) { + target.setFieldType(source.getFieldType().getCode()); + } + target.setDefaultValue(source.getDefaultValue()); + target.setPlaceholder(source.getPlaceholder()); + target.setStepId(source.getStepId()); + target.setStepTitle(source.getStepTitle()); + target.setMinValue(source.getMinValue()); + target.setMaxValue(source.getMaxValue()); + target.setMinLength(source.getMinLength()); + target.setMaxLength(source.getMaxLength()); + if (CollectionUtils.isNotEmpty(source.getFormFieldValues())) { + target.setFormFieldValues( + dynamicFormFieldValueConverterFactory.getObject().convertAll(source.getFormFieldValues())); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulator.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulator.java new file mode 100644 index 00000000..49a41ad0 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulator.java @@ -0,0 +1,44 @@ +package me.cxdev.commerce.forms.facade.populator; + +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.beans.factory.ObjectFactory; + +import de.hybris.platform.converters.Populator; +import de.hybris.platform.servicelayer.dto.converter.ConversionException; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.data.DynamicFormFieldData; +import me.cxdev.commerce.forms.data.DynamicFormFieldValueData; + +/** + * Populates {@link DynamicFormFieldValueData} from {@link DynamicFormFieldValueModel}. + */ +public class DynamicFormFieldValuePopulator + implements Populator { + + private final ObjectFactory> dynamicFormFieldConverterFactory; + + public DynamicFormFieldValuePopulator( + final ObjectFactory> dynamicFormFieldConverterFactory) { + this.dynamicFormFieldConverterFactory = dynamicFormFieldConverterFactory; + } + + /** + * Copies value metadata including conditional child fields. + * + * @param source source model + * @param target target data object + * @throws ConversionException if conversion fails + */ + @Override + public void populate(final DynamicFormFieldValueModel source, + final DynamicFormFieldValueData target) throws ConversionException { + target.setId(source.getId()); + target.setLabel(source.getLabel()); + if (CollectionUtils.isNotEmpty(source.getChildFields())) { + target.setChildFields(dynamicFormFieldConverterFactory.getObject().convertAll(source.getChildFields())); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulator.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulator.java new file mode 100644 index 00000000..e579a448 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulator.java @@ -0,0 +1,53 @@ +package me.cxdev.commerce.forms.facade.populator; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; + +import de.hybris.platform.converters.Populator; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.data.DynamicFormData; +import me.cxdev.commerce.forms.data.DynamicFormFieldData; + +/** + * Populates {@link DynamicFormData} from {@link DynamicFormModel}. + */ +public class DynamicFormPopulator implements Populator { + + private final Converter dynamicFormFieldConverter; + + public DynamicFormPopulator(final Converter dynamicFormFieldConverter) { + this.dynamicFormFieldConverter = dynamicFormFieldConverter; + } + + /** + * Copies all form metadata and only active form fields. + * + * @param source source model + * @param target target data object + */ + @Override + public void populate(final DynamicFormModel source, final DynamicFormData target) { + target.setId(source.getId()); + target.setTitle(source.getTitle()); + if (source.getType() != null) { + target.setType(source.getType().getCode()); + } + target.setDescription(source.getDescription()); + target.setDynamicRecipient(BooleanUtils.isTrue(source.getDynamicRecipient())); + if (CollectionUtils.isNotEmpty(source.getRecipients())) { + target.setRecipients(new ArrayList<>(source.getRecipients())); + } + if (CollectionUtils.isNotEmpty(source.getFormFields())) { + final List activeFieldModels = source.getFormFields().stream() + .filter(DynamicFormFieldModel::isActive).collect(Collectors.toList()); + target.setFormFields(dynamicFormFieldConverter.convertAll(activeFieldModels)); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormService.java new file mode 100644 index 00000000..7a08c16d --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormService.java @@ -0,0 +1,28 @@ +package me.cxdev.commerce.forms.service; + +import java.util.List; +import java.util.Optional; + +import me.cxdev.commerce.forms.model.DynamicFormModel; + +/** + * Service API for loading configured service request forms. + */ +public interface DynamicFormService { + + /** + * Returns all configured service request forms. + * + * @return all service request forms + */ + List getAllDynamicForms(); + + /** + * Returns a service request form by its identifier. + * + * @param id the form identifier + * @return the matching form or {@code null} if no form exists + */ + Optional getDynamicFormForId(String id); + +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormSubmissionService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormSubmissionService.java new file mode 100644 index 00000000..9f0a2216 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/DynamicFormSubmissionService.java @@ -0,0 +1,16 @@ +package me.cxdev.commerce.forms.service; + +import java.util.List; +import java.util.Map; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; + +/** Persistence boundary for validated submissions. @since 5.0.2 */ +public interface DynamicFormSubmissionService { + /** Saves validated answers and their effective field snapshot in one transaction. */ + DynamicFormSubmissionModel create(DynamicFormModel form, Map answers, + List fields, SubmissionMetadata metadata); +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormService.java new file mode 100644 index 00000000..364ee2e9 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormService.java @@ -0,0 +1,48 @@ +package me.cxdev.commerce.forms.service.impl; + +import static org.apache.commons.collections4.ListUtils.emptyIfNull; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import de.hybris.platform.servicelayer.internal.dao.GenericDao; + +import me.cxdev.commerce.forms.service.DynamicFormService; +import me.cxdev.commerce.forms.model.DynamicFormModel; + +/** + * Default implementation of {@link DynamicFormService}. Uses a {@link GenericDao} to retrieve form + * configurations from the database. + */ +public class DefaultDynamicFormService implements DynamicFormService { + private final GenericDao dynamicFormDao; + + public DefaultDynamicFormService(final GenericDao dynamicFormDao) { + this.dynamicFormDao = dynamicFormDao; + } + + /** + * Returns all configured dynamic forms. + * + * @return all forms + */ + @Override + public List getAllDynamicForms() { + return List.copyOf(dynamicFormDao.find()); + } + + /** + * Returns a dynamic form for a given id. + * + * @param id the form id + * @return the matching form or {@code null} + */ + @Override + public Optional getDynamicFormForId(final String id) { + return emptyIfNull(dynamicFormDao.find( + Map.of(DynamicFormModel.ID, id))) + .stream() + .findFirst(); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionService.java new file mode 100644 index 00000000..4a1ecb65 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionService.java @@ -0,0 +1,76 @@ +package me.cxdev.commerce.forms.service.impl; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import de.hybris.platform.core.Registry; +import de.hybris.platform.servicelayer.event.EventService; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; +import de.hybris.platform.servicelayer.model.ModelService; +import de.hybris.platform.servicelayer.user.UserService; +import de.hybris.platform.site.BaseSiteService; + +import org.springframework.transaction.support.TransactionTemplate; + +import me.cxdev.commerce.forms.event.DynamicFormSubmissionCreatedEvent; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.service.DynamicFormSubmissionService; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; +import me.cxdev.commerce.forms.submission.SubmissionSnapshotService; + +/** Stores a submission and schedules a commit-aware cluster event. @since 5.0.2 */ +public class DefaultDynamicFormSubmissionService implements DynamicFormSubmissionService { + private final ModelService modelService; + private final UserService userService; + private final BaseSiteService baseSiteService; + private final CommonI18NService commonI18NService; + private final EventService eventService; + private final TransactionTemplate transactionTemplate; + private final SubmissionSnapshotService snapshotService; + public DefaultDynamicFormSubmissionService(final ModelService modelService, final UserService userService, + final BaseSiteService baseSiteService, final CommonI18NService commonI18NService, final EventService eventService, + final TransactionTemplate transactionTemplate, final SubmissionSnapshotService snapshotService) { + this.modelService = modelService; + this.userService = userService; + this.baseSiteService = baseSiteService; + this.commonI18NService = commonI18NService; + this.eventService = eventService; + this.transactionTemplate = transactionTemplate; + this.snapshotService = snapshotService; + } + + @Override + public DynamicFormSubmissionModel create(final DynamicFormModel form, final Map answers, + final List fields, final SubmissionMetadata metadata) { + final String json = snapshotService.toJson(answers); + final String snapshot = snapshotService.snapshot(form, fields); + return transactionTemplate.execute(status -> { + final DynamicFormSubmissionModel submission = modelService.create(DynamicFormSubmissionModel.class); + submission.setId(UUID.randomUUID().toString()); + submission.setForm(form); + submission.setAnswersJson(json); + submission.setDefinitionSnapshotJson(snapshot); + submission.setSubmittedAt(new Date()); + submission.setUser(userService.getCurrentUser()); + submission.setBaseSite(baseSiteService.getCurrentBaseSite()); + submission.setLanguage(commonI18NService.getLocaleForLanguage(commonI18NService.getCurrentLanguage()).toLanguageTag()); + submission.setUserAgent(limit(metadata.userAgent(), 2048)); + submission.setRemoteAddress(limit(metadata.remoteAddress(), 64)); + modelService.save(submission); + eventService.publishEvent(new DynamicFormSubmissionCreatedEvent(submission.getPk(), submission.getId(), form.getId(), getNodeId())); + return submission; + }); + } + + protected int getNodeId() { + return Registry.getClusterID(); + } + + private String limit(final String value, final int length) { + return value == null ? null : value.substring(0, Math.min(length, value.length())); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionRequest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionRequest.java new file mode 100644 index 00000000..86584858 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionRequest.java @@ -0,0 +1,15 @@ +package me.cxdev.commerce.forms.submission; + +import java.util.Map; + +/** ID-keyed, typed answers; the form ID is supplied in the URL. @since 5.0.2 */ +public class DynamicFormSubmissionRequest { + private Map answers; + public Map getAnswers() { + return answers; + } + + public void setAnswers(final Map answers) { + this.answers = answers; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidator.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidator.java new file mode 100644 index 00000000..74a0f84d --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidator.java @@ -0,0 +1,216 @@ +package me.cxdev.commerce.forms.submission; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.IsoFields; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +import org.springframework.validation.Errors; +import org.springframework.validation.FieldError; +import org.springframework.validation.MapBindingResult; + +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.InternetAddress; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; + +/** Server-authoritative validation of effective fields and typed answers. @since 5.0.2 */ +public class DynamicFormSubmissionValidator { + private static final Pattern DECIMAL = Pattern.compile("-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?"); + private final SubmissionFileValidator fileValidator; + public DynamicFormSubmissionValidator(final SubmissionFileValidator fileValidator) { + this.fileValidator = fileValidator; + } + + /** Returns the ordered effective fields or throws an exception containing Spring Errors. */ + public List validate(final DynamicFormModel form, final Map answers) { + final Errors errors = new MapBindingResult(new HashMap<>(), "submission"); + if (answers == null || answers.size() > 1000) { + errors.reject("cxdevforms.submission.answers"); + throw new SubmissionValidationException(errors); + } + final Map effective = new LinkedHashMap<>(); + for (final DynamicFormFieldModel field : safe(form.getFormFields())) { + visit(field, answers, effective, new HashSet<>(), errors, 0); + } + for (final String id : answers.keySet()) { + if (!effective.containsKey(id)) { + reject(errors, id, "unknown"); + } + } + if (errors.hasErrors()) { + throw new SubmissionValidationException(errors); + } + return List.copyOf(effective.values()); + } + + private void visit(final DynamicFormFieldModel field, final Map answers, + final Map effective, final Set path, final Errors errors, final int depth) { + if (!field.isActive()) { + return; + } + final String id = field.getId(); + if (id == null || depth > 64 || path.contains(id) || effective.size() >= 1000) { + errors.reject("cxdevforms.submission.configuration"); + return; + } + if (effective.putIfAbsent(id, field) != null) { + return; + } + final Object value = answers.get(id); + validateValue(field, value, errors); + path.add(id); + for (final DynamicFormFieldValueModel option : safe(field.getFormFieldValues())) { + if (Objects.equals(option.getId(), value) || value instanceof List list && list.contains(option.getId())) { + for (final DynamicFormFieldModel child : safe(option.getChildFields()).stream() + .sorted(Comparator.comparing(DynamicFormFieldModel::getId, Comparator.nullsLast(String::compareTo))).toList()) { + visit(child, answers, effective, path, errors, depth + 1); + } + } + } + path.remove(id); + } + + private void validateValue(final DynamicFormFieldModel field, final Object value, final Errors errors) { + final String id = field.getId(); + final String type = field.getFieldType() == null ? "TEXT" : field.getFieldType().getCode(); + final boolean empty = value == null || value instanceof String s && s.isBlank() || value instanceof List l && l.isEmpty(); + if (field.isRequired() && (empty || "CHECKBOX".equals(type) && !Boolean.TRUE.equals(value))) { + reject(errors, id, "required"); + } + if (value == null) { + return; + } + if ("PASSWORD".equals(type)) { + reject(errors, id, "password"); + return; + } + if ("CHECKBOX".equals(type)) { + if (!(value instanceof Boolean)) { + reject(errors, id, "type"); + } + return; + } + if ("CHECKBOXES".equals(type) || "FILE".equals(type)) { + if (!(value instanceof List list) || list.size() > 1000 || list.stream().anyMatch(v -> !(v instanceof String))) { + reject(errors, id, "type"); + return; + } + if (new HashSet<>(list).size() != list.size()) { + reject(errors, id, "option"); + } + for (final Object entry : list) { + if ("FILE".equals(type)) { + if (((String) entry).length() > 1024 || !fileValidator.isValid(field, (String) entry)) { + reject(errors, id, "file"); + } + } else if (!hasOption(field, (String) entry)) { + reject(errors, id, "option"); + } + } + return; + } + if (!(value instanceof String text)) { + reject(errors, id, "type"); + return; + } + final int length = text.codePointCount(0, text.length()); + if (length > 65536 || field.getMaxLength() != null && length > field.getMaxLength()) { + reject(errors, id, "maxLength"); + } + if (empty) { + return; + } + if (field.getMinLength() != null && length < field.getMinLength()) { + reject(errors, id, "minLength"); + } + switch (type) { + case "NUMBER" -> validateNumber(field, text, errors); + case "EMAIL" -> { + try { + final InternetAddress address = new InternetAddress(text, true); + address.validate(); + if (!text.equals(address.getAddress()) || !text.contains("@")) { + reject(errors, id, "email"); + } + } catch (final AddressException e) { + reject(errors, id, "email"); + } + } + case "COLOR" -> { + if (!text.matches("#[0-9a-fA-F]{6}")) { + reject(errors, id, "color"); + } + } + case "DATE" -> { + try { + if (!text.matches("[0-9]{4}-[0-9]{2}-[0-9]{2}")) { + throw new IllegalArgumentException(); + } + LocalDate.parse(text); + } catch (final RuntimeException e) { + reject(errors, id, "date"); + } + } + case "WEEK" -> { + try { + if (!text.matches("[0-9]{4}-W[0-9]{2}")) { + throw new IllegalArgumentException(); + } + final int year = Integer.parseInt(text.substring(0, 4)); + final int week = Integer.parseInt(text.substring(6)); + if (week < 1 || week > LocalDate.of(year, 12, 28).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)) { + throw new IllegalArgumentException(); + } + } catch (final RuntimeException e) { + reject(errors, id, "week"); + } + } + case "SELECT", "RADIO" -> { + if (!hasOption(field, text)) { + reject(errors, id, "option"); + } + } + case "TEXT", "TEXTAREA", "HIDDEN" -> { + } + default -> reject(errors, id, "type"); + } + } + + private void validateNumber(final DynamicFormFieldModel field, final String text, final Errors errors) { + if (text.length() > 256 || !DECIMAL.matcher(text).matches()) { + reject(errors, field.getId(), "number"); + return; + } + final BigDecimal number = new BigDecimal(text); + if (field.getMinValue() != null && number.compareTo(BigDecimal.valueOf(field.getMinValue())) < 0) { + reject(errors, field.getId(), "minValue"); + } + if (field.getMaxValue() != null && number.compareTo(BigDecimal.valueOf(field.getMaxValue())) > 0) { + reject(errors, field.getId(), "maxValue"); + } + } + + private boolean hasOption(final DynamicFormFieldModel field, final String id) { + return safe(field.getFormFieldValues()).stream().anyMatch(option -> id.equals(option.getId())); + } + + private void reject(final Errors errors, final String id, final String code) { + ((MapBindingResult) errors).addError(new FieldError("submission", "answers[" + id + "]", null, false, + new String[] { "cxdevforms.submission." + code }, null, null)); + } + + private static Collection safe(final Collection values) { + return values == null ? List.of() : values; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/RejectingSubmissionFileValidator.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/RejectingSubmissionFileValidator.java new file mode 100644 index 00000000..baecbffd --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/RejectingSubmissionFileValidator.java @@ -0,0 +1,11 @@ +package me.cxdev.commerce.forms.submission; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; + +/** Rejects unverified file references. @since 5.0.2 */ +public class RejectingSubmissionFileValidator implements SubmissionFileValidator { + @Override + public boolean isValid(final DynamicFormFieldModel field, final String reference) { + return false; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionFileValidator.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionFileValidator.java new file mode 100644 index 00000000..25835d28 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionFileValidator.java @@ -0,0 +1,12 @@ +package me.cxdev.commerce.forms.submission; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; + +/** + * Project hook for ownership, type, size and malware checks of upload references. + * The default rejects uploads because this extension has no upload endpoint. + * @since 5.0.2 + */ +public interface SubmissionFileValidator { + boolean isValid(DynamicFormFieldModel field, String reference); +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionMetadata.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionMetadata.java new file mode 100644 index 00000000..585d886e --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionMetadata.java @@ -0,0 +1,5 @@ +package me.cxdev.commerce.forms.submission; + +/** Request metadata; identity, site and time are resolved on the server. @since 5.0.2 */ +public record SubmissionMetadata(String userAgent, String remoteAddress) { +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionPresentationService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionPresentationService.java new file mode 100644 index 00000000..51e54069 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionPresentationService.java @@ -0,0 +1,73 @@ +package me.cxdev.commerce.forms.submission; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.MessageSource; + +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; + +/** Shared, localized read model for Backoffice and notification templates. @since 5.0.2 */ +public class SubmissionPresentationService { + private final SubmissionSnapshotService snapshotService; + private final MessageSource messageSource; + public SubmissionPresentationService(final SubmissionSnapshotService snapshotService, final MessageSource messageSource) { + this.snapshotService = snapshotService; + this.messageSource = messageSource; + } + + public View present(final DynamicFormSubmissionModel submission, final Locale locale) { + final SubmissionSnapshot snapshot = snapshotService.readSnapshot(submission.getDefinitionSnapshotJson()); + final Map answers = snapshotService.readAnswers(submission.getAnswersJson()); + final Map> groups = new LinkedHashMap<>(); + final Map titles = new LinkedHashMap<>(); + for (final SubmissionSnapshot.Field field : snapshot.fields()) { + final String step = field.stepId() == null ? "" : field.stepId(); + titles.putIfAbsent(step, label(field.stepTitle(), locale, submission.getLanguage(), step.isEmpty() + ? messageSource.getMessage("cxdevforms.submission.details", null, locale) + : step)); + groups.computeIfAbsent(step, key -> new ArrayList<>()).add(new Answer(field.id(), + label(field.label(), locale, submission.getLanguage(), field.id()), + "PASSWORD".equals(field.type()) ? "••••••" : display(answers.get(field.id()), field, locale, submission.getLanguage()))); + } + final List
sections = groups.entrySet().stream().map(e -> new Section(titles.get(e.getKey()), e.getValue())).toList(); + return new View(label(snapshot.title(), locale, submission.getLanguage(), snapshot.formId()), sections); + } + + private String display(final Object value, final SubmissionSnapshot.Field field, final Locale locale, final String language) { + if (value == null || value instanceof String s && s.isBlank() || value instanceof List l && l.isEmpty()) { + return messageSource.getMessage("cxdevforms.submission.unanswered", null, locale); + } + if (value instanceof Boolean flag) { + return messageSource.getMessage("cxdevforms.submission." + (flag ? "yes" : "no"), null, locale); + } + if (value instanceof List values) { + return values.stream().map(v -> display(v, field, locale, language)).collect(Collectors.joining(", ")); + } + final String text = String.valueOf(value); + return label(field.options().get(text), locale, language, text); + } + + private String label(final Map translations, final Locale locale, final String language, final String fallback) { + if (translations == null) { + return fallback; + } + for (final String key : List.of(locale.toLanguageTag(), locale.getLanguage(), language, "en")) { + final String text = translations.get(key); + if (text != null && !text.isBlank()) { + return text; + } + } + return fallback; + } + public record View(String title, List
sections) { + } + public record Section(String title, List answers) { + } + public record Answer(String fieldId, String label, String value) { + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionReceipt.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionReceipt.java new file mode 100644 index 00000000..a721bc20 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionReceipt.java @@ -0,0 +1,7 @@ +package me.cxdev.commerce.forms.submission; + +import java.util.Date; + +/** Public creation receipt containing no answer or identity data. @since 5.0.2 */ +public record SubmissionReceipt(String submissionId, String formId, Date submittedAt) { +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshot.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshot.java new file mode 100644 index 00000000..b97b2e96 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshot.java @@ -0,0 +1,11 @@ +package me.cxdev.commerce.forms.submission; + +import java.util.List; +import java.util.Map; + +/** Immutable description of submitted fields and their translations. @since 5.0.2 */ +public record SubmissionSnapshot(String formId, Map title, List fields) { + public record Field(String id, String type, String stepId, Map stepTitle, + Map label, Map> options) { + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshotService.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshotService.java new file mode 100644 index 00000000..acb7e23b --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionSnapshotService.java @@ -0,0 +1,79 @@ +package me.cxdev.commerce.forms.submission; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.hybris.platform.servicelayer.i18n.CommonI18NService; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; + +/** Serializes answer values separately from immutable presentation metadata. @since 5.0.2 */ +public class SubmissionSnapshotService { + private final ObjectMapper objectMapper; + private final CommonI18NService commonI18NService; + public SubmissionSnapshotService(final ObjectMapper objectMapper, final CommonI18NService commonI18NService) { + this.objectMapper = objectMapper; + this.commonI18NService = commonI18NService; + } + + public String snapshot(final DynamicFormModel form, final List fields) { + final List snapshot = new ArrayList<>(); + for (final DynamicFormFieldModel field : fields) { + final Map> options = new LinkedHashMap<>(); + if (field.getFormFieldValues() != null) { + for (final DynamicFormFieldValueModel option : field.getFormFieldValues()) { + options.put(option.getId(), translations(option::getLabel)); + } + } + snapshot.add(new SubmissionSnapshot.Field(field.getId(), field.getFieldType().getCode(), field.getStepId(), + translations(field::getStepTitle), translations(field::getLabel), options)); + } + return toJson(new SubmissionSnapshot(form.getId(), translations(form::getTitle), snapshot)); + } + + public String toJson(final Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (final JsonProcessingException e) { + throw new IllegalStateException("Cannot serialize form submission", e); + } + } + + public SubmissionSnapshot readSnapshot(final String json) { + try { + return objectMapper.readValue(json, SubmissionSnapshot.class); + } catch (final JsonProcessingException e) { + throw new IllegalStateException("Cannot read submission definition", e); + } + } + + @SuppressWarnings("unchecked") + public Map readAnswers(final String json) { + try { + return objectMapper.readValue(json, LinkedHashMap.class); + } catch (final JsonProcessingException e) { + throw new IllegalStateException("Cannot read submission answers", e); + } + } + + private Map translations(final Function getter) { + final Map result = new LinkedHashMap<>(); + commonI18NService.getAllLanguages().forEach(language -> { + final Locale locale = commonI18NService.getLocaleForLanguage(language); + final String text = getter.apply(locale); + if (text != null && !text.isBlank()) { + result.put(locale.toLanguageTag(), text); + } + }); + return result; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionValidationException.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionValidationException.java new file mode 100644 index 00000000..4ac05402 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/src/me/cxdev/commerce/forms/submission/SubmissionValidationException.java @@ -0,0 +1,16 @@ +package me.cxdev.commerce.forms.submission; + +import org.springframework.validation.Errors; + +/** Carries standard Spring validation errors without submitted values. @since 5.0.2 */ +public class SubmissionValidationException extends RuntimeException { + private final Errors errors; + public SubmissionValidationException(final Errors errors) { + super("Invalid form submission"); + this.errors = errors; + } + + public Errors getErrors() { + return errors; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormSubmissionSpringConfigurationTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormSubmissionSpringConfigurationTests.java new file mode 100644 index 00000000..0fdd791f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormSubmissionSpringConfigurationTests.java @@ -0,0 +1,39 @@ +package me.cxdev.commerce.forms.config; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.event.EventService; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; +import de.hybris.platform.servicelayer.model.ModelService; +import de.hybris.platform.servicelayer.user.UserService; +import de.hybris.platform.site.BaseSiteService; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.transaction.PlatformTransactionManager; + +import me.cxdev.commerce.forms.facade.DynamicFormSubmissionFacade; +import me.cxdev.commerce.forms.service.DynamicFormService; + +@UnitTest +class DynamicFormSubmissionSpringConfigurationTests { + @Test + void wiresSubmissionGraphWithoutActivatingEmail() { + try (var context = new GenericApplicationContext()) { + context.getBeanFactory().registerSingleton("modelService", mock(ModelService.class)); + context.getBeanFactory().registerSingleton("userService", mock(UserService.class)); + context.getBeanFactory().registerSingleton("commonI18NService", mock(CommonI18NService.class)); + context.getBeanFactory().registerSingleton("eventService", mock(EventService.class)); + context.getBeanFactory().registerSingleton("baseSiteService", mock(BaseSiteService.class)); + context.getBeanFactory().registerSingleton("dynamicFormService", mock(DynamicFormService.class)); + context.getBeanFactory().registerSingleton("txManager", mock(PlatformTransactionManager.class)); + new XmlBeanDefinitionReader(context).loadBeanDefinitions("classpath:cxdevforms/cxdevforms-submissions-spring.xml"); + context.refresh(); + assertThat(context.getBean("dynamicFormSubmissionFacade")).isInstanceOf(DynamicFormSubmissionFacade.class); + assertThat(context.containsBean("cxDynamicFormSubmissionEmailListener")).isFalse(); + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormsSpringConfigurationTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormsSpringConfigurationTest.java new file mode 100644 index 00000000..9e7f3046 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/config/DynamicFormsSpringConfigurationTest.java @@ -0,0 +1,58 @@ +package me.cxdev.commerce.forms.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.converters.impl.AbstractPopulatingConverter; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; +import org.springframework.context.support.GenericApplicationContext; + +import me.cxdev.commerce.forms.facade.DynamicFormFacade; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.service.DynamicFormService; + +/** Tests module wiring with the real recursive converter graph. */ +@UnitTest +class DynamicFormsSpringConfigurationTest { + @Test + void shouldResolveRecursiveConverters() { + final DynamicFormService service = mock(DynamicFormService.class); + final DynamicFormModel form = mock(DynamicFormModel.class); + final DynamicFormFieldModel field = mock(DynamicFormFieldModel.class); + final DynamicFormFieldValueModel option = mock(DynamicFormFieldValueModel.class); + final DynamicFormFieldModel child = mock(DynamicFormFieldModel.class); + when(form.getId()).thenReturn("contact"); + when(form.getFormFields()).thenReturn(List.of(field)); + when(field.isActive()).thenReturn(true); + when(field.getId()).thenReturn("reason"); + when(field.getFormFieldValues()).thenReturn(List.of(option)); + when(option.getId()).thenReturn("technical"); + when(option.getChildFields()).thenReturn(List.of(child)); + when(child.getId()).thenReturn("serial"); + when(service.getDynamicFormForId("contact")).thenReturn(Optional.of(form)); + + try (GenericApplicationContext context = new GenericApplicationContext()) { + context.getBeanFactory().registerSingleton("dynamicFormService", service); + final RootBeanDefinition converter = new RootBeanDefinition(AbstractPopulatingConverter.class); + converter.setAbstract(true); + context.registerBeanDefinition("abstractPopulatingConverter", converter); + new XmlBeanDefinitionReader(context).loadBeanDefinitions("classpath:cxdevforms/cxdevforms-facades-spring.xml"); + context.refresh(); + + final DynamicFormFacade facade = context.getBean("cxDynamicFormFacade", DynamicFormFacade.class); + final var result = facade.getDynamicFormForId("contact"); + assertEquals("serial", result.getFormFields().get(0).getFormFieldValues().get(0).getChildFields().get(0).getId()); + + } + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsControllerTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsControllerTest.java new file mode 100644 index 00000000..9732c830 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsControllerTest.java @@ -0,0 +1,67 @@ +package me.cxdev.commerce.forms.controller; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.webservicescommons.mapping.DataMapper; + +import me.cxdev.commerce.forms.data.DynamicFormData; +import me.cxdev.commerce.forms.facade.DynamicFormFacade; +import me.cxdev.commerce.forms.dto.DynamicFormWsDTO; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class CxDynamicFormsControllerTest { + @Mock + private DynamicFormFacade dynamicFormFacade; + @Mock + private DataMapper dataMapper; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + final CxDynamicFormsController controller = new CxDynamicFormsController(); + ReflectionTestUtils.setField(controller, "dynamicFormFacade", dynamicFormFacade); + ReflectionTestUtils.setField(controller, "dataMapper", dataMapper); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .build(); + } + + @Test + void shouldReturnAllDynamicForms() throws Exception { + when(dynamicFormFacade.getAllDynamicForms()).thenReturn(List.of(new DynamicFormData())); + when(dataMapper.mapAsList(anyList(), eq(DynamicFormWsDTO.class), eq("DEFAULT"))).thenReturn(List.of(new DynamicFormWsDTO())); + + mockMvc.perform(get("/baseSiteId/forms")) + .andExpect(status().isOk()); + } + + @Test + void shouldReturnDynamicFormById() throws Exception { + final DynamicFormData formData = new DynamicFormData(); + when(dynamicFormFacade.getDynamicFormForId("request-form")).thenReturn(formData); + when(dataMapper.map(formData, DynamicFormWsDTO.class, "DEFAULT")).thenReturn(new DynamicFormWsDTO()); + + mockMvc.perform(get("/baseSiteId/forms/request-form")) + .andExpect(status().isOk()); + + verify(dynamicFormFacade).getDynamicFormForId("request-form"); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsControllerTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsControllerTests.java new file mode 100644 index 00000000..cc191e79 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/controller/CxDynamicFormsSubmissionsControllerTests.java @@ -0,0 +1,84 @@ +package me.cxdev.commerce.forms.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.exceptions.UnknownIdentifierException; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.FieldError; +import org.springframework.validation.MapBindingResult; + +import me.cxdev.commerce.forms.facade.DynamicFormSubmissionFacade; +import me.cxdev.commerce.forms.submission.SubmissionReceipt; +import me.cxdev.commerce.forms.submission.SubmissionValidationException; + +@UnitTest +class CxDynamicFormsSubmissionsControllerTests { + private final DynamicFormSubmissionFacade facade = mock(DynamicFormSubmissionFacade.class); + private MockMvc mvc; + @BeforeEach + void setup() { + final var controller = new CxDynamicFormsSubmissionsController(); + final var messages = new ResourceBundleMessageSource(); + messages.setBasename("localization/cxdevforms-submissions"); + messages.setDefaultEncoding("UTF-8"); + final var i18n = mock(CommonI18NService.class); + when(i18n.getLocaleForLanguage(any())).thenReturn(Locale.GERMAN); + ReflectionTestUtils.setField(controller, "facade", facade); + ReflectionTestUtils.setField(controller, "messageSource", messages); + ReflectionTestUtils.setField(controller, "commonI18NService", i18n); + mvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void returnsCreatedAndDelegatesTypedAnswersAndRequestMetadata() throws Exception { + when(facade.submit(eq("contact"), any(), any())).thenReturn(new SubmissionReceipt("uuid", "contact", new Date())); + final var response = mvc.perform(post("/electronics/forms/contact/submissions").contentType("application/json") + .header("User-Agent", "TestBrowser").content("{\"answers\":{\"consent\":true,\"choices\":[\"a\"]}}")) + .andReturn().getResponse(); + assertThat(response.getStatus()).isEqualTo(201); + assertThat(new ObjectMapper().readTree(response.getContentAsString()).get("submissionId").asText()).isEqualTo("uuid"); + verify(facade).submit(eq("contact"), argThat(r -> Boolean.TRUE.equals(r.getAnswers().get("consent")) + && List.of("a").equals(r.getAnswers().get("choices"))), argThat(m -> "TestBrowser".equals(m.userAgent()))); + } + + @Test + void localizesSpringFieldErrorsWith422() throws Exception { + final var errors = new MapBindingResult(new HashMap<>(), "submission"); + errors.addError(new FieldError("submission", "answers[email]", null, false, new String[] { "cxdevforms.submission.email" }, null, null)); + when(facade.submit(any(), any(), any())).thenThrow(new SubmissionValidationException(errors)); + final var response = mvc.perform(post("/electronics/forms/contact/submissions").contentType("application/json").content("{\"answers\":{}}")) + .andReturn().getResponse(); + assertThat(response.getStatus()).isEqualTo(422); + final var error = new ObjectMapper().readTree(response.getContentAsString()).get("errors").get(0); + assertThat(error.get("message").asText()).isEqualTo("Bitte eine gültige E-Mail-Adresse eingeben."); + assertThat(error.get("field").asText()).isEqualTo("answers[email]"); + assertThat(error.has("rejectedValue")).isFalse(); + } + + @Test + void returns400ForMalformedJsonAnd404ForUnknownForm() throws Exception { + assertThat(mvc.perform(post("/s/forms/f/submissions").contentType("application/json").content("{")) + .andReturn().getResponse().getStatus()).isEqualTo(400); + verifyNoInteractions(facade); + when(facade.submit(any(), any(), any())).thenThrow(new UnknownIdentifierException("not found")); + assertThat(mvc.perform(post("/s/forms/f/submissions").contentType("application/json").content("{\"answers\":{}}")) + .andReturn().getResponse().getStatus()).isEqualTo(404); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListenerTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListenerTests.java new file mode 100644 index 00000000..b2c6ab39 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/event/DynamicFormSubmissionEmailListenerTests.java @@ -0,0 +1,65 @@ +package me.cxdev.commerce.forms.event; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.core.PK; +import de.hybris.platform.servicelayer.model.ModelService; + +import org.apache.commons.mail2.jakarta.HtmlEmail; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticMessageSource; + +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.submission.SubmissionPresentationService; +import me.cxdev.commerce.forms.submission.SubmissionSnapshotService; +import me.cxdev.commerce.toolkit.email.HtmlEmailGenerator; +import me.cxdev.commerce.toolkit.email.HtmlEmailService; + +@UnitTest +class DynamicFormSubmissionEmailListenerTests { + @Test + void sendsOnlyOnOriginUsesConfiguredTemplateAndFallsBackWhenBlank() throws Exception { + final var models = mock(ModelService.class); + final var generator = mock(HtmlEmailGenerator.class, CALLS_REAL_METHODS); + final var mail = mock(HtmlEmailService.class); + final var snapshots = mock(SubmissionSnapshotService.class); + final var presentation = mock(SubmissionPresentationService.class); + final var messages = new StaticMessageSource(); + messages.addMessage("cxdevforms.submission.subject", Locale.GERMAN, "Neue Anfrage: {0}"); + final var listener = new DynamicFormSubmissionEmailListener(models, generator, mail, snapshots, presentation, messages) { + @Override + protected int getNodeId() { + return 7; + } + }; + final var event = new DynamicFormSubmissionCreatedEvent(PK.fromLong(1), "uuid", "contact", 7); + listener.onEvent(new DynamicFormSubmissionCreatedEvent(PK.fromLong(1), "uuid", "contact", 8)); + verifyNoInteractions(models); + final var submission = mock(DynamicFormSubmissionModel.class); + final var form = mock(DynamicFormModel.class); + when(models.get(event.getSubmissionPk())).thenReturn(submission); + when(submission.getForm()).thenReturn(form); + when(submission.getLanguage()).thenReturn("de"); + when(submission.getAnswersJson()).thenReturn("{}"); + when(form.getRecipients()).thenReturn(List.of("service@example.org")); + when(form.getSubmissionEmailTemplate(Locale.GERMAN)).thenReturn("

Custom

"); + when(presentation.present(submission, Locale.GERMAN)).thenReturn(new SubmissionPresentationService.View("Kontakt", List.of())); + when(snapshots.readAnswers("{}")).thenReturn(Map.of()); + when(generator.createHtmlEmail()).thenAnswer(call -> new HtmlEmail()); + listener.onEvent(event); + verify(generator).processTemplate(eq("

Custom

"), argThat(context -> context.containsKey("answersJson") && context.containsKey("answers")), eq(Locale.GERMAN)); + verify(mail).sendEmail(any()); + when(form.getSubmissionEmailTemplate(Locale.GERMAN)).thenReturn(" "); + listener.onEvent(event); + verify(generator).processTemplate(eq("html/cxdevforms-submission"), any(), eq(Locale.GERMAN)); + when(generator.createHtmlEmail()).thenThrow(new org.apache.commons.mail2.core.EmailException("private template")); + assertThatCode(() -> listener.onEvent(event)).doesNotThrowAnyException(); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacadeTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacadeTest.java new file mode 100644 index 00000000..faf0be2f --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormFacadeTest.java @@ -0,0 +1,74 @@ +package me.cxdev.commerce.forms.facade.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.service.DynamicFormService; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.data.DynamicFormData; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class DefaultDynamicFormFacadeTest { + @Mock + private DynamicFormService dynamicFormService; + @Mock + private Converter dynamicFormDataConverter; + @Mock + private DynamicFormModel dynamicFormModel; + + private DefaultDynamicFormFacade systemUnderTest; + + @BeforeEach + void setUp() { + systemUnderTest = new DefaultDynamicFormFacade(dynamicFormService, dynamicFormDataConverter); + } + + @Test + void shouldReturnConvertedDynamicForms() { + final DynamicFormData converted = new DynamicFormData(); + when(dynamicFormService.getAllDynamicForms()).thenReturn(List.of(dynamicFormModel)); + when(dynamicFormDataConverter.convertAll(List.of(dynamicFormModel))).thenReturn(List.of(converted)); + + final List result = systemUnderTest.getAllDynamicForms(); + + assertEquals(1, result.size()); + assertSame(converted, result.get(0)); + verify(dynamicFormService).getAllDynamicForms(); + } + + @Test + void shouldReturnEmptyDynamicFormWhenNoModelExists() { + when(dynamicFormService.getDynamicFormForId("missing")).thenReturn(Optional.empty()); + + final DynamicFormData result = systemUnderTest.getDynamicFormForId("missing"); + + assertNull(result.getId()); + } + + @Test + void shouldConvertDynamicFormWhenModelExists() { + final DynamicFormData expected = new DynamicFormData(); + when(dynamicFormService.getDynamicFormForId("form-1")).thenReturn(Optional.of(dynamicFormModel)); + when(dynamicFormDataConverter.convert(dynamicFormModel)).thenReturn(expected); + + final DynamicFormData result = systemUnderTest.getDynamicFormForId("form-1"); + + assertSame(expected, result); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacadeTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacadeTests.java new file mode 100644 index 00000000..d01fc2b8 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/impl/DefaultDynamicFormSubmissionFacadeTests.java @@ -0,0 +1,44 @@ +package me.cxdev.commerce.forms.facade.impl; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import de.hybris.bootstrap.annotations.UnitTest; + +import org.junit.jupiter.api.Test; + +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.service.DynamicFormService; +import me.cxdev.commerce.forms.service.DynamicFormSubmissionService; +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionRequest; +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionValidator; +import me.cxdev.commerce.forms.submission.RejectingSubmissionFileValidator; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; +import me.cxdev.commerce.forms.submission.SubmissionValidationException; + +@UnitTest +class DefaultDynamicFormSubmissionFacadeTests { + @Test + void invalidAnswersNeverReachPersistence() { + final var forms = mock(DynamicFormService.class); + final var submissions = mock(DynamicFormSubmissionService.class); + final var form = mock(DynamicFormModel.class); + when(form.getFormFields()).thenReturn(List.of()); + when(forms.getDynamicFormForId("contact")).thenReturn(Optional.of(form)); + final var facade = new DefaultDynamicFormSubmissionFacade(forms, submissions, new DynamicFormSubmissionValidator(new RejectingSubmissionFileValidator())); + final var request = new DynamicFormSubmissionRequest(); + request.setAnswers(Map.of("injected", "value")); + assertThatThrownBy(() -> facade.submit("contact", request, new SubmissionMetadata(null, null))).isInstanceOf(SubmissionValidationException.class); + verifyNoInteractions(submissions); + request.setAnswers(Map.of()); + final var saved = mock(DynamicFormSubmissionModel.class); + when(saved.getId()).thenReturn("uuid"); + when(submissions.create(eq(form), any(), any(), any())).thenReturn(saved); + assertThat(facade.submit("contact", request, new SubmissionMetadata(null, null)).submissionId()).isEqualTo("uuid"); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulatorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulatorTest.java new file mode 100644 index 00000000..bcc5d284 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldPopulatorTest.java @@ -0,0 +1,87 @@ +package me.cxdev.commerce.forms.facade.populator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.when; + +import java.util.List; + +import me.cxdev.commerce.forms.facade.populator.DynamicFormFieldPopulator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectFactory; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.enums.DynamicFormFieldType; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.data.DynamicFormFieldData; +import me.cxdev.commerce.forms.data.DynamicFormFieldValueData; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class DynamicFormFieldPopulatorTest { + @Mock + private ObjectFactory> converterFactory; + @Mock + private Converter valueConverter; + @Mock + private DynamicFormFieldModel source; + @Mock + private DynamicFormFieldValueModel valueModel; + @Mock + private DynamicFormFieldType fieldType; + + private DynamicFormFieldPopulator systemUnderTest; + + @BeforeEach + void setUp() { + systemUnderTest = new DynamicFormFieldPopulator(converterFactory); + } + + @Test + void shouldPopulateFieldAndConvertValues() { + final DynamicFormFieldValueData convertedValue = new DynamicFormFieldValueData(); + final DynamicFormFieldData target = new DynamicFormFieldData(); + + when(source.getId()).thenReturn("machine"); + when(source.getLabel()).thenReturn("Machine"); + when(source.getDescription()).thenReturn("Description"); + when(source.isHidden()).thenReturn(false); + when(source.isRequired()).thenReturn(Boolean.TRUE); + when(source.getFieldType()).thenReturn(fieldType); + when(fieldType.getCode()).thenReturn("TEXT"); + when(source.getDefaultValue()).thenReturn("default"); + when(source.getPlaceholder()).thenReturn("placeholder"); + when(source.getMinValue()).thenReturn(1.0); + when(source.getMaxValue()).thenReturn(10.0); + when(source.getMinLength()).thenReturn(2); + when(source.getMaxLength()).thenReturn(20); + when(source.getFormFieldValues()).thenReturn(List.of(valueModel)); + when(converterFactory.getObject()).thenReturn(valueConverter); + when(valueConverter.convertAll(List.of(valueModel))).thenReturn(List.of(convertedValue)); + + systemUnderTest.populate(source, target); + + assertEquals("machine", target.getId()); + assertEquals("TEXT", target.getFieldType()); + assertEquals(Boolean.TRUE, target.getRequired()); + assertEquals(1, target.getFormFieldValues().size()); + assertSame(convertedValue, target.getFormFieldValues().get(0)); + } + + @Test + void shouldKeepFormFieldValuesNullWhenSourceHasNone() { + final DynamicFormFieldData target = new DynamicFormFieldData(); + + systemUnderTest.populate(source, target); + + assertNull(target.getFormFieldValues()); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulatorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulatorTest.java new file mode 100644 index 00000000..024e79c8 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormFieldValuePopulatorTest.java @@ -0,0 +1,72 @@ +package me.cxdev.commerce.forms.facade.populator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.when; + +import java.util.List; + +import me.cxdev.commerce.forms.facade.populator.DynamicFormFieldValuePopulator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectFactory; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.data.DynamicFormFieldData; +import me.cxdev.commerce.forms.data.DynamicFormFieldValueData; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class DynamicFormFieldValuePopulatorTest { + @Mock + private ObjectFactory> converterFactory; + @Mock + private Converter fieldConverter; + @Mock + private DynamicFormFieldValueModel source; + @Mock + private DynamicFormFieldModel childField; + + private DynamicFormFieldValuePopulator systemUnderTest; + + @BeforeEach + void setUp() { + systemUnderTest = new DynamicFormFieldValuePopulator(converterFactory); + } + + @Test + void shouldPopulateValueAndConvertChildFields() { + final DynamicFormFieldData childFieldData = new DynamicFormFieldData(); + final DynamicFormFieldValueData target = new DynamicFormFieldValueData(); + + when(source.getId()).thenReturn("1"); + when(source.getLabel()).thenReturn("Label"); + when(source.getChildFields()).thenReturn(List.of(childField)); + when(converterFactory.getObject()).thenReturn(fieldConverter); + when(fieldConverter.convertAll(List.of(childField))).thenReturn(List.of(childFieldData)); + + systemUnderTest.populate(source, target); + + assertEquals("1", target.getId()); + assertEquals("Label", target.getLabel()); + assertEquals(1, target.getChildFields().size()); + assertSame(childFieldData, target.getChildFields().get(0)); + } + + @Test + void shouldKeepChildFieldsNullWhenSourceHasNone() { + final DynamicFormFieldValueData target = new DynamicFormFieldValueData(); + + systemUnderTest.populate(source, target); + + assertNull(target.getChildFields()); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulatorTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulatorTest.java new file mode 100644 index 00000000..caf26c90 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/facade/populator/DynamicFormPopulatorTest.java @@ -0,0 +1,79 @@ +package me.cxdev.commerce.forms.facade.populator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.when; + +import java.util.List; + +import me.cxdev.commerce.forms.facade.populator.DynamicFormPopulator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.dto.converter.Converter; + +import me.cxdev.commerce.forms.enums.DynamicFormType; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.data.DynamicFormData; +import me.cxdev.commerce.forms.data.DynamicFormFieldData; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class DynamicFormPopulatorTest { + @Mock + private Converter dynamicFormFieldConverter; + @Mock + private DynamicFormModel source; + @Mock + private DynamicFormType type; + @Mock + private DynamicFormFieldModel activeField; + @Mock + private DynamicFormFieldModel inactiveField; + + private DynamicFormPopulator systemUnderTest; + + @BeforeEach + void setUp() { + systemUnderTest = new DynamicFormPopulator(dynamicFormFieldConverter); + } + + @Test + void shouldPopulateOnlyActiveFormFields() { + final DynamicFormFieldData convertedField = new DynamicFormFieldData(); + final DynamicFormData target = new DynamicFormData(); + + when(source.getId()).thenReturn("service"); + when(source.getTitle()).thenReturn("Service"); + when(source.getType()).thenReturn(type); + when(type.getCode()).thenReturn("TECHNICAL_SUPPORT"); + when(source.getDescription()).thenReturn("Description"); + when(source.getDynamicRecipient()).thenReturn(Boolean.TRUE); + when(source.getRecipients()).thenReturn(List.of("team@example.com")); + when(source.getFormFields()).thenReturn(List.of(activeField, inactiveField)); + when(activeField.isActive()).thenReturn(true); + when(inactiveField.isActive()).thenReturn(false); + when(dynamicFormFieldConverter.convertAll(List.of(activeField))).thenReturn(List.of(convertedField)); + + systemUnderTest.populate(source, target); + + assertEquals("service", target.getId()); + assertEquals("TECHNICAL_SUPPORT", target.getType()); + assertEquals(1, target.getFormFields().size()); + assertSame(convertedField, target.getFormFields().get(0)); + } + + @Test + void shouldHandleOptionalTypeAndLists() { + final DynamicFormData target = new DynamicFormData(); + systemUnderTest.populate(source, target); + assertNull(target.getType()); + assertNull(target.getRecipients()); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormServiceTest.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormServiceTest.java new file mode 100644 index 00000000..3e791083 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormServiceTest.java @@ -0,0 +1,76 @@ +package me.cxdev.commerce.forms.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.internal.dao.GenericDao; + +import me.cxdev.commerce.toolkit.testing.itemmodel.InMemoryModelFactory; +import me.cxdev.commerce.forms.model.DynamicFormModel; + +@UnitTest +@ExtendWith(MockitoExtension.class) +class DefaultDynamicFormServiceTest { + + @Mock + private GenericDao dynamicFormDao; + + private DefaultDynamicFormService systemUnderTest; + + @BeforeEach + void setUp() { + systemUnderTest = new DefaultDynamicFormService(dynamicFormDao); + } + + @Test + void shouldReturnImmutableCopyOfAllDynamicForms() { + final DynamicFormModel form = InMemoryModelFactory.createTestableItemModel(DynamicFormModel.class); + final List forms = new ArrayList<>(List.of(form)); + + when(dynamicFormDao.find()).thenReturn(forms); + + final List result = systemUnderTest.getAllDynamicForms(); + + assertEquals(List.of(form), result); + assertThrows(UnsupportedOperationException.class, () -> result.add(form)); + verify(dynamicFormDao).find(); + } + + @Test + void shouldReturnFirstDynamicFormForId() { + final String formId = "technical-support"; + + final DynamicFormModel first = InMemoryModelFactory.createTestableItemModel(DynamicFormModel.class); + final DynamicFormModel second = InMemoryModelFactory.createTestableItemModel(DynamicFormModel.class); + + when(dynamicFormDao.find(Map.of(DynamicFormModel.ID, formId))).thenReturn(List.of(first, second)); + + final Optional result = systemUnderTest.getDynamicFormForId(formId); + + assertTrue(result.isPresent()); + assertSame(first, result.orElseThrow()); + } + + @Test + void shouldReturnEmptyOptionalWhenNoDynamicFormExistsForId() { + when(dynamicFormDao.find(Map.of(DynamicFormModel.ID, "missing"))).thenReturn(null); + + assertTrue(systemUnderTest.getDynamicFormForId("missing").isEmpty()); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionServiceTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionServiceTests.java new file mode 100644 index 00000000..2c732114 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DefaultDynamicFormSubmissionServiceTests.java @@ -0,0 +1,79 @@ +package me.cxdev.commerce.forms.service.impl; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.basecommerce.model.site.BaseSiteModel; +import de.hybris.platform.core.model.user.UserModel; +import de.hybris.platform.servicelayer.event.EventService; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; +import de.hybris.platform.servicelayer.model.ModelService; +import de.hybris.platform.servicelayer.user.UserService; +import de.hybris.platform.site.BaseSiteService; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import me.cxdev.commerce.forms.event.DynamicFormSubmissionCreatedEvent; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; +import me.cxdev.commerce.forms.submission.SubmissionSnapshotService; + +@UnitTest +class DefaultDynamicFormSubmissionServiceTests { + @Test + void capturesServerContextAndOnlyPublishesAfterSave() { + final var models = mock(ModelService.class); + final var users = mock(UserService.class); + final var sites = mock(BaseSiteService.class); + final var i18n = mock(CommonI18NService.class); + final var events = mock(EventService.class); + final var snapshots = mock(SubmissionSnapshotService.class); + final var tx = mock(TransactionTemplate.class); + when(tx.execute(any())).thenAnswer(call -> ((TransactionCallback) call.getArgument(0)).doInTransaction(null)); + final var service = new DefaultDynamicFormSubmissionService(models, users, sites, i18n, events, tx, snapshots) { + @Override + protected int getNodeId() { + return 7; + } + }; + final var form = mock(DynamicFormModel.class); + when(form.getId()).thenReturn("contact"); + final var saved = mock(DynamicFormSubmissionModel.class); + when(saved.getId()).thenReturn("uuid"); + when(models.create(DynamicFormSubmissionModel.class)).thenReturn(saved); + final var user = mock(UserModel.class); + final var site = mock(BaseSiteModel.class); + when(users.getCurrentUser()).thenReturn(user); + when(sites.getCurrentBaseSite()).thenReturn(site); + when(i18n.getLocaleForLanguage(any())).thenReturn(Locale.GERMAN); + when(snapshots.toJson(any())).thenReturn("{}"); + when(snapshots.snapshot(any(), any())).thenReturn("snapshot"); + assertThat(service.create(form, Map.of(), List.of(), new SubmissionMetadata("x".repeat(3000), "127.0.0.1"))).isSameAs(saved); + verify(saved).setUser(user); + verify(saved).setBaseSite(site); + verify(saved).setLanguage("de"); + verify(saved).setUserAgent("x".repeat(2048)); + verify(saved).setDefinitionSnapshotJson("snapshot"); + final var event = ArgumentCaptor.forClass(DynamicFormSubmissionCreatedEvent.class); + final var order = inOrder(models, events); + order.verify(models).save(saved); + order.verify(events).publishEvent(event.capture()); + assertThat(event.getValue().publishOnCommitOnly()).isTrue(); + assertThat(event.getValue().publish(7, 8)).isTrue(); + assertThat(event.getValue().getOriginNodeId()).isEqualTo(7); + assertThat(event.getValue().getFormId()).isEqualTo("contact"); + clearInvocations(events); + doThrow(new IllegalStateException("DB failed")).when(models).save(saved); + assertThatThrownBy(() -> service.create(form, Map.of(), List.of(), new SubmissionMetadata(null, null))).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(events); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DynamicFormSubmissionIntegrationTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DynamicFormSubmissionIntegrationTests.java new file mode 100644 index 00000000..b3923c31 --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/service/impl/DynamicFormSubmissionIntegrationTests.java @@ -0,0 +1,142 @@ +package me.cxdev.commerce.forms.service.impl; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import de.hybris.bootstrap.annotations.IntegrationTest; +import de.hybris.platform.basecommerce.model.site.BaseSiteModel; +import de.hybris.platform.commerceservices.enums.SiteChannel; +import de.hybris.platform.servicelayer.ServicelayerTest; +import de.hybris.platform.servicelayer.event.EventService; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; +import de.hybris.platform.servicelayer.model.ModelService; +import de.hybris.platform.servicelayer.search.FlexibleSearchQuery; +import de.hybris.platform.servicelayer.search.FlexibleSearchService; +import de.hybris.platform.site.BaseSiteService; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.ApplicationListener; +import org.springframework.transaction.support.TransactionTemplate; + +import jakarta.annotation.Resource; +import me.cxdev.commerce.forms.enums.DynamicFormFieldType; +import me.cxdev.commerce.forms.enums.DynamicFormType; +import me.cxdev.commerce.forms.event.DynamicFormSubmissionCreatedEvent; +import me.cxdev.commerce.forms.facade.DynamicFormSubmissionFacade; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; +import me.cxdev.commerce.forms.submission.DynamicFormSubmissionRequest; +import me.cxdev.commerce.forms.submission.SubmissionMetadata; + +/** Real database and commit/rollback checks; requires an initialized test tenant. */ +@IntegrationTest +public class DynamicFormSubmissionIntegrationTests extends ServicelayerTest { + @Resource + private ModelService modelService; + @Resource + private FlexibleSearchService flexibleSearchService; + @Resource + private CommonI18NService commonI18NService; + @Resource + private BaseSiteService baseSiteService; + @Resource + private EventService eventService; + @Resource(name = "cxDynamicFormSubmissionFacade") + private DynamicFormSubmissionFacade facade; + @Resource(name = "cxDynamicFormSubmissionTransactionTemplate") + private TransactionTemplate transactionTemplate; + private DynamicFormModel form; + private DynamicFormFieldModel field; + private BaseSiteModel site; + private final BlockingQueue received = new LinkedBlockingQueue<>(); + private final ApplicationListener listener = new ApplicationListener<>() { + @Override + public void onApplicationEvent(final DynamicFormSubmissionCreatedEvent event) { + received.add(event); + } + }; + + @Before + public void prepareForm() { + commonI18NService.setCurrentLanguage(commonI18NService.getLanguage("en")); + site = modelService.create(BaseSiteModel.class); + site.setUid("submission-test-" + UUID.randomUUID()); + site.setChannel(SiteChannel.B2C); + modelService.save(site); + baseSiteService.setCurrentBaseSite(site, false); + form = modelService.create(DynamicFormModel.class); + form.setId("submission-test-" + UUID.randomUUID()); + form.setType(DynamicFormType.valueOf("SUBMISSION_TEST")); + form.setTitle("Integration form"); + modelService.save(form); + field = modelService.create(DynamicFormFieldModel.class); + field.setId("message-" + UUID.randomUUID()); + field.setFieldType(DynamicFormFieldType.TEXT); + field.setLabel("Message"); + field.setRequired(true); + field.setActive(true); + form.setFormFields(List.of(field)); + modelService.saveAll(form, field); + eventService.registerEventListener(listener); + } + + @After + public void cleanUp() { + eventService.unregisterEventListener(listener); + if (form != null) { + modelService.removeAll(submissions()); + if (field != null) { + modelService.remove(field); + } + modelService.remove(form); + } + if (site != null) { + modelService.remove(site); + } + } + + @Test + public void savesToDatabaseAndPublishesCommittedReference() throws Exception { + final var request = new DynamicFormSubmissionRequest(); + request.setAnswers(Map.of(field.getId(), "Persist this answer")); + final var receipt = facade.submit(form.getId(), request, new SubmissionMetadata("IntegrationBrowser", "127.0.0.1")); + final var event = received.poll(5, TimeUnit.SECONDS); + assertThat(event).isNotNull(); + assertThat(event.getSubmissionId()).isEqualTo(receipt.submissionId()); + modelService.detachAll(); + final DynamicFormSubmissionModel reloaded = modelService.get(event.getSubmissionPk()); + assertThat(reloaded.getAnswersJson()).contains(field.getId(), "Persist this answer"); + assertThat(reloaded.getDefinitionSnapshotJson()).contains("Integration form"); + assertThat(reloaded.getUser()).isNotNull(); + assertThat(reloaded.getSubmittedAt()).isNotNull(); + assertThat(reloaded.getBaseSite().getUid()).isEqualTo(site.getUid()); + assertThat(reloaded.getUserAgent()).isEqualTo("IntegrationBrowser"); + } + + @Test + public void rollbackLeavesNeitherSubmissionNorPublishedEvent() throws Exception { + final var request = new DynamicFormSubmissionRequest(); + request.setAnswers(Map.of(field.getId(), "Persist this answer")); + transactionTemplate.execute(status -> { + facade.submit(form.getId(), request, new SubmissionMetadata(null, null)); + status.setRollbackOnly(); + return null; + }); + assertThat(submissions()).isEmpty(); + assertThat(received.poll(500, TimeUnit.MILLISECONDS)).isNull(); + } + + private List submissions() { + return flexibleSearchService.search(new FlexibleSearchQuery( + "SELECT {pk} FROM {DynamicFormSubmission} WHERE {form}=?form", Map.of("form", form))).getResult(); + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidatorTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidatorTests.java new file mode 100644 index 00000000..e336695d --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/DynamicFormSubmissionValidatorTests.java @@ -0,0 +1,128 @@ +package me.cxdev.commerce.forms.submission; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import de.hybris.bootstrap.annotations.UnitTest; + +import org.junit.jupiter.api.Test; + +import me.cxdev.commerce.forms.enums.DynamicFormFieldType; +import me.cxdev.commerce.forms.model.DynamicFormFieldModel; +import me.cxdev.commerce.forms.model.DynamicFormFieldValueModel; +import me.cxdev.commerce.forms.model.DynamicFormModel; + +@UnitTest +class DynamicFormSubmissionValidatorTests { + private final DynamicFormSubmissionValidator validator = new DynamicFormSubmissionValidator(new RejectingSubmissionFileValidator()); + @Test + void validatesRequiredConditionalFieldsAndRejectsUnselectedAnswers() { + final var reason = field("reason", DynamicFormFieldType.SELECT, true); + final var serial = field("serial", DynamicFormFieldType.TEXT, true); + final var technical = mock(DynamicFormFieldValueModel.class); + when(technical.getId()).thenReturn("technical"); + when(technical.getChildFields()).thenReturn(Set.of(serial)); + final var general = mock(DynamicFormFieldValueModel.class); + when(general.getId()).thenReturn("general"); + when(reason.getFormFieldValues()).thenReturn(List.of(technical, general)); + final var form = form(reason); + assertThat(validator.validate(form, Map.of("reason", "technical", "serial", "ABC"))).containsExactly(reason, serial); + assertCode(form, Map.of("reason", "technical"), "required"); + assertCode(form, Map.of("reason", "general", "serial", "ABC"), "unknown"); + assertCode(form, Map.of("reason", "foreign"), "option"); + } + + @Test + void validatesBooleansDecimalsUnicodeAndInclusiveBounds() { + final var consent = field("consent", DynamicFormFieldType.CHECKBOX, true); + final var number = field("number", DynamicFormFieldType.NUMBER, true); + when(number.getMinValue()).thenReturn(0.0); + when(number.getMaxValue()).thenReturn(2.5); + final var name = field("name", DynamicFormFieldType.TEXT, true); + when(name.getMaxLength()).thenReturn(1); + final var form = form(consent, number, name); + assertThat(validator.validate(form, Map.of("consent", true, "number", "0", "name", "😀"))).hasSize(3); + assertThat(validator.validate(form, Map.of("consent", true, "number", "2.5", "name", "A"))).hasSize(3); + assertCode(form, Map.of("consent", false, "number", "2.5000000000000001", "name", "AB"), "required", "maxValue", "maxLength"); + assertCode(form, Map.of("consent", "true", "number", 3, "name", "A"), "type"); + } + + @Test + void validatesDatesWeeksAndEmailWithoutLeakingValues() { + final var form = form(field("date", DynamicFormFieldType.DATE, false), field("week", DynamicFormFieldType.WEEK, false), field("email", DynamicFormFieldType.EMAIL, false)); + assertThat(validator.validate(form, Map.of("date", "2024-02-29", "week", "2020-W53", "email", "ada@example.org"))).hasSize(3); + assertCode(form, Map.of("date", "2023-02-29", "week", "2021-W53", "email", "SECRET"), "date", "week", "email"); + try { + validator.validate(form, Map.of("email", "SECRET")); + fail("Expected validation"); + } catch (SubmissionValidationException ex) { + assertThat(ex.getErrors().getFieldErrors()).allSatisfy(e -> assertThat(e.getRejectedValue()).isNull()); + } + } + + @Test + void rejectsInactiveFieldsDuplicateOptionsFilesAndPasswords() { + final var inactive = field("inactive", DynamicFormFieldType.TEXT, false); + when(inactive.isActive()).thenReturn(false); + final var choices = field("choices", DynamicFormFieldType.CHECKBOXES, false); + final var option = mock(DynamicFormFieldValueModel.class); + when(option.getId()).thenReturn("a"); + when(choices.getFormFieldValues()).thenReturn(List.of(option)); + final var form = form(inactive, choices, field("file", DynamicFormFieldType.FILE, false), field("password", DynamicFormFieldType.PASSWORD, false)); + assertCode(form, Map.of("inactive", "x", "choices", List.of("a", "a"), "file", List.of("foreign-upload"), "password", "secret"), "unknown", "option", "file", "password"); + } + + @Test + void rejectsCyclesAndAllowsSharedChildOnce() { + final var root = field("root", DynamicFormFieldType.SELECT, false); + final var option = mock(DynamicFormFieldValueModel.class); + when(option.getId()).thenReturn("a"); + when(option.getChildFields()).thenReturn(Set.of(root)); + when(root.getFormFieldValues()).thenReturn(List.of(option)); + assertCode(form(root), Map.of("root", "a"), "configuration"); + final var child = field("child", DynamicFormFieldType.TEXT, false); + when(option.getChildFields()).thenReturn(Set.of(child)); + assertThat(validator.validate(form(root, child), Map.of("root", "a"))).containsExactly(root, child); + } + + @Test + void optionalEmptyAndZeroHaveDistinctSemantics() { + assertThat(validator.validate(form(field("n", DynamicFormFieldType.NUMBER, true)), Map.of("n", "0"))).hasSize(1); + assertCode(form(field("n", DynamicFormFieldType.NUMBER, true)), Map.of("n", " "), "required"); + assertCode(form(), null, "answers"); + } + + private void assertCode(final DynamicFormModel form, final Map answers, final String... codes) { + try { + validator.validate(form, answers); + fail("Expected validation errors"); + } catch (SubmissionValidationException ex) { + assertThat(ex.getErrors().getAllErrors()).extracting(e -> e.getCode()) + .contains(Arrays.stream(codes).map(c -> "cxdevforms.submission." + c).toArray(String[]::new)); + } + } + + private DynamicFormModel form(final DynamicFormFieldModel... fields) { + final var form = mock(DynamicFormModel.class); + when(form.getFormFields()).thenReturn(List.of(fields)); + return form; + } + + private DynamicFormFieldModel field(final String id, final DynamicFormFieldType type, final boolean required) { + final var field = mock(DynamicFormFieldModel.class); + when(field.getId()).thenReturn(id); + when(field.getFieldType()).thenReturn(type); + when(field.getMinLength()).thenReturn(null); + when(field.getMaxLength()).thenReturn(null); + when(field.getMinValue()).thenReturn(null); + when(field.getMaxValue()).thenReturn(null); + when(field.isActive()).thenReturn(true); + when(field.isRequired()).thenReturn(required); + return field; + } +} diff --git a/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/SubmissionPresentationTests.java b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/SubmissionPresentationTests.java new file mode 100644 index 00000000..2e9f4a3b --- /dev/null +++ b/core-customize/hybris/bin/custom/cxdevtools/cxdevforms/testsrc/me/cxdev/commerce/forms/submission/SubmissionPresentationTests.java @@ -0,0 +1,61 @@ +package me.cxdev.commerce.forms.submission; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.hybris.bootstrap.annotations.UnitTest; +import de.hybris.platform.servicelayer.i18n.CommonI18NService; + +import org.junit.jupiter.api.Test; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring5.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; + +import me.cxdev.commerce.forms.model.DynamicFormModel; +import me.cxdev.commerce.forms.model.DynamicFormSubmissionModel; + +@UnitTest +class SubmissionPresentationTests { + @Test + void retainsTranslatedOptionsGroupsAndEscapesFallbackEmail() { + final var messages = new ResourceBundleMessageSource(); + messages.setBasename("localization/cxdevforms-submissions"); + messages.setDefaultEncoding("UTF-8"); + final var snapshots = new SubmissionSnapshotService(new ObjectMapper(), mock(CommonI18NService.class)); + final var snapshot = new SubmissionSnapshot("contact", Map.of("en", "Contact", "de", "Kontakt"), List.of( + new SubmissionSnapshot.Field("reason", "SELECT", "request", Map.of("de", "Anliegen"), Map.of("de", "Grund"), + Map.of("tech", Map.of("de", "Technik", "en", "Technical"))), + new SubmissionSnapshot.Field("text", "TEXT", "request", Map.of(), Map.of("de", "Nachricht"), Map.of()), + new SubmissionSnapshot.Field("consent", "CHECKBOX", "consent", Map.of("de", "Einwilligung"), Map.of(), Map.of()))); + final var form = mock(DynamicFormModel.class); + when(form.getId()).thenReturn("contact"); + final var submission = mock(DynamicFormSubmissionModel.class); + when(submission.getId()).thenReturn("uuid"); + when(submission.getForm()).thenThrow(new IllegalStateException("Definition no longer available")); + when(submission.getLanguage()).thenReturn("en"); + when(submission.getDefinitionSnapshotJson()).thenReturn(snapshots.toJson(snapshot)); + when(submission.getAnswersJson()).thenReturn(snapshots.toJson(Map.of("reason", "tech", "text", "", "consent", false))); + final var view = new SubmissionPresentationService(snapshots, messages).present(submission, Locale.GERMAN); + assertThat(view.title()).isEqualTo("Kontakt"); + assertThat(view.sections()).hasSize(2); + assertThat(view.sections().get(0).title()).isEqualTo("Anliegen"); + assertThat(view.sections().get(0).answers().get(0).value()).isEqualTo("Technik"); + assertThat(view.sections().get(1).answers().get(0).value()).isEqualTo("Nein"); + final var resolver = new ClassLoaderTemplateResolver(); + resolver.setPrefix("email-templates/"); + resolver.setSuffix(".html"); + resolver.setCharacterEncoding("UTF-8"); + final var engine = new SpringTemplateEngine(); + engine.setTemplateResolver(resolver); + final String html = engine.process("html/cxdevforms-submission", new Context(Locale.GERMAN, Map.of("view", view, "submission", submission))); + assertThat(html).contains("Technik", "Einwilligung", "<script>").doesNotContain("