summaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-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
17 files changed, 529 insertions, 81 deletions
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>