diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java index fae5d47e490..bee975b50fa 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java @@ -109,6 +109,8 @@ public class ExternalMessageDto extends SormasToSormasShareableDto { public static final String DIAGNOSTIC_DATE = "diagnosticDate"; public static final String ACTIVITIES_AS_CASE = "activitiesAsCase"; public static final String EXPOSURES = "exposures"; + public static final String ADDITIONAL_PERSON_CONTACT_DETAILS = "additionalPersonContactDetails"; + public static final String ADDITIONAL_PERSON_ADDRESSES = "additionalPersonAddresses"; public static final String RADIOGRAPHY_COMPATIBILITY = "radiographyCompatibility"; public static final String OTHER_DIAGNOSTIC_CRITERIA = "otherDiagnosticCriteria"; public static final String TUBERCULOSIS = "tuberculosis"; @@ -246,6 +248,8 @@ public class ExternalMessageDto extends SormasToSormasShareableDto { private String activitiesAsCase; private String exposures; + private String additionalPersonContactDetails; + private String additionalPersonAddresses; private RadiographyCompatibility radiographyCompatibility; @Size(max = FieldConstraints.CHARACTER_LIMIT_DEFAULT, message = Validations.textTooLong) @@ -836,6 +840,22 @@ public void setActivitiesAsCase(String activitiesAsCase) { this.activitiesAsCase = activitiesAsCase; } + public String getAdditionalPersonContactDetails() { + return additionalPersonContactDetails; + } + + public void setAdditionalPersonContactDetails(String additionalPersonContactDetails) { + this.additionalPersonContactDetails = additionalPersonContactDetails; + } + + public String getAdditionalPersonAddresses() { + return additionalPersonAddresses; + } + + public void setAdditionalPersonAddresses(String additionalPersonAddresses) { + this.additionalPersonAddresses = additionalPersonAddresses; + } + public String getExposures() { return exposures; } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractMessageProcessingFlowBase.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractMessageProcessingFlowBase.java index c8d1e2e9281..88e6f2f317b 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractMessageProcessingFlowBase.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractMessageProcessingFlowBase.java @@ -46,16 +46,11 @@ import de.symeda.sormas.api.externalmessage.processing.labmessage.LabMessageProcessingHelper; import de.symeda.sormas.api.externalmessage.processing.labmessage.SampleAndPathogenTests; import de.symeda.sormas.api.feature.FeatureType; -import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto; import de.symeda.sormas.api.infrastructure.facility.FacilityDto; import de.symeda.sormas.api.infrastructure.facility.FacilityReferenceDto; import de.symeda.sormas.api.infrastructure.facility.FacilityType; -import de.symeda.sormas.api.location.LocationDto; -import de.symeda.sormas.api.person.PersonContactDetailDto; -import de.symeda.sormas.api.person.PersonContactDetailType; import de.symeda.sormas.api.person.PersonDto; import de.symeda.sormas.api.person.PersonReferenceDto; -import de.symeda.sormas.api.person.PhoneNumberType; import de.symeda.sormas.api.sample.PathogenTestDto; import de.symeda.sormas.api.sample.SampleCriteria; import de.symeda.sormas.api.sample.SampleDto; @@ -1051,12 +1046,18 @@ protected abstract void markExternalMessageAsProcessed( ProcessingResult result, SurveillanceReportDto surveillanceReport); - protected void doPersonUpdates(EntitySelection personSelection) { - // requested for #13589 - // TODO: we need to find a better way to handle this + @Override + protected CompletionStage> pickOrCreatePerson(ExternalMessageProcessingResult previousResult) { + return super.pickOrCreatePerson(previousResult).thenCompose(result -> { + if (!result.getStatus().isCanceled() && result.getData() != null) { + mergePerson(result.getData().getSelectedPerson()); + } + return result.asCompletedFuture(); + }); + } - if (personSelection.isNew()) { - // no updates for new persons + protected void mergePerson(EntitySelection personSelection) { + if (personSelection == null) { return; } @@ -1065,124 +1066,14 @@ protected void doPersonUpdates(EntitySelection personSelection) { return; } - boolean doUpdate = false; - - final LocationDto personAddress = person.getAddress(); - - if (personAddress != null) { - final String houseNumber = getExternalMessage().getPersonHouseNumber(); - if (houseNumber != null) { - personAddress.setHouseNumber(houseNumber); - } - final String street = getExternalMessage().getPersonStreet(); - if (street != null) { - personAddress.setStreet(street); - } - final String city = getExternalMessage().getPersonCity(); - if (city != null) { - personAddress.setCity(city); - } - final String postalCode = getExternalMessage().getPersonPostalCode(); - if (postalCode != null) { - personAddress.setPostalCode(postalCode); - } - final CountryReferenceDto country = getExternalMessage().getPersonCountry(); - if (country != null) { - personAddress.setCountry(country); - } - - doUpdate = true; - } - - final List personContactDetails = person.getPersonContactDetails(); - - final String phoneNumber = getExternalMessage().getPersonPhone(); - final PhoneNumberType phoneNumberType = getExternalMessage().getPersonPhoneNumberType(); - - if (phoneNumber != null && !phoneNumber.isBlank()) { - final PersonContactDetailDto primaryPhone = personContactDetails.stream() - .filter(pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.PHONE && !pdc.isThirdParty() && pdc.isPrimaryContact()) - .findFirst() - .orElse(null); - - final PersonContactDetailDto existingPhone = personContactDetails.stream() - .filter(pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.PHONE && !pdc.isThirdParty() && phoneNumber.equals(pdc.getContactInformation())) - .findFirst() - .orElse(null); - - if(existingPhone != null) { - // if we have a existing phone number maybe it is not the new one - // make the primary phone not primary anymore and set the primary on the existing one - // coincidentally the existing one may be the primary one so set it to false first just in case - if(primaryPhone != null) { - primaryPhone.setPrimaryContact(false); - } - existingPhone.setPrimaryContact(true); - } else { - // we do not have the new phone number in the list so we need to create a new one - final PersonContactDetailDto personContactDetail = new PersonContactDetailDto(); - personContactDetail.setPerson(person.toReference()); - personContactDetail.setPrimaryContact(true); - personContactDetail.setPersonContactDetailType(PersonContactDetailType.PHONE); - personContactDetail.setPrimaryContact(true); - personContactDetail.setPhoneNumberType(phoneNumberType); - personContactDetail.setContactInformation(phoneNumber); - personContactDetail.setThirdParty(false); - personContactDetails.add(personContactDetail); - - // we need to set the old primary to false - if(primaryPhone != null) { - primaryPhone.setPrimaryContact(false); - } - } - - doUpdate = true; - } - - final String emailAddress = getExternalMessage().getPersonEmail(); - - if (emailAddress != null && !emailAddress.isBlank()) { - final PersonContactDetailDto primaryEmail = personContactDetails.stream() - .filter(pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.EMAIL && !pdc.isThirdParty() && pdc.isPrimaryContact()) - .findFirst() - .orElse(null); - - final PersonContactDetailDto existingEmail = personContactDetails.stream() - .filter(pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.EMAIL && !pdc.isThirdParty() && emailAddress.equals(pdc.getContactInformation())) - .findFirst() - .orElse(null); - - if(existingEmail != null) { - // if we have a existing email address maybe it is not the new one - // make the primary email not primary anymore and set the primary on the existing one - // coincidentally the existing one may be the primary one so set it to false first just in case - if(primaryEmail != null) { - primaryEmail.setPrimaryContact(false); - } - existingEmail.setPrimaryContact(true); - } else { - // we do not have the new email address in the list so we need to create a new one - final PersonContactDetailDto personContactDetail = new PersonContactDetailDto(); - personContactDetail.setPerson(person.toReference()); - personContactDetail.setPrimaryContact(true); - personContactDetail.setPersonContactDetailType(PersonContactDetailType.EMAIL); - personContactDetail.setPrimaryContact(true); - personContactDetail.setContactInformation(emailAddress); - personContactDetail.setThirdParty(false); - personContactDetails.add(personContactDetail); - - // we need to set the old primary to false - if(primaryEmail != null) { - primaryEmail.setPrimaryContact(false); - } - } - - doUpdate = true; + if (personSelection.isNew()) { + // no merges for new person + // additional contacts will be handled by {@link AbstractProcessingFlow#buildPerson()} + return; } - if (doUpdate) { - getExternalMessageProcessingFacade().updatePerson(person); - } + getMapper().mergePersonAddress(person); + getMapper().mergePersonContactDetails(person); } /** diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractProcessingFlow.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractProcessingFlow.java index 058646819be..ab0849eb0c1 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractProcessingFlow.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/AbstractProcessingFlow.java @@ -151,10 +151,6 @@ protected CompletionStage> pic return mapHandlerResult(callback, previousResult, personSelection -> { logger.debug("[MESSAGE PROCESSING] Continue processing with person: {}", personSelection); - - // requested for #13589 - doPersonUpdates(personSelection); - return previousResult.withPerson(personSelection.getEntity(), personSelection.isNew()); }); } @@ -175,14 +171,14 @@ protected CompletionStage> protected abstract void handlePickOrCreatePerson(PersonDto person, HandlerCallback> callback); - protected abstract void doPersonUpdates(EntitySelection personSelection); - private PersonDto buildPerson() { final PersonDto personDto = PersonDto.build(); mapper.mapToPerson(personDto); mapper.mapToLocation(personDto.getAddress()); + mapper.mapAdditionalPersonContactDetails(personDto); + mapper.mapAdditionalPersonAddresses(personDto); return personDto; } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/ExternalMessageMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/ExternalMessageMapper.java index b8ffad9c5a2..40b4df4f236 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/ExternalMessageMapper.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/processing/ExternalMessageMapper.java @@ -16,8 +16,10 @@ package de.symeda.sormas.api.externalmessage.processing; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; @@ -25,6 +27,11 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutableTriple; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import de.symeda.sormas.api.CountryHelper; import de.symeda.sormas.api.customizableenum.CustomEnumNotFoundException; @@ -33,13 +40,18 @@ import de.symeda.sormas.api.externalmessage.labmessage.SampleReportDto; import de.symeda.sormas.api.externalmessage.labmessage.TestReportDto; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto; import de.symeda.sormas.api.infrastructure.district.DistrictReferenceDto; import de.symeda.sormas.api.infrastructure.facility.FacilityDto; import de.symeda.sormas.api.infrastructure.facility.FacilityType; import de.symeda.sormas.api.infrastructure.region.RegionReferenceDto; import de.symeda.sormas.api.location.LocationDto; import de.symeda.sormas.api.person.ApproximateAgeType; +import de.symeda.sormas.api.person.OccupationType; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.PhoneNumberType; import de.symeda.sormas.api.sample.PathogenTestDto; import de.symeda.sormas.api.sample.PathogenTestResultType; import de.symeda.sormas.api.sample.SampleDto; @@ -49,6 +61,8 @@ public final class ExternalMessageMapper { + private static final Logger logger = LoggerFactory.getLogger(ExternalMessageMapper.class); + private final ExternalMessageDto externalMessage; private final ExternalMessageProcessingFacade processingFacade; @@ -58,6 +72,10 @@ public ExternalMessageMapper(ExternalMessageDto externalMessage, ExternalMessage this.processingFacade = processingFacade; } + public ExternalMessageDto getExternalMessage() { + return externalMessage; + } + public List mapToPerson(PersonDto person) { List changedFields = map( Stream.of( @@ -154,6 +172,356 @@ public List mapToLocation(LocationDto location) { LocationDto.FACILITY))); } + /** + * Deserializes {@link ExternalMessageDto#getAdditionalPersonContactDetails()} from JSON and merges + * the entries into the person. Entries already present by type + contactInformation are skipped. + */ + public List mapAdditionalPersonContactDetails(PersonDto person) { + if (externalMessage.getAdditionalPersonContactDetails() == null || externalMessage.getAdditionalPersonContactDetails().isEmpty()) { + return Collections.emptyList(); + } + try { + List additionalDetails = + new ObjectMapper().readValue(externalMessage.getAdditionalPersonContactDetails(), new TypeReference>() { + }); + return mapAdditionalPersonContactDetails(person, additionalDetails); + } catch (Exception e) { + logger.error("[MAPPER] Error while deserializing additional person contact details", e); + return Collections.emptyList(); + } + } + + /** + * Deserializes {@link ExternalMessageDto#getAdditionalPersonAddresses()} from JSON and appends + * the entries into the person's address list. No deduplication is performed. + */ + public List mapAdditionalPersonAddresses(PersonDto person) { + if (externalMessage.getAdditionalPersonAddresses() == null || externalMessage.getAdditionalPersonAddresses().isEmpty()) { + return Collections.emptyList(); + } + try { + List additionalAddresses = + new ObjectMapper().readValue(externalMessage.getAdditionalPersonAddresses(), new TypeReference>() { + }); + return mapAdditionalPersonAddresses(person, additionalAddresses); + } catch (Exception e) { + logger.error("[MAPPER] Error while deserializing additional person addresses", e); + return Collections.emptyList(); + } + } + + /** + * Applies guardian name, incapacitated/emancipated flags, and guardian contact details (email, phone) + * from the external message onto the given person. + * These fields are not covered by the regular person-creation form and must be persisted in a separate step. + * + * @param person + * The person to update. + * @return A list of changed UI field paths; empty if nothing was changed. + */ + public List mapGuardianData(PersonDto person) { + List changedFields = new ArrayList<>(); + + final String nameOfGuardian = + String + .format( + "%s %s", + externalMessage.getPersonGuardianFirstName() != null ? externalMessage.getPersonGuardianFirstName() : "", + externalMessage.getPersonGuardianLastName() != null ? externalMessage.getPersonGuardianLastName() : "") + .trim(); + + if (!nameOfGuardian.isBlank()) { + person.setNamesOfGuardians(nameOfGuardian); + // Both incapacitated and emancipated must be set together, otherwise the person is not shown correctly in the UI + person.setIncapacitated(true); + person.setEmancipated(false); + changedFields.add( + new String[] { + PersonDto.NAMES_OF_GUARDIANS }); + } + + if (externalMessage.getPersonGuardianEmail() != null && !externalMessage.getPersonGuardianEmail().isBlank()) { + List contactDetails = person.getPersonContactDetails(); + if (contactDetails.stream().noneMatch(pc -> externalMessage.getPersonGuardianEmail().equals(pc.getContactInformation()))) { + final PersonContactDetailDto pcd = new PersonContactDetailDto(); + pcd.setUuid(DataHelper.createUuid()); + pcd.setPerson(person.toReference()); + pcd.setPrimaryContact(false); + pcd.setPersonContactDetailType(PersonContactDetailType.EMAIL); + pcd.setContactInformation(externalMessage.getPersonGuardianEmail()); + pcd.setThirdParty(true); + pcd.setThirdPartyRole(externalMessage.getPersonGuardianRelationship()); + pcd.setThirdPartyName(nameOfGuardian); + contactDetails.add(pcd); + changedFields.add( + new String[] { + PersonDto.PERSON_CONTACT_DETAILS }); + } + } + + if (externalMessage.getPersonGuardianPhone() != null && !externalMessage.getPersonGuardianPhone().isBlank()) { + List contactDetails = person.getPersonContactDetails(); + if (contactDetails.stream().noneMatch(pc -> externalMessage.getPersonGuardianPhone().equals(pc.getContactInformation()))) { + final PersonContactDetailDto pcd = new PersonContactDetailDto(); + pcd.setUuid(DataHelper.createUuid()); + pcd.setPerson(person.toReference()); + pcd.setPrimaryContact(false); + pcd.setPersonContactDetailType(PersonContactDetailType.PHONE); + pcd.setContactInformation(externalMessage.getPersonGuardianPhone()); + pcd.setThirdParty(true); + pcd.setThirdPartyRole(externalMessage.getPersonGuardianRelationship()); + pcd.setThirdPartyName(nameOfGuardian); + contactDetails.add(pcd); + changedFields.add( + new String[] { + PersonDto.PERSON_CONTACT_DETAILS }); + } + } + + return changedFields; + } + + /** + * Applies occupation type and details from the external message onto the given person. + * The occupation type is resolved to the customizable enum value for "OTHER". + * If the enum value cannot be found, no changes are applied. + * + * @param person + * The person to update. + * @return A list of changed UI field paths; empty if nothing was changed. + */ + public List mapOccupationData(PersonDto person) { + if (externalMessage.getPersonOccupation() == null || externalMessage.getPersonOccupation().isBlank()) { + return Collections.emptyList(); + } + + try { + final OccupationType occupationTypeOther = processingFacade.getOccupationTypeOther(); + person.setOccupationType(occupationTypeOther); + person.setOccupationDetails(externalMessage.getPersonOccupation()); + return Collections.singletonList( + new String[] { + PersonDto.OCCUPATION_TYPE }); + } catch (CustomEnumNotFoundException e) { + // do nothing if OccupationType OTHER custom enum is not found + return Collections.emptyList(); + } + } + + /** + * Merges address fields from the external message onto the given person's primary address. + * Only non-null values from the external message overwrite the existing address fields. + * + * @param person + * The existing person to update. + * @return A list of changed UI field paths; empty if the person has no address. + */ + public List mergePersonAddress(PersonDto person) { + + if (person == null) { + return Collections.emptyList(); + } + + final LocationDto personAddress = person.getAddress(); + if (personAddress == null) { + // just to be safe for whatever reason if address is null, create a new one + final LocationDto location = LocationDto.build(); + // in this case we no longer need to merge the address, so we can just return the new location + person.setAddress(location); + return mapToLocation(location); + } + + final String houseNumber = externalMessage.getPersonHouseNumber(); + if (houseNumber != null) { + personAddress.setHouseNumber(houseNumber); + } + final String street = externalMessage.getPersonStreet(); + if (street != null) { + personAddress.setStreet(street); + } + final String city = externalMessage.getPersonCity(); + if (city != null) { + personAddress.setCity(city); + } + final String postalCode = externalMessage.getPersonPostalCode(); + if (postalCode != null) { + personAddress.setPostalCode(postalCode); + } + final CountryReferenceDto country = externalMessage.getPersonCountry(); + if (country != null) { + personAddress.setCountry(country); + } + + return Collections.singletonList( + new String[] { + PersonDto.ADDRESS }); + } + + /** + * Merges primary phone and email contact details from the external message onto the given person. + * If the incoming value already exists in the list it is promoted to primary and the old primary is demoted; + * otherwise a new primary entry is created and the old primary is demoted. + * + * @param person + * The existing person to update. + * @return A list of changed UI field paths; empty if nothing was changed. + */ + public List mergePersonContactDetails(PersonDto person) { + + if (person == null) { + return Collections.emptyList(); + } + + List changedFields = new ArrayList<>(); + + final List personContactDetails = person.getPersonContactDetails(); + + final String phoneNumber = externalMessage.getPersonPhone(); + final PhoneNumberType phoneNumberType = externalMessage.getPersonPhoneNumberType(); + + if (phoneNumber != null && !phoneNumber.isBlank()) { + final PersonContactDetailDto primaryPhone = personContactDetails.stream() + .filter(pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.PHONE && !pdc.isThirdParty() && pdc.isPrimaryContact()) + .findFirst() + .orElse(null); + final PersonContactDetailDto existingPhone = personContactDetails.stream() + .filter( + pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.PHONE + && !pdc.isThirdParty() + && phoneNumber.equals(pdc.getContactInformation())) + .findFirst() + .orElse(null); + + if (existingPhone != null) { + // Promote the existing entry to primary, demote the old primary + if (primaryPhone != null) { + primaryPhone.setPrimaryContact(false); + } + existingPhone.setPrimaryContact(true); + } else { + // Create a new primary entry and demote the old primary + final PersonContactDetailDto personContactDetail = new PersonContactDetailDto(); + personContactDetail.setUuid(DataHelper.createUuid()); + personContactDetail.setPerson(person.toReference()); + personContactDetail.setPrimaryContact(true); + personContactDetail.setPersonContactDetailType(PersonContactDetailType.PHONE); + personContactDetail.setPhoneNumberType(phoneNumberType); + personContactDetail.setContactInformation(phoneNumber); + personContactDetail.setThirdParty(false); + personContactDetails.add(personContactDetail); + if (primaryPhone != null) { + primaryPhone.setPrimaryContact(false); + } + } + changedFields.add( + new String[] { + PersonDto.PERSON_CONTACT_DETAILS }); + } + + final String emailAddress = externalMessage.getPersonEmail(); + + if (emailAddress != null && !emailAddress.isBlank()) { + final PersonContactDetailDto primaryEmail = personContactDetails.stream() + .filter(pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.EMAIL && !pdc.isThirdParty() && pdc.isPrimaryContact()) + .findFirst() + .orElse(null); + final PersonContactDetailDto existingEmail = personContactDetails.stream() + .filter( + pdc -> pdc.getPersonContactDetailType() == PersonContactDetailType.EMAIL + && !pdc.isThirdParty() + && emailAddress.equals(pdc.getContactInformation())) + .findFirst() + .orElse(null); + + if (existingEmail != null) { + // Promote the existing entry to primary, demote the old primary + if (primaryEmail != null) { + primaryEmail.setPrimaryContact(false); + } + existingEmail.setPrimaryContact(true); + } else { + // Create a new primary entry and demote the old primary + final PersonContactDetailDto personContactDetail = new PersonContactDetailDto(); + personContactDetail.setUuid(DataHelper.createUuid()); + personContactDetail.setPerson(person.toReference()); + personContactDetail.setPrimaryContact(true); + personContactDetail.setPersonContactDetailType(PersonContactDetailType.EMAIL); + personContactDetail.setContactInformation(emailAddress); + personContactDetail.setThirdParty(false); + personContactDetails.add(personContactDetail); + if (primaryEmail != null) { + primaryEmail.setPrimaryContact(false); + } + } + changedFields.add( + new String[] { + PersonDto.PERSON_CONTACT_DETAILS }); + } + + changedFields.addAll(mapAdditionalPersonContactDetails(person)); + + return changedFields; + } + + private List mapAdditionalPersonContactDetails(PersonDto person, List additionalDetails) { + if (person == null) { + return Collections.emptyList(); + } + + if (additionalDetails == null || additionalDetails.isEmpty()) { + return Collections.emptyList(); + } + + List existing = person.getPersonContactDetails(); + boolean changed = false; + + for (PersonContactDetailDto incoming : additionalDetails) { + final boolean alreadyPresent = existing.stream() + .anyMatch( + e -> e.getPersonContactDetailType() == incoming.getPersonContactDetailType() + && Objects.equals(e.getContactInformation(), incoming.getContactInformation())); + + if (alreadyPresent) { + continue; + } + + if (incoming.getUuid() == null) { + incoming.setUuid(DataHelper.createUuid()); + } + incoming.setPerson(person.toReference()); + existing.add(incoming); + changed = true; + } + + return changed + ? Collections.singletonList( + new String[] { + PersonDto.PERSON_CONTACT_DETAILS }) + : Collections.emptyList(); + } + + private List mapAdditionalPersonAddresses(PersonDto person, List additionalAddresses) { + + if (person == null) { + return Collections.emptyList(); + } + + if (additionalAddresses == null || additionalAddresses.isEmpty()) { + return Collections.emptyList(); + } + + for (LocationDto incoming : additionalAddresses) { + if (incoming.getUuid() == null) { + incoming.setUuid(DataHelper.createUuid()); + } + person.getAddresses().add(incoming); + } + + return Collections.singletonList( + new String[] { + PersonDto.ADDRESSES }); + } + public List mapToSample(SampleDto sample, SampleReportDto sampleReport) { List changedFields = map( Stream.of( diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java index 10316f62a01..bed08d26289 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java @@ -131,6 +131,8 @@ public class ExternalMessage extends AbstractDomainObject { public static final String ACTIVITIES_AS_CASE = "activitiesAsCase"; public static final String EXPOSURES = "exposures"; + public static final String ADDITIONAL_PERSON_CONTACT_DETAILS = "additionalPersonContactDetails"; + public static final String ADDITIONAL_PERSON_ADDRESSES = "additionalPersonAddresses"; public static final String RADIOGRAPHY_COMPATIBILITY = "radiographyCompatibility"; public static final String OTHER_DIAGNOSTIC_CRITERIA = "otherDiagnosticCriteria"; @@ -212,6 +214,8 @@ public class ExternalMessage extends AbstractDomainObject { private String activitiesAsCase; private String exposures; + private String additionalPersonContactDetails; + private String additionalPersonAddresses; private RadiographyCompatibility radiographyCompatibility; private String otherDiagnosticCriteria; @@ -820,6 +824,26 @@ public void setActivitiesAsCase(String activitiesAsCase) { this.activitiesAsCase = activitiesAsCase; } + @Column(columnDefinition = "jsonb") + @Type(type = "jsonb") + public String getAdditionalPersonContactDetails() { + return additionalPersonContactDetails; + } + + public void setAdditionalPersonContactDetails(String additionalPersonContactDetails) { + this.additionalPersonContactDetails = additionalPersonContactDetails; + } + + @Column(columnDefinition = "jsonb") + @Type(type = "jsonb") + public String getAdditionalPersonAddresses() { + return additionalPersonAddresses; + } + + public void setAdditionalPersonAddresses(String additionalPersonAddresses) { + this.additionalPersonAddresses = additionalPersonAddresses; + } + @Column(columnDefinition = "jsonb") @Type(type = "jsonb") public String getExposures() { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java index 070a3f0cf5d..b6cc4f4828b 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java @@ -216,6 +216,8 @@ ExternalMessage fillOrBuildEntity(@NotNull ExternalMessageDto source, ExternalMe target.setDiagnosticDate(source.getDiagnosticDate()); target.setActivitiesAsCase(source.getActivitiesAsCase()); target.setExposures(source.getExposures()); + target.setAdditionalPersonContactDetails(source.getAdditionalPersonContactDetails()); + target.setAdditionalPersonAddresses(source.getAdditionalPersonAddresses()); target.setDeceasedDate(source.getDeceasedDate()); target.setReportId(source.getReportId()); @@ -440,6 +442,8 @@ public ExternalMessageDto toDto(ExternalMessage source) { target.setDiagnosticDate(source.getDiagnosticDate()); target.setActivitiesAsCase(source.getActivitiesAsCase()); target.setExposures(source.getExposures()); + target.setAdditionalPersonContactDetails(source.getAdditionalPersonContactDetails()); + target.setAdditionalPersonAddresses(source.getAdditionalPersonAddresses()); target.setDeceasedDate(source.getDeceasedDate()); target.setReportId(source.getReportId()); diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessor.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessor.java index 919b17d7b74..7022cfe6510 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessor.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessor.java @@ -68,6 +68,7 @@ import de.symeda.sormas.api.utils.dataprocessing.HandlerCallback; import de.symeda.sormas.api.utils.dataprocessing.PickOrCreateEntryResult; import de.symeda.sormas.api.utils.dataprocessing.ProcessingResult; +import de.symeda.sormas.api.utils.dataprocessing.flow.FlowThen; import de.symeda.sormas.api.utils.luxembourg.LuxembourgNationalHealthIdValidator; import de.symeda.sormas.backend.caze.CaseFacadeEjb.CaseFacadeEjbLocal; import de.symeda.sormas.backend.caze.CaseService; @@ -396,9 +397,33 @@ && samplesHaveIgraPositiveTest(externalMessageDto.getSampleReports())) { } } + @Override + protected FlowThen doCaseSelectedFlow( + CaseSelectionDto caseSelection, + FlowThen flow) { + + // When reusing an existing case, the person's in-memory changes made by mergePerson() + // (address, contact details, additional addresses/contacts) are not persisted automatically, + // unlike the new-case path where handleCreateCase explicitly saves the person. + // We inject a step here to persist those changes before continuing the flow. + FlowThen flowWithPersonSaved = flow.then(previousResult -> { + PersonDto person = previousResult.getData().getPerson(); + if (person != null) { + personFacade.save(person); + } + return ProcessingResult.continueWith(previousResult.getData()).asCompletedFuture(); + }); + + return super.doCaseSelectedFlow(caseSelection, flowWithPersonSaved); + } + @Override protected void handleCreateCase(CaseDataDto caze, PersonDto person, ExternalMessageDto labMessage, HandlerCallback callback) { - callback.done(caseFacade.save(caze)); + CaseDataDto savedCase = caseFacade.save(caze); + // the person was already merged in-memory by mergePerson() in the base class + // (address, contact details, additional addresses/contacts), but not yet persisted + personFacade.save(person); + callback.done(savedCase); } @Override diff --git a/sormas-backend/src/main/resources/sql/sormas_schema.sql b/sormas-backend/src/main/resources/sql/sormas_schema.sql index f9648d69ff7..1a0f8c329e4 100644 --- a/sormas-backend/src/main/resources/sql/sormas_schema.sql +++ b/sormas-backend/src/main/resources/sql/sormas_schema.sql @@ -15255,4 +15255,15 @@ ALTER TABLE surveillancereports_history ADD COLUMN treatmentstartdate timestamp; INSERT INTO schema_version (version_number, comment) VALUES (610, '#13754 - Create notification should create a Report - Case - DD'); + +-- #13625 - Add handling for multiple person contacts and addresses + +ALTER TABLE externalmessage ADD COLUMN additionalPersonContactDetails jsonb; +ALTER TABLE externalmessage ADD COLUMN additionalPersonAddresses jsonb; + +ALTER TABLE externalmessage_history ADD COLUMN additionalPersonContactDetails jsonb; +ALTER TABLE externalmessage_history ADD COLUMN additionalPersonAddresses jsonb; + +INSERT INTO schema_version (version_number, comment) VALUES (611, '#13625 - Multiple person contacts and addresses'); + -- *** Insert new sql commands BEFORE this line. Remember to always consider _history tables. *** diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessorPersonContactTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessorPersonContactTest.java new file mode 100644 index 00000000000..2f5e941941f --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/labmessage/AutomaticLabMessageProcessorPersonContactTest.java @@ -0,0 +1,415 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.externalmessage.labmessage; + +import static de.symeda.sormas.api.utils.dataprocessing.ProcessingResultStatus.DONE; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.externalmessage.ExternalMessageStatus; +import de.symeda.sormas.api.externalmessage.ExternalMessageType; +import de.symeda.sormas.api.externalmessage.labmessage.SampleReportDto; +import de.symeda.sormas.api.externalmessage.labmessage.TestReportDto; +import de.symeda.sormas.api.externalmessage.processing.ExternalMessageProcessingResult; +import de.symeda.sormas.api.infrastructure.facility.FacilityDto; +import de.symeda.sormas.api.infrastructure.facility.FacilityType; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.Sex; +import de.symeda.sormas.api.sample.PathogenTestResultType; +import de.symeda.sormas.api.sample.PathogenTestType; +import de.symeda.sormas.api.sample.SampleMaterial; +import de.symeda.sormas.api.sample.SpecimenCondition; +import de.symeda.sormas.api.user.DefaultUserRole; +import de.symeda.sormas.api.utils.dataprocessing.ProcessingResult; +import de.symeda.sormas.backend.AbstractBeanTest; +import de.symeda.sormas.backend.TestDataCreator; +import de.symeda.sormas.backend.disease.DiseaseConfigurationFacadeEjb; + +/** + * Integration tests for person contact detail and address handling through the full + * {@link AutomaticLabMessageProcessor} processing flow. + * + *

+ * These tests complement the unit-level {@code ExternalMessageMapperPersonTest} by exercising + * the end-to-end path: from an {@link ExternalMessageDto} through the processor flow down to the + * persisted {@link PersonDto} retrieved from the database. + */ +class AutomaticLabMessageProcessorPersonContactTest extends AbstractBeanTest { + + private AutomaticLabMessageProcessor flow; + + private TestDataCreator.RDCF rdcf; + private FacilityDto lab; + + @Override + public void init() { + super.init(); + flow = getAutomaticLabMessageProcessingFlow(); + rdcf = creator.createRDCF(); + creator.createUser(rdcf, DefaultUserRole.SURVEILLANCE_OFFICER); + lab = creator.createFacility("Lab", rdcf.region, rdcf.district, f -> { + f.setType(FacilityType.LABORATORY); + f.setExternalID("test-facility-ext-id-1"); + }); + } + + // ---------------------------------------------------------------- + // New-person path: contact details applied during person creation + // --------------------------------------------------------------------------- + + /** + * When the lab message contains a {@code personPhone}, a new person created by the flow + * should have that number as their primary phone contact detail. + */ + @Test + void testNewPersonContactDetailsPhoneFromMessage() throws ExecutionException, InterruptedException { + + ExternalMessageDto message = createExternalMessage(m -> m.setPersonPhone("+49123456789")); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + assertThat(message.getStatus(), is(ExternalMessageStatus.PROCESSED)); + + List persons = getPersonFacade().getAllAfter(new Date(0)); + assertThat(persons, hasSize(1)); + assertThat(persons.get(0).getPhone(), is("+49123456789")); + + String primaryPhoneInfo = persons.get(0) + .getPersonContactDetails() + .stream() + .filter(pcd -> PersonContactDetailType.PHONE == pcd.getPersonContactDetailType() && pcd.isPrimaryContact()) + .findFirst() + .map(PersonContactDetailDto::getContactInformation) + .orElse(null); + assertThat(primaryPhoneInfo, is("+49123456789")); + } + + /** + * When the lab message contains a {@code personEmail}, a new person created by the flow + * should have that address as their primary e-mail contact detail. + */ + @Test + void testNewPersonContactDetailsEmailFromMessage() throws ExecutionException, InterruptedException { + + ExternalMessageDto message = createExternalMessage(m -> m.setPersonEmail("john.doe@example.com")); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + assertThat(message.getStatus(), is(ExternalMessageStatus.PROCESSED)); + + List persons = getPersonFacade().getAllAfter(new Date(0)); + assertThat(persons, hasSize(1)); + assertThat(persons.get(0).getEmailAddress(), is("john.doe@example.com")); + + String primaryEmailInfo = persons.get(0) + .getPersonContactDetails() + .stream() + .filter(pcd -> PersonContactDetailType.EMAIL == pcd.getPersonContactDetailType() && pcd.isPrimaryContact()) + .findFirst() + .map(PersonContactDetailDto::getContactInformation) + .orElse(null); + assertThat(primaryEmailInfo, is("john.doe@example.com")); + } + + /** + * When the lab message carries a non-empty {@code additionalPersonContactDetails} JSON payload, + * the entries are applied to a newly created person and persisted. + */ + @Test + void testNewPersonAdditionalContactDetailFromJson() throws Exception { + + PersonContactDetailDto extraContact = new PersonContactDetailDto(); + extraContact.setPersonContactDetailType(PersonContactDetailType.PHONE); + extraContact.setContactInformation("+49-extra-mobile"); + extraContact.setPrimaryContact(false); + + String additionalContactsJson = new ObjectMapper().writeValueAsString(Collections.singletonList(extraContact)); + + ExternalMessageDto message = createExternalMessage(m -> m.setAdditionalPersonContactDetails(additionalContactsJson)); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + + List persons = getPersonFacade().getAllAfter(new Date(0)); + assertThat(persons, hasSize(1)); + boolean hasExtraContact = + persons.get(0).getPersonContactDetails().stream().anyMatch(pcd -> "+49-extra-mobile".equals(pcd.getContactInformation())); + assertThat(hasExtraContact, is(true)); + } + + // --------------------------------------------------------------------------- + // Existing-person path: contact details merged when person is found by NHI + // --------------------------------------------------------------------------- + + /** + * When a message carries a phone number the existing person does not yet have, the processor + * should add it as the new primary phone and demote the current primary to non-primary. + */ + @Test + void testExistingPersonNewPhoneAddedAsPrimary() throws ExecutionException, InterruptedException { + + PersonDto existingPerson = creator.createPerson("John", "Doe", Sex.MALE, p -> { + p.setPhone("+4910000001"); + p.setNationalHealthId("1234567890"); + }); + + ExternalMessageDto message = createExternalMessage(m -> m.setPersonPhone("+4910000002")); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + + PersonDto updatedPerson = getPersonFacade().getByUuid(existingPerson.getUuid()); + assertThat(updatedPerson.getPhone(), is("+4910000002")); + assertThat(updatedPerson.getPersonContactDetails(), hasSize(2)); + + boolean oldPhoneIsDemoted = updatedPerson.getPersonContactDetails() + .stream() + .filter(pcd -> "+4910000001".equals(pcd.getContactInformation())) + .findFirst() + .map(pcd -> !pcd.isPrimaryContact()) + .orElse(false); + assertThat(oldPhoneIsDemoted, is(true)); + } + + /** + * When a message carries a phone number that the existing person already has as a + * non-primary entry, the processor should promote that entry to primary and demote the + * current primary – without creating a duplicate contact. + */ + @Test + void testExistingPersonKnownPhonePromotedToPrimary() throws ExecutionException, InterruptedException { + + PersonDto existingPerson = creator.createPerson("John", "Doe", Sex.MALE, p -> { + p.setPhone("+4910000003"); + p.setAdditionalPhone("+4910000004"); + p.setNationalHealthId("1234567890"); + }); + + ExternalMessageDto message = createExternalMessage(m -> m.setPersonPhone("+4910000004")); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + + PersonDto updatedPerson = getPersonFacade().getByUuid(existingPerson.getUuid()); + + // no new contact should have been added + assertThat(updatedPerson.getPersonContactDetails(), hasSize(2)); + assertThat(updatedPerson.getPhone(), is("+4910000004")); + + boolean oldPrimaryIsDemoted = updatedPerson.getPersonContactDetails() + .stream() + .filter(pcd -> "+4910000003".equals(pcd.getContactInformation())) + .findFirst() + .map(pcd -> !pcd.isPrimaryContact()) + .orElse(false); + assertThat(oldPrimaryIsDemoted, is(true)); + } + + /** + * When a message carries an e-mail address the existing person does not yet have, the + * processor should add it as the new primary e-mail and demote the current primary. + */ + @Test + void testExistingPersonNewEmailAddedAsPrimary() throws ExecutionException, InterruptedException { + + PersonDto existingPerson = creator.createPerson("John", "Doe", Sex.MALE, p -> { + p.setEmailAddress("old@example.com"); + p.setNationalHealthId("1234567890"); + }); + + ExternalMessageDto message = createExternalMessage(m -> m.setPersonEmail("new@example.com")); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + + PersonDto updatedPerson = getPersonFacade().getByUuid(existingPerson.getUuid()); + assertThat(updatedPerson.getEmailAddress(), is("new@example.com")); + assertThat(updatedPerson.getPersonContactDetails(), hasSize(2)); + + boolean oldEmailIsDemoted = updatedPerson.getPersonContactDetails() + .stream() + .filter(pcd -> "old@example.com".equals(pcd.getContactInformation())) + .findFirst() + .map(pcd -> !pcd.isPrimaryContact()) + .orElse(false); + assertThat(oldEmailIsDemoted, is(true)); + } + + /** + * When a message contains address fields and the existing person's address fields are null, + * the processor should write those values into the person's primary address. + */ + @Test + void testExistingPersonNullAddressFieldsFilledFromMessage() throws ExecutionException, InterruptedException { + + PersonDto existingPerson = creator.createPerson("John", "Doe", Sex.MALE, p -> { + // Leave address fields null so that personDetailsMatch succeeds regardless + // of the address values carried by the test message + p.setNationalHealthId("1234567890"); + }); + + ExternalMessageDto message = createExternalMessage(m -> { + m.setPersonCity("Hamburg"); + m.setPersonStreet("Main St 1"); + m.setPersonPostalCode("20095"); + }); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + + PersonDto updatedPerson = getPersonFacade().getByUuid(existingPerson.getUuid()); + assertThat(updatedPerson.getAddress().getCity(), is("Hamburg")); + assertThat(updatedPerson.getAddress().getStreet(), is("Main St 1")); + assertThat(updatedPerson.getAddress().getPostalCode(), is("20095")); + } + + /** + * When the existing person already has a non-null address field and the message carries a + * different value for that field, {@code mergePersonAddress} should overwrite it. + * + *

Note: {@code street}, {@code city} and {@code postalCode} are used by + * {@code personDetailsMatch} to identify the existing person, so they cannot differ between + * person and message without breaking the match. {@code houseNumber} is not part of that + * check and is therefore used here to verify the overwrite behaviour. + */ + @Test + void testExistingPersonAddressFieldOverwrittenByMessage() throws ExecutionException, InterruptedException { + + PersonDto existingPerson = creator.createPerson("John", "Doe", Sex.MALE, p -> { + p.getAddress().setStreet("Main St"); + p.getAddress().setHouseNumber("1"); + p.setNationalHealthId("1234567890"); + }); + + // street matches so personDetailsMatch succeeds; houseNumber is different → should be overwritten + ExternalMessageDto message = createExternalMessage(m -> { + m.setPersonStreet("Main St"); + m.setPersonHouseNumber("99"); + }); + + ProcessingResult result = runFlow(message); + + assertThat(result.getStatus(), is(DONE)); + + PersonDto updatedPerson = getPersonFacade().getByUuid(existingPerson.getUuid()); + assertThat(updatedPerson.getAddress().getStreet(), is("Main St")); + assertThat(updatedPerson.getAddress().getHouseNumber(), is("99")); + } + + /** + * When the same {@code additionalPersonContactDetails} JSON is present in two successive + * messages for the same person, the contact entry should appear exactly once in the + * persisted person data (deduplication by type + contactInformation). + */ + @Test + void testExistingPersonAdditionalContactDetailsNotDuplicated() throws Exception { + + PersonContactDetailDto extraContact = new PersonContactDetailDto(); + extraContact.setPersonContactDetailType(PersonContactDetailType.PHONE); + extraContact.setContactInformation("+49-shared-mobile"); + extraContact.setPrimaryContact(false); + + String additionalContactsJson = new ObjectMapper().writeValueAsString(Collections.singletonList(extraContact)); + + // First message creates the person and applies the additional contact + ExternalMessageDto firstMessage = createExternalMessage(m -> m.setAdditionalPersonContactDetails(additionalContactsJson)); + ProcessingResult firstResult = runFlow(firstMessage); + assertThat(firstResult.getStatus(), is(DONE)); + + List personsAfterFirst = getPersonFacade().getAllAfter(new Date(0)); + assertThat(personsAfterFirst, hasSize(1)); + PersonDto personAfterFirst = personsAfterFirst.get(0); + + long contactCountAfterFirst = + personAfterFirst.getPersonContactDetails().stream().filter(pcd -> "+49-shared-mobile".equals(pcd.getContactInformation())).count(); + assertThat(contactCountAfterFirst, is(1L)); + + // Configure threshold so the second message can be assigned to the existing case (not cancelled) + creator.updateDiseaseConfiguration(Disease.CORONAVIRUS, true, true, true, true, null, 10); + getBean(DiseaseConfigurationFacadeEjb.DiseaseConfigurationFacadeEjbLocal.class).loadData(); + + // Second message carries the same JSON for the same person (found by NHI) + ExternalMessageDto secondMessage = createExternalMessage(m -> m.setAdditionalPersonContactDetails(additionalContactsJson)); + ProcessingResult secondResult = runFlow(secondMessage); + assertThat(secondResult.getStatus(), is(DONE)); + + PersonDto personAfterSecond = getPersonFacade().getByUuid(personAfterFirst.getUuid()); + long contactCountAfterSecond = + personAfterSecond.getPersonContactDetails().stream().filter(pcd -> "+49-shared-mobile".equals(pcd.getContactInformation())).count(); + assertThat(contactCountAfterSecond, is(1L)); + } + + // -------- + // Helpers + // -------- + + private ProcessingResult runFlow(ExternalMessageDto labMessage) throws ExecutionException, InterruptedException { + return flow.processLabMessage(labMessage); + } + + private ExternalMessageDto createExternalMessage(Consumer extraConfig) { + return creator.createExternalMessage(externalMessage -> { + externalMessage.setType(ExternalMessageType.LAB_MESSAGE); + externalMessage.setMessageDateTime(new Date()); + externalMessage.setDisease(Disease.CORONAVIRUS); + externalMessage.setPersonFirstName("John"); + externalMessage.setPersonLastName("Doe"); + externalMessage.setPersonSex(Sex.MALE); + externalMessage.setPersonNationalHealthId("1234567890"); + externalMessage.setPersonFacility(rdcf.facility); + externalMessage.setReporterExternalIds(Collections.singletonList(lab.getExternalID())); + + SampleReportDto sampleReport = new SampleReportDto(); + sampleReport.setSampleDateTime(new Date()); + sampleReport.setSpecimenCondition(SpecimenCondition.ADEQUATE); + sampleReport.setSampleMaterial(SampleMaterial.CRUST); + + TestReportDto testReport = new TestReportDto(); + testReport.setTestResult(PathogenTestResultType.PENDING); + testReport.setTestDateTime(new Date()); + testReport.setTestType(PathogenTestType.PCR_RT_PCR); + + sampleReport.setTestReports(Collections.singletonList(testReport)); + externalMessage.setSampleReports(Collections.singletonList(sampleReport)); + + if (extraConfig != null) { + extraConfig.accept(externalMessage); + } + }); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/processing/ExternalMessageMapperPersonTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/processing/ExternalMessageMapperPersonTest.java new file mode 100644 index 00000000000..a0e0e317687 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/processing/ExternalMessageMapperPersonTest.java @@ -0,0 +1,467 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at 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 General License for more details. + * You should have received a copy of the GNU General License + * along with this program. If not, see . + */ + +package de.symeda.sormas.backend.externalmessage.processing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.externalmessage.processing.ExternalMessageMapper; +import de.symeda.sormas.api.location.LocationDto; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.PhoneNumberType; +import de.symeda.sormas.backend.AbstractBeanTest; + +class ExternalMessageMapperPersonTest extends AbstractBeanTest { + + // -------------------------- + // mergePersonContactDetails + // -------------------------- + + @Test + void testMergePersonContactDetailsNullPerson() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonPhone("+49123456789"); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mergePersonContactDetails(null); + assertTrue(result.isEmpty()); + } + + @Test + void testMergePersonContactDetailsNullValues() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + // phone and email both null in message – nothing should change + List result = mapper.mergePersonContactDetails(person); + assertTrue(result.isEmpty()); + assertTrue(person.getPersonContactDetails().isEmpty()); + } + + @Test + void testMergePersonContactDetailsAddsNewPhone() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonPhone("+49123456789"); + labMessage.setPersonPhoneNumberType(PhoneNumberType.MOBILE); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mergePersonContactDetails(person); + + assertEquals(1, result.size()); + assertEquals(PersonDto.PERSON_CONTACT_DETAILS, result.get(0)[0]); + assertEquals(1, person.getPersonContactDetails().size()); + PersonContactDetailDto pcd = person.getPersonContactDetails().get(0); + assertEquals(PersonContactDetailType.PHONE, pcd.getPersonContactDetailType()); + assertEquals("+49123456789", pcd.getContactInformation()); + assertEquals(PhoneNumberType.MOBILE, pcd.getPhoneNumberType()); + assertTrue(pcd.isPrimaryContact()); + assertFalse(pcd.isThirdParty()); + } + + @Test + void testMergePersonContactDetailsAddsNewEmail() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonEmail("test@example.com"); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mergePersonContactDetails(person); + + assertEquals(1, result.size()); + assertEquals(PersonDto.PERSON_CONTACT_DETAILS, result.get(0)[0]); + assertEquals(1, person.getPersonContactDetails().size()); + PersonContactDetailDto pcd = person.getPersonContactDetails().get(0); + assertEquals(PersonContactDetailType.EMAIL, pcd.getPersonContactDetailType()); + assertEquals("test@example.com", pcd.getContactInformation()); + assertTrue(pcd.isPrimaryContact()); + assertFalse(pcd.isThirdParty()); + } + + @Test + void testMergePersonContactDetailsPromotesExistingPhoneToPrimary() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonPhone("+49111222333"); + PersonDto person = PersonDto.build(); + + // existing primary with a different number + PersonContactDetailDto existingPrimary = PersonContactDetailDto + .build(person.toReference(), true, PersonContactDetailType.PHONE, null, null, "+49999888777", null, false, null, null); + // same number as in message – not yet primary + PersonContactDetailDto existingSecondary = PersonContactDetailDto + .build(person.toReference(), false, PersonContactDetailType.PHONE, null, null, "+49111222333", null, false, null, null); + person.getPersonContactDetails().add(existingPrimary); + person.getPersonContactDetails().add(existingSecondary); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + List result = mapper.mergePersonContactDetails(person); + + assertEquals(1, result.size()); + assertEquals(PersonDto.PERSON_CONTACT_DETAILS, result.get(0)[0]); + // no new entry added – only promotion + assertEquals(2, person.getPersonContactDetails().size()); + assertFalse(existingPrimary.isPrimaryContact()); + assertTrue(existingSecondary.isPrimaryContact()); + } + + @Test + void testMergePersonContactDetailsDemotesOldPrimaryWhenNewPhoneAdded() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonPhone("+49NEW000000"); + PersonDto person = PersonDto.build(); + + PersonContactDetailDto oldPrimary = PersonContactDetailDto + .build(person.toReference(), true, PersonContactDetailType.PHONE, null, null, "+49OLD000000", null, false, null, null); + person.getPersonContactDetails().add(oldPrimary); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + mapper.mergePersonContactDetails(person); + + // old primary must be demoted + assertFalse(oldPrimary.isPrimaryContact()); + // new entry is primary + PersonContactDetailDto newEntry = + person.getPersonContactDetails().stream().filter(p -> "+49NEW000000".equals(p.getContactInformation())).findFirst().orElse(null); + assertNotNull(newEntry); + assertTrue(newEntry.isPrimaryContact()); + assertEquals(2, person.getPersonContactDetails().size()); + } + + @Test + void testMergePersonContactDetailsPromotesExistingEmailToPrimary() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonEmail("new-primary@example.com"); + PersonDto person = PersonDto.build(); + + PersonContactDetailDto existingPrimary = PersonContactDetailDto + .build(person.toReference(), true, PersonContactDetailType.EMAIL, null, null, "old@example.com", null, false, null, null); + PersonContactDetailDto existingSecondary = PersonContactDetailDto + .build(person.toReference(), false, PersonContactDetailType.EMAIL, null, null, "new-primary@example.com", null, false, null, null); + person.getPersonContactDetails().add(existingPrimary); + person.getPersonContactDetails().add(existingSecondary); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + mapper.mergePersonContactDetails(person); + + assertFalse(existingPrimary.isPrimaryContact()); + assertTrue(existingSecondary.isPrimaryContact()); + assertEquals(2, person.getPersonContactDetails().size()); + } + + // ------------------- + // mergePersonAddress + // ------------------- + + @Test + void testMergePersonAddressNullPerson() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonStreet("Main St"); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mergePersonAddress(null); + assertTrue(result.isEmpty()); + } + + @Test + void testMergePersonAddressMergesAllFields() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonStreet("Main St"); + labMessage.setPersonHouseNumber("42"); + labMessage.setPersonCity("Berlin"); + labMessage.setPersonPostalCode("10115"); + + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mergePersonAddress(person); + + assertEquals(1, result.size()); + assertEquals(PersonDto.ADDRESS, result.get(0)[0]); + LocationDto address = person.getAddress(); + assertEquals("Main St", address.getStreet()); + assertEquals("42", address.getHouseNumber()); + assertEquals("Berlin", address.getCity()); + assertEquals("10115", address.getPostalCode()); + } + + @Test + void testMergePersonAddressDoesNotOverwriteWithNull() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + // street is null in message, only city is set + labMessage.setPersonCity("Hamburg"); + + PersonDto person = PersonDto.build(); + person.getAddress().setStreet("Existing Street"); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + mapper.mergePersonAddress(person); + + // street must be preserved because message has null + assertEquals("Existing Street", person.getAddress().getStreet()); + assertEquals("Hamburg", person.getAddress().getCity()); + } + + // --------------------------------------------- + // mapAdditionalPersonContactDetails (via JSON) + // ---------------------------------------------- + + @Test + void testMapAdditionalPersonContactDetailsEmpty() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mapAdditionalPersonContactDetails(person); + assertTrue(result.isEmpty()); + assertTrue(person.getPersonContactDetails().isEmpty()); + } + + @Test + void testMapAdditionalPersonContactDetailsAddsNewEntries() throws Exception { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + + PersonContactDetailDto pcd = new PersonContactDetailDto(); + pcd.setPersonContactDetailType(PersonContactDetailType.PHONE); + pcd.setContactInformation("+49555000111"); + pcd.setPrimaryContact(false); + + String json = new ObjectMapper().writeValueAsString(List.of(pcd)); + labMessage.setAdditionalPersonContactDetails(json); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + List result = mapper.mapAdditionalPersonContactDetails(person); + + assertEquals(1, result.size()); + assertEquals(PersonDto.PERSON_CONTACT_DETAILS, result.get(0)[0]); + assertEquals(1, person.getPersonContactDetails().size()); + assertEquals("+49555000111", person.getPersonContactDetails().get(0).getContactInformation()); + } + + @Test + void testMapAdditionalPersonContactDetailsSkipsDuplicates() throws Exception { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + + // pre-populate with the same entry + PersonContactDetailDto existing = PersonContactDetailDto + .build(person.toReference(), false, PersonContactDetailType.PHONE, null, null, "+49555000111", null, false, null, null); + person.getPersonContactDetails().add(existing); + + PersonContactDetailDto pcd = new PersonContactDetailDto(); + pcd.setPersonContactDetailType(PersonContactDetailType.PHONE); + pcd.setContactInformation("+49555000111"); + + String json = new ObjectMapper().writeValueAsString(List.of(pcd)); + labMessage.setAdditionalPersonContactDetails(json); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + List result = mapper.mapAdditionalPersonContactDetails(person); + + // nothing actually added – duplicate skipped + assertTrue(result.isEmpty()); + assertEquals(1, person.getPersonContactDetails().size()); + } + + // ---------------------------------------- + // mapAdditionalPersonAddresses (via JSON) + // ---------------------------------------- + + @Test + void testMapAdditionalPersonAddressesEmpty() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mapAdditionalPersonAddresses(person); + assertTrue(result.isEmpty()); + assertTrue(person.getAddresses().isEmpty()); + } + + @Test + void testMapAdditionalPersonAddressesAddsEntries() throws Exception { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + + LocationDto location = LocationDto.build(); + location.setStreet("Secondary St"); + location.setCity("Munich"); + + String json = new ObjectMapper().writeValueAsString(List.of(location)); + labMessage.setAdditionalPersonAddresses(json); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + List result = mapper.mapAdditionalPersonAddresses(person); + + assertEquals(1, result.size()); + assertEquals(PersonDto.ADDRESSES, result.get(0)[0]); + assertEquals(1, person.getAddresses().size()); + assertEquals("Secondary St", person.getAddresses().get(0).getStreet()); + assertEquals("Munich", person.getAddresses().get(0).getCity()); + } + + @Test + void testMapAdditionalPersonAddressesAppendsDuplicates() throws Exception { + // No deduplication is performed for additional addresses – appending is expected + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + + LocationDto location = LocationDto.build(); + location.setStreet("Duplicate St"); + + String json = new ObjectMapper().writeValueAsString(List.of(location, location)); + labMessage.setAdditionalPersonAddresses(json); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + mapper.mapAdditionalPersonAddresses(person); + + assertEquals(2, person.getAddresses().size()); + } + + // ---------------- + // mapGuardianData + // ---------------- + + @Test + void testMapGuardianDataNoBothNamesNoContacts() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mapGuardianData(person); + + assertTrue(result.isEmpty()); + assertNull(person.getNamesOfGuardians()); + } + + @Test + void testMapGuardianDataSetsGuardianName() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonGuardianFirstName("Jane"); + labMessage.setPersonGuardianLastName("Doe"); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + List result = mapper.mapGuardianData(person); + + assertTrue(result.stream().anyMatch(f -> PersonDto.NAMES_OF_GUARDIANS.equals(f[0]))); + assertEquals("Jane Doe", person.getNamesOfGuardians()); + assertTrue(person.isIncapacitated()); + assertFalse(person.isEmancipated()); + } + + @Test + void testMapGuardianDataAddsGuardianEmail() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonGuardianFirstName("Jane"); + labMessage.setPersonGuardianLastName("Doe"); + labMessage.setPersonGuardianEmail("guardian@example.com"); + labMessage.setPersonGuardianRelationship("Mother"); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + mapper.mapGuardianData(person); + + PersonContactDetailDto emailPcd = person.getPersonContactDetails() + .stream() + .filter(p -> PersonContactDetailType.EMAIL.equals(p.getPersonContactDetailType())) + .findFirst() + .orElse(null); + assertNotNull(emailPcd); + assertEquals("guardian@example.com", emailPcd.getContactInformation()); + assertTrue(emailPcd.isThirdParty()); + assertFalse(emailPcd.isPrimaryContact()); + assertEquals("Mother", emailPcd.getThirdPartyRole()); + assertEquals("Jane Doe", emailPcd.getThirdPartyName()); + } + + @Test + void testMapGuardianDataAddsGuardianPhone() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonGuardianFirstName("John"); + labMessage.setPersonGuardianLastName("Smith"); + labMessage.setPersonGuardianPhone("+49777111222"); + labMessage.setPersonGuardianRelationship("Father"); + PersonDto person = PersonDto.build(); + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + + mapper.mapGuardianData(person); + + PersonContactDetailDto phonePcd = person.getPersonContactDetails() + .stream() + .filter(p -> PersonContactDetailType.PHONE.equals(p.getPersonContactDetailType())) + .findFirst() + .orElse(null); + assertNotNull(phonePcd); + assertEquals("+49777111222", phonePcd.getContactInformation()); + assertTrue(phonePcd.isThirdParty()); + assertFalse(phonePcd.isPrimaryContact()); + assertEquals("Father", phonePcd.getThirdPartyRole()); + assertEquals("John Smith", phonePcd.getThirdPartyName()); + } + + @Test + void testMapGuardianDataDoesNotAddDuplicateEmail() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonGuardianFirstName("Jane"); + labMessage.setPersonGuardianLastName("Doe"); + labMessage.setPersonGuardianEmail("guardian@example.com"); + PersonDto person = PersonDto.build(); + + // pre-populate the same guardian email + PersonContactDetailDto existing = PersonContactDetailDto + .build(person.toReference(), false, PersonContactDetailType.EMAIL, null, null, "guardian@example.com", null, true, null, null); + person.getPersonContactDetails().add(existing); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + mapper.mapGuardianData(person); + + long emailCount = person.getPersonContactDetails().stream().filter(p -> "guardian@example.com".equals(p.getContactInformation())).count(); + assertEquals(1, emailCount); + } + + @Test + void testMapGuardianDataDoesNotAddDuplicatePhone() { + ExternalMessageDto labMessage = ExternalMessageDto.build(); + labMessage.setPersonGuardianFirstName("Jane"); + labMessage.setPersonGuardianLastName("Doe"); + labMessage.setPersonGuardianPhone("+49777111222"); + PersonDto person = PersonDto.build(); + + PersonContactDetailDto existing = PersonContactDetailDto + .build(person.toReference(), false, PersonContactDetailType.PHONE, null, null, "+49777111222", null, true, null, null); + person.getPersonContactDetails().add(existing); + + ExternalMessageMapper mapper = new ExternalMessageMapper(labMessage, getExternalMessageProcessingFacade()); + mapper.mapGuardianData(person); + + long phoneCount = person.getPersonContactDetails().stream().filter(p -> "+49777111222".equals(p.getContactInformation())).count(); + assertEquals(1, phoneCount); + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/notifier/CaseNotifierSideViewController.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/notifier/CaseNotifierSideViewController.java index 5148faf5149..acfd0b3fb06 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/notifier/CaseNotifierSideViewController.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/caze/notifier/CaseNotifierSideViewController.java @@ -343,13 +343,24 @@ private void openEditWindow(CaseDataDto caze, NotifierDto notifier, String title * the form containing the values */ private void updateSurveillanceReportFromForm(SurveillanceReportDto surveillanceReport, CaseNotifierForm notifierForm) { - // Update report date from notification date - final LocalDate notificationDate = notifierForm.getNotificationDate(); - surveillanceReport.setReportDate(Date.from(notificationDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + + // Update report date from notification date if provided + if (notifierForm.getNotificationDate() != null) { + surveillanceReport.setReportDate(Date.from(notifierForm.getNotificationDate().atStartOfDay(ZoneId.systemDefault()).toInstant())); + } else { + // if no notification date is provided, use the current date as long as the report date is not set + if (surveillanceReport.getReportDate() == null) { + surveillanceReport.setReportDate(new Date()); + } + } // Update diagnosis date if provided final LocalDate diagnosticDate = notifierForm.getDiagnosticDate(); - surveillanceReport.setDateOfDiagnosis(Date.from(diagnosticDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + if (diagnosticDate != null) { + surveillanceReport.setDateOfDiagnosis(Date.from(diagnosticDate.atStartOfDay(ZoneId.systemDefault()).toInstant())); + } else { + surveillanceReport.setDateOfDiagnosis(null); + } // Update treatment based on selected option final TreatmentOption selectedOption = notifierForm.getSelectedTreatmentOption(); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/doctordeclaration/DoctorDeclarationMessageProcessingFlow.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/doctordeclaration/DoctorDeclarationMessageProcessingFlow.java index 91a7b2a09d7..9e74e8160f3 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/doctordeclaration/DoctorDeclarationMessageProcessingFlow.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/doctordeclaration/DoctorDeclarationMessageProcessingFlow.java @@ -44,7 +44,6 @@ import de.symeda.sormas.api.caze.CaseSelectionDto; import de.symeda.sormas.api.contact.ContactDto; import de.symeda.sormas.api.contact.SimilarContactDto; -import de.symeda.sormas.api.customizableenum.CustomEnumNotFoundException; import de.symeda.sormas.api.event.EventDto; import de.symeda.sormas.api.event.EventIndexDto; import de.symeda.sormas.api.event.EventParticipantDto; @@ -63,10 +62,6 @@ import de.symeda.sormas.api.i18n.I18nProperties; import de.symeda.sormas.api.i18n.Strings; import de.symeda.sormas.api.infrastructure.facility.FacilityReferenceDto; -import de.symeda.sormas.api.person.OccupationType; -import de.symeda.sormas.api.person.PersonContactDetailDto; -import de.symeda.sormas.api.person.PersonContactDetailType; -import de.symeda.sormas.api.person.PersonContext; import de.symeda.sormas.api.person.PersonDto; import de.symeda.sormas.api.person.notifier.NotifierDto; import de.symeda.sormas.api.sample.PathogenTestDto; @@ -176,7 +171,6 @@ protected CompletionStage handleInfraDataChecks() { commitDiscardWrapperComponent.getCommitButton().setCaption(I18nProperties.getCaption(Captions.actionDone)); commitDiscardWrapperComponent.getDiscardButton().setVisible(false); // No discard button - //commitDiscardWrapperComponent.addCommitListener(() -> ret.complete(true)); commitDiscardWrapperComponent.addDiscardListener(() -> ret.complete(false)); VaadinUiUtil.showModalPopupWindow(commitDiscardWrapperComponent, I18nProperties.getCaption(Captions.info), true); @@ -298,8 +292,40 @@ protected void handlePickOrCreateEntry( // If multiple options are available, show a selection window if (optionsBuilder.size() > 1) { - ProcessingUiHelper - .showPickOrCreateEntryWindow(new EntrySelectionComponentForExternalMessage(externalMessage, optionsBuilder.build()), callback); + HandlerCallback postUpdateCallback = new HandlerCallback<>() { + + @Override + public void done(PickOrCreateEntryResult result) { + if (result.getCaze() != null) { + CaseDataDto caze = FacadeProvider.getCaseFacade().getByUuid(result.getCaze().getUuid()); + if (caze != null && caze.getPerson() != null) { + applyPersonUpdates(caze.getPerson().getUuid()); + applyNotifierUpdate(caze, externalMessage); + } + } else if (result.getContact() != null) { + ContactDto contact = FacadeProvider.getContactFacade().getByUuid(result.getContact().getUuid()); + if (contact != null && contact.getPerson() != null) { + applyPersonUpdates(contact.getPerson().getUuid()); + } + } else if (result.getEventParticipant() != null) { + EventParticipantDto ep = FacadeProvider.getEventParticipantFacade().getByUuid(result.getEventParticipant().getUuid()); + if (ep != null && ep.getPerson() != null) { + applyPersonUpdates(ep.getPerson().getUuid()); + } + } + + callback.done(result); + } + + @Override + public void cancel() { + callback.cancel(); + } + }; + + ProcessingUiHelper.showPickOrCreateEntryWindow( + new EntrySelectionComponentForExternalMessage(externalMessage, optionsBuilder.build()), + postUpdateCallback); } else { // If only one option is available, directly proceed with it callback.done(optionsBuilder.getSingleAvailableCreateResult()); @@ -322,136 +348,83 @@ protected void handlePickOrCreateEntry( protected void handleCreateCase(CaseDataDto caze, PersonDto person, ExternalMessageDto externalMessage, HandlerCallback callback) { LOGGER.debug("Handling create case for case: {}, person: {}, externalMessage: {}", caze, person, externalMessage); - HandlerCallback updateNotifierCallback = new HandlerCallback() { + ExternalMessageProcessingUIHelper.showCreateCaseWindow(caze, person, externalMessage, getMapper(), new HandlerCallback() { @Override public void done(CaseDataDto result) { - // If the external message contains notifier information, update the case with notifier details - if (externalMessage.getNotifierRegistrationNumber() != null) { - NotifierDto notifierDto = new NotifierDto(); - notifierDto.setRegistrationNumber(externalMessage.getNotifierRegistrationNumber()); - notifierDto.setFirstName(externalMessage.getNotifierFirstName()); - notifierDto.setLastName(externalMessage.getNotifierLastName()); - notifierDto.setAddress(externalMessage.getNotifierAddress()); - notifierDto.setPhone(externalMessage.getNotifierPhone()); - notifierDto.setEmail(externalMessage.getNotifierEmail()); - if (externalMessage.getReporterName() != null && externalMessage.getReporterName().contains("-")) { - // Split the reporter name into first and last names if it contains a hyphen - // Some names may already contain hyphens, assume first parts are the first name and last parts are the last name - final String[] nameParts = externalMessage.getReporterName().split("-"); - notifierDto.setAgentFirstName( - Arrays.stream(nameParts).limit(nameParts.length - 1).map(String::trim).collect(Collectors.joining(" "))); - notifierDto.setAgentLastName(nameParts.length > 0 ? nameParts[nameParts.length - 1].trim() : ""); - } - // Update the case with notifier details and complete the callback - callback.done(getExternalMessageProcessingFacade().updateAndSetCaseNotifier(result.getUuid(), notifierDto)); - return; + if (result.getPerson() != null) { + applyPersonUpdates(result.getPerson().getUuid()); } - // If no notifier information is present, complete the callback with the result - callback.done(result); + callback.done(applyNotifierUpdate(result, externalMessage)); } @Override public void cancel() { - // Handle cancellation of the operation callback.cancel(); } - }; - - HandlerCallback postUpdatePersonCallback = new HandlerCallback() { + }); + } - @Override - public void done(CaseDataDto result) { - // Additional person processing after case creation (needed for fields that are not visible in the person creation form) + /** + * Fetches the person by UUID, applies all relevant external message data + * (address, contact details, guardian, occupation), then persists the person in a single save. + * + * @param personUuid + * The UUID of the person to update. + */ + private void applyPersonUpdates(String personUuid) { - PersonDto casePerson = getExternalMessageProcessingFacade().getPersonByContext(PersonContext.CASE, result.getUuid()); + if (personUuid == null) { + return; + } - if (casePerson == null) { - updateNotifierCallback.done(result); - return; - } + final PersonDto person = FacadeProvider.getPersonFacade().getByUuid(personUuid); - boolean doUpdate = false; - - final String nameOfGuardian = - String - .format( - "%s %s", - externalMessage.getPersonGuardianFirstName() != null ? externalMessage.getPersonGuardianFirstName() : "", - externalMessage.getPersonGuardianLastName() != null ? externalMessage.getPersonGuardianLastName() : "") - .trim(); - - if (!nameOfGuardian.isBlank()) { - casePerson.setNamesOfGuardians(nameOfGuardian); - // we need to set both the incapacitated and emancipated fields, otherwise the person will not be shown in the UI - casePerson.setIncapacitated(true); - casePerson.setEmancipated(false); - doUpdate = true; - } + if (person == null) { + return; + } - if (externalMessage.getPersonGuardianEmail() != null && !externalMessage.getPersonGuardianEmail().isBlank()) { - List contactDetails = casePerson.getPersonContactDetails(); - - if (contactDetails.stream().noneMatch(pc -> externalMessage.getPersonGuardianEmail().equals(pc.getContactInformation()))) { - final PersonContactDetailDto pcd = new PersonContactDetailDto(); - pcd.setPerson(casePerson.toReference()); - pcd.setPrimaryContact(false); - pcd.setPersonContactDetailType(PersonContactDetailType.EMAIL); - pcd.setContactInformation(externalMessage.getPersonGuardianEmail()); - pcd.setThirdParty(true); - pcd.setThirdPartyRole(externalMessage.getPersonGuardianRelationship()); - pcd.setThirdPartyName(nameOfGuardian); - - contactDetails.add(pcd); - doUpdate = true; - } - } + getMapper().mergePersonAddress(person); + getMapper().mergePersonContactDetails(person); + getMapper().mapGuardianData(person); + getMapper().mapOccupationData(person); - if (externalMessage.getPersonGuardianPhone() != null && !externalMessage.getPersonGuardianPhone().isBlank()) { - List contactDetails = casePerson.getPersonContactDetails(); - - if (contactDetails.stream().noneMatch(pc -> externalMessage.getPersonGuardianPhone().equals(pc.getContactInformation()))) { - final PersonContactDetailDto pcd = new PersonContactDetailDto(); - pcd.setPerson(casePerson.toReference()); - pcd.setPrimaryContact(false); - pcd.setPersonContactDetailType(PersonContactDetailType.PHONE); - pcd.setContactInformation(externalMessage.getPersonGuardianPhone()); - pcd.setThirdParty(true); - pcd.setThirdPartyRole(externalMessage.getPersonGuardianRelationship()); - pcd.setThirdPartyName(nameOfGuardian); - - contactDetails.add(pcd); - doUpdate = true; - } - } + getExternalMessageProcessingFacade().updatePerson(person); + } - if (externalMessage.getPersonOccupation() != null && !externalMessage.getPersonOccupation().isBlank()) { - try { - final OccupationType occupationTypeOther = getExternalMessageProcessingFacade().getOccupationTypeOther(); - casePerson.setOccupationType(occupationTypeOther); - casePerson.setOccupationDetails(externalMessage.getPersonOccupation()); - doUpdate = true; - } catch (CustomEnumNotFoundException e) { - // do nothing if OccupationType OTHER custom enum is not found - } - } + /** + * Applies notifier information from the external message to the case. + * If no notifier registration number is present, the original case is returned unchanged. + * + * @param result + * The case to update. + * @param externalMessage + * The external message carrying the notifier data. + * @return The updated case, or {@code result} as-is when no notifier data is present. + */ + private CaseDataDto applyNotifierUpdate(CaseDataDto result, ExternalMessageDto externalMessage) { - if (doUpdate) { - getExternalMessageProcessingFacade().updatePerson(casePerson); - } - // Chain to the notifier callback - updateNotifierCallback.done(result); - } + if (externalMessage.getNotifierRegistrationNumber() == null) { + return result; + } - @Override - public void cancel() { - // Handle cancellation of the operation - updateNotifierCallback.cancel(); - } - }; + NotifierDto notifierDto = new NotifierDto(); + notifierDto.setRegistrationNumber(externalMessage.getNotifierRegistrationNumber()); + notifierDto.setFirstName(externalMessage.getNotifierFirstName()); + notifierDto.setLastName(externalMessage.getNotifierLastName()); + notifierDto.setAddress(externalMessage.getNotifierAddress()); + notifierDto.setPhone(externalMessage.getNotifierPhone()); + notifierDto.setEmail(externalMessage.getNotifierEmail()); + + if (externalMessage.getReporterName() != null && externalMessage.getReporterName().contains("-")) { + // Split the reporter name into first and last names if it contains a hyphen + // Some names may already contain hyphens, assume first parts are the first name and last parts are the last name + final String[] nameParts = externalMessage.getReporterName().split("-"); + notifierDto.setAgentFirstName(Arrays.stream(nameParts).limit(nameParts.length - 1).map(String::trim).collect(Collectors.joining(" "))); + notifierDto.setAgentLastName(nameParts.length > 0 ? nameParts[nameParts.length - 1].trim() : ""); + } - // Show the create case window with the provided data and callback - ExternalMessageProcessingUIHelper.showCreateCaseWindow(caze, person, externalMessage, getMapper(), postUpdatePersonCallback); + return getExternalMessageProcessingFacade().updateAndSetCaseNotifier(result.getUuid(), notifierDto); } /** @@ -480,11 +453,11 @@ protected void handleCreateContact( ControllerProvider.getContactController().getContactCreateComponent(null, false, null, true); contactCreateComponent.addCommitListener(() -> { - ExternalMessageProcessingUIHelper.updateAddressAndSavePerson( - FacadeProvider.getPersonFacade().getByUuid(contactCreateComponent.getWrappedComponent().getValue().getPerson().getUuid()), - getMapper()); - - callback.done(contactCreateComponent.getWrappedComponent().getValue()); + ContactDto createdContact = contactCreateComponent.getWrappedComponent().getValue(); + if (createdContact.getPerson() != null) { + applyPersonUpdates(createdContact.getPerson().getUuid()); + } + callback.done(createdContact); }); contactCreateComponent.addDiscardListener(callback::cancel); @@ -625,6 +598,10 @@ protected void handleCreateEventParticipant( EventParticipantDto savedDto = FacadeProvider.getEventParticipantFacade().save(dto); Notification.show(I18nProperties.getString(Strings.messageEventParticipantCreated), Notification.Type.ASSISTIVE_NOTIFICATION); + if (savedDto.getPerson() != null) { + applyPersonUpdates(savedDto.getPerson().getUuid()); + } + callback.done(savedDto); } }); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/labmessage/LabMessageProcessingFlow.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/labmessage/LabMessageProcessingFlow.java index 7675baf78b0..9924e87236d 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/labmessage/LabMessageProcessingFlow.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/labmessage/LabMessageProcessingFlow.java @@ -131,8 +131,51 @@ protected void handlePickOrCreateEntry( UserRight.EVENTPARTICIPANT_EDIT); if (optionsBuilder.size() > 1) { + HandlerCallback postUpdateCallback = new HandlerCallback<>() { + + @Override + public void done(PickOrCreateEntryResult result) { + + String personUuid = null; + + if (result.getCaze() != null) { + // we need to get the person from the case + final CaseDataDto caze = FacadeProvider.getCaseFacade().getByUuid(result.getCaze().getUuid()); + personUuid = caze != null && caze.getPerson() != null ? caze.getPerson().getUuid() : null; + } + + if (result.getContact() != null) { + // sanity check + if (personUuid != null) { + throw new IllegalStateException("Multiple selections should not happen at the same time"); + } + final ContactDto contact = FacadeProvider.getContactFacade().getByUuid(result.getContact().getUuid()); + personUuid = contact != null && contact.getPerson() != null ? contact.getPerson().getUuid() : null; + } + + if (result.getEventParticipant() != null) { + // sanity check + if (personUuid != null) { + throw new IllegalStateException("Multiple selections should not happen at the same time"); + } + final EventParticipantDto eventParticipant = + FacadeProvider.getEventParticipantFacade().getByUuid(result.getEventParticipant().getUuid()); + personUuid = eventParticipant != null && eventParticipant.getPerson() != null ? eventParticipant.getPerson().getUuid() : null; + } + + ExternalMessageProcessingUIHelper.updateAddressAndSavePerson(personUuid, getMapper()); + + callback.done(result); + } + + @Override + public void cancel() { + callback.cancel(); + } + }; + ProcessingUiHelper - .showPickOrCreateEntryWindow(new EntrySelectionComponentForExternalMessage(labMessage, optionsBuilder.build()), callback); + .showPickOrCreateEntryWindow(new EntrySelectionComponentForExternalMessage(labMessage, optionsBuilder.build()), postUpdateCallback); } else { callback.done(optionsBuilder.getSingleAvailableCreateResult()); } @@ -140,7 +183,29 @@ protected void handlePickOrCreateEntry( @Override protected void handleCreateCase(CaseDataDto caze, PersonDto person, ExternalMessageDto labMessage, HandlerCallback callback) { - ExternalMessageProcessingUIHelper.showCreateCaseWindow(caze, person, labMessage, getMapper(), callback); + + HandlerCallback postUpdateCallback = new HandlerCallback<>() { + + @Override + public void done(CaseDataDto result) { + // we need to call again the updates here + // compared to automatic processing the person is processed by the case controller + // @see{CaseController#getCaseCreateComponent} methods + // we need to load the person again from the database to get the updates following case form changes + ExternalMessageProcessingUIHelper.updateAddressAndSavePerson( + result.getPerson() != null ? result.getPerson().getUuid() : null, + getMapper()); + + callback.done(result); + } + + @Override + public void cancel() { + callback.cancel(); + } + }; + + ExternalMessageProcessingUIHelper.showCreateCaseWindow(caze, person, labMessage, getMapper(), postUpdateCallback); } @Override @@ -186,11 +251,17 @@ protected void handleCreateContact(ContactDto contact, PersonDto person, Externa ControllerProvider.getContactController().getContactCreateComponent(null, false, null, true); contactCreateComponent.addCommitListener(() -> { + // we need to call again the updates here + // compared to automatic processing the person is processed by the contact controller + // @see{ContactController#getContactCreateComponent} methods + // we need to load the person again from the database to get the updates following contact form changes + // ofc. here we have another way to do it because whoever did it was too lazy to fix the contact controller + final ContactDto processedContact = contactCreateComponent.getWrappedComponent().getValue(); ExternalMessageProcessingUIHelper.updateAddressAndSavePerson( - FacadeProvider.getPersonFacade().getByUuid(contactCreateComponent.getWrappedComponent().getValue().getPerson().getUuid()), + processedContact.getPerson() != null ? processedContact.getPerson().getUuid() : null, getMapper()); - callback.done(contactCreateComponent.getWrappedComponent().getValue()); + callback.done(processedContact); }); contactCreateComponent.addDiscardListener(callback::cancel); @@ -289,6 +360,10 @@ protected void handleCreateEventParticipant( EventParticipantDto savedDto = FacadeProvider.getEventParticipantFacade().save(dto); Notification.show(I18nProperties.getString(Strings.messageEventParticipantCreated), Notification.Type.ASSISTIVE_NOTIFICATION); + ExternalMessageProcessingUIHelper.updateAddressAndSavePerson( + savedDto.getPerson() != null ? savedDto.getPerson().getUuid() : null, + getMapper()); + callback.done(savedDto); } }); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/physiciansreport/AbstractPhysiciansReportProcessingFlow.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/physiciansreport/AbstractPhysiciansReportProcessingFlow.java index 17d2661ec79..912d3b2ccbc 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/physiciansreport/AbstractPhysiciansReportProcessingFlow.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/physiciansreport/AbstractPhysiciansReportProcessingFlow.java @@ -31,7 +31,6 @@ import de.symeda.sormas.api.person.PersonDto; import de.symeda.sormas.api.person.PersonReferenceDto; import de.symeda.sormas.api.user.UserDto; -import de.symeda.sormas.api.utils.dataprocessing.EntitySelection; import de.symeda.sormas.api.utils.dataprocessing.HandlerCallback; import de.symeda.sormas.api.utils.dataprocessing.PickOrCreateEntryResult; import de.symeda.sormas.api.utils.dataprocessing.ProcessingResult; @@ -138,9 +137,4 @@ protected void postBuildCase(CaseDataDto caseDto, ExternalMessageDto externalMes protected void postBuildPerson(PersonDto personDto, ExternalMessageDto externalMessageDto) { // No additional actions needed for physicians report } - - @Override - protected void doPersonUpdates(EntitySelection personSelection) { - // No additional actions needed for physicians report - } } diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/processing/ExternalMessageProcessingUIHelper.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/processing/ExternalMessageProcessingUIHelper.java index 79acde9242a..e03f6f07270 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/processing/ExternalMessageProcessingUIHelper.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/processing/ExternalMessageProcessingUIHelper.java @@ -146,13 +146,9 @@ public static void showCreateCaseWindow( CommitDiscardWrapperComponent caseCreateComponent = ControllerProvider.getCaseController().getCaseCreateComponent(null, null, null, null, null, true); caseCreateComponent.addCommitListener(() -> { - updateAddressAndSavePerson( - FacadeProvider.getPersonFacade().getByUuid(caseCreateComponent.getWrappedComponent().getValue().getPerson().getUuid()), - mapper); - callback.done(caseCreateComponent.getWrappedComponent().getValue()); - }); + caseCreateComponent.addDiscardListener(callback::cancel); caseCreateComponent.getWrappedComponent().setValue(caze); @@ -164,14 +160,31 @@ public static void showCreateCaseWindow( showFormWithLabMessage(labMessage, caseCreateComponent, window, I18nProperties.getString(Strings.headingCreateNewCase), false); } - public static void updateAddressAndSavePerson(PersonDto personDto, ExternalMessageMapper mapper) { - if (personDto.getAddress().getCity() == null - && personDto.getAddress().getHouseNumber() == null - && personDto.getAddress().getPostalCode() == null - && personDto.getAddress().getStreet() == null) { - mapper.mapToLocation(personDto.getAddress()); + /** + * Loads the person by UUID, merges address and contact details from the external message mapper, and saves the person. + * This should be called after a case, contact or event participant is created, because the respective controllers + * process the person data independently and we need to ensure the external message data is applied. + * Does nothing if {@code personUuid} is {@code null} or no person with that UUID exists. + * + * @param personUuid + * the UUID of the person to update + * @param mapper + * the external message mapper providing the address/contact data + */ + public static void updateAddressAndSavePerson(String personUuid, ExternalMessageMapper mapper) { + if (personUuid == null) { + return; + } + + final PersonDto person = FacadeProvider.getPersonFacade().getByUuid(personUuid); + if (person == null) { + return; } - FacadeProvider.getPersonFacade().save(personDto); + + mapper.mergePersonAddress(person); + mapper.mergePersonContactDetails(person); + // finally it is safe to save even if no changes were actually made + FacadeProvider.getPersonFacade().save(person); } public static void showEditSampleWindow(