summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul-Christian Volkmer2026-09-21 11:43:03 +0200
committerGitHub2026-09-21 11:43:03 +0200
commitd9961a9baccaff511662b1aa1de18bdd02f94f5e (patch)
treeb4d47b8eed6aaceebaead375e8265e5f245bff82
parentb0fb01d12fcadf86cabbe71b16cce60fb88b0ca4 (diff)
feat: add switch/routing to send dedicated data to nNGM (#322)
-rw-r--r--README.md27
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt6
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt21
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt15
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt24
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt22
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt52
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt30
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt33
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt1
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt67
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt107
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt59
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt31
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt117
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt48
-rw-r--r--src/main/resources/application-dev.yml8
-rw-r--r--src/main/resources/db/migration/mariadb/V0_18_0_1__Destination.sql2
-rw-r--r--src/main/resources/db/migration/postgresql/V0_18_0_1__Destination.sql2
-rw-r--r--src/main/resources/templates/fragments.html5
-rw-r--r--src/main/resources/templates/report.html2
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckServiceTest.kt1
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt12
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt338
-rw-r--r--src/web/style.css20
25 files changed, 949 insertions, 101 deletions
diff --git a/README.md b/README.md
index dafab4d..71b4356 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,14 @@ Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in
![Modell DNPM-ETL-Strecke](docs/etl.png)
+### 🔥 Wichtige Änderungen in Version 0.18
+
+Ab Version 0.18 kann diese Anwendung zur Datenausleitung über die nNGM-REST-API verwendet werden.
+Dazu ist eine Konfiguration des in dieser Version eingeführten [Routing-Mechanismus](#routed-rest) erforderlich
+
+Hier wird jeder `PatientRecord`, dessen (letzte) Diagnose einen ICD-10-Code beginnend mit `C34` und beliebiger Endung hat,
+nicht über den Standard-Weg versendet, sondern an den für nNGM konfigurierten API-Endpunkt.
+
### 🔥 Wichtige Änderungen in Version 0.17
Ab Version 0.17 verwendet diese Anwendung [pcvolkmer/mv64e-mtb-model](https://github.com/pcvolkmer/mv64e-mtb-model) und ersetzt
@@ -402,8 +410,27 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein MTB-File an DNPM:DIP gesendet wird:
* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel `http://localhost:9000/api`
+* `APP_REST_TYPE`: Mögliche Werte `dip` (Standard) für DNPM:DIP und `nngm` für die nNGM-API.
* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt
* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt
+* `APP_REST_API_KEY`: Angabe eines API-Keys zur Verwendung mit `APP_REST_TYPE=nngm` für die nNGM-API
+
+#### Routed REST
+
+Seit Version 0.18 ist eine Konfiguration des neu eingeführten **Routing**-Mechanismus möglich.
+Damit kann für bestimmte Entitäten und Diagnosen ein abweichender REST-Endpunkt definiert werden.
+
+Beispiel:
+
+* `APP_ROUTING_NNGM_URI`: URI der nNGM-REST-API.
+* `APP_ROUTING_NNGM_API_KEY`: API-Key zur Nutzung der nNGM-REST-API.
+* `APP_ROUTING_NNGM_ICD10[0]`: Erster ICD-Code - weitere möglich.
+
+Mit dieser Konfiguration wird jeder `PatientRecord`, dessen (letzte) Diagnose einen ICD-10-Code hat, dessen Anfang
+konfiguriert ist, nicht über den Standard-Weg versendet, sondern an den für nNGM konfigurierten API-Endpunkt.
+
+Wird zum Beispiel `APP_ROUTING_NNGM_ICD10[0] = C34` konfiguriert, werden Diagnosen wie "C34.0", "C34.1", etc. an das nNGM
+übermittelt.
#### Kafka-Topics
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt
index 9f1e183..d54c0bf 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt
@@ -24,6 +24,8 @@ import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.DnpmV2MtbFileRequest
import dev.dnpm.etl.processor.output.MtbFileSender
+import dev.dnpm.etl.processor.output.RestDipMtbFileSender
+import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.pcvolkmer.mv64e.model.Coding
import dev.pcvolkmer.mv64e.model.ConsentProvisionType
import dev.pcvolkmer.mv64e.model.ModelProjectConsentPurpose
@@ -57,7 +59,7 @@ import tools.jackson.databind.json.JsonMapper
@Testcontainers
@ExtendWith(SpringExtension::class)
@SpringBootTest
-@MockitoBean(types = [MtbFileSender::class])
+@MockitoBean(types = [RestMtbFileSender::class])
@TestPropertySource(
properties =
[
@@ -91,7 +93,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
)
inner class TransformationTest {
- @MockitoBean private lateinit var mtbFileSender: MtbFileSender
+ @MockitoBean private lateinit var mtbFileSender: RestMtbFileSender
private lateinit var mockMvc: MockMvc
private lateinit var jsonMapper: JsonMapper
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
index 8052ed2..f2bfd90 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
@@ -29,6 +29,8 @@ import dev.dnpm.etl.processor.input.KafkaInputListener
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
+import dev.dnpm.etl.processor.output.RestNngmMtbFileSender
+import dev.dnpm.etl.processor.output.RoutedRestNngmMtbFileSender
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.GpasSoapPseudonymGenerator
@@ -61,6 +63,7 @@ import tools.jackson.databind.json.JsonMapper
KafkaAutoConfiguration::class,
AppKafkaConfiguration::class,
AppRestConfiguration::class,
+ AppRoutedRestConfiguration::class,
ConsentEvaluator::class,
],
)
@@ -83,6 +86,24 @@ class AppConfigurationTest {
assertThrows<NoSuchBeanDefinitionException> {
context.getBean<KafkaMtbFileSender>()
}
+ assertThrows<NoSuchBeanDefinitionException> {
+ context.getBean<RestNngmMtbFileSender>()
+ }
+ }
+ }
+
+ @Nested
+ @TestPropertySource(properties = ["app.rest.uri=http://localhost:9000", "app.routing.nngm.uri=http://localhost:9999"])
+ inner class AppConfigurationSwitchedRestTest(
+ private val context: ApplicationContext,
+ ) {
+ @Test
+ fun shouldUseRestMtbFileSenderAndSwitchedNngmFileSender() {
+ assertThat(context.getBean<RestDipMtbFileSender>()).isNotNull
+ assertThat(context.getBean<RoutedRestNngmMtbFileSender>()).isNotNull
+ assertThrows<NoSuchBeanDefinitionException> {
+ context.getBean<KafkaMtbFileSender>()
+ }
}
}
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt
index bbade1c..b599134 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt
@@ -21,13 +21,9 @@
package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.*
-import dev.dnpm.etl.processor.monitoring.Request
-import dev.dnpm.etl.processor.monitoring.RequestRepository
-import dev.dnpm.etl.processor.monitoring.RequestStatus
-import dev.dnpm.etl.processor.monitoring.RequestType
-import dev.dnpm.etl.processor.monitoring.SubmissionType
-import dev.dnpm.etl.processor.output.MtbFileSender
-import java.time.Instant
+import dev.dnpm.etl.processor.monitoring.*
+import dev.dnpm.etl.processor.output.RestDipMtbFileSender
+import dev.dnpm.etl.processor.output.RestMtbFileSender
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -39,14 +35,15 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional
import org.testcontainers.junit.jupiter.Testcontainers
+import java.time.Instant
@Testcontainers
@ExtendWith(SpringExtension::class)
@SpringBootTest
@Transactional
-@MockitoBean(types = [MtbFileSender::class])
+@MockitoBean(types = [RestMtbFileSender::class])
@TestPropertySource(
- properties = ["app.pseudonymize.generator=buildin", "app.rest.uri=http://example.com"]
+ properties = ["app.pseudonymize.generator=buildin", "app.rest.uri=http://example.com", "app.rest.type=dip"]
)
class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
index 01bab32..fb93618 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
@@ -111,12 +111,36 @@ data class RestTargetProperties(
val uri: String?,
val username: String?,
val password: String?,
+ val apiKey: String? = null,
+ val type: RestTargetType = RestTargetType.DIP,
) {
companion object {
const val NAME = "app.rest"
}
}
+enum class RestTargetType {
+ DIP,
+ NNGM,
+}
+
+@ConfigurationProperties(RoutingProperties.NAME)
+data class RoutingProperties(
+ val nngm: SwitchTargetProperties? = null,
+) {
+ companion object {
+ const val NAME = "app.routing"
+ }
+}
+
+data class SwitchTargetProperties(
+ val uri: String,
+ val username: String?,
+ val password: String?,
+ val apiKey: String?,
+ val icd10: List<String> = listOf(),
+)
+
@ConfigurationProperties(KafkaProperties.NAME)
data class KafkaProperties(
val inputTopic: String?,
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt
index d10a32f..70cfca9 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt
@@ -24,10 +24,11 @@ import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
-import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
+import dev.dnpm.etl.processor.output.RestMtbFileSender
+import dev.dnpm.etl.processor.output.RestNngmMtbFileSender
import org.slf4j.LoggerFactory
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
@@ -40,7 +41,6 @@ import reactor.core.publisher.Sinks
@Configuration
@EnableConfigurationProperties(value = [RestTargetProperties::class])
@ConditionalOnProperty(value = ["app.rest.uri"])
-@ConditionalOnMissingBean(MtbFileSender::class)
@Order(-10)
class AppRestConfiguration {
private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java)
@@ -51,12 +51,20 @@ class AppRestConfiguration {
restTargetProperties: RestTargetProperties,
retryTemplate: RetryTemplate,
reportService: ReportService,
- ): MtbFileSender {
- logger.info("Selected 'RestDipMtbFileSender'")
- return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
- }
+ ): RestMtbFileSender =
+ when (restTargetProperties.type) {
+ RestTargetType.DIP -> {
+ logger.info("Selected 'RestDipMtbFileSender'")
+ RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
+ }
+ RestTargetType.NNGM -> {
+ logger.info("Selected 'RestNngmMtbFileSender'")
+ RestNngmMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
+ }
+ }
@Bean
+ @ConditionalOnBean(RestDipMtbFileSender::class)
fun restConnectionCheckService(
restTemplate: RestTemplate,
restTargetProperties: RestTargetProperties,
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt
new file mode 100644
index 0000000..f962a6c
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRoutedRestConfiguration.kt
@@ -0,0 +1,52 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.config
+
+import dev.dnpm.etl.processor.monitoring.ReportService
+import dev.dnpm.etl.processor.output.RoutedMtbFileSender
+import dev.dnpm.etl.processor.output.RoutedRestNngmMtbFileSender
+import org.slf4j.LoggerFactory
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.core.annotation.Order
+import org.springframework.retry.support.RetryTemplate
+import org.springframework.web.client.RestTemplate
+
+@Configuration
+@EnableConfigurationProperties(value = [RoutingProperties::class])
+@Order(-10)
+class AppRoutedRestConfiguration {
+ private val logger = LoggerFactory.getLogger(AppRoutedRestConfiguration::class.java)
+
+ @Bean
+ @ConditionalOnProperty(prefix = "app.routing", name = ["nngm.uri"])
+ fun switchRestMtbFileSender(
+ restTemplate: RestTemplate,
+ routingProperties: RoutingProperties,
+ retryTemplate: RetryTemplate,
+ reportService: ReportService,
+ ): RoutedMtbFileSender {
+ logger.info("Added switched 'RestNngmMtbFileSender' ... ")
+ return RoutedRestNngmMtbFileSender(restTemplate, routingProperties, retryTemplate, reportService)
+ }
+}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt
new file mode 100644
index 0000000..e3d1915
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Destination.kt
@@ -0,0 +1,30 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.monitoring
+
+enum class Destination(
+ val value: String,
+) {
+ UNKNOWN("unknown"),
+ KAFKA("kafka"),
+ DNPM_DIP("dnpm_dip"),
+ NNGM_API("nngm_api"),
+}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt
index b9359fc..96cdb5c 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt
@@ -35,19 +35,23 @@ class ReportService(private val jsonMapper: JsonMapper) {
return listOf()
}
return try {
- jsonMapper.readValue(dataQualityReport, DataQualityReport::class.java).issues.sortedBy {
+ jsonMapper.readValue(dataQualityReport, DipDataQualityReport::class.java).issues.sortedBy {
it.severity
}
} catch (_: JacksonException) {
- val otherIssue =
- Issue(Severity.ERROR, Optional.of("Not parsable data quality report '$dataQualityReport'"))
- return listOf(otherIssue)
+ return try {
+ jsonMapper.readValue(dataQualityReport, NngmDataQualityReport::class.java).getIssues()
+ } catch (_: JacksonException) {
+ val otherIssue =
+ Issue(Severity.ERROR, Optional.of("Not parsable data quality report '$dataQualityReport'"))
+ return listOf(otherIssue)
+ }
} catch (e: Exception) {
throw e
}
}
- private data class DataQualityReport(
+ private data class DipDataQualityReport(
val issues: List<Issue>
)
@@ -57,7 +61,7 @@ class ReportService(private val jsonMapper: JsonMapper) {
val details: Optional<String> = Optional.empty(),
val path: Optional<String> = Optional.empty(),
) {
- fun getMessage() = message.orElse(details.orElse("No details available"))
+ fun getMessageText() = message.orElse(details.orElse("No details available"))
}
@EnumNaming(EnumNamingStrategies.LowerCaseStrategy::class)
@@ -67,6 +71,22 @@ class ReportService(private val jsonMapper: JsonMapper) {
WARNING("warning"),
INFO("info"),
}
+
+ private data class NngmDataQualityReport(
+ val qualityChecksReport: QualityChecksReport,
+ ) {
+ fun getIssues(): List<Issue> {
+ val severity = if (this.qualityChecksReport.importAccepted) Severity.INFO else Severity.ERROR
+ return this.qualityChecksReport.qualityChecks
+ .split('|')
+ .map { Issue(severity, Optional.of(it)) }
+ }
+ }
+
+ private data class QualityChecksReport(
+ val importAccepted: Boolean,
+ val qualityChecks: String
+ )
}
fun List<Issue>.asRequestStatus(): RequestStatus {
@@ -74,6 +94,7 @@ fun List<Issue>.asRequestStatus(): RequestStatus {
return when (severity) {
Severity.FATAL,
Severity.ERROR -> RequestStatus.ERROR
+
Severity.WARNING -> RequestStatus.WARNING
else -> RequestStatus.SUCCESS
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt
index d62a503..290df76 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt
@@ -55,6 +55,7 @@ data class Request(
@Column("submission_accepted") var submissionAccepted: Boolean = false,
@Column("followup_count") var followupCount: Int = 0,
@Column("expected_followup_count") var expectedFollowupCount: Int = 0,
+ @Column("destination") var destination: Destination = Destination.UNKNOWN,
) {
constructor(
uuid: RequestId,
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt
index 04e673a..1bcca5e 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt
@@ -39,14 +39,12 @@ import org.springframework.web.client.exchange
import org.springframework.web.util.UriComponentsBuilder
class RestDipMtbFileSender(
- private val restTemplate: RestTemplate,
+ restTemplate: RestTemplate,
private val restTargetProperties: RestTargetProperties,
- private val retryTemplate: RetryTemplate,
- private val reportService: ReportService,
-) : MtbFileSender {
- private val logger = LoggerFactory.getLogger(RestDipMtbFileSender::class.java)
-
- fun sendUrl(): String =
+ retryTemplate: RetryTemplate,
+ reportService: ReportService,
+) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
+ override fun sendUrl(): String =
UriComponentsBuilder
.fromUriString(restTargetProperties.uri.toString())
.pathSegment("mtb")
@@ -54,7 +52,7 @@ class RestDipMtbFileSender(
.pathSegment("patient-record")
.toUriString()
- fun deleteUrl(patientId: PatientPseudonym): String =
+ override fun deleteUrl(patientId: PatientPseudonym): String =
UriComponentsBuilder
.fromUriString(restTargetProperties.uri.toString())
.pathSegment("mtb")
@@ -63,60 +61,9 @@ class RestDipMtbFileSender(
.pathSegment(patientId.value)
.toUriString()
- override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
- try {
- return retryTemplate.execute<MtbFileSender.Response, Exception> {
- val headers = getHttpHeaders(request)
- val entityReq = HttpEntity(request.content, headers)
- val response =
- restTemplate.exchange<String>(sendUrl(), HttpMethod.POST, entityReq)
- if (!response.statusCode.is2xxSuccessful) {
- logger.warn("Error sending to remote system: {}", response.body)
- return@execute MtbFileSender.Response(
- reportService.deserialize(response.body).asRequestStatus(),
- "Status-Code: ${response.statusCode.value()}",
- )
- }
- logger.debug("Sent file via RestMtbFileSender")
- return@execute MtbFileSender.Response(
- reportService.deserialize(response.body).asRequestStatus(),
- response.body.orEmpty(),
- )
- }
- } catch (e: IllegalArgumentException) {
- logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
- } catch (e: RestClientResponseException) {
- logger.info(restTargetProperties.uri!!.toString())
- logger.error("Request data not accepted by remote system", e)
- return MtbFileSender.Response(
- reportService.deserialize(e.responseBodyAsString).asRequestStatus(),
- e.responseBodyAsString,
- )
- }
- return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
- }
-
- override fun send(request: DeleteRequest): MtbFileSender.Response {
- try {
- return retryTemplate.execute<MtbFileSender.Response, Exception> {
- val headers = getHttpHeaders(request)
- val entityReq = HttpEntity(null, headers)
- restTemplate.delete(deleteUrl(request.patientId), entityReq, String::class.java)
- logger.debug("Sent file via RestMtbFileSender")
- return@execute MtbFileSender.Response(RequestStatus.SUCCESS)
- }
- } catch (e: IllegalArgumentException) {
- logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
- } catch (e: RestClientException) {
- logger.info(restTargetProperties.uri!!.toString())
- logger.error("Cannot send data to remote system", e)
- }
- return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
- }
-
override fun endpoint(): String = this.restTargetProperties.uri.orEmpty()
- private fun getHttpHeaders(request: MtbRequest): HttpHeaders {
+ override fun getHttpHeaders(request: MtbRequest): HttpHeaders {
val username = restTargetProperties.username
val password = restTargetProperties.password
val headers = HttpHeaders()
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt
new file mode 100644
index 0000000..c88f606
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt
@@ -0,0 +1,107 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import dev.dnpm.etl.processor.CustomMediaType
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.config.RestTargetProperties
+import dev.dnpm.etl.processor.monitoring.ReportService
+import dev.dnpm.etl.processor.monitoring.RequestStatus
+import dev.dnpm.etl.processor.monitoring.asRequestStatus
+import org.slf4j.LoggerFactory
+import org.springframework.http.HttpEntity
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpMethod
+import org.springframework.http.MediaType
+import org.springframework.retry.support.RetryTemplate
+import org.springframework.web.client.RestClientException
+import org.springframework.web.client.RestClientResponseException
+import org.springframework.web.client.RestTemplate
+import org.springframework.web.client.exchange
+import org.springframework.web.util.UriComponentsBuilder
+
+abstract class RestMtbFileSender(
+ private val restTemplate: RestTemplate,
+ private val restTargetProperties: RestTargetProperties,
+ private val retryTemplate: RetryTemplate,
+ private val reportService: ReportService,
+) : MtbFileSender {
+ private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
+
+ abstract fun sendUrl(): String
+
+ abstract fun deleteUrl(patientId: PatientPseudonym): String
+
+ override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
+ try {
+ return retryTemplate.execute<MtbFileSender.Response, Exception> {
+ val headers = getHttpHeaders(request)
+ val entityReq = HttpEntity(request.content, headers)
+ val response =
+ restTemplate.exchange<String>(sendUrl(), HttpMethod.POST, entityReq)
+ if (!response.statusCode.is2xxSuccessful) {
+ logger.warn("Error sending to remote system: {}", response.body)
+ return@execute MtbFileSender.Response(
+ reportService.deserialize(response.body).asRequestStatus(),
+ "Status-Code: ${response.statusCode.value()}",
+ )
+ }
+ logger.debug("Sent file via ${this::class.java.simpleName}")
+ return@execute MtbFileSender.Response(
+ reportService.deserialize(response.body).asRequestStatus(),
+ response.body.orEmpty(),
+ )
+ }
+ } catch (_: IllegalArgumentException) {
+ logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
+ } catch (e: RestClientResponseException) {
+ logger.info(restTargetProperties.uri!!.toString())
+ logger.error("Request data not accepted by remote system", e)
+ return MtbFileSender.Response(
+ RequestStatus.ERROR,
+ e.responseBodyAsString,
+ )
+ }
+ return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
+ }
+
+ override fun send(request: DeleteRequest): MtbFileSender.Response {
+ try {
+ return retryTemplate.execute<MtbFileSender.Response, Exception> {
+ val headers = getHttpHeaders(request)
+ val entityReq = HttpEntity(null, headers)
+ restTemplate.delete(deleteUrl(request.patientId), entityReq, String::class.java)
+ logger.debug("Sent file via RestDipMtbFileSender")
+ return@execute MtbFileSender.Response(RequestStatus.SUCCESS)
+ }
+ } catch (_: IllegalArgumentException) {
+ logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
+ } catch (e: RestClientException) {
+ logger.info(restTargetProperties.uri!!.toString())
+ logger.error("Cannot send data to remote system", e)
+ }
+ return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
+ }
+
+ override fun endpoint(): String = this.restTargetProperties.uri.orEmpty()
+
+ protected abstract fun getHttpHeaders(request: MtbRequest): HttpHeaders
+}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt
new file mode 100644
index 0000000..13722d2
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestNngmMtbFileSender.kt
@@ -0,0 +1,59 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.config.RestTargetProperties
+import dev.dnpm.etl.processor.monitoring.ReportService
+import org.springframework.http.HttpHeaders
+import org.springframework.http.MediaType
+import org.springframework.retry.support.RetryTemplate
+import org.springframework.web.client.RestTemplate
+import org.springframework.web.util.UriComponentsBuilder
+
+class RestNngmMtbFileSender(
+ restTemplate: RestTemplate,
+ private val restTargetProperties: RestTargetProperties,
+ retryTemplate: RetryTemplate,
+ reportService: ReportService,
+) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
+ override fun sendUrl(): String =
+ UriComponentsBuilder
+ .fromUriString(restTargetProperties.uri.toString())
+ .toUriString()
+
+ override fun deleteUrl(patientId: PatientPseudonym): String = throw UnsupportedOperationException("Not implemented")
+
+ override fun endpoint(): String = this.restTargetProperties.uri.orEmpty()
+
+ override fun getHttpHeaders(request: MtbRequest): HttpHeaders {
+ val apiKey = restTargetProperties.apiKey
+ val headers = HttpHeaders()
+ headers.contentType = MediaType.APPLICATION_JSON
+
+ if (apiKey.isNullOrBlank()) {
+ return headers
+ }
+
+ headers.set(HttpHeaders.AUTHORIZATION, "X-API-KEY $apiKey")
+ return headers
+ }
+}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt
new file mode 100644
index 0000000..1eab675
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedMtbFileSender.kt
@@ -0,0 +1,31 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2023-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import dev.dnpm.etl.processor.output.MtbFileSender.Response
+import dev.pcvolkmer.mv64e.model.MtbDiagnosis
+import dev.pcvolkmer.mv64e.model.PatientRecord
+
+interface RoutedMtbFileSender {
+ fun supportsDiagnosis(diagnosis: MtbDiagnosis): Boolean
+
+ fun send(request: MtbFileRequest<PatientRecord>): Response
+}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt
new file mode 100644
index 0000000..957645e
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RoutedRestNngmMtbFileSender.kt
@@ -0,0 +1,117 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
+ * Copyright (c) 2025-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import dev.dnpm.etl.processor.config.RoutingProperties
+import dev.dnpm.etl.processor.monitoring.ReportService
+import dev.dnpm.etl.processor.monitoring.RequestStatus
+import dev.dnpm.etl.processor.monitoring.asRequestStatus
+import dev.pcvolkmer.mv64e.model.MtbDiagnosis
+import dev.pcvolkmer.mv64e.model.PatientRecord
+import org.slf4j.LoggerFactory
+import org.springframework.http.HttpEntity
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpMethod
+import org.springframework.http.MediaType
+import org.springframework.retry.support.RetryTemplate
+import org.springframework.web.client.RestClientResponseException
+import org.springframework.web.client.RestTemplate
+import org.springframework.web.client.exchange
+import org.springframework.web.util.UriComponentsBuilder
+
+class RoutedRestNngmMtbFileSender(
+ private val restTemplate: RestTemplate,
+ private val routingProperties: RoutingProperties,
+ private val retryTemplate: RetryTemplate,
+ private val reportService: ReportService,
+) : RoutedMtbFileSender {
+ private val logger = LoggerFactory.getLogger(RoutedRestNngmMtbFileSender::class.java)
+
+ fun sendUrl(): String =
+ UriComponentsBuilder
+ .fromUriString(routingProperties.nngm?.uri.toString())
+ .toUriString()
+
+ override fun supportsDiagnosis(diagnosis: MtbDiagnosis): Boolean {
+ val code = diagnosis.code?.code ?: return false
+
+ if (code.isBlank()) {
+ return false
+ }
+
+ this.routingProperties.nngm?.icd10?.forEach {
+ if (code.startsWith(it)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ override fun send(request: MtbFileRequest<PatientRecord>): MtbFileSender.Response {
+ try {
+ return retryTemplate.execute<MtbFileSender.Response, Exception> {
+ val entityReq = HttpEntity(request.content, getHttpHeaders())
+ val response =
+ restTemplate.exchange<String>(sendUrl(), HttpMethod.POST, entityReq)
+ if (!response.statusCode.is2xxSuccessful) {
+ logger.warn("Error sending to remote system: {}", response.body)
+ return@execute MtbFileSender.Response(
+ reportService.deserialize(response.body).asRequestStatus(),
+ "Status-Code: ${response.statusCode.value()}",
+ )
+ }
+ logger.debug("Sent file via RoutedRestNngmMtbFileSender")
+ return@execute MtbFileSender.Response(
+ reportService.deserialize(response.body).asRequestStatus(),
+ response.body.orEmpty(),
+ )
+ }
+ } catch (_: IllegalArgumentException) {
+ logger.error("Not a valid URI to export to: '{}'", routingProperties.nngm?.uri!!)
+ } catch (e: RestClientResponseException) {
+ logger.info(routingProperties.nngm?.uri!!)
+ logger.error("Request data not accepted by remote system", e)
+ return MtbFileSender.Response(
+ reportService.deserialize(e.responseBodyAsString).asRequestStatus(),
+ e.responseBodyAsString,
+ )
+ }
+ return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
+ }
+
+ fun endpoint(): String =
+ this.routingProperties.nngm
+ ?.uri
+ .orEmpty()
+
+ private fun getHttpHeaders(): HttpHeaders {
+ val apiKey = routingProperties.nngm?.apiKey
+ val headers = HttpHeaders()
+ headers.contentType = MediaType.APPLICATION_JSON
+
+ if (apiKey.isNullOrBlank()) {
+ return headers
+ }
+
+ headers.set(HttpHeaders.AUTHORIZATION, "X-API-KEY $apiKey")
+ return headers
+ }
+}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
index 320f828..6ce9d14 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
@@ -24,10 +24,7 @@ import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.monitoring.*
-import dev.dnpm.etl.processor.output.DeleteRequest
-import dev.dnpm.etl.processor.output.DnpmV2MtbFileRequest
-import dev.dnpm.etl.processor.output.MtbFileRequest
-import dev.dnpm.etl.processor.output.MtbFileSender
+import dev.dnpm.etl.processor.output.*
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.pseudonym.addGenomDeTan
import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith
@@ -49,6 +46,7 @@ class RequestProcessor(
private val pseudonymizeService: PseudonymizeService,
private val transformationService: TransformationService,
private val sender: MtbFileSender,
+ private val switchedSenders: List<RoutedMtbFileSender>,
private val requestService: RequestService,
private val jsonMapper: JsonMapper,
private val applicationEventPublisher: ApplicationEventPublisher,
@@ -111,6 +109,18 @@ class RequestProcessor(
}
}
+ var destination = when (sender) {
+ is RestDipMtbFileSender -> Destination.DNPM_DIP
+ is RestNngmMtbFileSender -> Destination.NNGM_API
+ is KafkaMtbFileSender -> Destination.KAFKA
+ else -> Destination.UNKNOWN
+ }
+
+ val routedSender = selectRoutedSender(request)
+ if (routedSender.isPresent && routedSender.get() is RoutedRestNngmMtbFileSender) {
+ destination = Destination.NNGM_API
+ }
+
val maxFollowUpCount = this.requestService.allRequestsByPatientPseudonym(request.patientPseudonym())
.maxByOrNull { it.followupCount }
?.followupCount ?: -1
@@ -132,6 +142,7 @@ class RequestProcessor(
tan = Tan(request.content.metadata?.transferTAN.orEmpty()),
followupCount = maxFollowUpCount,
expectedFollowupCount = request.content.followUps?.size ?: 0,
+ destination = destination,
)
)
// Exit - no further processing
@@ -172,6 +183,7 @@ class RequestProcessor(
tan = Tan(request.content.metadata?.transferTAN.orEmpty()),
followupCount = maxFollowUpCount,
expectedFollowupCount = request.content.followUps?.size ?: 0,
+ destination = destination,
)
)
@@ -182,7 +194,11 @@ class RequestProcessor(
return
}
- val responseStatus = sender.send(request)
+ val responseStatus = if (!routedSender.isEmpty) {
+ routedSender.get().send(request)
+ } else {
+ sender.send(request)
+ }
applicationEventPublisher.publishEvent(
ResponseEvent(
@@ -199,6 +215,28 @@ class RequestProcessor(
)
}
+ /*
+ * Selects MtbFileSender based on latest diagnosis ICD10 code
+ */
+ private fun selectRoutedSender(request: MtbFileRequest<PatientRecord>): Optional<RoutedMtbFileSender> {
+
+ when (request) {
+ is DnpmV2MtbFileRequest -> {
+ val latestDiagnosis = request.content.diagnoses.maxBy { it.recordedOn } ?: return Optional.empty()
+
+ this.switchedSenders.forEach { sender ->
+ if (sender.supportsDiagnosis(latestDiagnosis)) {
+ return Optional.of(sender)
+ }
+ }
+
+ }
+ }
+
+ // Default
+ return Optional.empty()
+ }
+
private fun hasFollowUpAfterLastSuccessfulSubmission(request: DnpmV2MtbFileRequest): Boolean {
val lastSuccessfulSubmission = this.requestService.allRequestsByPatientPseudonym(request.patientPseudonym())
.sortedBy { it.processedAt }
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 6c1e51f..77bf157 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -119,6 +119,12 @@ app:
# input-topic: test_input
# output-topic: test_output
# output-response-topic: test_response
+ switch:
+ nngm:
+ uri: https://mvh-test.medicalsyn.com/api/v1.0/mvh-import/import-dnpm
+ api-key: a12b34decacdefg1213
+ icd10:
+ - C34
security:
admin-user: admin
admin-password: "{noop}very-secret"
@@ -126,4 +132,4 @@ app:
server:
port: 8000
- shutdown: immediate \ No newline at end of file
+ shutdown: immediate
diff --git a/src/main/resources/db/migration/mariadb/V0_18_0_1__Destination.sql b/src/main/resources/db/migration/mariadb/V0_18_0_1__Destination.sql
new file mode 100644
index 0000000..a65c016
--- /dev/null
+++ b/src/main/resources/db/migration/mariadb/V0_18_0_1__Destination.sql
@@ -0,0 +1,2 @@
+ALTER TABLE request ADD COLUMN destination varchar(16) DEFAULT 'UNKNOWN';
+UPDATE request SET destination = 'UNKNOWN';
diff --git a/src/main/resources/db/migration/postgresql/V0_18_0_1__Destination.sql b/src/main/resources/db/migration/postgresql/V0_18_0_1__Destination.sql
new file mode 100644
index 0000000..a65c016
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V0_18_0_1__Destination.sql
@@ -0,0 +1,2 @@
+ALTER TABLE request ADD COLUMN destination varchar(16) DEFAULT 'UNKNOWN';
+UPDATE request SET destination = 'UNKNOWN';
diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html
index ea83ed2..1a7a257 100644
--- a/src/main/resources/templates/fragments.html
+++ b/src/main/resources/templates/fragments.html
@@ -54,7 +54,10 @@
<div th:if="${request.type.value == 'delete'}">
<span>🗑 Löschanfrage vom </span><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time>
</div>
- <div sec:authorize="hasRole('USER') or hasRole('ADMIN')" th:if="${postInitialSubmissionBlock}">
+ <div class="destination" th:if="${request.destination.value == 'kafka'}">Kafka Broker</div>
+ <div class="destination" th:if="${request.destination.value == 'dnpm_dip'}">DNPM:DIP</div>
+ <div class="destination" th:if="${request.destination.value == 'nngm_api'}">nNGM-API</div>
+ <div class="accepted" sec:authorize="hasRole('USER') or hasRole('ADMIN')" th:if="${postInitialSubmissionBlock}">
<button class="btn btn-green" hx-swap="outerHTML" th:hx-target="${'#request_' + request.uuid}" th:hx-delete="@{/submission/{requestId}/accepted(requestId=${request.uuid})}" th:if="${ request.submissionType.value == 'initial' and (request.status.value == 'success' or request.status.value == 'warning') and request.submissionAccepted == true}" title="Doch keine Meldebestätigung - jetzt blockieren">
🔓 Bestätigte Initialmeldung
</button>
diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html
index bdc7457..b739bbf 100644
--- a/src/main/resources/templates/report.html
+++ b/src/main/resources/templates/report.html
@@ -34,7 +34,7 @@
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'fatal'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
<td>
- <div class="issue-message">[[ ${issue.getMessage()} ]]</div>
+ <div class="issue-message">[[ ${issue.getMessageText()} ]]</div>
<div class="issue-path" th:if="${issue.path.isPresent()}">[[ ${issue.path.get()} ]]</div>
<div class="issue-path" th:if="${issue.path.isEmpty()}"><i>Keine Angabe</i></div>
</td>
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckServiceTest.kt
index 62ece6c..c5cc839 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckServiceTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckServiceTest.kt
@@ -56,6 +56,7 @@ class ConnectionCheckServiceTest {
"http://localhost/api",
"user",
"password",
+ null,
)
this.sink = Sinks.many().multicast().onBackpressureBuffer()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt
index e9c3107..c45dd95 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt
@@ -60,13 +60,13 @@ class ReportServiceTest {
assertThat(actual).hasSize(4)
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.FATAL)
- assertThat(actual[0].getMessage()).isEqualTo("Fatal Message")
+ assertThat(actual[0].getMessageText()).isEqualTo("Fatal Message")
assertThat(actual[1].severity).isEqualTo(ReportService.Severity.ERROR)
- assertThat(actual[1].getMessage()).isEqualTo("Error Message")
+ assertThat(actual[1].getMessageText()).isEqualTo("Error Message")
assertThat(actual[2].severity).isEqualTo(ReportService.Severity.WARNING)
- assertThat(actual[2].getMessage()).isEqualTo("Warning Message")
+ assertThat(actual[2].getMessageText()).isEqualTo("Warning Message")
assertThat(actual[3].severity).isEqualTo(ReportService.Severity.INFO)
- assertThat(actual[3].getMessage()).isEqualTo("Info Message")
+ assertThat(actual[3].getMessageText()).isEqualTo("Info Message")
assertThat(actual.asRequestStatus()).isEqualTo(RequestStatus.ERROR)
}
@@ -87,7 +87,7 @@ class ReportServiceTest {
assertThat(actual).hasSize(1)
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.ERROR)
- assertThat(actual[0].getMessage()).isEqualTo("/specimens(0)/type/code: error.path.missing")
+ assertThat(actual[0].getMessageText()).isEqualTo("/specimens(0)/type/code: error.path.missing")
assertThat(actual.asRequestStatus()).isEqualTo(RequestStatus.ERROR)
}
@@ -110,7 +110,7 @@ class ReportServiceTest {
assertThat(actual).hasSize(1)
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.ERROR)
- assertThat(actual[0].getMessage()).isEqualTo("Not parsable data quality report '$invalidResponse'")
+ assertThat(actual[0].getMessageText()).isEqualTo("Not parsable data quality report '$invalidResponse'")
}
companion object {
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
index 3edb480..8433e93 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
@@ -29,11 +29,15 @@ import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.monitoring.SubmissionType
import dev.dnpm.etl.processor.output.DeleteRequest
import dev.dnpm.etl.processor.output.DnpmV2MtbFileRequest
+import dev.dnpm.etl.processor.output.MtbFileRequest
import dev.dnpm.etl.processor.output.MtbFileSender
+import dev.dnpm.etl.processor.output.RoutedMtbFileSender
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
+import dev.pcvolkmer.mv64e.model.Coding
import dev.pcvolkmer.mv64e.model.ConsentProvisionType
import dev.pcvolkmer.mv64e.model.FollowUp
import dev.pcvolkmer.mv64e.model.ModelProjectConsentPurpose
+import dev.pcvolkmer.mv64e.model.MtbDiagnosis
import dev.pcvolkmer.mv64e.model.MtbEpisodeOfCare
import dev.pcvolkmer.mv64e.model.MvhMetadata
import dev.pcvolkmer.mv64e.model.MvhMetadataModelProjectConsent
@@ -66,6 +70,7 @@ class RequestProcessorTest {
private lateinit var pseudonymizeService: PseudonymizeService
private lateinit var transformationService: TransformationService
private lateinit var sender: MtbFileSender
+ private lateinit var routedMtbFileSender: RoutedMtbFileSender
private lateinit var requestService: RequestService
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var appConfigProperties: AppConfigProperties
@@ -78,6 +83,7 @@ class RequestProcessorTest {
@Mock pseudonymizeService: PseudonymizeService,
@Mock transformationService: TransformationService,
@Mock sender: MtbFileSender,
+ @Mock routedMtbFileSender: RoutedMtbFileSender,
@Mock requestService: RequestService,
@Mock applicationEventPublisher: ApplicationEventPublisher,
@Mock consentProcessor: ConsentProcessor,
@@ -85,6 +91,7 @@ class RequestProcessorTest {
this.pseudonymizeService = pseudonymizeService
this.transformationService = transformationService
this.sender = sender
+ this.routedMtbFileSender = routedMtbFileSender
this.requestService = requestService
this.applicationEventPublisher = applicationEventPublisher
this.appConfigProperties = AppConfigProperties()
@@ -96,6 +103,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -112,7 +120,7 @@ class RequestProcessorTest {
randomRequestId(),
PatientPseudonym("TEST_12345678901"),
PatientId("P1"),
- Fingerprint("syehahte4oyqd5m2rbd5imnth4rx6md32g2msb7sztnayxoc4kaq"),
+ Fingerprint("tyfimuuc7okhnoholo6jx5o5os3n7haelezphr4477uxbzlotvra"),
RequestType.MTB_FILE,
SubmissionType.TEST,
RequestStatus.SUCCESS,
@@ -138,6 +146,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder()
@@ -169,7 +183,7 @@ class RequestProcessorTest {
randomRequestId(),
PatientPseudonym("TEST_12345678901"),
PatientId("P1"),
- Fingerprint("me6ockoru4boi4ypghfia5myfqtuffwlbszwhtop2rtltb3ycjva"),
+ Fingerprint("q3qfwecsw3tcmdsg4kg5ki4ob3qw3warnwi4qc54nptjgzft53nq"),
RequestType.MTB_FILE,
SubmissionType.TEST,
RequestStatus.SUCCESS,
@@ -195,6 +209,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder()
@@ -256,6 +276,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder()
@@ -321,6 +347,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(
MvhMetadata.builder()
.modelProjectConsent(
@@ -423,6 +455,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -433,6 +466,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata())
.episodesOfCare(
listOf(
@@ -516,6 +555,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata())
.episodesOfCare(
listOf(
@@ -596,6 +641,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder()
@@ -647,6 +698,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -657,6 +709,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2023-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata())
.episodesOfCare(
listOf(
@@ -717,6 +775,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -789,6 +848,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -799,6 +859,7 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(listOf(MtbDiagnosis().code(Coding().code("C80.9")).recordedOn(Date())))
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder()
@@ -894,6 +955,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -904,6 +966,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1006,6 +1074,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1016,6 +1085,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1105,6 +1180,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1115,6 +1191,7 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(listOf(MtbDiagnosis().code(Coding().code("C80.9"))))
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1187,6 +1264,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1262,6 +1340,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1273,6 +1352,12 @@ class RequestProcessorTest {
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.episodesOfCare(
listOf(
MtbEpisodeOfCare.builder()
@@ -1384,6 +1469,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1394,6 +1480,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1492,6 +1584,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1502,6 +1595,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1616,6 +1715,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1626,6 +1726,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1754,6 +1860,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1764,6 +1871,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1883,6 +1996,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1893,6 +2007,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -1979,6 +2099,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -1989,6 +2110,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -2089,6 +2216,7 @@ class RequestProcessorTest {
pseudonymizeService,
transformationService,
sender,
+ listOf(routedMtbFileSender),
requestService,
jsonMapper,
applicationEventPublisher,
@@ -2099,6 +2227,12 @@ class RequestProcessorTest {
val mtbFile =
PatientRecord.builder()
.patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
.metadata(MvhMetadata.builder().type(MvhSubmissionType.INITIAL).build())
.episodesOfCare(
listOf(
@@ -2135,6 +2269,206 @@ class RequestProcessorTest {
}
}
+ @Nested
+ inner class SwitchedSenders {
+
+ private lateinit var pseudonymizeService: PseudonymizeService
+ private lateinit var transformationService: TransformationService
+ private lateinit var sender: MtbFileSender
+ private lateinit var routedMtbFileSender: RoutedMtbFileSender
+ private lateinit var requestService: RequestService
+ private lateinit var applicationEventPublisher: ApplicationEventPublisher
+ private lateinit var appConfigProperties: AppConfigProperties
+ private lateinit var consentProcessor: ConsentProcessor
+ private lateinit var requestProcessor: RequestProcessor
+ private lateinit var jsonMapper: JsonMapper
+
+ @BeforeEach
+ fun setup(
+ @Mock pseudonymizeService: PseudonymizeService,
+ @Mock transformationService: TransformationService,
+ @Mock sender: MtbFileSender,
+ @Mock routedMtbFileSender: RoutedMtbFileSender,
+ @Mock requestService: RequestService,
+ @Mock applicationEventPublisher: ApplicationEventPublisher,
+ @Mock consentProcessor: ConsentProcessor,
+ ) {
+ this.pseudonymizeService = pseudonymizeService
+ this.transformationService = transformationService
+ this.sender = sender
+ this.routedMtbFileSender = routedMtbFileSender
+ this.requestService = requestService
+ this.applicationEventPublisher = applicationEventPublisher
+ this.appConfigProperties = AppConfigProperties()
+ this.consentProcessor = consentProcessor
+ this.jsonMapper = JsonMapper()
+
+ requestProcessor =
+ RequestProcessor(
+ pseudonymizeService,
+ transformationService,
+ sender,
+ listOf(routedMtbFileSender),
+ requestService,
+ jsonMapper,
+ applicationEventPublisher,
+ appConfigProperties,
+ consentProcessor,
+ )
+
+ doAnswer {
+ val diagnosis = it.arguments[0] as MtbDiagnosis
+ diagnosis.code.code.equals("C34.9")
+ }.whenever(routedMtbFileSender).supportsDiagnosis(any<MtbDiagnosis>())
+ }
+
+ @Test
+ fun testShouldSendMtbFileUsingSwitchedSenderAndSendSuccessEvent() {
+ doAnswer {
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("different"),
+ RequestType.MTB_FILE,
+ SubmissionType.TEST,
+ RequestStatus.SUCCESS,
+ Tan.empty(),
+ Instant.parse("2023-08-08T02:00:00Z"),
+ )
+ }
+ .whenever(requestService)
+ .lastMtbFileRequestForPatientPseudonym(anyValueClass())
+
+ doAnswer { false }
+ .whenever(requestService)
+ .isLastRequestWithKnownStatusDeletion(anyValueClass())
+
+ doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) }
+ .whenever(routedMtbFileSender)
+ .send(any<DnpmV2MtbFileRequest>())
+
+ doAnswer { it.arguments[0] as String }
+ .whenever(pseudonymizeService)
+ .patientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] }.whenever(transformationService).transform(any<PatientRecord>())
+
+ whenever(consentProcessor.consentGatedCheckAndTryEmbedding(any())).thenReturn(true)
+
+ val mtbFile =
+ PatientRecord.builder()
+ .patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C34.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
+ .episodesOfCare(
+ listOf(
+ MtbEpisodeOfCare.builder()
+ .id("1")
+ .patient(Reference.builder().id("123").build())
+ .period(
+ PeriodDate.builder()
+ .start(Date.from(Instant.parse("2021-01-01T00:00:00.00Z")))
+ .build()
+ )
+ .build()
+ )
+ )
+ .build()
+
+ this.requestProcessor.processMtbFile(mtbFile)
+
+ // Do not use DNPM:DIP sender
+ verify(sender, never()).send(any<MtbFileRequest<PatientRecord>>())
+ // Use switched sender instead
+ verify(routedMtbFileSender, times(1)).send(any<MtbFileRequest<PatientRecord>>())
+
+ val eventCaptor = argumentCaptor<ResponseEvent>()
+ verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
+ assertThat(eventCaptor.firstValue).isNotNull
+ assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
+ }
+
+ @Test
+ fun testShouldNotSendMtbFileUsingSwitchedSenderAndSendSuccessEvent() {
+ doAnswer {
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("different"),
+ RequestType.MTB_FILE,
+ SubmissionType.TEST,
+ RequestStatus.SUCCESS,
+ Tan.empty(),
+ Instant.parse("2023-08-08T02:00:00Z"),
+ )
+ }
+ .whenever(requestService)
+ .lastMtbFileRequestForPatientPseudonym(anyValueClass())
+
+ doAnswer { false }
+ .whenever(requestService)
+ .isLastRequestWithKnownStatusDeletion(anyValueClass())
+
+ doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) }
+ .whenever(sender)
+ .send(any<DnpmV2MtbFileRequest>())
+
+ doAnswer { it.arguments[0] as String }
+ .whenever(pseudonymizeService)
+ .patientPseudonym(anyValueClass())
+
+ doAnswer { it.arguments[0] }.whenever(transformationService).transform(any<PatientRecord>())
+
+ whenever(consentProcessor.consentGatedCheckAndTryEmbedding(any())).thenReturn(true)
+
+ val mtbFile =
+ PatientRecord.builder()
+ .patient(Patient.builder().id("123").build())
+ .diagnoses(
+ listOf(
+ MtbDiagnosis().code(Coding().code("C80.9"))
+ .recordedOn(Date.from(Instant.parse("2020-01-01T02:00:00.00Z")))
+ )
+ )
+ .episodesOfCare(
+ listOf(
+ MtbEpisodeOfCare.builder()
+ .id("1")
+ .patient(Reference.builder().id("123").build())
+ .period(
+ PeriodDate.builder()
+ .start(Date.from(Instant.parse("2021-01-01T00:00:00.00Z")))
+ .build()
+ )
+ .build()
+ )
+ )
+ .build()
+
+ this.requestProcessor.processMtbFile(mtbFile)
+
+ // Use DNPM:DIP sender
+ verify(sender, times(1)).send(any<MtbFileRequest<PatientRecord>>())
+ // Not switched sender
+ verify(routedMtbFileSender, never()).send(any<MtbFileRequest<PatientRecord>>())
+
+ val eventCaptor = argumentCaptor<ResponseEvent>()
+ verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
+ assertThat(eventCaptor.firstValue).isNotNull
+ assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
+ }
+
+ }
+
+
@Test
fun shouldCatchExceptionsWhenProcessingMtbFileAndSaveError() {
val invalidMtbFile = PatientRecord.builder().build()
diff --git a/src/web/style.css b/src/web/style.css
index 6ae77b0..5c88bd6 100644
--- a/src/web/style.css
+++ b/src/web/style.css
@@ -521,7 +521,25 @@ table.config-table td > button:last-of-type {
background: #ffdddd;
}
-.card > .card-sub-header > *:nth-child(2) {
+.card > .card-sub-header > .destination {
+ background: #ccc;
+ border: 1px solid #333;
+ border-radius: 3px;
+ padding-right: 4px;
+ font-size: small;
+ margin-left: 1rem;
+}
+
+.card > .card-sub-header > .destination:before {
+ content: "Ziel";
+ border-right: 1px solid #333;
+ padding: 0 4px;
+ margin-right: 4px;
+ color: #ccc;
+ background: #333;
+}
+
+.card > .card-sub-header > .accepted {
margin: 0 0 0 auto;
}