diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c75c6af..080fa37 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -103,7 +103,7 @@ jobs: - name: Yarn install uses: actions/setup-node@v2 with: - node-version: '18' + node-version: '20' - run: yarn install - name: markdownlint run: yarn coding-standards-check/markdownlint diff --git a/.markdownlintrc b/.markdownlintrc index 48c49b4..7563715 100644 --- a/.markdownlintrc +++ b/.markdownlintrc @@ -1,9 +1,15 @@ { // @see https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.jsonc - // MD013/line-length - Line length + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md "MD013": { // Exclude code blocks "code_blocks": false + }, + + // Prevent complaining on duplicated headings in CHANGELOG.md + // https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md + "MD024": { + "siblings_only": true } } diff --git a/README.md b/README.md index 04a2593..12e4ad0 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,20 @@ docker run --rm --volume ${PWD}:/app --workdir /app itkdev/php8.1-fpm composer c ### Markdown ```sh -docker run --rm --volume ${PWD}:/app --workdir /app node:18 yarn install -docker run --rm --volume ${PWD}:/app --workdir /app node:18 yarn coding-standards-check/markdownlint +docker run --rm --volume ${PWD}:/app --workdir /app node:20 yarn install +docker run --rm --volume ${PWD}:/app --workdir /app node:20 yarn coding-standards-check/markdownlint # Fix (some) coding standards issues. -docker run --rm --volume ${PWD}:/app --workdir /app node:18 yarn coding-standards-apply/markdownlint +docker run --rm --volume ${PWD}:/app --workdir /app node:20 yarn coding-standards-apply/markdownlint +``` + +## Code analysis + +We use [PHPStan](https://phpstan.org/) for static code analysis. + +Running statis code analysis on a standalone Drupal module is a bit tricky, so +we use a helper script to run the analysis: + +```sh +./scripts/code-analysis ``` diff --git a/composer.json b/composer.json index 5b9c8af..7a67940 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,9 @@ } }, "require": { + "php": "^8.1", + "ext-dom": "*", + "ext-soap": "*", "cweagans/composer-patches": "^1.6.5", "dompdf/dompdf": "^2.0", "drupal/admin_toolbar": "^3.0", @@ -68,11 +71,17 @@ "drupal/webform_validation": "^2.0", "drupal/webform_views": "^5.0@alpha", "drupal/workflow_participants": "^2.4", - "os2web/os2web_datalookup": "^1.0", + "http-interop/http-factory-guzzle": "^1.0.0", + "itk-dev/beskedfordeler-drupal": "^1.0", + "itk-dev/serviceplatformen": "dev-feature/guzzle6-adapter as 1.5", + "os2web/os2web_datalookup": "^1.5", "os2web/os2web_nemlogin": "^1.0", + "php-http/guzzle6-adapter": "^2.0", "phpoffice/phpword": "^0.18.2", + "symfony/options-resolver": "^5.4 || ^6.0", "tecnickcom/tcpdf": "~6", "webmozart/path-util": "^2.3", + "wsdltophp/packagebase": "^5.0", "zaporylie/composer-drupal-optimizations": "^1.2" }, "suggest": { @@ -81,7 +90,12 @@ "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", "drupal/coder": "^8.3", - "drupal/maillog": "^1.0" + "drupal/maillog": "^1.0", + "mglaman/phpstan-drupal": "^1.1", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpunit/phpunit": "^9.5", + "wsdltophp/packagegenerator": "^4.0" }, "extra" : { "composer-exit-on-patch-failure": false, @@ -105,6 +119,12 @@ } }, "scripts": { + "code-analysis/phpstan": [ + "phpstan analyse" + ], + "code-analysis": [ + "@code-analysis/phpstan" + ], "coding-standards-check/phpcs": [ "phpcs --standard=phpcs.xml.dist" ], @@ -121,9 +141,11 @@ "config": { "sort-packages": true, "allow-plugins": { - "simplesamlphp/composer-module-installer": true, - "dealerdirect/phpcodesniffer-composer-installer": true, "cweagans/composer-patches": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true, + "simplesamlphp/composer-module-installer": true, + "vaimo/composer-patches": true, "zaporylie/composer-drupal-optimizations": true } } diff --git a/modules/os2forms_digital_post/.gitignore b/modules/os2forms_digital_post/.gitignore new file mode 100644 index 0000000..d242cb6 --- /dev/null +++ b/modules/os2forms_digital_post/.gitignore @@ -0,0 +1,6 @@ +vendor +composer.lock +node_modules +yarn.lock + +src/Controller/SF1601Controller.php.log diff --git a/modules/os2forms_digital_post/CHANGELOG.md b/modules/os2forms_digital_post/CHANGELOG.md new file mode 100644 index 0000000..dfdbf1c --- /dev/null +++ b/modules/os2forms_digital_post/CHANGELOG.md @@ -0,0 +1,117 @@ + +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic +Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.0.1] + +### Fixed + +- Fixed issue with wrong service being injected. + [PR-50](https://github.com/itk-dev/os2forms_digital_post/pull/50) + +## [3.0.0] + +### Added + +- Added API for sending digital post + [PR-40](https://github.com/itk-dev/os2forms_digital_post/pull/40) + +### Changed + +- Changed dependency on CPR and CVR lookup modules. Handled physical post + (“forsendelse”) + [PR-37](https://github.com/itk-dev/os2forms_digital_post/pull/37) + +### Removed + +- Removed support for [SF-1600](https://digitaliseringskataloget.dk/integration/sf1600). + +## [2.0.2] + +### Added + +- Added the `CPR / Navn validering` element to allowed recipient element names + [PR-43](https://github.com/itk-dev/os2forms_digital_post/pull/43) + +## Changed + +- Changed composer name to `os2forms/os2forms_digital_post` + [PR-47](https://github.com/itk-dev/os2forms_digital_post/pull/47) + +## [2.0.1] + +## Changed + +- Updated allowed attachment elements to contain `os2forms_attachment` + +## [2.0.0] + +### Changed + +- Updates `dompdf/dompdf` requirement to `^2.0` + [PR-41](https://github.com/itk-dev/os2forms_digital_post/pull/41) + +## [1.2.3] + +### Changed + +- Updated recipient element names + [PR-38](https://github.com/itk-dev/os2forms_digital_post/pull/38) + +## [1.2.2] + +### Added + +- Added creation of Beskedfordeler table. + +## [1.2.0] + +### Added + +- Added handling of CVR recipients. +- Added handling of Beskedfordeler messages. + +### Changed + +- Update dompdf dependency. + +## [1.1.2] + +- Updated logging. +- Fixed setting person id + +## [1.1.1] + +### Changed + +- Remove CPR from exception +- Added more recipient field types +- Fixed error logging. + +## [1.1.0] + +### Added + +- Added support for [SF1601 » + “KombiPostAfsend”](https://digitaliseringskataloget.dk/integration/sf1601). +- Added GitHub Actions for coding standards checks and code analysis. + +[Unreleased]: https://github.com/itk-dev/os2forms_digital_post/compare/3.0.1...HEAD +[3.0.1]: https://github.com/itk-dev/os2forms_digital_post/compare/3.0.0...3.0.1 +[3.0.0]: https://github.com/itk-dev/os2forms_digital_post/compare/2.0.2...3.0.0 +[2.0.2]: https://github.com/itk-dev/os2forms_digital_post/compare/2.0.1...2.0.2 +[2.0.1]: https://github.com/itk-dev/os2forms_digital_post/compare/2.0.0...2.0.1 +[2.0.0]: https://github.com/itk-dev/os2forms_digital_post/compare/1.2.3...2.0.0 +[1.2.3]: https://github.com/itk-dev/os2forms_digital_post/compare/1.2.2...1.2.3 +[1.2.2]: https://github.com/itk-dev/os2forms_digital_post/compare/1.2.0...1.2.2 +[1.2.0]: https://github.com/itk-dev/os2forms_digital_post/compare/1.1.2...1.2.0 +[1.1.2]: https://github.com/itk-dev/os2forms_digital_post/compare/1.1.1...1.1.2 +[1.1.1]: https://github.com/itk-dev/os2forms_digital_post/compare/1.1.0...1.1.1 +[1.1.0]: https://github.com/itk-dev/os2forms_digital_post/compare/1.0.2...1.1.0 diff --git a/modules/os2forms_digital_post/README.md b/modules/os2forms_digital_post/README.md new file mode 100644 index 0000000..020eb66 --- /dev/null +++ b/modules/os2forms_digital_post/README.md @@ -0,0 +1,108 @@ +# OS2Forms Digital Post + +Send Digital Post to danish citizens from a webform. + +This module uses the +[SF1601](https://digitaliseringskataloget.dk/integration/sf1601) service from +Serviceplatformen. Information and documentation can be obtained by following +that link. + +## Usage + +This module provides functionality for sending digital post to danish citizens. +A WebformHandler is provided that you can add to your webform, and if configured +it will send the submitted data as digital post. + +## Beskedfordeler endpoint + +This module provides an endpoint, +`/os2forms_digital_post/PostStatusBeskedModtag`, for +“[PostStatusBeskedModtag](https://digitaliseringskataloget.dk/integration/sf1601)” +to get information on how or why not a digital post has been delivered. See +“PostStatusBeskedHent” on + for details. + +## Installation + +Require it with composer: + +```shell +composer require "os2forms/os2forms_digital_post" +``` + +Enable it with drush: + +```shell +drush pm:enable os2forms_digital_post +``` + +### Example forms + +See [OS2Forms Digital Post +examples](modules/os2forms_digital_post_examples/README.md). + +## Configuration + +Go to `/admin/os2forms_digital_post/settings` to set up global settings for +digital post. + +## Drush commands + +```sh +drush --uri=$(itkdev-docker-compose url) os2forms_digital_post:digital-post:send --help + +drush --uri=$(itkdev-docker-compose url) os2forms_digital_post:digital-post:memo-show --help +``` + +## Queue + +Digital post is sent via jobs via an [Advanced +Queue](https://www.drupal.org/project/advancedqueue) called +`os2forms_digital_post`. + +The queue is processed via [Drupal's cron +run](https://www.drupal.org/docs/administering-a-drupal-site/cron-automated-tasks/cron-automated-tasks-overview), +but you can manually process the queue with `drush` if you want to process it +more often than other Drupal cron jobs: + +```sh +drush advancedqueue:queue:process os2forms_digital_post +``` + +List the queue (and all other queues) with + +```sh +drush advancedqueue:queue:list +``` + +or go to `/admin/config/system/queues/jobs/os2forms_digital_post` for a +graphical overview of jobs in the queue. + + + + + + +## Coding standards + +All coding standards are checked with [GitHub +Actions](https://github.com/features/actions) when a pull request is made (cf. +<.github/workflows/pr.yaml>). + +Check coding standards: + +```sh +docker run --rm --interactive --tty --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer install +docker run --rm --interactive --tty --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer coding-standards-check + +docker run --rm --interactive --tty --volume ${PWD}:/app node:18 yarn --cwd /app install +docker run --rm --interactive --tty --volume ${PWD}:/app node:18 yarn --cwd /app coding-standards-check +``` + +Apply coding standards: + +```shell +docker run --rm --interactive --tty --volume ${PWD}:/app itkdev/php8.1-fpm:latest composer coding-standards-apply + +docker run --rm --interactive --tty --volume ${PWD}:/app node:18 yarn --cwd /app coding-standards-apply +``` diff --git a/modules/os2forms_digital_post/config/install/advancedqueue.advancedqueue_queue.os2forms_digital_post.yml b/modules/os2forms_digital_post/config/install/advancedqueue.advancedqueue_queue.os2forms_digital_post.yml new file mode 100644 index 0000000..bb4e772 --- /dev/null +++ b/modules/os2forms_digital_post/config/install/advancedqueue.advancedqueue_queue.os2forms_digital_post.yml @@ -0,0 +1,16 @@ +langcode: en +status: true +dependencies: + module: + - os2forms_digital_post + enforced: + module: + - os2forms_digital_post +id: os2forms_digital_post +label: 'OSForms digital post' +backend: database +backend_configuration: + lease_time: 300 +processor: cron +processing_time: 90 +locked: false diff --git a/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/README.md b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/README.md new file mode 100644 index 0000000..0431297 --- /dev/null +++ b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/README.md @@ -0,0 +1,11 @@ +# OS2Forms Digital Post examples + +Examples for OS2Forms Digital Post. + +## Installation + +```sh +drush pm:enable os2forms_digital_post_examples +``` + +Go to `/admin/structure/webform?category=Example` to see the example forms. diff --git a/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/config/install/webform.webform.os2forms_digital_post_cvr.yml b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/config/install/webform.webform.os2forms_digital_post_cvr.yml new file mode 100644 index 0000000..a6aadbb --- /dev/null +++ b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/config/install/webform.webform.os2forms_digital_post_cvr.yml @@ -0,0 +1,240 @@ +langcode: da +status: open +dependencies: + enforced: + module: + - os2forms_digital_post_examples + module: + - os2forms_digital_post_examples +third_party_settings: + webform_revisions: + contentEntity_id: null +weight: 0 +open: null +close: null +uid: 1 +template: false +archive: false +id: os2forms_digital_post_cvr +title: 'OS2Forms Digital post example (CVR)' +description: 'Simple example form with a digital post handler' +category: Example +elements: |- + message: + '#type': textarea + '#title': Message + '#required': true + '#default_value': |- + [current-date:long] + + [random:hash:sha512] + recipient_cvr: + '#type': textfield + '#title': 'Recipient CVR' + '#required': true + '#default_value': '43486829' + digital_post_content_pdf: + '#type': 'webform_entity_print_attachment:pdf' + '#title': 'Digital post (PDF)' + '#display_on': view + '#filename': hat-og-briller.pdf +css: '' +javascript: '' +settings: + ajax: false + ajax_scroll_top: form + ajax_progress_type: '' + ajax_effect: '' + ajax_speed: null + page: true + page_submit_path: '' + page_confirm_path: '' + page_theme_name: '' + form_title: both + form_submit_once: false + form_open_message: '' + form_close_message: '' + form_exception_message: '' + form_previous_submissions: true + form_confidential: false + form_confidential_message: '' + form_disable_remote_addr: false + form_convert_anonymous: false + form_prepopulate: false + form_prepopulate_source_entity: false + form_prepopulate_source_entity_required: false + form_prepopulate_source_entity_type: '' + form_unsaved: false + form_disable_back: false + form_submit_back: false + form_disable_autocomplete: false + form_novalidate: false + form_disable_inline_errors: false + form_required: false + form_autofocus: false + form_details_toggle: false + form_reset: false + form_access_denied: default + form_access_denied_title: '' + form_access_denied_message: '' + form_access_denied_attributes: { } + form_file_limit: '' + form_attributes: { } + form_method: '' + form_action: '' + share: false + share_node: false + share_theme_name: '' + share_title: true + share_page_body_attributes: { } + submission_label: '' + submission_exception_message: '' + submission_locked_message: '' + submission_log: false + submission_excluded_elements: { } + submission_exclude_empty: false + submission_exclude_empty_checkbox: false + submission_views: { } + submission_views_replace: { } + submission_user_columns: { } + submission_user_duplicate: false + submission_access_denied: default + submission_access_denied_title: '' + submission_access_denied_message: '' + submission_access_denied_attributes: { } + previous_submission_message: '' + previous_submissions_message: '' + autofill: false + autofill_message: '' + autofill_excluded_elements: { } + wizard_progress_bar: true + wizard_progress_pages: false + wizard_progress_percentage: false + wizard_progress_link: false + wizard_progress_states: false + wizard_start_label: '' + wizard_preview_link: false + wizard_confirmation: true + wizard_confirmation_label: '' + wizard_auto_forward: true + wizard_auto_forward_hide_next_button: false + wizard_keyboard: true + wizard_track: '' + wizard_prev_button_label: '' + wizard_next_button_label: '' + wizard_toggle: false + wizard_toggle_show_label: '' + wizard_toggle_hide_label: '' + preview: 0 + preview_label: '' + preview_title: '' + preview_message: '' + preview_attributes: { } + preview_excluded_elements: { } + preview_exclude_empty: true + preview_exclude_empty_checkbox: false + draft: none + draft_multiple: false + draft_auto_save: false + draft_saved_message: '' + draft_loaded_message: '' + draft_pending_single_message: '' + draft_pending_multiple_message: '' + confirmation_type: message + confirmation_url: '' + confirmation_title: '' + confirmation_message: '' + confirmation_attributes: { } + confirmation_back: true + confirmation_back_label: '' + confirmation_back_attributes: { } + confirmation_exclude_query: false + confirmation_exclude_token: false + confirmation_update: false + limit_total: null + limit_total_interval: null + limit_total_message: '' + limit_total_unique: false + limit_user: null + limit_user_interval: null + limit_user_message: '' + limit_user_unique: false + entity_limit_total: null + entity_limit_total_interval: null + entity_limit_user: null + entity_limit_user_interval: null + purge: all + purge_days: 30 + results_disabled: false + results_disabled_ignore: false + results_customize: false + token_view: false + token_update: false + token_delete: false + serial_disabled: false +access: + create: + roles: + - anonymous + - authenticated + users: { } + permissions: { } + view_any: + roles: { } + users: { } + permissions: { } + update_any: + roles: { } + users: { } + permissions: { } + delete_any: + roles: { } + users: { } + permissions: { } + purge_any: + roles: { } + users: { } + permissions: { } + view_own: + roles: { } + users: { } + permissions: { } + update_own: + roles: { } + users: { } + permissions: { } + delete_own: + roles: { } + users: { } + permissions: { } + administer: + roles: { } + users: { } + permissions: { } + test: + roles: { } + users: { } + permissions: { } + configuration: + roles: { } + users: { } + permissions: { } +handlers: + digital_post_sf1601: + id: digital_post_sf1601 + handler_id: digital_post_sf1601 + label: 'Digital post (sf1601)' + notes: '' + status: true + conditions: { } + weight: 0 + settings: + debug: false + memo_message: + type: 'Automatisk Valg' + recipient_element: recipient_cvr + attachment_element: digital_post_content_pdf + sender_label: 'Hilsen fra [site:url-brief]' + message_header_label: SF1601 + memo_actions: { } +variants: { } diff --git a/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/config/install/webform.webform.os2forms_digital_post_example.yml b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/config/install/webform.webform.os2forms_digital_post_example.yml new file mode 100644 index 0000000..6a18f4d --- /dev/null +++ b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/config/install/webform.webform.os2forms_digital_post_example.yml @@ -0,0 +1,253 @@ +langcode: da +status: open +dependencies: + enforced: + module: + - os2forms_digital_post_examples + module: + - os2forms_digital_post_examples +third_party_settings: + webform_revisions: + contentEntity_id: null +weight: 0 +open: null +close: null +uid: 1 +template: false +archive: false +id: os2forms_digital_post_example +title: 'OS2Forms Digital post example' +description: 'Simple example form with a digital post handler' +category: Example +elements: |- + message: + '#type': textarea + '#title': Message + '#required': true + '#default_value': |- + [current-date:long] + + [random:hash:sha512] + recipient_cpr: + '#type': textfield + '#title': 'Recipient cpr' + '#required': true + '#default_value': '1705880000' + digital_post_content_pdf: + '#type': 'webform_entity_print_attachment:pdf' + '#title': 'Digital post (PDF)' + '#display_on': view + '#filename': hat-og-briller.pdf +css: '' +javascript: '' +settings: + ajax: false + ajax_scroll_top: form + ajax_progress_type: '' + ajax_effect: '' + ajax_speed: null + page: true + page_submit_path: '' + page_confirm_path: '' + page_theme_name: '' + form_title: both + form_submit_once: false + form_open_message: '' + form_close_message: '' + form_exception_message: '' + form_previous_submissions: true + form_confidential: false + form_confidential_message: '' + form_disable_remote_addr: false + form_convert_anonymous: false + form_prepopulate: false + form_prepopulate_source_entity: false + form_prepopulate_source_entity_required: false + form_prepopulate_source_entity_type: '' + form_unsaved: false + form_disable_back: false + form_submit_back: false + form_disable_autocomplete: false + form_novalidate: false + form_disable_inline_errors: false + form_required: false + form_autofocus: false + form_details_toggle: false + form_reset: false + form_access_denied: default + form_access_denied_title: '' + form_access_denied_message: '' + form_access_denied_attributes: { } + form_file_limit: '' + form_attributes: { } + form_method: '' + form_action: '' + share: false + share_node: false + share_theme_name: '' + share_title: true + share_page_body_attributes: { } + submission_label: '' + submission_exception_message: '' + submission_locked_message: '' + submission_log: false + submission_excluded_elements: { } + submission_exclude_empty: false + submission_exclude_empty_checkbox: false + submission_views: { } + submission_views_replace: { } + submission_user_columns: { } + submission_user_duplicate: false + submission_access_denied: default + submission_access_denied_title: '' + submission_access_denied_message: '' + submission_access_denied_attributes: { } + previous_submission_message: '' + previous_submissions_message: '' + autofill: false + autofill_message: '' + autofill_excluded_elements: { } + wizard_progress_bar: true + wizard_progress_pages: false + wizard_progress_percentage: false + wizard_progress_link: false + wizard_progress_states: false + wizard_start_label: '' + wizard_preview_link: false + wizard_confirmation: true + wizard_confirmation_label: '' + wizard_auto_forward: true + wizard_auto_forward_hide_next_button: false + wizard_keyboard: true + wizard_track: '' + wizard_prev_button_label: '' + wizard_next_button_label: '' + wizard_toggle: false + wizard_toggle_show_label: '' + wizard_toggle_hide_label: '' + preview: 0 + preview_label: '' + preview_title: '' + preview_message: '' + preview_attributes: { } + preview_excluded_elements: { } + preview_exclude_empty: true + preview_exclude_empty_checkbox: false + draft: none + draft_multiple: false + draft_auto_save: false + draft_saved_message: '' + draft_loaded_message: '' + draft_pending_single_message: '' + draft_pending_multiple_message: '' + confirmation_type: message + confirmation_url: '' + confirmation_title: '' + confirmation_message: '' + confirmation_attributes: { } + confirmation_back: true + confirmation_back_label: '' + confirmation_back_attributes: { } + confirmation_exclude_query: false + confirmation_exclude_token: false + confirmation_update: false + limit_total: null + limit_total_interval: null + limit_total_message: '' + limit_total_unique: false + limit_user: null + limit_user_interval: null + limit_user_message: '' + limit_user_unique: false + entity_limit_total: null + entity_limit_total_interval: null + entity_limit_user: null + entity_limit_user_interval: null + purge: all + purge_days: 30 + results_disabled: false + results_disabled_ignore: false + results_customize: false + token_view: false + token_update: false + token_delete: false + serial_disabled: false +access: + create: + roles: + - anonymous + - authenticated + users: { } + permissions: { } + view_any: + roles: { } + users: { } + permissions: { } + update_any: + roles: { } + users: { } + permissions: { } + delete_any: + roles: { } + users: { } + permissions: { } + purge_any: + roles: { } + users: { } + permissions: { } + view_own: + roles: { } + users: { } + permissions: { } + update_own: + roles: { } + users: { } + permissions: { } + delete_own: + roles: { } + users: { } + permissions: { } + administer: + roles: { } + users: { } + permissions: { } + test: + roles: { } + users: { } + permissions: { } + configuration: + roles: { } + users: { } + permissions: { } +handlers: + digital_post_sf1601: + id: digital_post_sf1601 + handler_id: digital_post_sf1601 + label: 'Digital post (sf1601)' + notes: '' + status: true + conditions: { } + weight: 0 + settings: + debug: false + memo_message: + type: 'Automatisk Valg' + recipient_element: recipient_cpr + attachment_element: digital_post_content_pdf + sender_label: 'Hilsen fra [site:url-brief]' + message_header_label: SF1601 + memo_actions: + actions: + - + action: INFORMATION + url: 'http://dr.dk' + label: 'Se her!' + - + action: SELVBETJENING + url: 'https://selvbetjening.aarhuskommune.dk/da/content/book-aarhus' + label: 'Book ressource' + - + action: FORBEREDELSE + url: 'http://tv2.dk' + label: 'Forbered dig med' +variants: { } diff --git a/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/os2forms_digital_post_examples.info.yml b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/os2forms_digital_post_examples.info.yml new file mode 100644 index 0000000..39db382 --- /dev/null +++ b/modules/os2forms_digital_post/modules/os2forms_digital_post_examples/os2forms_digital_post_examples.info.yml @@ -0,0 +1,8 @@ +name: 'OS2Forms Digital Post examples' +type: module +description: 'Example forms for OS2Forms Digital Post.' +package: 'OS2Forms' + +core_version_requirement: ^9 +dependencies: + - 'os2forms_digital_post:os2forms_digital_post' diff --git a/modules/os2forms_digital_post/os2forms_digital_post.info.yml b/modules/os2forms_digital_post/os2forms_digital_post.info.yml new file mode 100644 index 0000000..266ecf7 --- /dev/null +++ b/modules/os2forms_digital_post/os2forms_digital_post.info.yml @@ -0,0 +1,13 @@ +name: 'OS2Forms Digital Post' +type: module +description: 'Provides integration to Print service provided by Serviceplatformen.' +package: 'OS2Forms' +core_version_requirement: ^9 +dependencies: + - 'beskedfordeler:beskedfordeler' + - 'drupal:advancedqueue' + - 'os2web_datalookup:os2web_datalookup' + - 'webform:webform' + - 'webform:webform_submission_log' + +configure: os2forms_digital_post.admin.settings diff --git a/modules/os2forms_digital_post/os2forms_digital_post.install b/modules/os2forms_digital_post/os2forms_digital_post.install new file mode 100644 index 0000000..ce48380 --- /dev/null +++ b/modules/os2forms_digital_post/os2forms_digital_post.install @@ -0,0 +1,33 @@ + + */ +function os2forms_digital_post_schema() { + return Drupal::service(BeskedfordelerHelper::class)->schema(); +} + +/** + * Enable Beskedfordeler module. + */ +function os2forms_digital_post_update_9001(): void { + Drupal::service('module_installer')->install(['beskedfordeler']); +} + +/** + * Create beskedfordeler table. + */ +function os2forms_digital_post_update_9002(): void { + Drupal::service(BeskedfordelerHelper::class)->update9002(); +} diff --git a/modules/os2forms_digital_post/os2forms_digital_post.links.menu.yml b/modules/os2forms_digital_post/os2forms_digital_post.links.menu.yml new file mode 100644 index 0000000..6ee36c2 --- /dev/null +++ b/modules/os2forms_digital_post/os2forms_digital_post.links.menu.yml @@ -0,0 +1,5 @@ +os2forms_digital_post.admin.settings: + title: OS2Forms digital post + description: Configure the OS2Forms digital post module + parent: system.admin_config_system + route_name: os2forms_digital_post.admin.settings diff --git a/modules/os2forms_digital_post/os2forms_digital_post.routing.yml b/modules/os2forms_digital_post/os2forms_digital_post.routing.yml new file mode 100644 index 0000000..7220baf --- /dev/null +++ b/modules/os2forms_digital_post/os2forms_digital_post.routing.yml @@ -0,0 +1,7 @@ +os2forms_digital_post.admin.settings: + path: '/admin/os2forms_digital_post/settings' + defaults: + _form: '\Drupal\os2forms_digital_post\Form\SettingsForm' + _title: 'Digital post settings' + requirements: + _permission: 'administer site configuration' diff --git a/modules/os2forms_digital_post/os2forms_digital_post.services.yml b/modules/os2forms_digital_post/os2forms_digital_post.services.yml new file mode 100644 index 0000000..e493053 --- /dev/null +++ b/modules/os2forms_digital_post/os2forms_digital_post.services.yml @@ -0,0 +1,68 @@ +services: + logger.channel.os2forms_digital_post: + parent: logger.channel_base + arguments: [ 'os2forms_digital_post' ] + + logger.channel.os2forms_digital_post_submission: + parent: logger.channel_base + arguments: [ 'os2forms_digital_post_submission' ] + + Drupal\os2forms_digital_post\Helper\Settings: + arguments: + - "@keyvalue" + + Drupal\os2forms_digital_post\Helper\CertificateLocatorHelper: + arguments: + - "@Drupal\\os2forms_digital_post\\Helper\\Settings" + + Drupal\os2forms_digital_post\Helper\MeMoHelper: + arguments: + - "@Drupal\\os2forms_digital_post\\Helper\\Settings" + - "@plugin.manager.element_info" + - "@webform.token_manager" + + Drupal\os2forms_digital_post\Helper\ForsendelseHelper: + arguments: + - "@Drupal\\os2forms_digital_post\\Helper\\Settings" + - "@plugin.manager.element_info" + - "@webform.token_manager" + + Drupal\os2forms_digital_post\Helper\DigitalPostHelper: + arguments: + - "@Drupal\\os2forms_digital_post\\Helper\\Settings" + - "@Drupal\\os2forms_digital_post\\Helper\\CertificateLocatorHelper" + - "@plugin.manager.os2web_datalookup" + - "@Drupal\\os2forms_digital_post\\Helper\\MeMoHelper" + - "@Drupal\\os2forms_digital_post\\Helper\\ForsendelseHelper" + - "@Drupal\\os2forms_digital_post\\Helper\\BeskedfordelerHelper" + - "@logger.channel.os2forms_digital_post" + - "@logger.channel.os2forms_digital_post_submission" + + Drupal\os2forms_digital_post\Helper\WebformHelperSF1601: + arguments: + - "@Drupal\\os2forms_digital_post\\Helper\\Settings" + - "@entity_type.manager" + - "@plugin.manager.os2web_datalookup" + - "@Drupal\\os2forms_digital_post\\Helper\\MeMoHelper" + - "@Drupal\\os2forms_digital_post\\Helper\\ForsendelseHelper" + - "@Drupal\\os2forms_digital_post\\Helper\\BeskedfordelerHelper" + - "@logger.channel.os2forms_digital_post" + - "@logger.channel.os2forms_digital_post_submission" + - "@Drupal\\os2forms_digital_post\\Helper\\DigitalPostHelper" + + Drupal\os2forms_digital_post\Helper\SF1461Helper: + + Drupal\os2forms_digital_post\EventSubscriber\BeskedfordelerEventSubscriber: + arguments: + - '@Drupal\os2forms_digital_post\Helper\BeskedfordelerHelper' + - '@Drupal\beskedfordeler\Helper\MessageHelper' + - '@Drupal\os2forms_digital_post\Helper\WebformHelperSF1601' + - '@logger.channel.os2forms_digital_post' + tags: + - { name: 'event_subscriber' } + + Drupal\os2forms_digital_post\Helper\BeskedfordelerHelper: + arguments: + - '@database' + - '@Drupal\os2forms_digital_post\Helper\MeMoHelper' + - '@logger.channel.os2forms_digital_post' diff --git a/modules/os2forms_digital_post/phpcs.xml.dist b/modules/os2forms_digital_post/phpcs.xml.dist new file mode 100644 index 0000000..7c0b3af --- /dev/null +++ b/modules/os2forms_digital_post/phpcs.xml.dist @@ -0,0 +1,21 @@ + + + The coding standard. + + . + vendor/ + + + + + + + + + + + + + + + diff --git a/modules/os2forms_digital_post/phpstan.neon b/modules/os2forms_digital_post/phpstan.neon new file mode 100644 index 0000000..16572fc --- /dev/null +++ b/modules/os2forms_digital_post/phpstan.neon @@ -0,0 +1,20 @@ +parameters: + level: 6 + paths: + - . + excludePaths: + # @see https://github.com/mglaman/drupal-check/issues/261#issuecomment-1030141772/ + - vendor + - '*/node_modules/*' + ignoreErrors: + - + # Ignore some weird errors reported by PHPStan + # @todo Investigate further + messages: + # These errors may be related to classes that don't add anything to their base class (e.g. `class BrugerFlerRelationType extends FlerRelationType {}`) + - '#expects (DataGovDk\\Model\\Core\\)(.+)(\|null)?, \1\2\\\2AType given.#' + - '#expects (DigitalPost\\MeMo\\)(.+)(\|null)?, \1\2\\\2AType given.#' + - '#expects (Oio\\(?:.+\\)*)(.+)(\|null)?, \1\2Type given.#' + - '#expects array<(DigitalPost\\MeMo\\)(.+)>(\|null)?, array given.#' + - '#should return (DigitalPost\\MeMo\\)(.+) but returns \1\2\\\2AType.#' + - '#should return (Oio\\Fjernprint\\)(.+) but returns \1\2Type.#' diff --git a/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php b/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php new file mode 100644 index 0000000..05e9be6 --- /dev/null +++ b/modules/os2forms_digital_post/src/EventSubscriber/BeskedfordelerEventSubscriber.php @@ -0,0 +1,89 @@ +beskedfordelerHelper = $beskedfordelerHelper; + $this->messageHelper = $messageHelper; + $this->webformHelper = $webformHelper; + } + + /** + * {@inheritdoc} + */ + protected function processPostStatusBeskedModtag(PostStatusBeskedModtagEvent $event): void { + $message = $event->getDocument()->saveXML(); + try { + $data = $this->messageHelper->getBeskeddata($message); + + $channel = $data['KanalKode'] ?? NULL; + if (self::KANAL_KODE !== $channel) { + $this->logger->debug('Ignoring message data on channel @channel', [ + '@channel' => $channel ?? '(null)', + ]); + return; + } + + $messageUUID = $data[self::MESSAGE_UUID_KEY] ?? NULL; + if (NULL === $messageUUID) { + $this->logger->debug('Missing message UUID (@message_uuid_key) in data on channel @channel: @data', [ + '@message_uuid_key' => self::MESSAGE_UUID_KEY, + '@channel' => $channel, + '@data' => json_encode($data), + ]); + return; + } + + if ($this->beskedfordelerHelper->addBeskedfordelerMessage($messageUUID, $message)) { + $message = $this->beskedfordelerHelper->loadMessage($messageUUID); + $this->webformHelper->processBeskedfordelerData($message->submissionId, $data); + } + } + catch (\Exception $exception) { + $this->logger->error('Error processing message: @exception_message', [ + '@exception_message' => $exception->getMessage(), + 'message' => $message, + ]); + } + } + +} diff --git a/modules/os2forms_digital_post/src/Exception/CertificateLocatorException.php b/modules/os2forms_digital_post/src/Exception/CertificateLocatorException.php new file mode 100644 index 0000000..40fae3c --- /dev/null +++ b/modules/os2forms_digital_post/src/Exception/CertificateLocatorException.php @@ -0,0 +1,10 @@ +settings = $settings; + $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); + $this->certificateLocatorHelper = $certificateLocatorHelper; + } + + /** + * {@inheritdoc} + * + * @phpstan-return self + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get(Settings::class), + $container->get('entity_type.manager'), + $container->get(CertificateLocatorHelper::class) + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'os2forms_digital_post_settings'; + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + * @phpstan-return array + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form['test_mode'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Test mode'), + '#default_value' => $this->settings->getTestMode(), + ]; + + $sender = $this->settings->getSender(); + $form['sender'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Sender'), + '#tree' => TRUE, + + Settings::SENDER_IDENTIFIER_TYPE => [ + '#type' => 'select', + '#title' => $this->t('Identifier type'), + '#options' => [ + 'CVR' => $this->t('CVR'), + ], + '#default_value' => $sender[Settings::SENDER_IDENTIFIER_TYPE] ?? 'CVR', + '#required' => TRUE, + ], + + Settings::SENDER_IDENTIFIER => [ + '#type' => 'textfield', + '#title' => $this->t('Identifier'), + '#default_value' => $sender[Settings::SENDER_IDENTIFIER] ?? NULL, + '#required' => TRUE, + ], + + Settings::FORSENDELSES_TYPE_IDENTIFIKATOR => [ + '#type' => 'textfield', + '#title' => $this->t('Forsendelsestypeidentifikator'), + '#default_value' => $sender[Settings::FORSENDELSES_TYPE_IDENTIFIKATOR] ?? NULL, + '#required' => TRUE, + ], + ]; + + $certificate = $this->settings->getCertificate(); + $form['certificate'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Certificate'), + '#tree' => TRUE, + + 'locator_type' => [ + '#type' => 'select', + '#title' => $this->t('Certificate locator type'), + '#options' => [ + 'azure_key_vault' => $this->t('Azure key vault'), + 'file_system' => $this->t('File system'), + ], + '#default_value' => $certificate['locator_type'] ?? NULL, + ], + ]; + + $form['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT] = [ + '#type' => 'fieldset', + '#title' => $this->t('Azure key vault'), + '#states' => [ + 'visible' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + ], + ]; + + $settings = [ + 'tenant_id' => ['title' => $this->t('Tenant id')], + 'application_id' => ['title' => $this->t('Application id')], + 'client_secret' => ['title' => $this->t('Client secret')], + 'name' => ['title' => $this->t('Name')], + 'secret' => ['title' => $this->t('Secret')], + 'version' => ['title' => $this->t('Version')], + ]; + + foreach ($settings as $key => $info) { + $form['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] = [ + '#type' => 'textfield', + '#title' => $info['title'], + '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT][$key] ?? NULL, + '#states' => [ + 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_AZURE_KEY_VAULT]], + ], + ]; + } + + $form['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM] = [ + '#type' => 'fieldset', + '#title' => $this->t('File system'), + '#states' => [ + 'visible' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + ], + + 'path' => [ + '#type' => 'textfield', + '#title' => $this->t('Path'), + '#default_value' => $certificate[CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL, + '#states' => [ + 'required' => [':input[name="certificate[locator_type]"]' => ['value' => CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]], + ], + ], + ]; + + $form['certificate']['passphrase'] = [ + '#type' => 'textfield', + '#title' => $this->t('Passphrase'), + '#default_value' => $certificate['passphrase'] ?? NULL, + ]; + + $processing = $this->settings->getProcessing(); + $form['processing'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Processing'), + '#tree' => TRUE, + ]; + + $defaultValue = $processing['queue'] ?? 'os2forms_digital_post'; + $form['processing']['queue'] = [ + '#type' => 'select', + '#title' => $this->t('Queue'), + '#options' => array_map( + static fn(EntityInterface $queue) => $queue->label(), + $this->queueStorage->loadMultiple() + ), + '#default_value' => $defaultValue, + '#description' => $this->t("Queue for digital post jobs. The queue must be run via Drupal's cron or via drush advancedqueue:queue:process @queue(in a cron job).", [ + '@queue' => $defaultValue, + ':queue_url' => '/admin/config/system/queues/jobs/' . urlencode($defaultValue), + ]), + ]; + + $form['actions']['#type'] = 'actions'; + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Save settings'), + ]; + + $form['actions']['testCertificate'] = [ + '#type' => 'submit', + '#name' => 'testCertificate', + '#value' => $this->t('Test certificate'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + */ + public function validateForm(array &$form, FormStateInterface $formState): void { + $triggeringElement = $formState->getTriggeringElement(); + if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { + return; + } + + $values = $formState->getValues(); + if (CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM === $values['certificate']['locator_type']) { + $path = $values['certificate'][CertificateLocatorHelper::LOCATOR_TYPE_FILE_SYSTEM]['path'] ?? NULL; + if (!file_exists($path)) { + $formState->setErrorByName('certificate][file_system][path', $this->t('Invalid certificate path: %path', ['%path' => $path])); + } + } + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + */ + public function submitForm(array &$form, FormStateInterface $formState): void { + $triggeringElement = $formState->getTriggeringElement(); + if ('testCertificate' === ($triggeringElement['#name'] ?? NULL)) { + $this->testCertificate(); + return; + } + + try { + $settings['test_mode'] = (bool) $formState->getValue('test_mode'); + $settings['sender'] = $formState->getValue('sender'); + $settings['certificate'] = $formState->getValue('certificate'); + $settings['processing'] = $formState->getValue('processing'); + $this->settings->setSettings($settings); + $this->messenger()->addStatus($this->t('Settings saved')); + } + catch (OptionsResolverException $exception) { + $this->messenger()->addError($this->t('Settings not saved (@message)', ['@message' => $exception->getMessage()])); + } + } + + /** + * Test certificate. + */ + private function testCertificate(): void { + try { + $certificateLocator = $this->certificateLocatorHelper->getCertificateLocator(); + $certificateLocator->getCertificates(); + $this->messenger()->addStatus($this->t('Certificate succesfully tested')); + } + catch (\Throwable $throwable) { + $message = $this->t('Error testing certificate: %message', ['%message' => $throwable->getMessage()]); + $this->messenger()->addError($message); + } + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php b/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php new file mode 100644 index 0000000..100bac4 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/AbstractMessageHelper.php @@ -0,0 +1,77 @@ + $handlerSettings + */ + protected function getMainDocument(WebformSubmissionInterface $submission, array $handlerSettings): Document { + // Lifted from Drupal\webform_attachment\Controller\WebformAttachmentController::download. + $element = $handlerSettings[WebformHandlerSF1601::MEMO_MESSAGE][WebformHandlerSF1601::ATTACHMENT_ELEMENT]; + $element = $submission->getWebform()->getElement($element) ?: []; + [$type] = explode(':', $element['#type']); + $instance = $this->elementInfoManager->createInstance($type); + + if (!$instance instanceof WebformAttachmentBase) { + throw new InvalidAttachmentElementException(sprintf('Attachment element must be an instance of %s. Found %s.', WebformAttachmentBase::class, get_class($instance))); + } + + $fileName = $instance::getFileName($element, $submission); + $mimeType = $instance::getFileMimeType($element, $submission); + $content = $instance::getFileContent($element, $submission); + + return new Document( + $content, + $mimeType, + $fileName + ); + } + + /** + * Replace tokens. + */ + protected function replaceTokens(string $text, WebformSubmissionInterface $submission): string { + return $this->webformTokenManager->replace($text, $submission); + } + + /** + * Convert MeMo message to DOM document. + */ + public function message2dom(Message|ForsendelseI $message): \DOMDocument { + $document = new \DOMDocument(); + $document->loadXML((new Serializer())->serialize($message)); + + return $document; + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php b/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php new file mode 100644 index 0000000..ddb8206 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/BeskedfordelerHelper.php @@ -0,0 +1,213 @@ +database = $database; + $this->meMoHelper = $meMoHelper; + $this->setLogger($logger); + } + + /** + * Save MeMo message in database. + */ + public function createMessage(int $submissionId, MeMoMessage $message, string $receipt): int { + $messageUUID = $message->getMessageHeader()->getMessageUUID(); + $message = $this->meMoHelper->message2dom($message)->saveXML(); + + return $this->database + ->insert(self::TABLE_NAME) + ->fields([ + 'submission_id' => $submissionId, + 'message_uuid' => $messageUUID, + 'message' => $message, + 'receipt' => $receipt, + ]) + ->execute(); + } + + /** + * Load message. + * + * @param string $messageUUID + * The message UUID. + * + * @return \Drupal\os2forms_digital_post\Model\Message|null + * The message. + */ + public function loadMessage(string $messageUUID): ?Message { + // @phpstan-ignore-next-line (fetchObject invoked with 2 parameters) + return $this->database + ->select(self::TABLE_NAME, 'm') + ->fields('m') + ->condition('message_uuid', $messageUUID) + ->execute() + ->fetchObject(Message::class, []) ?: NULL; + } + + /** + * Add Beskedfordeler message to message. + */ + public function addBeskedfordelerMessage(string $messageUUID, string $beskedfordelerMessage): bool { + $message = $this->loadMessage($messageUUID); + + if (NULL === $message) { + throw new InvalidMessageException(sprintf('Invalid message UUID: %s', $messageUUID)); + } + + return $this->database + ->update(self::TABLE_NAME) + ->fields([ + 'beskedfordeler_message' => $beskedfordelerMessage, + ]) + ->condition('message_uuid', $messageUUID) + ->execute() > 0; + } + + /** + * Delete messages for submissions. + * + * @param array|WebformSubmissionInterface[] $submissions + * The submissions. + */ + public function deleteMessages(array $submissions): void { + $submissionIds = array_map(static function (WebformSubmissionInterface $submission) { + return $submission->id(); + }, $submissions); + + $this->database + ->delete(self::TABLE_NAME) + ->condition('submission_id', $submissionIds, 'IN') + ->execute(); + } + + /** + * Implements hook_schema(). + * + * @phpstan-return array + */ + public function schema(): array { + return [ + self::TABLE_NAME => [ + 'description' => 'OSForms digital post beskedfordeler', + 'fields' => [ + 'submission_id' => [ + 'description' => 'The submission id.', + 'type' => 'int', + 'not null' => TRUE, + ], + 'message_uuid' => [ + 'description' => 'The message UUID (formatted with dashes).', + 'type' => 'varchar', + 'length' => 36, + 'not null' => TRUE, + ], + 'message' => [ + 'description' => 'The MeMo message (XML).', + 'type' => 'text', + 'size' => 'medium', + 'not null' => TRUE, + ], + 'receipt' => [ + 'description' => 'The MeMo message receipt (XML).', + 'type' => 'text', + 'size' => 'medium', + 'not null' => TRUE, + ], + 'beskedfordeler_message' => [ + 'description' => 'The Beskedfordeler message (XML).', + 'type' => 'text', + 'size' => 'medium', + 'not null' => FALSE, + ], + ], + 'indexes' => [ + 'submission_id' => ['submission_id'], + ], + 'primary key' => ['message_uuid'], + ], + ]; + } + + /** + * Implements hook_update_N(). + * + * Creates beskedfordeler table. + */ + public function update9002(): void { + $spec = [ + 'description' => 'OSForms digital post beskedfordeler', + 'fields' => [ + 'submission_id' => [ + 'description' => 'The submission id.', + 'type' => 'int', + 'not null' => TRUE, + ], + 'message_uuid' => [ + 'description' => 'The message UUID (formatted with dashes).', + 'type' => 'varchar', + 'length' => 36, + 'not null' => TRUE, + ], + 'message' => [ + 'description' => 'The MeMo message (XML).', + 'type' => 'text', + 'size' => 'medium', + 'not null' => TRUE, + ], + 'receipt' => [ + 'description' => 'The MeMo message receipt (XML).', + 'type' => 'text', + 'size' => 'medium', + 'not null' => TRUE, + ], + 'beskedfordeler_message' => [ + 'description' => 'The Beskedfordeler message (XML).', + 'type' => 'text', + 'size' => 'medium', + 'not null' => FALSE, + ], + ], + 'indexes' => [ + 'submission_id' => ['submission_id'], + ], + 'primary key' => ['message_uuid'], + ]; + + $this->database->schema()->createTable(self::TABLE_NAME, $spec); + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php b/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php new file mode 100644 index 0000000..c14be13 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/CertificateLocatorHelper.php @@ -0,0 +1,79 @@ +settings->getCertificate(); + + $locatorType = $certificateSettings['locator_type']; + $options = $certificateSettings[$locatorType]; + $options += [ + 'passphrase' => $certificateSettings['passphrase'] ?: '', + ]; + + if (self::LOCATOR_TYPE_AZURE_KEY_VAULT === $locatorType) { + $httpClient = new GuzzleAdapter(new Client()); + $requestFactory = new RequestFactory(); + + $vaultToken = new VaultToken($httpClient, $requestFactory); + + $token = $vaultToken->getToken( + $options['tenant_id'], + $options['application_id'], + $options['client_secret'], + ); + + $vault = new VaultSecret( + $httpClient, + $requestFactory, + $options['name'], + $token->getAccessToken() + ); + + return new AzureKeyVaultCertificateLocator( + $vault, + $options['secret'], + $options['version'], + $options['passphrase'], + ); + } + elseif (self::LOCATOR_TYPE_FILE_SYSTEM === $locatorType) { + $certificatepath = realpath($options['path']) ?: NULL; + if (NULL === $certificatepath) { + throw new CertificateLocatorException(sprintf('Invalid certificate path %s', $options['path'])); + } + return new FilesystemCertificateLocator($certificatepath, $options['passphrase']); + } + + throw new CertificateLocatorException(sprintf('Invalid certificate locator type: %s', $locatorType)); + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php b/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php new file mode 100644 index 0000000..76c362a --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/DigitalPostHelper.php @@ -0,0 +1,145 @@ + + */ + public function sendDigitalPost(string $type, Message $message, ?ForsendelseI $forsendelse, WebformSubmissionInterface $submission): array { + $senderSettings = $this->settings->getSender(); + $options = [ + 'test_mode' => (bool) $this->settings->getTestMode(), + 'authority_cvr' => $senderSettings[Settings::SENDER_IDENTIFIER], + 'certificate_locator' => $this->certificateLocatorHelper->getCertificateLocator(), + ]; + $service = new SF1601($options); + $transactionId = Serializer::createUuid(); + $response = $service->kombiPostAfsend($transactionId, $type, $message, $forsendelse); + + $this->beskedfordelerHelper->createMessage($submission->id(), $message, (string) $response->getContent()); + + return [$response, $service->getLastKombiMeMoMessage()]; + } + + /** + * {@inheritdoc} + */ + public function log($level, $message, array $context = []): void { + $this->logger->log($level, $message, $context); + // @see https://www.drupal.org/node/3020595 + if (isset($context['webform_submission']) && $context['webform_submission'] instanceof WebformSubmissionInterface) { + $this->submissionLogger->log($level, $message, $context); + } + } + + /** + * Look up CPR. + */ + public function lookupCpr(string $cpr): CprLookupResult { + $instance = $this->dataLookupManager->createDefaultInstanceByGroup('cpr_lookup'); + if (!($instance instanceof DataLookupInterfaceCpr)) { + throw new RuntimeException('Cannot get CPR data lookup instance'); + } + $lookupResult = $instance->lookup($cpr); + if (!$lookupResult->isSuccessful()) { + throw new RuntimeException('Cannot lookup CPR'); + } + + return $lookupResult; + } + + /** + * Look up CVR. + */ + public function lookupCvr(string $cvr): CompanyLookupResult { + $instance = $this->dataLookupManager->createDefaultInstanceByGroup('cvr_lookup'); + if (!($instance instanceof DataLookupInterfaceCompany)) { + throw new RuntimeException('Cannot get CVR data lookup instance'); + } + $lookupResult = $instance->lookup($cvr); + if (!$lookupResult->isSuccessful()) { + throw new RuntimeException('Cannot lookup CVR'); + } + + return $lookupResult; + } + + /** + * Look up recipient. + */ + public function lookupRecipient(string $recipient): CprLookupResult|CompanyLookupResult { + try { + return preg_match('/^\d{8}$/', $recipient) + ? $this->lookupCvr($recipient) + : $this->lookupCpr($recipient); + } + catch (\Exception) { + throw new RuntimeException('Cannot lookup recipient'); + } + } + + /** + * Get MeMeHelper. + */ + public function getMeMoHelper(): MeMoHelper { + return $this->meMoHelper; + } + + /** + * Get ForsendelseHelper. + */ + public function getForsendelseHelper(): ForsendelseHelper { + return $this->forsendelseHelper; + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/ForsendelseHelper.php b/modules/os2forms_digital_post/src/Helper/ForsendelseHelper.php new file mode 100644 index 0000000..42cbbea --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/ForsendelseHelper.php @@ -0,0 +1,131 @@ +settings->getSender(); + $forsendelse + ->setPostParametre((new PostParametre()) + ->setPostKategoriKode(self::POST_KATEGORI_KODE_PRIORITAIRE)) + ->setForsendelseModtager($this->createModtager($recipientData)) + ->setForsendelseTypeIdentifikator($senderOptions[Settings::FORSENDELSES_TYPE_IDENTIFIKATOR]) + ->setAfsendelseIdentifikator(Serializer::createUuid()) + ->setTransaktionsParametreI() + ->setDokumentParametre((new DokumentParametre()) + ->setTitelTekst($messageLabel)); + + if (!$document->isPdf()) { + throw new InvalidForsendelseException('Document must be a PDF'); + } + + $forsendelse + ->setFilformatNavn('PDF') + ->setMeddelelseIndholdData($document->content); + + return $forsendelse; + + } + + /** + * Build forsendelse form a webform submission. + * + * @phpstan-param array $options + * @phpstan-param array $handlerSettings + */ + public function buildSubmissionForsendelse(WebformSubmissionInterface $submission, array $options, array $handlerSettings, CprLookupResult|CompanyLookupResult $recipientData): ForsendelseI { + $label = $this->replaceTokens($options[WebformHandlerSF1601::MESSAGE_HEADER_LABEL], $submission); + $document = $this->getMainDocument($submission, $handlerSettings); + + return $this->buildForsendelse($recipientData, $label, $document); + } + + /** + * Remove document content. + */ + public function removeDocumentContent(ForsendelseI $forsendelse): ForsendelseI { + $forsendelse->setMeddelelseIndholdData(''); + $forsendelse->setBilagSamling([]); + + return $forsendelse; + } + + /** + * Create modtager. + */ + private function createModtager(CprLookupResult|CompanyLookupResult|null $recipient): ForsendelseModtager { + $afsendelseModtager = new AfsendelseModtager(); + $modtagerAdresse = (new ModtagerAdresse()); + + if ($recipient instanceof CprLookupResult) { + // @see https://digitaliseringskataloget.srvitkhulk.itkdev.dk/digitaliseringskataloget.dk/sf1601/SF1601%20Bilag%2020211025/SF1601%20Postkomponent%20-%20KombiPostAfsend%20-%20Feltbeskrivelse.pdf#page=7 + $afsendelseModtager->setCPRnummerIdentifikator('0000000000'); + + $modtagerAdresse + ->setPersonName($recipient->getName()) + ->setStreetName($recipient->getStreet()) + ->setStreetBuildingIdentifier($recipient->getHouseNr()) + ->setPostCodeIdentifier($recipient->getPostalCode()) + ->setCountryIdentificationCode((new CountryIdentificationCode('DK')) + ->setScheme('iso3166-alpha2') + ); + + if ($floor = trim($recipient->getFloor())) { + $modtagerAdresse->setFloorIdentifier($floor); + } + if ($suite = trim($recipient->getApartmentNr())) { + $modtagerAdresse->setSuiteIdentifier($suite); + } + } + elseif ($recipient instanceof CompanyLookupResult) { + $afsendelseModtager->setCVRnummerIdentifikator($recipient->getCvr()); + + $modtagerAdresse + ->setPersonName($recipient->getName()) + ->setStreetName($recipient->getStreet()) + ->setStreetBuildingIdentifier($recipient->getHouseNr()) + ->setPostCodeIdentifier($recipient->getPostalCode()) + ->setCountryIdentificationCode((new CountryIdentificationCode('DK')) + ->setScheme('iso3166-alpha2') + ); + + if ($floor = trim($recipient->getFloor())) { + $modtagerAdresse->setFloorIdentifier($floor); + } + if ($suite = trim($recipient->getApartmentNr())) { + $modtagerAdresse->setSuiteIdentifier($suite); + } + } + + return (new ForsendelseModtager()) + ->setAfsendelseModtager($afsendelseModtager) + ->setModtagerAdresse($modtagerAdresse); + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/MeMoHelper.php b/modules/os2forms_digital_post/src/Helper/MeMoHelper.php new file mode 100644 index 0000000..769dcad --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/MeMoHelper.php @@ -0,0 +1,187 @@ + $actions + */ + public function buildMessage(CprLookupResult|CompanyLookupResult $recipientData, string $senderLabel, string $messageLabel, Document $document, array $actions): Message { + $messageUUID = Serializer::createUuid(); + $messageID = Serializer::createUuid(); + + $message = new Message(); + + $senderOptions = $this->settings->getSender(); + $sender = (new Sender()) + ->setIdType($senderOptions[Settings::SENDER_IDENTIFIER_TYPE]) + ->setSenderID($senderOptions[Settings::SENDER_IDENTIFIER]) + ->setLabel($senderLabel); + + [$recipientIdType, $recipientID] = $recipientData instanceof CompanyLookupResult + ? ['CVR', $recipientData->getCvr()] + : ['CPR', $recipientData->getCpr()]; + + $recipient = (new Recipient()) + ->setIdType($recipientIdType) + ->setRecipientID($recipientID); + + $this->enrichRecipient($recipient, $recipientData); + + $messageHeader = (new MessageHeader()) + ->setMessageType(SF1601::MESSAGE_TYPE_DIGITAL_POST) + ->setMessageUUID($messageUUID) + ->setMessageID($messageID) + ->setLabel($messageLabel) + ->setMandatory(FALSE) + ->setLegalNotification(FALSE) + ->setSender($sender) + ->setRecipient($recipient); + + $message->setMessageHeader($messageHeader); + + $body = (new MessageBody()) + ->setCreatedDateTime(new \DateTime()); + + $mainDocument = (new MainDocument()) + ->setFile([ + (new File()) + ->setEncodingFormat($document->mimeType) + ->setLanguage($document->language) + ->setFilename($document->filename) + ->setContent($document->content), + ]); + + foreach ($actions as $action) { + $mainDocument->addToAction($action); + } + + $body->setMainDocument($mainDocument); + + $message->setMessageBody($body); + + return $message; + } + + /** + * Build MeMo message from a webform submission. + * + * @phpstan-param array $options + * @phpstan-param array $handlerSettings + */ + public function buildWebformSubmissionMessage(WebformSubmissionInterface $submission, array $options, array $handlerSettings, CprLookupResult|CompanyLookupResult|null $recipientData = NULL): Message { + $senderLabel = $this->replaceTokens($options[WebformHandlerSF1601::SENDER_LABEL], $submission); + $messageLabel = $this->replaceTokens($options[WebformHandlerSF1601::MESSAGE_HEADER_LABEL], $submission); + $document = $this->getMainDocument($submission, $handlerSettings); + $actions = []; + if (isset($handlerSettings[WebformHandlerSF1601::MEMO_ACTIONS]['actions'])) { + foreach ($handlerSettings[WebformHandlerSF1601::MEMO_ACTIONS]['actions'] as $spec) { + $actions[] = $this->buildAction($spec, $submission); + } + } + + return $this->buildMessage($recipientData, $senderLabel, $messageLabel, $document, $actions); + } + + /** + * Enrich recipient with additional data from a lookup. + */ + private function enrichRecipient(Recipient $recipient, CprLookupResult|CompanyLookupResult|null $recipientData): Recipient { + if ($recipientData instanceof CprLookupResult) { + $name = $recipientData->getName(); + $recipient->setLabel($name); + $address = (new Address()) + ->setCo('') + ->setAddressLabel($recipientData->getStreet() ?: '') + ->setHouseNumber($recipientData->getHouseNr() ?: '') + ->setFloor($recipientData->getFloor() ?: '') + ->setDoor($recipientData->getApartmentNr() ?: '') + ->setZipCode($recipientData->getPostalCode() ?: '') + ->setCity($recipientData->getCity() ?: '') + ->setCountry('DA'); + $attentionData = (new AttentionData()) + ->setAttentionPerson((new AttentionPerson()) + ->setLabel($recipient->getLabel()) + ->setPersonID($recipient->getRecipientID()) + ) + ->setAddress($address); + + $recipient->setAttentionData($attentionData); + } + elseif ($recipientData instanceof CompanyLookupResult) { + $name = $recipientData->getName(); + + $recipient->setLabel($name); + $address = (new Address()) + ->setCo('') + ->setAddressLabel($recipientData->getStreet() ?: '') + ->setHouseNumber($recipientData->getHouseNr() ?: '') + ->setFloor($recipientData->getFloor() ?: '') + ->setDoor($recipientData->getApartmentNr() ?: '') + ->setZipCode($recipientData->getPostalCode() ?: '') + ->setCity($recipientData->getCity() ?: '') + ->setCountry('DA'); + $attentionData = (new AttentionData()) + ->setAttentionPerson((new AttentionPerson()) + ->setLabel($recipient->getLabel()) + ->setPersonID($recipient->getRecipientID()) + ) + ->setAddress($address); + + $recipient->setAttentionData($attentionData); + } + + return $recipient; + } + + /** + * Build action. + * + * @phpstan-param array $options + */ + private function buildAction(array $options, WebformSubmissionInterface $submission): Action { + $label = $this->replaceTokens($options['label'], $submission); + $action = (new Action()) + ->setActionCode($options['action']) + ->setLabel($label); + if (SF1601::ACTION_AFTALE === $options['action']) { + throw new \RuntimeException(sprintf('Cannot handle action %s', $options['action'])); + } + elseif ($options['url']) { + $url = $this->replaceTokens($options['url'], $submission); + $action->setEntryPoint( + (new EntryPoint()) + ->setUrl($url) + ); + } + + return $action; + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/SF1461Helper.php b/modules/os2forms_digital_post/src/Helper/SF1461Helper.php new file mode 100644 index 0000000..171abe1 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/SF1461Helper.php @@ -0,0 +1,40 @@ + + + + + + + +XML; + + $document = new \DOMDocument(); + $document->loadXML($xml); + $xpath = new \DOMXPath($document); + $xpath->registerNamespace('default', 'urn:oio:sag-dok:3.0.0'); + + $xpath->query('//default:StatusKode')->item(0)->nodeValue = $statusCode; + $xpath->query('//default:FejlbeskedTekst')->item(0)->nodeValue = $errorMessage; + + return $document; + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/Settings.php b/modules/os2forms_digital_post/src/Helper/Settings.php new file mode 100644 index 0000000..e64be73 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/Settings.php @@ -0,0 +1,125 @@ +store = $keyValueFactory->get($this->collection); + } + + /** + * Get test mode. + */ + public function getTestMode(): bool { + return (bool) $this->get('test_mode', TRUE); + } + + /** + * Get sender. + * + * @phpstan-return array + */ + public function getSender(): array { + $value = $this->get('sender'); + return is_array($value) ? $value : []; + } + + /** + * Get certificate. + * + * @phpstan-return array + */ + public function getCertificate(): array { + $value = $this->get('certificate'); + return is_array($value) ? $value : []; + } + + /** + * Get processing. + * + * @phpstan-return array + */ + public function getProcessing(): array { + $value = $this->get('processing'); + return is_array($value) ? $value : []; + } + + /** + * Get a setting value. + * + * @param string $key + * The key. + * @param mixed|null $default + * The default value. + * + * @return mixed + * The setting value. + */ + private function get(string $key, $default = NULL) { + $resolver = $this->getSettingsResolver(); + if (!$resolver->isDefined($key)) { + throw new InvalidSettingException(sprintf('Setting %s is not defined', $key)); + } + + return $this->store->get($key, $default); + } + + /** + * Set settings. + * + * @throws \Symfony\Component\OptionsResolver\Exception\ExceptionInterface + * + * @phpstan-param array $settings + */ + public function setSettings(array $settings): self { + $settings = $this->getSettingsResolver()->resolve($settings); + foreach ($settings as $key => $value) { + $this->store->set($key, $value); + } + + return $this; + } + + /** + * Get settings resolver. + */ + private function getSettingsResolver(): OptionsResolver { + return (new OptionsResolver()) + ->setDefaults([ + 'test_mode' => TRUE, + 'sender' => [], + 'certificate' => [], + 'processing' => [], + ]); + } + +} diff --git a/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php b/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php new file mode 100644 index 0000000..2c1e602 --- /dev/null +++ b/modules/os2forms_digital_post/src/Helper/WebformHelperSF1601.php @@ -0,0 +1,334 @@ +webformSubmissionStorage = $entityTypeManager->getStorage('webform_submission'); + $this->queueStorage = $entityTypeManager->getStorage('advancedqueue_queue'); + } + + /** + * Send digital post. + * + * @param \Drupal\webform\WebformSubmissionInterface $submission + * The submission. + * @param array $handlerSettings + * The Handler settings. + * @param array $submissionData + * Submission data. Only for overriding during testing and development. + * + * @return array + * [The response, The kombi post message]. + * + * @phpstan-param array $handlerSettings + * @phpstan-param array $submissionData + * @phpstan-return array + */ + public function sendDigitalPost(WebformSubmissionInterface $submission, array $handlerSettings, array $submissionData = []): array { + $submissionData = $submissionData + $submission->getData(); + + $handlerMessageSettings = $handlerSettings[WebformHandlerSF1601::MEMO_MESSAGE]; + $recipientIdentifierKey = $handlerMessageSettings[WebformHandlerSF1601::RECIPIENT_ELEMENT] ?? NULL; + if (NULL === $recipientIdentifierKey) { + $message = 'Recipient identifier element (key: @element_key) not found in submission'; + $context = [ + '@element_key' => WebformHandlerSF1601::RECIPIENT_ELEMENT, + ]; + + $this->error($message, $context); + throw new InvalidRecipientIdentifierElementException(str_replace(array_keys($context), array_values($context), + $message)); + } + + $recipientIdentifier = $submissionData[$recipientIdentifierKey] ?? NULL; + + // Fix if os2forms_person_lookup (cpr & name validation) element is used. + if (is_array($recipientIdentifier)) { + // Example: + // [ + // 'cpr_number' => 1234567890, + // 'name' => Eksempel Eksempelsen, + // ]. + $recipientIdentifier = $recipientIdentifier['cpr_number'] ?? NULL; + } + + if (NULL === $recipientIdentifier) { + $message = 'Recipient identifier element (key: @element_key) not found in submission'; + $context = [ + '@element_key' => WebformHandlerSF1601::RECIPIENT_ELEMENT, + ]; + + $this->error($message, $context); + throw new InvalidRecipientIdentifierElementException(str_replace(array_keys($context), array_values($context), + $message)); + } + + // Remove all non-digits from recipient identifier. + $recipientIdentifier = preg_replace('/[^\d]+/', '', $recipientIdentifier); + + /** @var \Drupal\os2web_datalookup\LookupResult\CprLookupResult|\Drupal\os2web_datalookup\LookupResult\CompanyLookupResult|null $lookupResult */ + $lookupResult = NULL; + + if (preg_match('/^\d{8}$/', $recipientIdentifier)) { + $instance = $this->dataLookupManager->createDefaultInstanceByGroup('cvr_lookup'); + if (!($instance instanceof DataLookupInterfaceCompany)) { + throw new RuntimeException('Cannot get CVR data lookup instance'); + } + $lookupResult = $instance->lookup($recipientIdentifier); + if (!$lookupResult->isSuccessful()) { + throw new RuntimeException('Cannot validate recipient CVR'); + } + $recipientIdentifierType = 'CVR'; + } + else { + $instance = $this->dataLookupManager->createDefaultInstanceByGroup('cpr_lookup'); + if (!($instance instanceof DataLookupInterfaceCpr)) { + throw new RuntimeException('Cannot get CPR data lookup instance'); + } + $lookupResult = $instance->lookup($recipientIdentifier); + if (!$lookupResult->isSuccessful()) { + throw new RuntimeException('Cannot validate recipient CPR'); + } + $recipientIdentifierType = 'CPR'; + } + + $senderSettings = $this->settings->getSender(); + $messageOptions = [ + self::RECIPIENT_IDENTIFIER_TYPE => $recipientIdentifierType, + self::RECIPIENT_IDENTIFIER => $recipientIdentifier, + + Settings::SENDER_IDENTIFIER_TYPE => $senderSettings[Settings::SENDER_IDENTIFIER_TYPE], + Settings::SENDER_IDENTIFIER => $senderSettings[Settings::SENDER_IDENTIFIER], + + WebformHandlerSF1601::SENDER_LABEL => $handlerMessageSettings[WebformHandlerSF1601::SENDER_LABEL], + WebformHandlerSF1601::MESSAGE_HEADER_LABEL => $handlerMessageSettings[WebformHandlerSF1601::MESSAGE_HEADER_LABEL], + ]; + $message = $this->meMoHelper->buildWebformSubmissionMessage($submission, $messageOptions, $handlerSettings, $lookupResult); + $forsendelseOptions = [ + self::RECIPIENT_IDENTIFIER_TYPE => $recipientIdentifierType, + self::RECIPIENT_IDENTIFIER => $recipientIdentifier, + + Settings::SENDER_IDENTIFIER_TYPE => $senderSettings[Settings::SENDER_IDENTIFIER_TYPE], + Settings::SENDER_IDENTIFIER => $senderSettings[Settings::SENDER_IDENTIFIER], + Settings::FORSENDELSES_TYPE_IDENTIFIKATOR => $senderSettings[Settings::FORSENDELSES_TYPE_IDENTIFIKATOR], + + WebformHandlerSF1601::SENDER_LABEL => $handlerMessageSettings[WebformHandlerSF1601::SENDER_LABEL], + WebformHandlerSF1601::MESSAGE_HEADER_LABEL => $handlerMessageSettings[WebformHandlerSF1601::MESSAGE_HEADER_LABEL], + ]; + $forsendelse = $this->forsendelseHelper->buildSubmissionForsendelse($submission, $forsendelseOptions, $handlerSettings, $lookupResult); + + $type = $handlerMessageSettings[WebformHandlerSF1601::TYPE] ?? SF1601::TYPE_DIGITAL_POST; + + return $this->digitalPostHelper->sendDigitalPost($type, $message, $forsendelse, $submission); + } + + /** + * Load webform submission by id. + */ + public function loadSubmission(int $id): ?WebformSubmissionInterface { + return $this->webformSubmissionStorage->load($id); + } + + /** + * Load queue. + */ + private function loadQueue(): QueueInterface { + $processingSettings = $this->settings->getProcessing(); + + /** @var \Drupal\advancedqueue\Entity\QueueInterface $queue */ + $queue = $this->queueStorage->load($processingSettings['queue'] ?? 'os2forms_digital_post'); + + return $queue; + } + + /** + * {@inheritdoc} + */ + public function log($level, $message, array $context = []): void { + $this->logger->log($level, $message, $context); + // @see https://www.drupal.org/node/3020595 + if (isset($context['webform_submission']) && $context['webform_submission'] instanceof WebformSubmissionInterface) { + $this->submissionLogger->log($level, $message, $context); + } + } + + /** + * Create a job. + * + * @see self::processJob() + * + * @phpstan-param array $handlerConfiguration + */ + public function createJob(WebformSubmissionInterface $webformSubmission, array $handlerConfiguration): ?Job { + $context = [ + 'webform_submission' => $webformSubmission, + ]; + + try { + $job = Job::create(SendDigitalPostSF1601::class, [ + 'formId' => $webformSubmission->getWebform()->id(), + 'submissionId' => $webformSubmission->id(), + 'handlerConfiguration' => $handlerConfiguration, + ]); + $queue = $this->loadQueue(); + $queue->enqueueJob($job); + $context['@queue'] = $queue->id(); + $this->notice('Job for sending digital post add to the queue @queue.', $context + [ + 'handler_id' => 'os2forms_digital_post', + 'operation' => 'digital post queued for sending', + ]); + + return $job; + } + catch (\Exception $exception) { + $this->error('Error creating job for sending digital post.', $context + [ + 'handler_id' => 'os2forms_digital_post', + 'operation' => 'digital post failed', + ]); + return NULL; + } + } + + /** + * Process a job. + * + * @see self::createJob() + */ + public function processJob(Job $job): JobResult { + $payload = $job->getPayload(); + + try { + $submissionId = $payload['submissionId']; + $submission = $this->loadSubmission($submissionId); + if (NULL === $submission) { + $message = 'Cannot load submission @submissionId'; + $context = [ + '@submissionId' => $submissionId, + ]; + $this->error($message, $context); + + throw new SubmissionNotFoundException(str_replace(array_keys($context), array_values($context), + $message)); + } + + $this->sendDigitalPost($submission, $payload['handlerConfiguration']); + + $this->notice('Digital post sent', [ + 'handler_id' => 'os2forms_digital_post', + 'operation' => 'digital post send', + 'webform_submission' => $submission, + ]); + + return JobResult::success(); + } + catch (\Exception $e) { + $this->error('Error: @message', [ + '@message' => $e->getMessage(), + 'handler_id' => 'os2forms_digital_post', + 'operation' => 'digital post send', + ]); + + return JobResult::failure($e->getMessage()); + } + } + + /** + * Process Beskedfordeler data. + * + * @phpstan-param array $data + */ + public function processBeskedfordelerData(int $submissionId, array $data): void { + $webformSubmission = $this->loadSubmission($submissionId); + if (NULL !== $webformSubmission) { + $context = [ + 'webform_submission' => $webformSubmission, + 'handler_id' => 'os2forms_digital_post', + ]; + $status = $data['TransaktionsStatusKode']; + + if (!empty($data['FejlDetaljer'])) { + $this->error('@status; @error_code: @error_text', $context + [ + 'operation' => 'digital post failed', + '@status' => $status, + '@error_code' => $data['FejlDetaljer']['FejlKode'], + '@error_text' => $data['FejlDetaljer']['FejlTekst'], + 'data' => $data, + ]); + } + else { + $this->info('@status', $context + [ + 'operation' => 'digital post success', + '@status' => $status, + ]); + } + } + } + + /** + * Proxy for BeskedfordelerHelper::deleteMessages(). + * + * @see BeskedfordelerHelper::deleteMessages() + * + * @phpstan-param array $webformSubmissions + */ + public function deleteMessages(array $webformSubmissions): void { + $this->beskedfordelerHelper->deleteMessages($webformSubmissions); + } + +} diff --git a/modules/os2forms_digital_post/src/Model/Document.php b/modules/os2forms_digital_post/src/Model/Document.php new file mode 100644 index 0000000..42768ad --- /dev/null +++ b/modules/os2forms_digital_post/src/Model/Document.php @@ -0,0 +1,31 @@ +mimeType; + } + +} diff --git a/modules/os2forms_digital_post/src/Model/Message.php b/modules/os2forms_digital_post/src/Model/Message.php new file mode 100644 index 0000000..86ba422 --- /dev/null +++ b/modules/os2forms_digital_post/src/Model/Message.php @@ -0,0 +1,63 @@ + 'submissionId', + 'message_uuid' => 'messageUUID', + 'beskedfordeler_message' => 'beskedfordelerMessage', + ][$name] ?? $name; + + if (!property_exists($this, $property)) { + throw new \RuntimeException(sprintf('Invalid property: %s', $property)); + } + + $this->$property = $value; + } + +} diff --git a/modules/os2forms_digital_post/src/Plugin/AdvancedQueue/JobType/SendDigitalPostSF1601.php b/modules/os2forms_digital_post/src/Plugin/AdvancedQueue/JobType/SendDigitalPostSF1601.php new file mode 100644 index 0000000..6e10480 --- /dev/null +++ b/modules/os2forms_digital_post/src/Plugin/AdvancedQueue/JobType/SendDigitalPostSF1601.php @@ -0,0 +1,66 @@ + $configuration + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get(WebformHelperSF1601::class) + ); + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $configuration + */ + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + WebformHelperSF1601 $helper + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->helper = $helper; + } + + /** + * {@inheritdoc} + */ + public function process(Job $job): JobResult { + return $this->helper->processJob($job); + } + +} diff --git a/modules/os2forms_digital_post/src/Plugin/WebformHandler/WebformHandlerSF1601.php b/modules/os2forms_digital_post/src/Plugin/WebformHandler/WebformHandlerSF1601.php new file mode 100644 index 0000000..667368d --- /dev/null +++ b/modules/os2forms_digital_post/src/Plugin/WebformHandler/WebformHandlerSF1601.php @@ -0,0 +1,375 @@ + $configuration + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $instance = new static($configuration, $plugin_id, $plugin_definition); + + $instance->loggerFactory = $container->get('logger.factory'); + $instance->configFactory = $container->get('config.factory'); + $instance->renderer = $container->get('renderer'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->conditionsValidator = $container->get('webform_submission.conditions_validator'); + $instance->tokenManager = $container->get('webform.token_manager'); + $instance->helper = $container->get(WebformHelperSF1601::class); + + $instance->setConfiguration($configuration); + + return $instance; + } + + /** + * {@inheritdoc} + * + * @phpstan-return array + */ + public function defaultConfiguration() { + return [ + 'debug' => FALSE, + ]; + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + * @phpstan-return array + */ + public function buildConfigurationForm(array $form, FormStateInterface $formState) { + $form[self::MEMO_MESSAGE] = [ + '#type' => 'fieldset', + '#title' => $this->t('Message'), + ]; + + $form[self::MEMO_MESSAGE][self::TYPE] = [ + '#type' => 'select', + '#title' => $this->t('Type'), + '#required' => TRUE, + '#options' => [ + SF1601::TYPE_AUTOMATISK_VALG => SF1601::TYPE_AUTOMATISK_VALG, + SF1601::TYPE_DIGITAL_POST => SF1601::TYPE_DIGITAL_POST, + SF1601::TYPE_FYSISK_POST => SF1601::TYPE_FYSISK_POST, + ], + '#default_value' => $this->configuration[self::MEMO_MESSAGE][self::TYPE] ?? SF1601::TYPE_AUTOMATISK_VALG, + ]; + + $availableElements = $this->getRecipientElements(); + $form[self::MEMO_MESSAGE][static::RECIPIENT_ELEMENT] = [ + '#type' => 'select', + '#title' => $this->t('Element that contains the identifier (CPR or CVR) of the recipient'), + '#required' => TRUE, + '#default_value' => $this->configuration[self::MEMO_MESSAGE][static::RECIPIENT_ELEMENT] ?? NULL, + '#options' => $availableElements, + ]; + + $availableElements = $this->getAttachmentElements(); + $form[self::MEMO_MESSAGE][static::ATTACHMENT_ELEMENT] = [ + '#type' => 'select', + '#title' => $this->t('Element that contains the document to send'), + '#required' => TRUE, + '#default_value' => $this->configuration[self::MEMO_MESSAGE][static::ATTACHMENT_ELEMENT] ?? NULL, + '#options' => $availableElements, + ]; + + $form[self::MEMO_MESSAGE][self::SENDER_LABEL] = [ + '#type' => 'textfield', + '#title' => $this->t('Sender label'), + '#required' => TRUE, + '#default_value' => $this->configuration[self::MEMO_MESSAGE][self::SENDER_LABEL] ?? NULL, + '#maxlength' => self::SENDER_LABEL_MAX_LENGTH, + ]; + + $form[self::MEMO_MESSAGE][self::MESSAGE_HEADER_LABEL] = [ + '#type' => 'textfield', + '#title' => $this->t('Message header label'), + '#required' => TRUE, + '#default_value' => $this->configuration[self::MEMO_MESSAGE][self::MESSAGE_HEADER_LABEL] ?? NULL, + '#maxlength' => self::MESSAGE_HEADER_LABEL_MAX_LENGTH, + ]; + + $form[self::MEMO_ACTIONS] = [ + '#type' => 'fieldset', + '#title' => $this->t('Actions'), + '#description' => $this->t('Remove an action by clearing %action and saving.', ['%action' => (string) $this->t('Action')]), + ]; + + $form[self::MEMO_ACTIONS]['actions'] = [ + '#type' => 'table', + ]; + + $actionOptions = [ + // @todo Handle SF1601::ACTION_AFTALE. + SF1601::ACTION_BEKRAEFT => $this->getTranslatedActionName(SF1601::ACTION_BEKRAEFT), + SF1601::ACTION_BETALING => $this->getTranslatedActionName(SF1601::ACTION_BETALING), + SF1601::ACTION_FORBEREDELSE => $this->getTranslatedActionName(SF1601::ACTION_FORBEREDELSE), + SF1601::ACTION_INFORMATION => $this->getTranslatedActionName(SF1601::ACTION_INFORMATION), + SF1601::ACTION_SELVBETJENING => $this->getTranslatedActionName(SF1601::ACTION_SELVBETJENING), + SF1601::ACTION_TILMELDING => $this->getTranslatedActionName(SF1601::ACTION_TILMELDING), + SF1601::ACTION_UNDERSKRIV => $this->getTranslatedActionName(SF1601::ACTION_UNDERSKRIV), + ]; + $actions = $this->configuration[self::MEMO_ACTIONS]['actions'] ?? []; + for ($i = 0; $i <= count($actions); $i++) { + $action = $actions[$i]; + $form[self::MEMO_ACTIONS]['actions'][$i]['action'] = [ + '#type' => 'select', + '#title' => $this->t('Action'), + '#options' => $actionOptions, + '#default_value' => $action['action'] ?? NULL, + '#empty_value' => '', + ]; + $form[self::MEMO_ACTIONS]['actions'][$i]['url'] = [ + '#type' => 'textfield', + '#title' => $this->t('Url'), + '#default_value' => $action['url'] ?? NULL, + '#states' => [ + 'required' => [sprintf(':input[name="settings[memo_actions][actions][%d][action]"]', $i) => ['filled' => TRUE]], + ], + ]; + $form[self::MEMO_ACTIONS]['actions'][$i]['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#default_value' => $action['label'] ?? NULL, + '#states' => [ + 'required' => [sprintf(':input[name="settings[memo_actions][actions][%d][action]"]', $i) => ['filled' => TRUE]], + ], + ]; + } + + // Development. + $form['development'] = [ + '#type' => 'details', + '#title' => $this->t('Development settings'), + ]; + + $form['development']['debug'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable debugging'), + '#description' => $this->t('If checked, every handler method invoked will be displayed onscreen to all users.'), + '#return_value' => TRUE, + '#default_value' => $this->configuration['debug'] ?? NULL, + ]; + + return $this->setSettingsParents($form); + } + + /** + * Get recipient elements. + * + * @phpstan-return array + */ + private function getRecipientElements(): array { + $elements = $this->getWebform()->getElementsDecodedAndFlattened(); + + $elementTypes = [ + 'textfield', + 'os2forms_nemid_company_cvr', + 'os2forms_nemid_company_cvr_fetch_data', + 'os2forms_nemid_cpr', + 'os2forms_person_lookup', + // @todo Remove these when we remove the elements. + 'cpr_element', + 'cpr_value_element', + 'cvr_element', + 'cvr_value_element', + ]; + $elements = array_filter( + $elements, + static function (array $element) use ($elementTypes) { + return in_array($element['#type'], $elementTypes, TRUE); + } + ); + + return array_map(static function (array $element) { + return $element['#title']; + }, $elements); + } + + /** + * Get attachment elements. + * + * @phpstan-return array + */ + private function getAttachmentElements(): array { + $elements = $this->getWebform()->getElementsDecodedAndFlattened(); + + $elementTypes = [ + 'webform_entity_print_attachment:pdf', + 'os2forms_attachment', + ]; + $elements = array_filter( + $elements, + static function (array $element) use ($elementTypes) { + return in_array($element['#type'], $elementTypes, TRUE); + } + ); + + return array_map(static function (array $element) { + return $element['#title']; + }, $elements); + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + * @phpstan-return void + */ + public function validateConfigurationForm(array &$form, FormStateInterface $formState) { + $actions = $formState->getValue(self::MEMO_ACTIONS)['actions'] ?? []; + + $definedActions = []; + foreach ($actions as $index => $action) { + if (!empty($action['action'])) { + if (empty($action['url'])) { + $formState->setErrorByName( + self::MEMO_ACTIONS . '][actions][' . $index . '][url', + $this->t('Url for action %action is required.', [ + '%action' => $this->getTranslatedActionName($action['action']), + '%url' => $action['url'] ?? '', + ]) + ); + } + if (isset($definedActions[$action['action']])) { + $formState->setErrorByName( + self::MEMO_ACTIONS . '][actions][' . $index . '][action', + $this->t('Action %action already defined.', [ + '%action' => $this->getTranslatedActionName($action['action']), + ]) + ); + } + $definedActions[$action['action']] = $action; + } + } + } + + /** + * {@inheritdoc} + * + * @phpstan-param array $form + * @phpstan-return void + */ + public function submitConfigurationForm(array &$form, FormStateInterface $formState) { + parent::submitConfigurationForm($form, $formState); + + $this->configuration[self::MEMO_MESSAGE] = $formState->getValue(self::MEMO_MESSAGE); + // Filter out actions with no action set. + $actions = $formState->getValue(self::MEMO_ACTIONS); + $actions['actions'] = array_values(array_filter( + $actions['actions'], + static function (array $action) { + return !empty($action['action']); + } + )); + $this->configuration[self::MEMO_ACTIONS] = $actions; + + $this->configuration['debug'] = (bool) $formState->getValue('debug'); + } + + /** + * {@inheritdoc} + * + * @phpstan-return void + */ + public function postSave(WebformSubmissionInterface $webformSubmission, $update = TRUE) { + $this->helper->createJob($webformSubmission, $this->configuration); + } + + /** + * {@inheritdoc} + * + * @phpstan-return void + */ + public function postDelete(WebformSubmissionInterface $webformSubmission) { + $this->helper->deleteMessages([$webformSubmission]); + } + + /** + * {@inheritdoc} + * + * @phpstan-return void + */ + public function postPurge(array $webformSubmissions) { + $this->helper->deleteMessages($webformSubmissions); + } + + /** + * Translated action names. + * + * @var array|null + * + * @phpstan-var array + */ + private ?array $translatedActionNames = NULL; + + /** + * Get translated action name. + */ + private function getTranslatedActionName(string $action): string { + if (NULL === $this->translatedActionNames) { + $this->translatedActionNames = [ + SF1601::ACTION_AFTALE => (string) $this->t('Aftale', [], ['context' => 'memo action']), + SF1601::ACTION_BEKRAEFT => (string) $this->t('Bekræft', [], ['context' => 'memo action']), + SF1601::ACTION_BETALING => (string) $this->t('Betaling', [], ['context' => 'memo action']), + SF1601::ACTION_FORBEREDELSE => (string) $this->t('Forberedelse', [], ['context' => 'memo action']), + SF1601::ACTION_INFORMATION => (string) $this->t('Information', [], ['context' => 'memo action']), + SF1601::ACTION_SELVBETJENING => (string) $this->t('Selvbetjening', [], ['context' => 'memo action']), + SF1601::ACTION_TILMELDING => (string) $this->t('Tilmelding', [], ['context' => 'memo action']), + SF1601::ACTION_UNDERSKRIV => (string) $this->t('Underskriv', [], ['context' => 'memo action']), + ]; + } + + return $this->translatedActionNames[$action] ?? $action; + } + +} diff --git a/package.json b/package.json index 5004929..52fcd34 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,12 @@ "license": "UNLICENSED", "private": true, "devDependencies": { - "markdownlint-cli": "^0.31.1" + "markdownlint-cli": "^0.32.2" }, "scripts": { - "coding-standards-check/markdownlint": "markdownlint '*.md' 'web/modules/custom/**/*.md'", + "coding-standards-check/markdownlint": "yarn markdownlint --ignore LICENSE.md --ignore vendor --ignore node_modules '*.md' 'modules/os2forms_digital_post/**/*.md'", "coding-standards-check": "yarn coding-standards-check/markdownlint", - "coding-standards-apply/markdownlint": "markdownlint --fix '*.md' 'web/modules/custom/**/*.md'", + "coding-standards-apply/markdownlint": "yarn markdownlint --ignore LICENSE.md --ignore vendor --ignore node_modules '*.md' 'modules/os2forms_digital_post/**/*.md' --fix", "coding-standards-apply": "yarn coding-standards-apply/markdownlint" } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index c00d4d6..47df7f9 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -15,6 +15,12 @@ - - + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..8ece9b1 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,20 @@ +parameters: + level: 6 + paths: + - modules/os2forms_digital_post/ + excludePaths: + # @see https://github.com/mglaman/drupal-check/issues/261#issuecomment-1030141772/ + - vendor + - '*/node_modules/*' + ignoreErrors: + - + # Ignore some weird errors reported by PHPStan + # @todo Investigate further + messages: + # These errors may be related to classes that don't add anything to their base class (e.g. `class BrugerFlerRelationType extends FlerRelationType {}`) + - '#expects (DataGovDk\\Model\\Core\\)(.+)(\|null)?, \1\2\\\2AType given.#' + - '#expects (DigitalPost\\MeMo\\)(.+)(\|null)?, \1\2\\\2AType given.#' + - '#expects (Oio\\(?:.+\\)*)(.+)(\|null)?, \1\2Type given.#' + - '#expects array<(DigitalPost\\MeMo\\)(.+)>(\|null)?, array given.#' + - '#should return (DigitalPost\\MeMo\\)(.+) but returns \1\2\\\2AType.#' + - '#should return (Oio\\Fjernprint\\)(.+) but returns \1\2Type.#' diff --git a/scripts/code-analysis b/scripts/code-analysis new file mode 100755 index 0000000..9fac7c8 --- /dev/null +++ b/scripts/code-analysis @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -o errexit -o errtrace -o noclobber -o nounset -o pipefail +IFS=$'\n\t' + +script_dir=$(pwd) + +# @see https://unix.stackexchange.com/a/13474 +upsearch () { + slashes=${PWD//[^\/]/} + directory="$PWD" + for (( n=${#slashes}; n>0; --n )) + do + test -e "$directory/$1" && echo "$directory/$1" && return + directory="$directory/.." + done +} + + +# The Drupal root contains a `web` folder. +drupal_root=$(dirname "$(cd "$(upsearch web)" && pwd)") +# Module path from drupal_root (note trailing slash) (cf. https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html) +module_path=${script_dir#"${drupal_root}/"} + +echo "Drupal root: $drupal_root" +echo "Module path: $module_path" + +# Work around +# PHP Fatal error: Cannot redeclare drupal_get_filename() (previously declared in /Users/rimi/ITK/github/itk-dev/os2forms_selvbetjening/web/sites/default/modules/os2forms/vendor/drupal/core/includes/bootstrap.inc:190) in /Users/rimi/ITK/github/itk-dev/os2forms_selvbetjening/web/core/includes/bootstrap.inc on line 223 +# Remove our non-develop requirements and a develop dependency requiring drupal/core. +docker run --rm --interactive efrecon/jq:1.7 'del(.require, ."require-dev"["drupal/maillog"])' < composer.json >| drupal-module-code-analysis-composer.json + +# It seems that the file system needs a little time to sync. +sleep 1 + +docker run --rm --env COMPOSER=drupal-module-code-analysis-composer.json --interactive --tty --volume "${script_dir}":/app itkdev/php8.1-fpm:latest composer install +# Clean up +rm drupal-module-code-analysis-composer.* + +# https://getcomposer.org/doc/03-cli.md#global-options +# docker run --rm --interactive --tty --volume ${drupal_root}:/app itkdev/php8.1-fpm:latest composer --working-dir $module_path code-analysis/drupal-check +docker run --rm --env PHP_MEMORY_LIMIT=-1 --interactive --tty --volume "${drupal_root}":/app itkdev/php8.1-fpm:latest composer --working-dir "$module_path" code-analysis/phpstan