diff --git a/src/main/java/dev/dnpm/etl/processor/consent/ConsentCheckFileBased.java b/src/main/java/dev/dnpm/etl/processor/consent/ConsentCheckFileBased.java index 8bfe7f8..a18baca 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/ConsentCheckFileBased.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/ConsentCheckFileBased.java @@ -1,5 +1,8 @@ package dev.dnpm.etl.processor.consent; +import java.util.Date; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Consent.ConsentProvisionType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,7 +15,29 @@ public class ConsentCheckFileBased implements ICheckConsent{ } @Override - public TtpConsentStatus getTtpConsentStatus(String personIdentifierValue) { + public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { return TtpConsentStatus.UNKNOWN_CHECK_FILE; } + + @Override + public Bundle getBroadConsent(String personIdentifierValue, Date requestDate) { + return ICheckConsent.super.getBroadConsent(personIdentifierValue, requestDate); + } + + @Override + public Bundle getGenomDeConsent(String personIdentifierValue, Date requestDate) { + return ICheckConsent.super.getGenomDeConsent(personIdentifierValue, requestDate); + } + + @Override + public Bundle currentConsentForPersonAndTemplate(String personIdentifierValue, + ConsentDomain targetConsentDomain, Date requestDate) { + return new Bundle(); + } + + @Override + public ConsentProvisionType getProvisionTypeByPolicyCode(Bundle consentBundle, + Date requestDate, ConsentDomain consentDomain) { + return ConsentProvisionType.NULL; + } } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java b/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java index 7770c46..6d0b160 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/ConsentDomain.java @@ -1,6 +1,13 @@ package dev.dnpm.etl.processor.consent; public enum ConsentDomain { + /** + * MII Broad consent + */ BroadConsent, + + /** + * GenomDe Modelvohaben §64e + */ Modelvorhaben64e } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java index 09da32b..4e0d9c1 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/GicsConsentService.java @@ -5,16 +5,24 @@ import ca.uhn.fhir.parser.DataFormatException; import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.GIcsConfigProperties; import java.util.Date; +import java.util.Optional; +import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.r4.model.BooleanType; import org.hl7.fhir.r4.model.Bundle; import org.hl7.fhir.r4.model.Coding; +import org.hl7.fhir.r4.model.Consent; +import org.hl7.fhir.r4.model.Consent.ConsentProvisionType; +import org.hl7.fhir.r4.model.Consent.ConsentState; +import org.hl7.fhir.r4.model.Consent.ProvisionComponent; import org.hl7.fhir.r4.model.DateType; import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.OperationOutcome; import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; +import org.hl7.fhir.r4.model.Period; +import org.hl7.fhir.r4.model.ResourceType; import org.hl7.fhir.r4.model.StringType; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; @@ -63,7 +71,7 @@ public class GicsConsentService implements ICheckConsent { throw new IllegalArgumentException( "gICS base URL is empty - should call gICS with false configuration."); } - url = UriComponentsBuilder.fromUriString(gIcsBaseUri).path(IS_CONSENTED_ENDPOINT) + url = UriComponentsBuilder.fromUriString(gIcsBaseUri).path(endpoint) .toUriString(); } return url; @@ -91,8 +99,8 @@ public class GicsConsentService implements ICheckConsent { result.addParameter(new ParametersParameterComponent().setName("domain") .setValue(new StringType().setValue(configProperties.getBroadConsentDomainName()))); result.addParameter(new ParametersParameterComponent().setName("policy").setValue( - new Coding().setCode(configProperties.getPolicyCode()) - .setSystem(configProperties.getPolicySystem()))); + new Coding().setCode(configProperties.getBroadConsentPolicyCode()) + .setSystem(configProperties.getBroadConsentPolicySystem()))); /* * is mandatory parameter, but we ignore it via additional configuration parameter @@ -152,7 +160,7 @@ public class GicsConsentService implements ICheckConsent { } @Override - public TtpConsentStatus getTtpConsentStatus(String personIdentifierValue) { + public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) { var parameter = GicsConsentService.getIsConsentedRequestParam(gIcsConfigProperties, personIdentifierValue); @@ -199,30 +207,14 @@ public class GicsConsentService implements ICheckConsent { private String getConsentDomain(ConsentDomain targetConsentDomain) { String consentDomain; switch (targetConsentDomain) { - case BroadConsent -> { - consentDomain = gIcsConfigProperties.getBroadConsentDomainName(); - } - case Modelvorhaben64e -> { - consentDomain = gIcsConfigProperties.getGnomDeConsentDomainName(); - } - default -> { - throw new IllegalArgumentException( - "target ConsentDomain is missing but must be provided!"); - } + case BroadConsent -> consentDomain = gIcsConfigProperties.getBroadConsentDomainName(); + case Modelvorhaben64e -> consentDomain = gIcsConfigProperties.getGenomDeConsentDomainName(); + default -> throw new IllegalArgumentException( + "target ConsentDomain is missing but must be provided!"); } return consentDomain; } - public Bundle getBroadConsent(String personIdentifierValue, Date requestDate) { - return currentConsentForPersonAndTemplate(personIdentifierValue, ConsentDomain.BroadConsent, - requestDate); - } - - public Bundle getGenomDeConsent(String personIdentifierValue, Date requestDate) { - return currentConsentForPersonAndTemplate(personIdentifierValue, - ConsentDomain.Modelvorhaben64e, requestDate); - } - protected static Parameters buildRequestParameterCurrentPolicyStatesForPerson( GIcsConfigProperties gIcsConfigProperties, String personIdentifierValue, Date requestDate, String targetDomain) { @@ -264,9 +256,9 @@ public class GicsConsentService implements ICheckConsent { return TtpConsentStatus.FAILED_TO_ASK; } if (isConsented.booleanValue()) { - return TtpConsentStatus.CONSENTED; + return TtpConsentStatus.BROAD_CONSENT_GIVEN; } else { - return TtpConsentStatus.CONSENT_MISSING_OR_REJECTED; + return TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED; } } else if (response instanceof OperationOutcome outcome) { log.error("failed to get consent status from ttp. probably configuration error. " @@ -278,4 +270,84 @@ public class GicsConsentService implements ICheckConsent { } return TtpConsentStatus.FAILED_TO_ASK; } + + /** + * @param consentBundle consent resource + * @param requestDate date which must be within validation period of provision + * @return type of provision, will be {@link ConsentProvisionType#NULL} if none is found. + */ + public ConsentProvisionType getProvisionTypeByPolicyCode(Bundle consentBundle, + Date requestDate, ConsentDomain consentDomain) { + String code; + String system; + if (ConsentDomain.BroadConsent == consentDomain) { + code = gIcsConfigProperties.getBroadConsentPolicyCode(); + system = gIcsConfigProperties.getBroadConsentPolicySystem(); + } else if (ConsentDomain.Modelvorhaben64e == consentDomain) { + code = gIcsConfigProperties.getGenomeDePolicyCode(); + system = gIcsConfigProperties.getGenomeDePolicySystem(); + } else { + throw new NotImplementedException("unknown consent domain " + consentDomain.name()); + } + + Optional provisionTypeByPolicyCode = getProvisionTypeByPolicyCode( + consentBundle, code, + system, requestDate); + return provisionTypeByPolicyCode.orElse(ConsentProvisionType.NULL); + } + + /** + * @param consentBundle consent resource + * @param policyAndProvisionCode policyRule and provision code value + * @param policyAndProvisionSystem policyRule and provision system value + * @param requestDate date which must be within validation period of provision + * @return type of provision, will be {@link ConsentProvisionType#NULL} if none is found. + */ + public Optional getProvisionTypeByPolicyCode(Bundle consentBundle, + String policyAndProvisionCode, String policyAndProvisionSystem, Date requestDate) { + return consentBundle.getEntry().stream().filter(entry -> { + if (entry.getResource().getResourceType() != ResourceType.Consent) { + // no consent in bundle + return false; + } + + Consent consent = (Consent) entry.getResource(); + + // consent ist active and its policy rule must fits search criteria + return consent.getStatus() == ConsentState.ACTIVE && checkCoding( + policyAndProvisionCode, policyAndProvisionSystem, + consent.getPolicyRule().getCodingFirstRep()) && isIsRequestDateInRange(requestDate, + consent.getProvision().getPeriod()); + + }).map(consentWithTargetPolicy -> { + ProvisionComponent provision = ((Consent) consentWithTargetPolicy.getResource()).getProvision(); + var provisionComponentByCode = provision.getProvision().stream().filter(prov -> + + checkCoding(policyAndProvisionCode, policyAndProvisionSystem, + prov.getCodeFirstRep().getCodingFirstRep()) && isIsRequestDateInRange( + requestDate, prov.getPeriod()) + + ).findFirst(); + + if (provisionComponentByCode.isPresent()) { + // actual provision we search for + return provisionComponentByCode.get().getType(); + } + // no fitting nested provision found - fall back to wrapping provision with default value + return provision.getType(); + }).findFirst().or(() -> Optional.of(ConsentProvisionType.NULL)); + } + + protected static boolean checkCoding(String researchAllowedPolicyOid, + String researchAllowedPolicySystem, Coding coding) { + + return coding.getSystem().equals(researchAllowedPolicySystem) && coding.getCode() + .equals(researchAllowedPolicyOid); + } + + protected static boolean isIsRequestDateInRange(Date requestdate, Period provPeriod) { + var isRequestDateAfterOrEqualStart = provPeriod.getStart().compareTo(requestdate); + var isRequestDateBeforeOrEqualEnd = provPeriod.getEnd().compareTo(requestdate); + return isRequestDateAfterOrEqualStart <= 0 && isRequestDateBeforeOrEqualEnd >= 0; + } } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/ICheckConsent.java b/src/main/java/dev/dnpm/etl/processor/consent/ICheckConsent.java index 01c4483..7b40144 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/ICheckConsent.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/ICheckConsent.java @@ -1,8 +1,61 @@ package dev.dnpm.etl.processor.consent; +import java.util.Date; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Consent.ConsentProvisionType; + public interface ICheckConsent { - TtpConsentStatus getTtpConsentStatus(String personIdentifierValue); + /** + * Get broad consent status for a patient identifier + * + * @param personIdentifierValue patient identifier used for consent data + * @return status of broad consent + * @apiNote cannot not differ between not asked and rejected + */ + TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue); + /** + * Get broad consent policies with respect to a request date + * + * @param personIdentifierValue patient identifier used for consent data + * @param requestDate target date until consent data should be considered + * @return consent policies as bundle;

if empty patient has not been asked, yet.

+ */ + default Bundle getBroadConsent(String personIdentifierValue, Date requestDate) { + return currentConsentForPersonAndTemplate(personIdentifierValue, ConsentDomain.BroadConsent, + requestDate); + } + + /** + * Get 'GenomDe Modelvorhaben §64e' consent policies with respect to a request date + * + * @param personIdentifierValue patient identifier used for consent data + * @param requestDate target date until consent data should be considered + * @return consent policies as bundle;

if empty patient has not been asked, yet.

+ */ + default Bundle getGenomDeConsent(String personIdentifierValue, Date requestDate) { + return currentConsentForPersonAndTemplate(personIdentifierValue, ConsentDomain.BroadConsent, + requestDate); + } + + /** + * Get consent policies with respect to a request date + * + * @param personIdentifierValue patient identifier used for consent data + * @param targetConsentDomain domain which should be used to request consent + * @param requestDate target date until consent data should be considered + * @return consent policies as bundle;

if empty patient has not been asked, yet.

+ */ + Bundle currentConsentForPersonAndTemplate(String personIdentifierValue, + ConsentDomain targetConsentDomain, Date requestDate); + /** + * + * @param consentBundle consent resource + * @param requestDate date which must be within validation period of provision + * @return type of provision, will be {@link ConsentProvisionType#NULL} if none is found. + */ + ConsentProvisionType getProvisionTypeByPolicyCode(Bundle consentBundle, + Date requestDate, ConsentDomain consentDomain); } diff --git a/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java b/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java index f98f4f5..2af1683 100644 --- a/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java +++ b/src/main/java/dev/dnpm/etl/processor/consent/TtpConsentStatus.java @@ -4,17 +4,35 @@ public enum TtpConsentStatus { /** * Valid consent found */ - CONSENTED, - - CONSENT_MISSING_OR_REJECTED, - + BROAD_CONSENT_GIVEN, /** - * Due technical problems consent status is unknown + * Missing or rejected...actually unknown */ - FAILED_TO_ASK, + BROAD_CONSENT_MISSING_OR_REJECTED, + /** + * No Broad consent policy found + */ + BROAD_CONSENT_MISSING, + /** + * Research policy has been rejected + */ + BROAD_CONSENT_REJECTED, + GENOM_DE_CONSENT_SEQUENCING_PERMIT, + /** + * No GenomDE consent policy found + */ + GENOM_DE_CONSENT_MISSING, + /** + * GenomDE consent policy found, but has been rejected + */ + GENOM_DE_SEQUENCING_REJECTED, /** * Consent status is validate via file property 'consent.status' */ - UNKNOWN_CHECK_FILE + UNKNOWN_CHECK_FILE, + /** + * Due technical problems consent status is unknown + */ + FAILED_TO_ASK } 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 7499dde..5010cfb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -87,17 +87,27 @@ data class GIcsConfigProperties( /** * Domain of Modelvorhaben 64e consent resources **/ - val gnomDeConsentDomainName: String = "GenomDE_MV", + val genomDeConsentDomainName: String = "GenomDE_MV", /** * Value to expect in case of positiv consent */ - val policyCode: String = "2.16.840.1.113883.3.1937.777.24.5.3.6", + val broadConsentPolicyCode: String = "2.16.840.1.113883.3.1937.777.24.5.3.6", /** * Consent Policy which should be used for consent check */ - val policySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3" + val broadConsentPolicySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3", + + /** + * Value to expect in case of positiv consent + */ + val genomeDePolicyCode: String = "sequencing", + + /** + * Consent Policy which should be used for consent check + */ + val genomeDePolicySystem: String = "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV" ) { companion object { const val NAME = "app.consent.gics" diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt index 0bc8965..4905ab9 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt @@ -63,16 +63,16 @@ class MtbFileRestController( } private fun checkConsentStatus(mtbFile: MtbFile): Pair { - var ttpConsentStatus = iCheckConsent.getTtpConsentStatus(mtbFile.patient.id) + var ttpConsentStatus = iCheckConsent.getTtpBroadConsentStatus(mtbFile.patient.id) val isConsentOK = (ttpConsentStatus.equals(TtpConsentStatus.UNKNOWN_CHECK_FILE) && mtbFile.consent.status == Consent.Status.ACTIVE) || ttpConsentStatus.equals( - TtpConsentStatus.CONSENTED + TtpConsentStatus.BROAD_CONSENT_GIVEN ) if (ttpConsentStatus.equals(TtpConsentStatus.UNKNOWN_CHECK_FILE) && mtbFile.consent.status == Consent.Status.REJECTED) { // in case ttp check is disabled - we propagate rejected status anyway - ttpConsentStatus = TtpConsentStatus.CONSENT_MISSING_OR_REJECTED + ttpConsentStatus = TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED } return Pair(ttpConsentStatus, isConsentOK) } 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 d89bb86..3bb010c 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -23,7 +23,8 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.MtbFile import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.config.AppConfigProperties -import dev.dnpm.etl.processor.consent.GicsConsentService +import dev.dnpm.etl.processor.consent.ConsentDomain +import dev.dnpm.etl.processor.consent.ICheckConsent import dev.dnpm.etl.processor.consent.TtpConsentStatus import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Request @@ -44,9 +45,15 @@ import org.apache.commons.codec.digest.DigestUtils import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Consent +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service +import java.io.IOException +import java.lang.RuntimeException +import java.time.Clock import java.time.Instant +import java.time.ZoneId import java.util.* @Service @@ -58,9 +65,10 @@ class RequestProcessor( private val objectMapper: ObjectMapper, private val applicationEventPublisher: ApplicationEventPublisher, private val appConfigProperties: AppConfigProperties, - private val gicsConsentService: GicsConsentService? + private val consentService: ICheckConsent? ) { + private var logger: Logger = LoggerFactory.getLogger("RequestProcessor") fun processMtbFile(mtbFile: MtbFile) { processMtbFile(mtbFile, randomRequestId()) } @@ -77,21 +85,79 @@ class RequestProcessor( processMtbFile(mtbFile, randomRequestId()) } - fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { - val pid = PatientId(mtbFile.patient.id) + /** + * In case an instance of {@link ICheckConsent} is active, consent will be embedded and checked. + * + * Logik: + * * true IF consent check is disabled. + * * true IF broad consent (BC) has been given. + * * true BC has been asked AND declined but genomDe consent has been consented. + * * ELSE false is returned. + * + * @param mtbFile File v2 (will be enriched with consent data) + * @return true if consent is given + * + */ + fun consentGatedCheck(mtbFile: Mtb): Boolean { + if (consentService == null) { + // consent check seems to be disabled + return true + } - addConsentToMtb(mtbFile) - mtbFile pseudonymizeWith pseudonymizeService - mtbFile anonymizeContentWith pseudonymizeService - val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile)) - saveAndSend(request, pid) + initMetaDataAtMtbFile(mtbFile) + + val personIdentifierValue = extractPatientIdentifier(mtbFile) + val requestDate = Date.from(Instant.now(Clock.system(ZoneId.of("ECT")))) + + // 1. Broad consent Entry exists? + // 1.1. -> yes and research consent is given -> send mtb file + // 1.2. -> no -> return status error - consent has not been asked + // 2. -> Broad consent found but rejected -> is GenomDe consent provision 'sequencing' given? + // 2.1 -> yes -> send mtb file + // 2.2 -> no -> warn/info no consent given + + /* + * broad consent + */ + val broadConsent = consentService.getBroadConsent(personIdentifierValue, requestDate) + val broadConsentHasBeenAsked = !broadConsent.entry.isEmpty() + + // fast exit - if patient has not been asked, we can skip and exit + if (!broadConsentHasBeenAsked) return false + + val genomeDeConsent = consentService.getGenomDeConsent( + personIdentifierValue, requestDate + ) + + addGenomeDbProvisions(mtbFile, genomeDeConsent) + embedBroadConsentResources(mtbFile, broadConsent) + + val broadConsentStatus = consentService.getProvisionTypeByPolicyCode( + broadConsent, + requestDate, + ConsentDomain.BroadConsent + ) + + val genomDeSequencingStatus = consentService.getProvisionTypeByPolicyCode( + genomeDeConsent, requestDate, + ConsentDomain.Modelvorhaben64e + ) + + if (Consent.ConsentProvisionType.PERMIT == broadConsentStatus) return true + if (Consent.ConsentProvisionType.DENY == broadConsentStatus && Consent.ConsentProvisionType.PERMIT == genomDeSequencingStatus) return true + if (Consent.ConsentProvisionType.NULL == broadConsentStatus) { + // bc not asked + return false + } + + return false } - fun addConsentToMtb(mtbFile: Mtb) { - if (gicsConsentService == null) return + + private fun initMetaDataAtMtbFile(mtbFile: Mtb) { // init metadata if necessary if (mtbFile.metadata == null) { - val mvhMetadata = MvhMetadata.builder().build(); + val mvhMetadata = MvhMetadata.builder().build() mtbFile.metadata = mvhMetadata if (mtbFile.metadata.researchConsents == null) { mtbFile.metadata.researchConsents = mutableListOf() @@ -101,21 +167,29 @@ class RequestProcessor( mtbFile.metadata.modelProjectConsent.provisions = mutableListOf() } } - - // fixme Date should be extracted from mtbFile - val consentGnomeDe = - gicsConsentService.getGenomDeConsent(mtbFile.patient.id, Date.from(Instant.now())) - addGenomeDbProvisions(mtbFile, consentGnomeDe) - - // fixme Date should be extracted from mtbFile - val broadConsent = - gicsConsentService.getBroadConsent(mtbFile.patient.id, Date.from(Instant.now())) - embedBroadConsentResources(mtbFile, broadConsent) } + fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { + val pid = PatientId(extractPatientIdentifier(mtbFile)) + + if (consentGatedCheck(mtbFile)) { + mtbFile pseudonymizeWith pseudonymizeService + mtbFile anonymizeContentWith pseudonymizeService + val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile)) + saveAndSend(request, pid) + } else { + logger.warn("consent check failed file will not be processed further!") + applicationEventPublisher.publishEvent( + ResponseEvent( + requestId, Instant.now(), RequestStatus.NO_CONSENT + ) + ) + } + } + + fun embedBroadConsentResources( - mtbFile: Mtb, - broadConsent: Bundle + mtbFile: Mtb, broadConsent: Bundle ) { broadConsent.entry.forEach { it -> mtbFile.metadata.researchConsents.add(mapOf(it.resource.id to it as IBaseResource)) @@ -123,36 +197,36 @@ class RequestProcessor( } fun addGenomeDbProvisions( - mtbFile: Mtb, - consentGnomeDe: Bundle + mtbFile: Mtb, consentGnomeDe: Bundle ) { consentGnomeDe.entry.forEach { it -> { - val consent = it.resource as Consent - val provisionComponent = consent.provision.provision.firstOrNull() + val consentFhirResource = it.resource as Consent + + // we expect only one provision in collection, therefore get first or none + val provisionComponent = consentFhirResource.provision.provision.firstOrNull() val provisionCode = provisionComponent?.code?.firstOrNull()?.coding?.firstOrNull()?.code - var isValidCode = true + if (provisionCode != null) { - var modelProjectConsentPurpose: ModelProjectConsentPurpose = - ModelProjectConsentPurpose.SEQUENCING - if (provisionCode == "Teilnahme") { - modelProjectConsentPurpose = ModelProjectConsentPurpose.SEQUENCING - } else if (provisionCode == "Fallidentifizierung") { - modelProjectConsentPurpose = ModelProjectConsentPurpose.CASE_IDENTIFICATION - } else if (provisionCode == "Rekontaktierung") { - modelProjectConsentPurpose = ModelProjectConsentPurpose.REIDENTIFICATION - } else { - isValidCode = false + try { + val modelProjectConsentPurpose: ModelProjectConsentPurpose = + ModelProjectConsentPurpose.valueOf(provisionCode) + mtbFile.metadata.modelProjectConsent.provisions.add( + Provision.builder().type( + ConsentProvision.forValue(provisionComponent.type.name) + ).date(provisionComponent.period.start).purpose( + modelProjectConsentPurpose + ).build() + ) + } catch (ioe: IOException) { + logger.error( + "provision code '$provisionCode' is unknown and cannot be mapped.", + ioe.toString() + ) } - if (isValidCode) mtbFile.metadata.modelProjectConsent.provisions.add( - Provision.builder().type( - ConsentProvision.forValue(provisionComponent.type.name) - ).date(provisionComponent.period.start).purpose( - modelProjectConsentPurpose - ).build() - ) } + } } } @@ -172,9 +246,7 @@ class RequestProcessor( if (appConfigProperties.duplicationDetection && isDuplication(request)) { applicationEventPublisher.publishEvent( ResponseEvent( - request.requestId, - Instant.now(), - RequestStatus.DUPLICATION + request.requestId, Instant.now(), RequestStatus.DUPLICATION ) ) return @@ -206,9 +278,7 @@ class RequestProcessor( val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym) - return null != lastMtbFileRequestForPatient - && !isLastRequestDeletion - && lastMtbFileRequestForPatient.fingerprint == fingerprint( + return null != lastMtbFileRequestForPatient && !isLastRequestDeletion && lastMtbFileRequestForPatient.fingerprint == fingerprint( pseudonymizedMtbFileRequest ) } @@ -222,9 +292,12 @@ class RequestProcessor( val patientPseudonym = pseudonymizeService.patientPseudonym(patientId) val requestStatus: RequestStatus = when (isConsented) { - TtpConsentStatus.CONSENT_MISSING_OR_REJECTED -> RequestStatus.NO_CONSENT + TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED, TtpConsentStatus.BROAD_CONSENT_MISSING, TtpConsentStatus.BROAD_CONSENT_REJECTED -> RequestStatus.NO_CONSENT TtpConsentStatus.FAILED_TO_ASK -> RequestStatus.ERROR - TtpConsentStatus.CONSENTED, TtpConsentStatus.UNKNOWN_CHECK_FILE -> RequestStatus.UNKNOWN + TtpConsentStatus.BROAD_CONSENT_GIVEN, TtpConsentStatus.UNKNOWN_CHECK_FILE -> RequestStatus.UNKNOWN + TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT, TtpConsentStatus.GENOM_DE_CONSENT_MISSING, TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED -> { + throw RuntimeException("processDelete should never deal with '" + isConsented.name + "' consent status. This is a bug and need to be fixed!") + } } requestService.save( @@ -242,10 +315,7 @@ class RequestProcessor( applicationEventPublisher.publishEvent( ResponseEvent( - requestId, - Instant.now(), - responseStatus.status, - when (responseStatus.status) { + requestId, Instant.now(), responseStatus.status, when (responseStatus.status) { RequestStatus.WARNING, RequestStatus.ERROR -> Optional.of(responseStatus.body) else -> Optional.empty() } @@ -276,10 +346,10 @@ class RequestProcessor( private fun fingerprint(s: String): Fingerprint { return Fingerprint( - Base32().encodeAsString(DigestUtils.sha256(s)) - .replace("=", "") - .lowercase() + Base32().encodeAsString(DigestUtils.sha256(s)).replace("=", "").lowercase() ) } } + +private fun extractPatientIdentifier(mtbFile: Mtb): String = mtbFile.patient.id diff --git a/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java b/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java index 445d930..bb26312 100644 --- a/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java +++ b/src/test/java/dev/dnpm/etl/processor/consent/GicsConsentServiceTest.java @@ -4,13 +4,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import ca.uhn.fhir.context.FhirContext; import com.fasterxml.jackson.databind.ObjectMapper; import dev.dnpm.etl.processor.config.AppConfiguration; import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.GIcsConfigProperties; +import java.io.IOException; +import java.io.InputStream; import java.time.Instant; +import java.time.OffsetDateTime; import java.util.Date; import org.hl7.fhir.r4.model.BooleanType; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Consent.ConsentProvisionType; import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.OperationOutcome; import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity; @@ -21,8 +27,11 @@ import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.StringType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.client.RestClientTest; +import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; @@ -58,7 +67,7 @@ public class GicsConsentServiceTest { } @Test - void getTtpConsentStatus() { + void getTtpBroadConsentStatus() { final Parameters responseConsented = new Parameters().addParameter( new ParametersParameterComponent().setName("consented") .setValue(new BooleanType().setValue(true))); @@ -70,8 +79,8 @@ public class GicsConsentServiceTest { .encodeResourceToString(responseConsented), MediaType.APPLICATION_JSON)); - var consentStatus = gicsConsentService.getTtpConsentStatus("123456"); - assertThat(consentStatus).isEqualTo(TtpConsentStatus.CONSENTED); + var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.BROAD_CONSENT_GIVEN); } @Test @@ -87,8 +96,8 @@ public class GicsConsentServiceTest { .encodeResourceToString(responseRevoced), MediaType.APPLICATION_JSON)); - var consentStatus = gicsConsentService.getTtpConsentStatus("123456"); - assertThat(consentStatus).isEqualTo(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED); + var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456"); + assertThat(consentStatus).isEqualTo(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED); } @@ -105,7 +114,7 @@ public class GicsConsentServiceTest { .encodeResourceToString(responseErrorOutcome), MediaType.APPLICATION_JSON)); - var consentStatus = gicsConsentService.getTtpConsentStatus("123456"); + var consentStatus = gicsConsentService.getTtpBroadConsentStatus("123456"); assertThat(consentStatus).isEqualTo(TtpConsentStatus.FAILED_TO_ASK); } @@ -114,11 +123,50 @@ public class GicsConsentServiceTest { String pid = "12345678"; var result = GicsConsentService.buildRequestParameterCurrentPolicyStatesForPerson(gIcsConfigProperties, - pid, Date.from(Instant.now()),gIcsConfigProperties.getGnomDeConsentDomainName()); + pid, Date.from(Instant.now()),gIcsConfigProperties.getGenomDeConsentDomainName()); assertThat(result.getParameter().size()).as("should contain 3 parameter resources").isEqualTo(3); - assertThat(((StringType)result.getParameter("domain").getValue()).getValue()).isEqualTo(gIcsConfigProperties.getGnomDeConsentDomainName()); + assertThat(((StringType)result.getParameter("domain").getValue()).getValue()).isEqualTo(gIcsConfigProperties.getGenomDeConsentDomainName()); assertThat(((Identifier)result.getParameter("personIdentifier").getValue()).getValue()).isEqualTo(pid); } + + + @ParameterizedTest + @CsvSource({ + "2.16.840.1.113883.3.1937.777.24.5.3.8,urn:oid:2.16.840.1.113883.3.1937.777.24.5.3,2025-07-23T00:00:00+02:00,PERMIT,expect permit", + "2.16.840.1.113883.3.1937.777.24.5.3.8,urn:oid:2.16.840.1.113883.3.1937.777.24.5.3,2025-06-23T00:00:00+02:00,PERMIT,expect permit date is exactly on start", + "2.16.840.1.113883.3.1937.777.24.5.3.8,urn:oid:2.16.840.1.113883.3.1937.777.24.5.3,2055-06-23T00:00:00+02:00,PERMIT,expect permit date is exactly on end", + "2.16.840.1.113883.3.1937.777.24.5.3.8,urn:oid:2.16.840.1.113883.3.1937.777.24.5.3,2021-06-23T00:00:00+02:00,NULL,date is before start", + "2.16.840.1.113883.3.1937.777.24.5.3.8,urn:oid:2.16.840.1.113883.3.1937.777.24.5.3,2060-06-23T00:00:00+02:00,NULL,date is after end", + "2.16.840.1.113883.3.1937.777.24.5.3.8,XXXX,2025-07-23T00:00:00+02:00,NULL,system not found - therefore expect NULL", + "2.16.840.1.113883.3.1937.777.24.5.3.27,urn:oid:2.16.840.1.113883.3.1937.777.24.5.3,2025-07-23T00:00:00+02:00,DENY,provision is denied"}) + void getProvisionTypeByPolicyCode(String code, String system, String timeStamp, String expected, + String desc) { + + var testData = getDummyBroadConsent(); + + Date requestDate = Date.from(OffsetDateTime.parse(timeStamp).toInstant()); + + var result = gicsConsentService.getProvisionTypeByPolicyCode(testData, code, system, requestDate); + assertThat(result).isNotNull(); + assertThat(result).isNotEmpty(); + + assertThat(result.get()).as(desc).isEqualTo(ConsentProvisionType.valueOf(expected)); + } + + private Bundle getDummyBroadConsent() { + + InputStream bundle; + try { + bundle = new ClassPathResource( + "fake_broadConsent_gics_response_permit.json").getInputStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return FhirContext.forR4().newJsonParser().parseResource(Bundle.class, bundle); + + } + } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt index e375148..df5ed8d 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -101,7 +101,7 @@ class MtbFileRestControllerTest { verify(requestProcessor, times(1)).processDeletion( anyValueClass(), - org.mockito.kotlin.eq(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED) + org.mockito.kotlin.eq(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED) ) } @@ -149,7 +149,7 @@ class MtbFileRestControllerTest { @ValueSource(strings = ["ACTIVE", "REJECTED"]) fun shouldProcessPostRequest(status: String) { - whenever(gicsConsentService.getTtpConsentStatus(any())).thenReturn(TtpConsentStatus.CONSENTED) + whenever(gicsConsentService.getTtpBroadConsentStatus(any())).thenReturn(TtpConsentStatus.BROAD_CONSENT_GIVEN) mockMvc.post("/mtbfile") { content = @@ -169,7 +169,7 @@ class MtbFileRestControllerTest { @ValueSource(strings = ["ACTIVE", "REJECTED"]) fun shouldProcessPostRequestWithRejectedConsent(status: String) { - whenever(gicsConsentService.getTtpConsentStatus(any())).thenReturn(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED) + whenever(gicsConsentService.getTtpBroadConsentStatus(any())).thenReturn(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED) mockMvc.post("/mtbfile") { content = @@ -184,7 +184,7 @@ class MtbFileRestControllerTest { // consent status from ttp should override file consent value verify(requestProcessor, times(1)).processDeletion( anyValueClass(), - org.mockito.kotlin.eq(TtpConsentStatus.CONSENT_MISSING_OR_REJECTED) + org.mockito.kotlin.eq(TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED) ) } @@ -201,7 +201,7 @@ class MtbFileRestControllerTest { anyValueClass(), org.mockito.kotlin.eq(TtpConsentStatus.UNKNOWN_CHECK_FILE) ) - verify(gicsConsentService, times(0)).getTtpConsentStatus(any()) + verify(gicsConsentService, times(0)).getTtpBroadConsentStatus(any()) } } @@ -253,7 +253,7 @@ class MtbFileRestControllerTest { verify(requestProcessor, times(1)).processDeletion( anyValueClass(), org.mockito.kotlin.eq( - TtpConsentStatus.CONSENT_MISSING_OR_REJECTED + TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED ) ) } @@ -283,11 +283,12 @@ class MtbFileRestControllerTest { @BeforeEach fun setup( - @Mock requestProcessor: RequestProcessor + @Mock requestProcessor: RequestProcessor, + @Mock gicsConsentService: GicsConsentService ) { this.requestProcessor = requestProcessor val controller = MtbFileRestController(requestProcessor, - ConsentCheckFileBased() + gicsConsentService ) this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() } diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/TransformationServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/TransformationServiceTest.kt index 32c443c..a0b9a8c 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/TransformationServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/TransformationServiceTest.kt @@ -24,12 +24,15 @@ import de.ukw.ccc.bwhc.dto.Diagnosis import de.ukw.ccc.bwhc.dto.Icd10 import de.ukw.ccc.bwhc.dto.MtbFile import dev.dnpm.etl.processor.config.JacksonConfig +import dev.pcvolkmer.mv64e.mtb.ConsentProvision import dev.pcvolkmer.mv64e.mtb.ModelProjectConsent +import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import dev.pcvolkmer.mv64e.mtb.Mtb import dev.pcvolkmer.mv64e.mtb.MvhMetadata +import dev.pcvolkmer.mv64e.mtb.Provision import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding @@ -124,50 +127,28 @@ class TransformationServiceTest { @Test fun shouldTransformConsent() { - val mvhMetadata = MvhMetadata.builder().transferTan("transfertan12345").build(); + val mvhMetadata = MvhMetadata.builder().transferTan("transfertan12345").build() assertThat(mvhMetadata).isNotNull mvhMetadata.modelProjectConsent = - ModelProjectConsent.builder().date(Date.from(Instant.now())).version("1").build() - val consent1 = org.hl7.fhir.r4.model.Consent() - consent1.id = "consent 1 id" - consent1.patient.reference = "Patient/1234-pat1" - - consent1.provision.setType(org.hl7.fhir.r4.model.Consent.ConsentProvisionType.fromCode("deny")) - consent1.provision.period.start = Date.from(Instant.parse("2025-06-23T00:00:00.00Z")) - consent1.provision.period.end = Date.from(Instant.parse("3000-01-01T00:00:00.00Z")) - - - val addProvision1 = consent1.provision.addProvision() - addProvision1.setType(org.hl7.fhir.r4.model.Consent.ConsentProvisionType.fromCode("permit")) - addProvision1.period.start = Date.from(Instant.parse("2025-06-23T00:00:00.00Z")) - addProvision1.period.end = Date.from(Instant.parse("3000-01-01T00:00:00.00Z")) - addProvision1.code.addLast( - CodeableConcept( - Coding( - "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV", - "Teilnahme", - "Teilnahme am Modellvorhaben und Einwilligung zur Genomsequenzierung" + ModelProjectConsent.builder().date(Date.from(Instant.parse("2025-06-23T00:00:00.00Z"))) + .version("1").provisions( + listOf( + Provision.builder().type(ConsentProvision.PERMIT) + .purpose(ModelProjectConsentPurpose.SEQUENCING) + .date(Date.from(Instant.parse("2025-06-23T00:00:00.00Z"))).build(), + Provision.builder().type(ConsentProvision.PERMIT) + .purpose(ModelProjectConsentPurpose.REIDENTIFICATION) + .date(Date.from(Instant.parse("2025-06-23T00:00:00.00Z"))).build(), + Provision.builder().type(ConsentProvision.DENY) + .purpose(ModelProjectConsentPurpose.CASE_IDENTIFICATION) + .date(Date.from(Instant.parse("2025-06-23T00:00:00.00Z"))).build() ) - ) - ) + ).build() + val consent = getDummyConsent() - val addProvision2 = consent1.provision.addProvision() - addProvision2.setType(org.hl7.fhir.r4.model.Consent.ConsentProvisionType.fromCode("deny")) - addProvision2.period.start = Date.from(Instant.parse("2025-06-23T00:00:00.00Z")) - addProvision2.period.end = Date.from(Instant.parse("3000-01-01T00:00:00.00Z")) - addProvision2.code.addLast( - CodeableConcept( - Coding( - "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV", - "Rekontaktierung", - "Re-Identifizierung meiner Daten über die Vertrauensstelle beim Robert Koch-Institut und in die erneute Kontaktaufnahme durch meine behandelnde Ärztin oder meinen behandelnden Arzt" - ) - ) - ) - - mvhMetadata.researchConsents = mutableListOf() - mvhMetadata.researchConsents.add(mapOf(consent1.id to consent1 as IBaseResource)) + mvhMetadata.researchConsents = mutableListOf() + mvhMetadata.researchConsents.add(mapOf(consent.id to consent as IBaseResource)) val mtbFile = Mtb.builder().metadata(mvhMetadata).build() @@ -175,4 +156,49 @@ class TransformationServiceTest { assertThat(transformed.metadata.modelProjectConsent.date).isNotNull } +} + +fun getDummyConsent(): org.hl7.fhir.r4.model.Consent { + val modelVorhabenConsent = org.hl7.fhir.r4.model.Consent() + modelVorhabenConsent.id = "consent 1 id" + modelVorhabenConsent.patient.reference = "Patient/1234-pat1" + + modelVorhabenConsent.provision.setType( + org.hl7.fhir.r4.model.Consent.ConsentProvisionType.fromCode( + "deny" + ) + ) + modelVorhabenConsent.provision.period.start = + Date.from(Instant.parse("2025-06-23T00:00:00.00Z")) + modelVorhabenConsent.provision.period.end = Date.from(Instant.parse("3000-01-01T00:00:00.00Z")) + + + val addProvision1 = modelVorhabenConsent.provision.addProvision() + addProvision1.setType(org.hl7.fhir.r4.model.Consent.ConsentProvisionType.fromCode("permit")) + addProvision1.period.start = Date.from(Instant.parse("2025-06-23T00:00:00.00Z")) + addProvision1.period.end = Date.from(Instant.parse("3000-01-01T00:00:00.00Z")) + addProvision1.code.addLast( + CodeableConcept( + Coding( + "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV", + "Teilnahme", + "Teilnahme am Modellvorhaben und Einwilligung zur Genomsequenzierung" + ) + ) + ) + + val addProvision2 = modelVorhabenConsent.provision.addProvision() + addProvision2.setType(org.hl7.fhir.r4.model.Consent.ConsentProvisionType.fromCode("deny")) + addProvision2.period.start = Date.from(Instant.parse("2025-06-23T00:00:00.00Z")) + addProvision2.period.end = Date.from(Instant.parse("3000-01-01T00:00:00.00Z")) + addProvision2.code.addLast( + CodeableConcept( + Coding( + "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV", + "Rekontaktierung", + "Re-Identifizierung meiner Daten über die Vertrauensstelle beim Robert Koch-Institut und in die erneute Kontaktaufnahme durch meine behandelnde Ärztin oder meinen behandelnden Arzt" + ) + ) + ) + return modelVorhabenConsent } \ No newline at end of file diff --git a/src/test/resources/fake_broadConsent_gics_response_permit.json b/src/test/resources/fake_broadConsent_gics_response_permit.json index 639b4ea..b38c459 100644 --- a/src/test/resources/fake_broadConsent_gics_response_permit.json +++ b/src/test/resources/fake_broadConsent_gics_response_permit.json @@ -606,7 +606,7 @@ }, "provision": [ { - "type": "permit", + "type": "deny", "period": { "start": "2025-06-23T00:00:00+02:00", "end": "2055-06-23T00:00:00+02:00"