Skip to content

Commit 260f505

Browse files
committed
Implement Conflict Handling in Consent
1 parent 9791ca3 commit 260f505

33 files changed

+2337
-721
lines changed

src/main/java/de/medizininformatikinitiative/torch/config/AppConfig.java

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
88
import de.medizininformatikinitiative.torch.consent.ConsentCodeMapper;
9-
import de.medizininformatikinitiative.torch.consent.ConsentFetcher;
109
import de.medizininformatikinitiative.torch.consent.ConsentHandler;
1110
import de.medizininformatikinitiative.torch.consent.ConsentValidator;
1211
import de.medizininformatikinitiative.torch.cql.CqlClient;
@@ -17,24 +16,8 @@
1716
import de.medizininformatikinitiative.torch.model.mapping.DseMappingTreeBase;
1817
import de.medizininformatikinitiative.torch.model.mapping.DseTreeRoot;
1918
import de.medizininformatikinitiative.torch.rest.CapabilityStatementController;
20-
import de.medizininformatikinitiative.torch.service.BatchCopierRedacter;
21-
import de.medizininformatikinitiative.torch.service.CascadingDelete;
22-
import de.medizininformatikinitiative.torch.service.CrtdlProcessingService;
23-
import de.medizininformatikinitiative.torch.service.CrtdlValidatorService;
24-
import de.medizininformatikinitiative.torch.service.DataStore;
25-
import de.medizininformatikinitiative.torch.service.DirectResourceLoader;
26-
import de.medizininformatikinitiative.torch.service.FilterService;
27-
import de.medizininformatikinitiative.torch.service.PatientBatchToCoreBundleWriter;
28-
import de.medizininformatikinitiative.torch.service.ReferenceBundleLoader;
29-
import de.medizininformatikinitiative.torch.service.ReferenceResolver;
30-
import de.medizininformatikinitiative.torch.service.StandardAttributeGenerator;
31-
import de.medizininformatikinitiative.torch.util.ElementCopier;
32-
import de.medizininformatikinitiative.torch.util.ProfileMustHaveChecker;
33-
import de.medizininformatikinitiative.torch.util.Redaction;
34-
import de.medizininformatikinitiative.torch.util.ReferenceExtractor;
35-
import de.medizininformatikinitiative.torch.util.ReferenceHandler;
36-
import de.medizininformatikinitiative.torch.util.ResourceReader;
37-
import de.medizininformatikinitiative.torch.util.ResultFileManager;
19+
import de.medizininformatikinitiative.torch.service.*;
20+
import de.medizininformatikinitiative.torch.util.*;
3821
import de.numcodex.sq2cql.Translator;
3922
import de.numcodex.sq2cql.model.Mapping;
4023
import de.numcodex.sq2cql.model.MappingContext;
@@ -315,16 +298,6 @@ public Redaction redaction(StructureDefinitionHandler cds) {
315298
return new Redaction(cds);
316299
}
317300

