diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
new file mode 100644
index 0000000..143f948
--- /dev/null
+++ b/.github/workflows/pr.yaml
@@ -0,0 +1,140 @@
+on: pull_request
+name: PR Review
+jobs:
+ changelog:
+ runs-on: ubuntu-latest
+ name: Changelog should be updated
+ strategy:
+ fail-fast: false
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 2
+
+ - name: Git fetch
+ run: git fetch
+
+ - name: Check that changelog has been updated.
+ run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0
+
+ test-composer-files:
+ name: Validate composer
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: [ '7.4', '8.0', '8.1' ]
+ dependency-version: [ prefer-lowest, prefer-stable ]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: Validate composer files
+ run: |
+ composer validate --strict composer.json
+ # Check that dependencies resolve.
+ composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
+
+ php-check-coding-standards:
+ name: PHP - Check Coding Standards
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: [ '7.4', '8.0', '8.1' ]
+ dependency-version: [ prefer-lowest, prefer-stable ]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: Install Dependencies
+ run: |
+ composer install --no-interaction --no-progress
+ - name: PHPCS
+ run: |
+ composer coding-standards-check/phpcs
+
+ php-code-analysis:
+ name: PHP - Code analysis
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: [ '7.4', '8.0', '8.1' ]
+ dependency-version: [ prefer-lowest, prefer-stable ]
+ steps:
+ - uses: actions/checkout@master
+ - name: Setup PHP, with composer and extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json, gd
+ coverage: none
+ tools: composer:v2
+ # https://github.com/shivammathur/setup-php#cache-composer-dependencies
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+ - name: Cache dependencies
+ uses: actions/cache@v2
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+ - name: drupal-check
+ run: |
+ # We need a Drupal project to run drupal-check (cf. https://github.com/mglaman/drupal-check#usage)
+ # Install Drupal
+ composer --no-interaction create-project drupal/recommended-project:^9 --stability=dev drupal
+ # Copy our module source code into the Drupal module folder.
+ mkdir -p drupal/web/modules/contrib/os2forms_get_organized
+ cp -r os2forms_get_organized.* composer.json src drupal/web/modules/contrib/os2forms_get_organized
+ # Add our module as a composer repository.
+ composer --no-interaction --working-dir=drupal config repositories.os2forms/os2forms_get_organized path web/modules/contrib/os2forms_get_organized
+ # Restore Drupal composer repository.
+ composer --no-interaction --working-dir=drupal config repositories.drupal composer https://packages.drupal.org/8
+
+ composer --no-interaction --working-dir=drupal config --no-plugins allow-plugins.cweagans/composer-patches true
+ # @see https://getcomposer.org/doc/03-cli.md#modifying-extra-values
+ composer --no-interaction --working-dir=drupal config --no-plugins --json extra.enable-patching true
+
+ # Require our module.
+ composer --no-interaction --working-dir=drupal require 'os2forms/os2forms_get_organized:*'
+
+ # Check code
+ composer --no-interaction --working-dir=drupal require --dev drupal/core-dev
+ cd drupal/web/modules/contrib/os2forms_get_organized
+ # Remove our non-dev dependencies to prevent duplicated Drupal installation
+ # PHP Fatal error: Cannot redeclare drupal_get_filename() (previously declared in /home/runner/work/os2forms_get_organized/os2forms_get_organized/drupal/web/modules/contrib/os2forms_get_organized/vendor/drupal/core/includes/bootstrap.inc:190) in /home/runner/work/os2forms_get_organized/os2forms_get_organized/drupal/web/core/includes/bootstrap.inc on line 190
+ # Use sed to remove the "require" property in composer.json
+ sed -i '/^\s*"require":/,/^\s*}/d' composer.json
+ composer --no-interaction install
+ composer code-analysis
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a9875b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/vendor/
+composer.lock
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b311a47
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+See [keep a changelog](https://keepachangelog.com/en/1.0.0/) for information
+about writing changes to this log.
+
+## [Unreleased]
+
+[Unreleased]: https://github.com/OS2Forms/os2forms_get_organized/
diff --git a/README.md b/README.md
index 2e013f5..98e0fae 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,34 @@
-# os2forms_get_organized
\ No newline at end of file
+# OS2Forms GetOrganized
+
+Adds [GetOrganized](https://www.getorganized.net/) handler for archiving purposes.
+
+## Installation
+
+```sh
+composer require os2forms/os2forms_get_organized
+vendor/bin/drush pm:enable os2forms_get_organized
+```
+
+## Settings
+
+Set GetOrganized `username`, `password` and `base url`
+on `/admin/os2forms_get_organized/settings`.
+
+You can also test that the provided
+details work on `/admin/os2forms_get_organized/settings`.
+
+## Coding standards
+
+Check coding standards:
+
+```sh
+docker run --rm --interactive --tty --volume ${PWD}:/app itkdev/php7.4-fpm:latest composer install
+docker run --rm --interactive --tty --volume ${PWD}:/app itkdev/php7.4-fpm:latest composer coding-standards-check
+```
+
+Apply coding standards:
+
+```shell
+docker run --rm --interactive --tty --volume ${PWD}:/app itkdev/php7.4-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/composer.json b/composer.json
new file mode 100644
index 0000000..338b317
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,57 @@
+{
+ "name": "os2forms/os2forms_get_organized",
+ "description": "OS2Forms GetOrganized integration",
+ "type": "drupal-module",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Jeppe Kuhlmann Andersen",
+ "email": "jekua@aarhus.dk"
+ }
+ ],
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "repositories": [
+ {
+ "type": "composer",
+ "url": "https://packages.drupal.org/8"
+ }
+ ],
+ "require": {
+ "itk-dev/getorganized-api-client-php": "^1.2",
+ "drupal/webform": "^6.1",
+ "drupal/advancedqueue": "^1.0",
+ "symfony/options-resolver": "^5.4"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "drupal/coder": "^8.3",
+ "mglaman/drupal-check": "^1.4"
+ },
+ "scripts": {
+ "code-analysis/drupal-check": [
+ "# @see https://github.com/mglaman/drupal-check/issues/261#issuecomment-1030141772 for details on exclude-dir value",
+ "drupal-check --deprecations --analysis --exclude-dir='vendor,*/Client/*' *.* src"
+ ],
+ "code-analysis": [
+ "@code-analysis/drupal-check"
+ ],
+ "coding-standards-check/phpcs": [
+ "vendor/bin/phpcs --standard=phpcs.xml.dist"
+ ],
+ "coding-standards-check": [
+ "@coding-standards-check/phpcs"
+ ],
+ "coding-standards-apply/phpcs": [
+ "vendor/bin/phpcbf --standard=phpcs.xml.dist"
+ ],
+ "coding-standards-apply": [
+ "@coding-standards-apply/phpcs"
+ ]
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ }
+}
diff --git a/config/install/advancedqueue.advancedqueue_queue.get_organized_queue.yml b/config/install/advancedqueue.advancedqueue_queue.get_organized_queue.yml
new file mode 100644
index 0000000..1ae32ba
--- /dev/null
+++ b/config/install/advancedqueue.advancedqueue_queue.get_organized_queue.yml
@@ -0,0 +1,11 @@
+langcode: da
+status: true
+dependencies: { }
+id: get_organized_queue
+label: 'Get Organized Queue'
+backend: database
+backend_configuration:
+ lease_time: 300
+processor: cron
+processing_time: 90
+locked: false
diff --git a/os2forms_get_organized.info.yml b/os2forms_get_organized.info.yml
new file mode 100644
index 0000000..2e52d5b
--- /dev/null
+++ b/os2forms_get_organized.info.yml
@@ -0,0 +1,10 @@
+name: 'OS2Forms GetOrganized'
+type: module
+description: 'GetOrganized integration'
+package: OS2Forms
+core_version_requirement: ^9
+dependencies:
+ - drupal:webform
+ - drupal:advancedqueue
+ - drupal:webform_entity_print_attachment
+configure: os2forms_get_organized.admin.settings
diff --git a/os2forms_get_organized.links.menu.yml b/os2forms_get_organized.links.menu.yml
new file mode 100644
index 0000000..14f55a9
--- /dev/null
+++ b/os2forms_get_organized.links.menu.yml
@@ -0,0 +1,5 @@
+os2forms_get_organized.admin.settings:
+ title: OS2Forms Get Organized
+ description: Configure the OS2Forms GetOrganized module
+ parent: system.admin_config_system
+ route_name: os2forms_get_organized.admin.settings
diff --git a/os2forms_get_organized.routing.yml b/os2forms_get_organized.routing.yml
new file mode 100644
index 0000000..60bf9cd
--- /dev/null
+++ b/os2forms_get_organized.routing.yml
@@ -0,0 +1,7 @@
+os2forms_get_organized.admin.settings:
+ path: '/admin/os2forms_get_organized/settings'
+ defaults:
+ _form: '\Drupal\os2forms_get_organized\Form\SettingsForm'
+ _title: 'GetOrganized settings'
+ requirements:
+ _permission: 'administer site configuration'
diff --git a/os2forms_get_organized.services.yml b/os2forms_get_organized.services.yml
new file mode 100644
index 0000000..c48dc9e
--- /dev/null
+++ b/os2forms_get_organized.services.yml
@@ -0,0 +1,9 @@
+services:
+ Drupal\os2forms_get_organized\Helper\Settings:
+ arguments:
+ - "@keyvalue"
+
+ Drupal\os2forms_get_organized\Helper\ArchiveHelper:
+ arguments:
+ - '@entity_type.manager'
+ - '@Drupal\os2forms_get_organized\Helper\Settings'
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..fc0e2b6
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,23 @@
+
+
+ The coding standard.
+
+ .
+
+ vendor/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Exception/ArchivingMethodException.php b/src/Exception/ArchivingMethodException.php
new file mode 100644
index 0000000..4c47e12
--- /dev/null
+++ b/src/Exception/ArchivingMethodException.php
@@ -0,0 +1,9 @@
+settings = $settings;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container): SettingsForm {
+ return new static(
+ $container->get(Settings::class),
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'os2forms_get_organized_settings';
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $form
+ * @phpstan-return array
+ */
+ public function buildForm(array $form, FormStateInterface $form_state): array {
+
+ $form[self::GET_ORGANIZED_USERNAME] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Username'),
+ '#required' => TRUE,
+ '#default_value' => $this->settings->getUsername(),
+ ];
+
+ $form[self::GET_ORGANIZED_PASSWORD] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Password'),
+ '#required' => TRUE,
+ '#default_value' => $this->settings->getPassword(),
+ ];
+
+ $form[self::GET_ORGANIZED_BASE_URL] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('GetOrganized base url'),
+ '#required' => TRUE,
+ '#default_value' => $this->settings->getBaseUrl(),
+ '#description' => $this->t('GetOrganized base url. Example: "https://ad.go.aarhuskommune.dk/_goapi"'),
+ ];
+
+ $form['actions']['#type'] = 'actions';
+
+ $form['actions']['submit'] = [
+ '#type' => 'submit',
+ '#value' => $this->t('Save settings'),
+ ];
+
+ $form['actions']['testSettings'] = [
+ '#type' => 'submit',
+ '#name' => 'testSettings',
+ '#value' => $this->t('Test provided information'),
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $form
+ */
+ public function submitForm(array &$form, FormStateInterface $formState): void {
+ $username = $formState->getValue(self::GET_ORGANIZED_USERNAME);
+ $password = $formState->getValue(self::GET_ORGANIZED_PASSWORD);
+ $baseUrl = $formState->getValue(self::GET_ORGANIZED_BASE_URL);
+
+ $triggeringElement = $formState->getTriggeringElement();
+ if ('testSettings' === ($triggeringElement['#name'] ?? NULL)) {
+ $this->testSettings($username, $password, $baseUrl);
+ return;
+ }
+
+ try {
+ $settings[self::GET_ORGANIZED_USERNAME] = $username;
+ $settings[self::GET_ORGANIZED_PASSWORD] = $password;
+ $settings[self::GET_ORGANIZED_BASE_URL] = $baseUrl;
+
+ $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()]));
+ }
+
+ $this->messenger()->addStatus($this->t('Settings saved'));
+ }
+
+ /**
+ * Test settings by making some arbitrary call to the GetOrganized API.
+ */
+ private function testSettings(string $username, string $password, string $baseUrl): void {
+ try {
+ $client = new Client($username, $password, $baseUrl);
+ /** @var \ItkDev\GetOrganized\Service\Tiles $tileService */
+ $tileService = $client->api('tiles');
+
+ $result = $tileService->GetTilesNavigation();
+
+ if (empty($result)) {
+ $message = $this->t('Error occurred while testing the GetOrganized API with provided settings.');
+ $this->messenger()->addError($message);
+ }
+ else {
+ $this->messenger()->addStatus($this->t('Settings succesfully tested'));
+ }
+ }
+ catch (\Throwable $throwable) {
+ $message = $this->t('Error testing provided information: %message', ['%message' => $throwable->getMessage()]);
+ $this->messenger()->addError($message);
+ }
+ }
+
+}
diff --git a/src/Helper/ArchiveHelper.php b/src/Helper/ArchiveHelper.php
new file mode 100644
index 0000000..da7ee76
--- /dev/null
+++ b/src/Helper/ArchiveHelper.php
@@ -0,0 +1,441 @@
+entityTypeManager = $entityTypeManager;
+ $this->settings = $settings;
+ }
+
+ /**
+ * Adds document to GetOrganized case.
+ *
+ * @phpstan-param array $handlerConfiguration
+ */
+ public function archive(string $submissionId, array $handlerConfiguration): void {
+ // Setup Client and services.
+ if (NULL === $this->client) {
+ $this->setupClient();
+ }
+
+ if (NULL === $this->caseService) {
+ /** @var \ItkDev\GetOrganized\Service\Cases $caseService */
+ $caseService = $this->client->api('cases');
+ $this->caseService = $caseService;
+ }
+
+ if (NULL === $this->documentService) {
+ /** @var \ItkDev\GetOrganized\Service\Documents $docService */
+ $docService = $this->client->api('documents');
+ $this->documentService = $docService;
+ }
+
+ // Detect which archiving method is required.
+ $archivingMethod = $handlerConfiguration['choose_archiving_method']['archiving_method'];
+
+ if ('archive_to_case_id' === $archivingMethod) {
+ $this->archiveToCaseId($submissionId, $handlerConfiguration);
+ }
+ elseif ('archive_to_citizen' === $archivingMethod) {
+ $this->archiveToCitizen($submissionId, $handlerConfiguration);
+ }
+
+ }
+
+ /**
+ * Sets up Client.
+ */
+ private function setupClient(): void {
+ $username = $this->settings->getUsername();
+ $password = $this->settings->getPassword();
+ $baseUrl = $this->settings->getBaseUrl();
+
+ $this->client = new Client($username, $password, $baseUrl);
+ }
+
+ /**
+ * Gets WebformSubmission from id.
+ *
+ * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+ * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+ */
+ private function getSubmission(string $submissionId): EntityInterface {
+ $storage = $this->entityTypeManager->getStorage('webform_submission');
+ return $storage->load($submissionId);
+ }
+
+ /**
+ * Archives document to GetOrganized case id.
+ *
+ * @phpstan-param array $handlerConfiguration
+ */
+ private function archiveToCaseId(string $submissionId, array $handlerConfiguration): void {
+
+ /** @var \Drupal\webform\Entity\WebformSubmission $submission */
+ $submission = $this->getSubmission($submissionId);
+
+ $getOrganizedCaseId = $handlerConfiguration['choose_archiving_method']['case_id'];
+ $webformAttachmentElementId = $handlerConfiguration['general']['attachment_element'];
+ $shouldBeFinalized = $handlerConfiguration['general']['should_be_finalized'] ?? FALSE;
+ $shouldArchiveFiles = $handlerConfiguration['general']['should_archive_files'] ?? FALSE;
+
+ // Ensure case id exists.
+ $case = $this->caseService->getByCaseId($getOrganizedCaseId);
+
+ if (!$case) {
+ $message = sprintf('Could not find a case with id %s.', $getOrganizedCaseId);
+ throw new GetOrganizedCaseIdException($message);
+ }
+
+ $this->uploadDocumentToCase($getOrganizedCaseId, $webformAttachmentElementId, $submission, $shouldArchiveFiles, $shouldBeFinalized);
+ }
+
+ /**
+ * Archives document to GetOrganized citizen subcase.
+ *
+ * @phpstan-param array $handlerConfiguration
+ */
+ private function archiveToCitizen(string $submissionId, array $handlerConfiguration): void {
+ // Step 1: Find/create parent case
+ // Step 2: Find/create subcase
+ // Step 3: Upload to subcase.
+ if (NULL === $this->client) {
+ $this->setupClient();
+ }
+
+ /** @var \Drupal\webform\Entity\WebformSubmission $submission */
+ $submission = $this->getSubmission($submissionId);
+
+ $cprValueElementId = $handlerConfiguration['choose_archiving_method']['cpr_value_element'];
+ $cprElementValue = $submission->getData()[$cprValueElementId];
+
+ $cprNameElementId = $handlerConfiguration['choose_archiving_method']['cpr_name_element'];
+ $cprNameElementValue = $submission->getData()[$cprNameElementId];
+
+ // Step 1: Find/create parent case.
+ $caseQuery = [
+ 'FieldProperties' => [
+ [
+ 'InternalName' => 'ows_CCMContactData_CPR',
+ 'Value' => $cprElementValue,
+ ],
+ ],
+ 'CaseTypePrefixes' => [
+ self::CITIZEN_CASE_TYPE_PREFIX,
+ ],
+ 'LogicalOperator' => 'AND',
+ 'ExcludeDeletedCases' => TRUE,
+ 'ReturnCasesNumber' => 25,
+ ];
+
+ $caseResult = $this->caseService->FindByCaseProperties(
+ $caseQuery
+ );
+
+ // Subcases may also contain contain the 'ows_CCMContactData_CPR' property,
+ // i.e. we need to check result cases are not subcases.
+ // $caseResult will always contain the 'CasesInfo' key,
+ // and its value will always be an array.
+ $caseInfo = array_filter($caseResult['CasesInfo'], function ($caseInfo) {
+ // Parent cases are always on the form AAA-XXXX-XXXXXX,
+ // Subcases are always on the form AAA-XXXX-XXXXXX-XXX,
+ // I.e. we can filter out subcases by checking number of dashes in id.
+ return 2 === substr_count($caseInfo['CaseID'], '-');
+ });
+
+ $parentCaseCount = count($caseInfo);
+
+ if (0 === $parentCaseCount) {
+ $parentCaseId = $this->createCitizenCase($cprElementValue, $cprNameElementValue);
+ }
+ elseif (1 < $parentCaseCount) {
+ $message = sprintf('Too many (%d) parent cases.', $parentCaseCount);
+ throw new CitizenArchivingException($message);
+ }
+ else {
+ $parentCaseId = $caseResult['CasesInfo'][0]['CaseID'];
+ }
+
+ // Step 2: Find/create subcase.
+ $subcaseName = $handlerConfiguration['choose_archiving_method']['sub_case_title'];
+
+ $subCasesQuery = [
+ 'FieldProperties' => [
+ [
+ 'InternalName' => 'ows_CaseId',
+ 'Value' => $parentCaseId . '-',
+ 'ComparisonType' => 'Contains',
+ ],
+ [
+ 'InternalName' => 'ows_Title',
+ 'Value' => $subcaseName,
+ 'ComparisonType' => 'Equal',
+ ],
+ ],
+ 'CaseTypePrefixes' => [
+ self::CITIZEN_CASE_TYPE_PREFIX,
+ ],
+ 'LogicalOperator' => 'AND',
+ 'ExcludeDeletedCases' => TRUE,
+ // Unsure how many subcases may exist, but fetching 25 should be enough.
+ 'ReturnCasesNumber' => 25,
+ ];
+
+ $subCases = $this->caseService->FindByCaseProperties(
+ $subCasesQuery
+ );
+
+ $subCaseCount = count($subCases['CasesInfo']);
+
+ if (0 === $subCaseCount) {
+ $subCaseId = $this->createSubCase($parentCaseId, $subcaseName);
+ }
+ elseif (1 === $subCaseCount) {
+ $subCaseId = $subCases['CasesInfo'][0]['CaseID'];
+ }
+ else {
+ $message = sprintf('Too many (%d) subcases with the name %s', $subCaseCount, $subcaseName);
+ throw new CitizenArchivingException($message);
+ }
+
+ // Step 3: Upload to subcase.
+ $webformAttachmentElementId = $handlerConfiguration['general']['attachment_element'];
+ $shouldBeFinalized = $handlerConfiguration['general']['should_be_finalized'] ?? FALSE;
+ $shouldArchiveFiles = $handlerConfiguration['general']['should_archive_files'] ?? FALSE;
+
+ $this->uploadDocumentToCase($subCaseId, $webformAttachmentElementId, $submission, $shouldArchiveFiles, $shouldBeFinalized);
+ }
+
+ /**
+ * Creates citizen parent case in GetOrganized.
+ */
+ private function createCitizenCase(string $cprElementValue, string $cprNameElementValue): string {
+
+ $metadataArray = [
+ 'ows_Title' => $cprElementValue . ' - ' . $cprNameElementValue,
+ // CCMContactData format: 'name;#ID;#CRP;#CVR;#PNumber',
+ // We don't create GetOrganized parties (parter) so we leave that empty
+ // We also don't use cvr- or p-number so we leave those empty.
+ 'ows_CCMContactData' => $cprNameElementValue . ';#;#' . $cprElementValue . ';#;#',
+ 'ows_CaseStatus' => 'Åben',
+ ];
+
+ $response = $this->caseService->createCase(self::CITIZEN_CASE_TYPE_PREFIX, $metadataArray);
+
+ // Example response.
+ // {"CaseID":"BOR-2022-000046","CaseRelativeUrl":"\/cases\/BOR12\/BOR-2022-000046",...}.
+ return $response['CaseID'];
+ }
+
+ /**
+ * Creates citizen subcase in GetOrganized.
+ */
+ private function createSubCase(string $caseId, string $caseName): string {
+ $metadataArray = [
+ 'ows_Title' => $caseName,
+ 'ows_CCMParentCase' => $caseId,
+ // For creating subcases the 'ows_ContentTypeId' must be set explicitly to
+ // '0x0100512AABDB08FA4fadB4A10948B5A56C7C01'.
+ 'ows_ContentTypeId' => '0x0100512AABDB08FA4fadB4A10948B5A56C7C01',
+ 'ows_CaseStatus' => 'Åben',
+ ];
+
+ $response = $this->caseService->createCase(self::CITIZEN_CASE_TYPE_PREFIX, $metadataArray);
+
+ // Example response.
+ // {"CaseID":"BOR-2022-000046-001","CaseRelativeUrl":"\/cases\/BOR12\/BOR-2022-000046",...}.
+ return $response['CaseID'];
+ }
+
+ /**
+ * Uploads attachment document and attached files to GetOrganized case.
+ */
+ private function uploadDocumentToCase(string $caseId, string $webformAttachmentElementId, WebformSubmission $submission, bool $shouldArchiveFiles, bool $shouldBeFinalized): void {
+ // Handle main document (the attachment).
+ $element = $submission->getWebform()->getElement($webformAttachmentElementId);
+ $fileContent = WebformEntityPrintAttachment::getFileContent($element, $submission);
+
+ // Ids that should possibly be finalized (jornaliseret) later.
+ $documentIdsForFinalizing = [];
+
+ // Create temp file with attachment-element contents.
+ $webformLabel = $submission->getWebform()->label();
+ $getOrganizedFileName = $webformLabel . '-' . $submission->serial() . '.pdf';
+
+ $parentDocumentId = $this->archiveDocumentToGetOrganizedCase($caseId, $getOrganizedFileName, $fileContent);
+
+ $documentIdsForFinalizing[] = $parentDocumentId;
+
+ // Handle attached files.
+ if ($shouldArchiveFiles) {
+ $fileIds = $this->getFileElementKeysFromSubmission($submission);
+
+ $childDocumentIds = [];
+
+ $fileStorage = $this->entityTypeManager->getStorage('file');
+
+ foreach ($fileIds as $fileId) {
+ /** @var \Drupal\file\Entity\File $file */
+ $file = $fileStorage->load($fileId);
+
+ $fileContent = file_get_contents($file->getFileUri());
+ $getOrganizedFileName = $webformLabel . '-' . $submission->serial() . '-' . $file->getFilename();
+
+ $childDocumentId = $this->archiveDocumentToGetOrganizedCase($caseId, $getOrganizedFileName, $fileContent);
+
+ $childDocumentIds[] = $childDocumentId;
+ }
+
+ $documentIdsForFinalizing = array_merge($documentIdsForFinalizing, $childDocumentIds);
+
+ $this->documentService->RelateDocuments($parentDocumentId, $childDocumentIds, 1);
+ }
+
+ if ($shouldBeFinalized) {
+ $this->documentService->FinalizeMultiple($documentIdsForFinalizing);
+ }
+ }
+
+ /**
+ * Get available elements by type.
+ *
+ * @phpstan-param array $elements
+ * @phpstan-return array
+ */
+ private function getAvailableElementsByType(string $type, array $elements): array {
+ $attachmentElements = array_filter($elements, function ($element) use ($type) {
+ return $type === $element['#type'];
+ });
+
+ return array_map(function ($element) {
+ return $element['#title'];
+ }, $attachmentElements);
+ }
+
+ /**
+ * Archives file content to GetOrganized case.
+ */
+ private function archiveDocumentToGetOrganizedCase(string $caseId, string $getOrganizedFileName, string $fileContent): int {
+ $tempFile = tempnam('/tmp', $caseId . '-' . uniqid());
+
+ try {
+ file_put_contents($tempFile, $fileContent);
+
+ $result = $this->documentService->AddToDocumentLibrary($tempFile, $caseId, $getOrganizedFileName);
+
+ if (!isset($result['DocId'])) {
+ throw new ArchivingMethodException('Could not get document id from response.');
+ }
+
+ $documentId = $result['DocId'];
+ } finally {
+ // Remove temp file.
+ unlink($tempFile);
+ }
+
+ return (int) $documentId;
+ }
+
+ /**
+ * Returns array of file elements keys in submission.
+ *
+ * @phpstan-return array
+ */
+ private function getFileElementKeysFromSubmission(WebformSubmission $submission): array {
+ $elements = $submission->getWebform()->getElementsDecodedAndFlattened();
+
+ $fileElements = [];
+
+ foreach (self::FILE_ELEMENT_TYPES as $fileElementType) {
+ $fileElements[] = $this->getAvailableElementsByType($fileElementType, $elements);
+ }
+
+ // https://dev.to/klnjmm/never-use-arraymerge-in-a-for-loop-in-php-5go1
+ $fileElements = array_merge(...$fileElements);
+
+ $elementKeys = array_keys($fileElements);
+
+ $fileIds = [];
+
+ foreach ($elementKeys as $elementKey) {
+ if (empty($submission->getData()[$elementKey])) {
+ continue;
+ }
+
+ // Convert occurrences of singular file into array.
+ $elementFileIds = (array) $submission->getData()[$elementKey];
+
+ $fileIds[] = $elementFileIds;
+ }
+
+ return array_merge(...$fileIds);
+ }
+
+}
diff --git a/src/Helper/Settings.php b/src/Helper/Settings.php
new file mode 100644
index 0000000..579b287
--- /dev/null
+++ b/src/Helper/Settings.php
@@ -0,0 +1,133 @@
+
+ */
+ private $defaultSettings = [
+ SettingsForm::GET_ORGANIZED_USERNAME => '',
+ SettingsForm::GET_ORGANIZED_PASSWORD => '',
+ SettingsForm::GET_ORGANIZED_BASE_URL => '',
+ ];
+
+ /**
+ * Constructor.
+ */
+ public function __construct(KeyValueFactoryInterface $keyValueFactory) {
+ $this->store = $keyValueFactory->get($this->collection);
+ }
+
+ /**
+ * Get username.
+ *
+ * @return string
+ * The sources.
+ */
+ public function getUsername(): string {
+ $value = $this->get(SettingsForm::GET_ORGANIZED_USERNAME);
+ return is_string($value) ? $value : '';
+ }
+
+ /**
+ * Get password.
+ *
+ * @return string
+ * The sources.
+ */
+ public function getPassword(): string {
+ $value = $this->get(SettingsForm::GET_ORGANIZED_PASSWORD);
+ return is_string($value) ? $value : '';
+ }
+
+ /**
+ * Get password.
+ *
+ * @return string
+ * The sources.
+ */
+ public function getBaseUrl(): string {
+ $value = $this->get(SettingsForm::GET_ORGANIZED_BASE_URL);
+ return is_string($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, mixed $default = NULL): mixed {
+ $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($this->defaultSettings)
+ ->setAllowedTypes(SettingsForm::GET_ORGANIZED_USERNAME, 'string')
+ ->setAllowedTypes(SettingsForm::GET_ORGANIZED_PASSWORD, 'string')
+ ->setAllowedTypes(SettingsForm::GET_ORGANIZED_BASE_URL, 'string')
+ ->setRequired([
+ SettingsForm::GET_ORGANIZED_USERNAME,
+ SettingsForm::GET_ORGANIZED_PASSWORD,
+ SettingsForm::GET_ORGANIZED_BASE_URL,
+ ]);
+ }
+
+}
diff --git a/src/Plugin/AdvancedQueue/JobType/ArchiveDocument.php b/src/Plugin/AdvancedQueue/JobType/ArchiveDocument.php
new file mode 100644
index 0000000..d81553c
--- /dev/null
+++ b/src/Plugin/AdvancedQueue/JobType/ArchiveDocument.php
@@ -0,0 +1,106 @@
+ $configuration
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get(ArchiveHelper::class),
+ $container->get('logger.factory')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $configuration
+ */
+ public function __construct(
+ array $configuration,
+ $plugin_id,
+ $plugin_definition,
+ ArchiveHelper $helper,
+ LoggerChannelFactoryInterface $loggerFactory
+ ) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->helper = $helper;
+ $this->submissionLogger = $loggerFactory->get('webform_submission');
+ }
+
+ /**
+ * Processes the ArchiveDocument job.
+ */
+ public function process(Job $job): JobResult {
+ try {
+ $payload = $job->getPayload();
+
+ /** @var \Drupal\webform\WebformSubmissionInterface $webformSubmission */
+ $webformSubmission = WebformSubmission::load($payload['submissionId']);
+ $logger_context = [
+ 'handler_id' => 'os2forms_get_organized',
+ 'channel' => 'webform_submission',
+ 'webform_submission' => $webformSubmission,
+ 'operation' => 'response from queue',
+ ];
+
+ try {
+ $this->helper->archive($payload['submissionId'], $payload['handlerConfiguration']);
+ $this->submissionLogger->notice($this->t('The submission #@serial was successfully delivered', ['@serial' => $webformSubmission->serial()]), $logger_context);
+
+ return JobResult::success();
+ }
+ catch (\Exception $e) {
+ $this->submissionLogger->error($this->t('The submission #@serial failed (@message)', [
+ '@serial' => $webformSubmission->serial(),
+ '@message' => $e->getMessage(),
+ ]), $logger_context);
+
+ return JobResult::failure($e->getMessage());
+ }
+ }
+ catch (\Exception $e) {
+ return JobResult::failure($e->getMessage());
+ }
+ }
+
+}
diff --git a/src/Plugin/WebformHandler/GetOrganizedWebformHandler.php b/src/Plugin/WebformHandler/GetOrganizedWebformHandler.php
new file mode 100644
index 0000000..b352894
--- /dev/null
+++ b/src/Plugin/WebformHandler/GetOrganizedWebformHandler.php
@@ -0,0 +1,294 @@
+ $configuration
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerChannelFactoryInterface $loggerFactory, ConfigFactoryInterface $configFactory, RendererInterface $renderer, EntityTypeManagerInterface $entityTypeManager, WebformSubmissionConditionsValidatorInterface $conditionsValidator, WebformTokenManagerInterface $tokenManager) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition);
+ $this->setConfiguration($configuration);
+ $this->loggerFactory = $loggerFactory;
+ $this->configFactory = $configFactory;
+ $this->renderer = $renderer;
+ $this->entityTypeManager = $entityTypeManager;
+ $this->conditionsValidator = $conditionsValidator;
+ $this->tokenManager = $tokenManager;
+ $this->submissionLogger = $loggerFactory->get('webform_submission');
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $configuration
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('logger.factory'),
+ $container->get('config.factory'),
+ $container->get('renderer'),
+ $container->get('entity_type.manager'),
+ $container->get('webform_submission.conditions_validator'),
+ $container->get('webform.token_manager')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $form
+ * @phpstan-return array
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+
+ $elements = $this->getWebform()->getElementsDecodedAndFlattened();
+
+ $form['general'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Choose general settings'),
+ ];
+
+ $form['general']['should_archive_files'] = [
+ '#title' => $this->t('Should files be archived?'),
+ '#type' => 'checkbox',
+ '#default_value' => $this->configuration['general']['should_archive_files'] ?? FALSE,
+ '#description' => $this->t('If enabled, files will be archived in GetOrganized.'),
+ '#required' => FALSE,
+ ];
+
+ $form['general']['should_be_finalized'] = [
+ '#title' => $this->t('Should documents be finalized?'),
+ '#type' => 'checkbox',
+ '#default_value' => $this->configuration['general']['should_be_finalized'] ?? FALSE,
+ '#description' => $this->t('If enabled, documents will be finalized (journaliseret) in GetOrganized.'),
+ '#required' => FALSE,
+ ];
+
+ $form['general']['attachment_element'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Attachment element'),
+ '#options' => $this->getAvailableElementsByType('webform_entity_print_attachment:pdf', $elements),
+ '#default_value' => $this->configuration['general']['attachment_element'] ?? '',
+ '#description' => $this->t('Choose the element responsible for creating response attachments.'),
+ '#required' => TRUE,
+ '#size' => 5,
+ ];
+
+ $form['choose_archiving_method'] = [
+ '#type' => 'fieldset',
+ '#title' => $this->t('Choose archiving method'),
+ ];
+
+ $form['choose_archiving_method']['archiving_method'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Choose method for archiving attachment'),
+ '#options' => [
+ 'archive_to_case_id' => $this->t('GetOrganized case ID'),
+ 'archive_to_citizen' => $this->t('Citizen CPR number'),
+ ],
+ '#default_value' => $this->configuration['choose_archiving_method']['archiving_method'] ?? 'archive_to_case_id',
+ '#required' => TRUE,
+ '#size' => 3,
+ ];
+
+ $form['choose_archiving_method']['case_id'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('GetOrganized case ID'),
+ '#description' => $this->t('The GetOrganized case that responses should be uploaded to.'),
+ '#default_value' => $this->configuration['choose_archiving_method']['case_id'] ?? '',
+ '#states' => [
+ 'visible' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_case_id']],
+ ],
+ // The only effect this has is showing the required asterisk (*).
+ // Actual validation happens in validateConfigurationForm.
+ 'required' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_case_id']],
+ ],
+ ],
+ ];
+
+ $form['choose_archiving_method']['sub_case_title'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('GetOrganized case title'),
+ '#description' => $this->t('The GetOrganized case that responses should be uploaded to. If no case with provided title exists one will be created. If multiple exists nothing will be uploaded.'),
+ '#default_value' => $this->configuration['choose_archiving_method']['sub_case_title'] ?? '',
+ '#states' => [
+ 'visible' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_citizen']],
+ ],
+ // The only effect this has is showing the required asterisk (*).
+ // Actual validation happens in validateConfigurationForm.
+ 'required' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_citizen']],
+ ],
+ ],
+ ];
+
+ $form['choose_archiving_method']['cpr_value_element'] = [
+ '#type' => 'select',
+ '#title' => $this->t('CPR element'),
+ '#options' => $this->getAvailableElementsByType('cpr_value_element', $elements),
+ '#default_value' => $this->configuration['choose_archiving_method']['cpr_value_element'] ?? '',
+ '#description' => $this->t('Choose the element containing CPR number'),
+ '#size' => 5,
+ '#states' => [
+ 'visible' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_citizen']],
+ ],
+ // The only effect this has is showing the required asterisk (*).
+ // Actual validation happens in validateConfigurationForm.
+ 'required' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_citizen']],
+ ],
+ ],
+ ];
+
+ $form['choose_archiving_method']['cpr_name_element'] = [
+ '#type' => 'select',
+ '#title' => $this->t('CPR element'),
+ '#options' => $this->getAvailableElementsByType('cpr_name_element', $elements),
+ '#default_value' => $this->configuration['choose_archiving_method']['cpr_name_element'] ?? '',
+ '#description' => $this->t('Choose the element containing CPR name'),
+ '#size' => 5,
+ '#states' => [
+ 'visible' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_citizen']],
+ ],
+ // The only effect this has is showing the required asterisk (*).
+ // Actual validation happens in validateConfigurationForm.
+ 'required' => [
+ [':input[name="settings[choose_archiving_method][archiving_method]"]' => ['value' => 'archive_to_citizen']],
+ ],
+ ],
+ ];
+
+ return $this->setSettingsParents($form);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $form
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
+ parent::submitConfigurationForm($form, $form_state);
+ $this->configuration['general']['should_be_finalized'] = $form_state->getValue('general')['should_be_finalized'];
+ $this->configuration['general']['should_archive_files'] = $form_state->getValue('general')['should_archive_files'];
+ $this->configuration['general']['attachment_element'] = $form_state->getValue('general')['attachment_element'];
+ $this->configuration['choose_archiving_method']['archiving_method'] = $form_state->getValue('choose_archiving_method')['archiving_method'];
+ $this->configuration['choose_archiving_method']['case_id'] = $form_state->getValue('choose_archiving_method')['case_id'];
+ $this->configuration['choose_archiving_method']['cpr_value_element'] = $form_state->getValue('choose_archiving_method')['cpr_value_element'];
+ $this->configuration['choose_archiving_method']['cpr_name_element'] = $form_state->getValue('choose_archiving_method')['cpr_name_element'];
+ $this->configuration['choose_archiving_method']['sub_case_title'] = $form_state->getValue('choose_archiving_method')['sub_case_title'];
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @phpstan-param array $form
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
+ parent::validateConfigurationForm($form, $form_state);
+
+ $configuration = $form_state->getValues();
+ if ($configuration['choose_archiving_method']['archiving_method'] === 'archive_to_case_id') {
+ if (empty($configuration['choose_archiving_method']['case_id'])) {
+ $form_state->setErrorByName('no_case_id_provided', $this->t('No GetOrganized case ID provided.'));
+ }
+ }
+
+ if ($configuration['choose_archiving_method']['archiving_method'] === 'archive_to_citizen') {
+ if (empty($configuration['choose_archiving_method']['cpr_value_element'])) {
+ $form_state->setErrorByName('no_cpr_value_element_selected', $this->t('No CPR value element selected.'));
+ }
+ if (empty($configuration['choose_archiving_method']['cpr_name_element'])) {
+ $form_state->setErrorByName('no_cpr_name_element_selected', $this->t('No CPR name element selected.'));
+ }
+ if (empty($configuration['choose_archiving_method']['sub_case_title'])) {
+ $form_state->setErrorByName('no_sub_case_title', $this->t('No GetOrganized case title provided.'));
+ }
+ }
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE): void {
+ $queueStorage = $this->entityTypeManager->getStorage('advancedqueue_queue');
+ /** @var \Drupal\advancedqueue\Entity\Queue $queue */
+ $queue = $queueStorage->load('get_organized_queue');
+ $job = Job::create(ArchiveDocument::class, [
+ 'submissionId' => $webform_submission->id(),
+ 'handlerConfiguration' => $this->configuration,
+ ]);
+ $queue->enqueueJob($job);
+
+ $logger_context = [
+ 'handler_id' => 'os2forms_get_organized',
+ 'channel' => 'webform_submission',
+ 'webform_submission' => $webform_submission,
+ 'operation' => 'submission queued',
+ ];
+
+ $this->submissionLogger->notice($this->t('Added submission #@serial to queue for processing', ['@serial' => $webform_submission->serial()]), $logger_context);
+ }
+
+ /**
+ * Get available elements by type.
+ *
+ * @phpstan-param array $elements
+ * @phpstan-return array
+ */
+ private function getAvailableElementsByType(string $type, array $elements): array {
+ $attachmentElements = array_filter($elements, function ($element) use ($type) {
+ return $type === $element['#type'];
+ });
+
+ return array_map(function ($element) {
+ return $element['#title'];
+ }, $attachmentElements);
+ }
+
+}