318-
@Bean
319-
ConsentHandler handler(DataStore dataStore, ConsentFetcher consentFetcherBuilder) {
320-
return new ConsentHandler(dataStore, consentFetcherBuilder);
321-
}
322-
323-
@Bean
324-
ConsentFetcher consentFetcherBuilder(DataStore dataStore, ConsentCodeMapper mapper, FhirContext ctx) {
325-
return new ConsentFetcher(dataStore, mapper, ctx);
326-
}
327-
328301
@Bean
329302
ConsentValidator consentValidator(FhirContext ctx, ObjectMapper mapper, String consentToProfileFilePath) throws IOException {
330303
JsonNode resourcetoField = mapper.readTree(new File(consentToProfileFilePath).getAbsoluteFile());
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package de.medizininformatikinitiative.torch.consent;
2+
3+
import de.medizininformatikinitiative.torch.exceptions.PatientIdNotFoundException;
4+
import de.medizininformatikinitiative.torch.model.consent.ConsentProvisions;
5+
import de.medizininformatikinitiative.torch.model.fhir.Query;
6+
import de.medizininformatikinitiative.torch.model.management.PatientBatch;
7+
import de.medizininformatikinitiative.torch.service.DataStore;
8+
import de.medizininformatikinitiative.torch.util.ResourceUtils;
9+
import org.hl7.fhir.r4.model.Encounter;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
import org.springframework.stereotype.Component;
13+
import reactor.core.publisher.Mono;
14+
import reactor.util.function.Tuple2;
15+
import reactor.util.function.Tuples;
16+
17+
import java.util.Collection;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.stream.Collectors;
21+
22+
import static de.medizininformatikinitiative.torch.model.fhir.QueryParams.stringValue;
23+
import static java.util.Objects.requireNonNull;
24+
25+
26+
/**
27+
* Service responsible for adjusting consent provisions based on associated patient encounters.
28+
* <p>
29+
* This class fetches patient encounters from a FHIR server and updates the start times of
30+
* {@link ConsentProvisions} if the provision start falls within an encounter period.
31+
* </p>
32+
*/
33+
@Component
34+
public class ConsentAdjuster {
35+
36+
private static final Logger logger = LoggerFactory.getLogger(ConsentAdjuster.class);
37+
private static final String CDS_ENCOUNTER_PROFILE_URL = "https://www.medizininformatik-initiative.de/fhir/core/modul-fall/StructureDefinition/KontaktGesundheitseinrichtung";
38+
39+
private final DataStore dataStore;
40+
41+
42+
public ConsentAdjuster(DataStore dataStore) {
43+
this.dataStore = requireNonNull(dataStore);
44+
}
45+
46+
47+
/**
48+
* Fetches encounters for all patients in the given batch and adjusts the provided
49+
* consent provisions accordingly.
50+
* <p>
51+
* This method first retrieves encounters grouped by patient, then updates each
52+
* {@link ConsentProvisions} entry based on the periods of those encounters.
53+
* </p>
54+
*
55+
* @param batch the {@link PatientBatch} containing patient IDs whose encounters should be fetched
56+
* @param provisions the list of consent provisions to be adjusted
57+
* @return a {@link Mono} emitting a map from patient ID to the list of adjusted provisions
58+
*/
59+
public Mono<Map<String, List<ConsentProvisions>>> fetchEncounterAndAdjustByEncounter(PatientBatch batch, Map<String, List<ConsentProvisions>> provisions) {
60+
return fetchAndGroupEncounterByPatient(batch)
61+
.map(encountersByPatient ->
62+
adjustProvisionsByEncounters(provisions, encountersByPatient)
63+
);
64+
}
65+
66+
/**
67+
* Builds a FHIR Search {@code Query} to fetch all Encounters for a given patient batch
68+
* that conform to the CDS Encounter profile.
69+
*
70+
* @param batch The patient batch for which to fetch encounters.
71+
* @return A {@link Query} configured for the batch.
72+
*/
73+
private static Query getEncounterQuery(PatientBatch batch) {
74+
return Query.of("Encounter", batch.compartmentSearchParam("Encounter").appendParam("_profile:below", stringValue(CDS_ENCOUNTER_PROFILE_URL)));
75+
}
76+
77+
/**
78+
* Fetches all encounters for the patients in the batch and groups them by patient ID.
79+
*
80+
* @param batch The patient batch containing the patient IDs.
81+
* @return A {@link Mono} emitting a map of patient ID to their associated encounters.
82+
*/
83+
private Mono<Map<String, Collection<Encounter>>> fetchAndGroupEncounterByPatient(PatientBatch batch) {
84+
return dataStore.search(getEncounterQuery(batch), Encounter.class)
85+
.doOnSubscribe(s -> logger.trace("Fetching encounters for batch: {}", batch.ids()))
86+
.flatMap(encounter -> {
87+
try {
88+
String patientId = ResourceUtils.patientId(encounter);
89+
return Mono.just(Tuples.of(patientId, encounter));
90+
} catch (PatientIdNotFoundException e) {
91+
logger.warn("Skipping encounter without patient ID: {}", encounter.getId(), e);
92+
return Mono.empty();
93+
}
94+
}).collectMultimap(Tuple2::getT1, Tuple2::getT2);
95+
}
96+
97+
98+
/**
99+
* Pure function that adjusts a list of {@link ConsentProvisions} based on a map of patient encounters.
100+
* <p>
101+
* For each provision, if its start date falls within any of the patient's encounter periods,
102+
* the provision start is shifted to the earliest overlapping encounter start.
103+
*
104+
* @param provisions The list of consent provisions to adjust.
105+
* @param encountersByPatient A map from patient ID to their associated encounters.
106+
* @return A map from patient ID to the list of adjusted {@link ConsentProvisions}.
107+
*/
108+
public Map<String, List<ConsentProvisions>> adjustProvisionsByEncounters(
109+
Map<String, List<ConsentProvisions>> provisions,
110+
Map<String, Collection<Encounter>> encountersByPatient
111+
) {
112+
return provisions
113+
.entrySet().stream()
114+
.collect(Collectors.toMap(
115+
Map.Entry::getKey,
116+
entry -> entry.getValue().stream()
117+
.map(cp -> cp.updateByEncounters(
118+
encountersByPatient.getOrDefault(entry.getKey(), List.of())))
119+
.toList()
120+
));
121+
}
122+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package de.medizininformatikinitiative.torch.consent;
2+
3+
import de.medizininformatikinitiative.torch.exceptions.ConsentViolatedException;
4+
import de.medizininformatikinitiative.torch.model.consent.ConsentProvisions;
5+
import de.medizininformatikinitiative.torch.model.consent.NonContinuousPeriod;
6+
import de.medizininformatikinitiative.torch.model.consent.Provision;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.HashMap;
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.Set;
13+
import java.util.stream.Collectors;
14+
import java.util.stream.Stream;
15+
16+
@Component
17+
public class ConsentCalculator {
18+
19+
20+
private final ConsentCodeMapper mapper;
21+
22+
ConsentCalculator(ConsentCodeMapper mapper) {
23+
this.mapper = mapper;
24+
}
25+
26+
27+
/**
28+
* Calculates the allowed consent periods per code for a single patient.
29+
* <p>
30+
* This method processes a list of {@link ConsentProvisions} sorted by date. For each provision:
31+
* <ul>
32+
* <li>If the provision is a permit, its period is merged into the existing allowed periods for the code.</li>
33+
* <li>If the provision is a denial, its period is subtracted from the existing allowed periods for the code.</li>
34+
* </ul>
35+
* Only codes relevant to the provided {@code consentKey} (as determined by {@link ConsentCodeMapper}) are considered.
36+
*
37+
* @param consentProvisions list of consent provisions for a patient
38+
* @param consentKey the key identifying the type of consent to calculate
39+
* @return a map from consent code to {@link NonContinuousPeriod} representing allowed periods for that code
40+
*/
41+
Map<String, NonContinuousPeriod> subtractAndMergeByCode(
42+
List<ConsentProvisions> consentProvisions,
43+
String consentKey
44+
) {
45+
// Get relevant codes for this consent key
46+
Set<String> relevantCodes = mapper.getRelevantCodes(consentKey);
47+
48+
// Flatten all provisions, filter relevant codes
49+
List<Provision> relevantProvisions = consentProvisions.stream()
50+
.flatMap(cp -> cp.provisions().stream())
51+
.filter(p -> relevantCodes.contains(p.code()))
52+
.toList();
53+
54+
// Separate permits and denies
55+
List<Provision> permits = relevantProvisions.stream()
56+
.filter(Provision::permit)
57+
.toList();
58+
59+
List<Provision> denies = relevantProvisions.stream()
60+
.filter(p -> !p.permit())
61+
.toList();
62+
63+
Map<String, NonContinuousPeriod> result = new HashMap<>();
64+
65+
// Apply permits first
66+
for (Provision p : permits) {
67+
NonContinuousPeriod existing = result.getOrDefault(p.code(), NonContinuousPeriod.of());
68+
result.put(p.code(), existing.merge(NonContinuousPeriod.of(p.period())));
69+
}
70+
71+
// Apply denials later
72+
for (Provision p : denies) {
73+
NonContinuousPeriod existing = result.getOrDefault(p.code(), NonContinuousPeriod.of());
74+
result.put(p.code(), existing.substract(p.period()));
75+
}
76+
77+
return result.keySet().equals(relevantCodes) ? result : Map.of();
78+
}
79+
80+
/**
81+
* Returns the intersection of all provided consent periods by code.
82+
* <p>
83+
* This method calculates the overlapping periods across all consent codes.
84+
* <ul>
85+
* <li>If {@code consentsByCode} is empty, a {@link ConsentViolatedException} is thrown.</li>
86+
* <li>If the intersection of all periods is empty, a {@link ConsentViolatedException} is thrown.</li>
87+
* </ul>
88+
*
89+
* @param consentsByCode map from consent code to {@link NonContinuousPeriod}
90+
* @return a {@link NonContinuousPeriod} representing the intersection of all provided periods
91+
* @throws ConsentViolatedException if there are no consent periods or if the intersection is empty
92+
*/
93+
public NonContinuousPeriod intersectConsent(Map<String, NonContinuousPeriod> consentsByCode) throws ConsentViolatedException {
94+
if (consentsByCode.isEmpty()) {
95+
throw new ConsentViolatedException("No consent periods found");
96+
}
97+
98+
NonContinuousPeriod result = consentsByCode.values().stream()
99+
.reduce(NonContinuousPeriod::intersect)
100+
.orElseThrow(() -> new ConsentViolatedException("No consent periods found"));
101+
102+
if (result.isEmpty()) {
103+
throw new ConsentViolatedException("Consent periods do not overlap");
104+
}
105+
106+
return result;
107+
}
108+
109+
/**
110+
* Calculates the effective consent periods for multiple patients.
111+
* <p>
112+
* For each patient in {@code consentsByPatient}:
113+
* <ul>
114+
* <li>Calculates allowed periods per code using {@link #subtractAndMergeByCode(List, String)}</li>
115+
* <li>Computes the intersection of all consent periods for that patient using {@link #intersectConsent(Map)}</li>
116+
* <li>If a patient has no overlapping consent periods, they are skipped in the result</li>
117+
* </ul>
118+
*
119+
* @param consentKey the key identifying the type of consent to calculate
120+
* @param consentsByPatient a map from patient ID to a list of their {@link ConsentProvisions}
121+
* @return a map from patient ID to {@link NonContinuousPeriod} representing their effective consent periods
122+
*/
123+
public Map<String, NonContinuousPeriod> calculateConsent(
124+
String consentKey,
125+
Map<String, List<ConsentProvisions>> consentsByPatient
126+
) {
127+
return consentsByPatient.entrySet().stream()
128+
.flatMap(entry -> {
129+
String patientId = entry.getKey();
130+
List<ConsentProvisions> provisions = entry.getValue();
131+
132+
try {
133+
NonContinuousPeriod finalConsent =
134+
intersectConsent(subtractAndMergeByCode(provisions, consentKey));
135+
return Stream.of(Map.entry(patientId, finalConsent));
136+
} catch (ConsentViolatedException e) {
137+
// skip patient with empty consent
138+
return Stream.empty();
139+
}
140+
})
141+
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
142+
}
143+
144+
}

0 commit comments

Comments
 (0)