diff --git a/README.md b/README.md index 6a1f0cb8..73255775 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ MoPat is a web-based platform designed to transform conventional paper-based que Currently MoPat supports the automatic export to the following data formats or systems: - [CDISC ODM v1.3.2](https://www.cdisc.org/standards/data-exchange/odm) -- [HL7 FHIR R3](https://hl7.org/fhir/) (R4B coming soon) +- [HL7 FHIR (STU3, R4B, R5)](https://hl7.org/fhir/) - [HL7 v2.3](https://www.hl7.org/implement/standards/product_brief.cfm?product_id=185) - [REDCap v14+](https://www.project-redcap.org/) diff --git a/db/installationInit.sql b/db/installationInit.sql index 26333ba3..35d2105c 100644 --- a/db/installationInit.sql +++ b/db/installationInit.sql @@ -115,7 +115,8 @@ INSERT INTO `configuration` (`id`, `type`, `configuration_group_id`, `parent`, ` (90, 'GENERAL', 11, 87, 9, 'FHIRViaHL7v2SendingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7SendingFacility', NULL, NULL, '87e35940-97e2-4c3e-ae6c-b35ea65717e3', '', NULL), (91, 'GENERAL', 11, 87, 10, 'FHIRViaHL7v2ReceivingApplication', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingApplication', NULL, NULL, 'd500a3a1-c1ce-4091-ace9-4557481dba37', '', NULL), (92, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2ReceivingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingFacility', NULL, NULL, '613cc529-396e-4a06-b785-158c15629d02', '', NULL), -(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL); +(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL), +(96, 'GENERAL', 1, NULL, 19, 'enableEncounterTemplateDownload', 'BOOLEAN', 'configuration.label.enableEncounterTemplateDownload', 'GLOBAL', 'configuration.label.enableEncounterTemplateDownload', NULL, NULL,'d8219938-021c-49b9-838e-1880b04e7e2e', false, NULL); diff --git a/db/installationInitTest.sql b/db/installationInitTest.sql index e5e04531..5e9535e9 100644 --- a/db/installationInitTest.sql +++ b/db/installationInitTest.sql @@ -122,7 +122,8 @@ INSERT INTO `configuration` (`id`, `type`, `configuration_group_id`, `parent`, ` (90, 'GENERAL', 11, 87, 9, 'FHIRViaHL7v2SendingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7SendingFacility', NULL, NULL, '87e35940-97e2-4c3e-ae6c-b35ea65717e3', '', NULL), (91, 'GENERAL', 11, 87, 10, 'FHIRViaHL7v2ReceivingApplication', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingApplication', NULL, NULL, 'd500a3a1-c1ce-4091-ace9-4557481dba37', '', NULL), (92, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2ReceivingFacility', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7ReceivingFacility', NULL, NULL, '613cc529-396e-4a06-b785-158c15629d02', '', NULL), -(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL); +(93, 'GENERAL', 11, 87, 11, 'FHIRViaHL7v2OBRFillerOrderNumber', 'STRING', NULL, 'de.imi.mopat.io.impl.EncounterExporterTemplateFHIR', 'configuration.label.ODMviaHL7OBRFillerOrderNumber', NULL, NULL, 'e78a6310-b2b1-4ee7-aea0-378ab33c3f73', '', NULL), +(96, 'GENERAL', 1, NULL, 19, 'enableEncounterTemplateDownload', 'BOOLEAN', 'configuration.label.enableEncounterTemplateDownload', 'GLOBAL', 'configuration.label.enableEncounterTemplateDownload', NULL, NULL,'d8219938-021c-49b9-838e-1880b04e7e2e', false, NULL); CREATE TABLE IF NOT EXISTS `clinic_configuration` ( diff --git a/db/update/v3.4.0.sql b/db/update/v3.4.0.sql new file mode 100644 index 00000000..0bb68a12 --- /dev/null +++ b/db/update/v3.4.0.sql @@ -0,0 +1,54 @@ +USE +`moPat`; + +ALTER TABLE moPat.configuration MODIFY COLUMN value TEXT DEFAULT NULL NULL; + +UPDATE export_template +SET export_template_type = REPLACE(export_template_type, 'FHIR', 'FHIR_DSTU3') +WHERE export_template_type LIKE 'FHIR'; + +UPDATE moPat.export_template +SET export_template_type = 'HL7v2' +WHERE export_template_type = 'ORBIS'; + +SELECT id +INTO @ORBIS_CONFIG_GROUP +FROM moPat.configuration_group +WHERE label_message_code = 'configurationGroup.label.ORBIS' LIMIT 1; + +SELECT id +INTO @HL7v2_CONFIG_GROUP +FROM moPat.configuration_group +WHERE label_message_code = 'configurationGroup.label.HLSeven' LIMIT 1; + +UPDATE moPat.export_template +SET configuration_group = @HL7v2_CONFIG_GROUP +WHERE configuration_group = @ORBIS_CONFIG_GROUP; + +DELETE +FROM moPat.configuration +WHERE label_message_code = 'configuration.label.exportOrbisPath'; +DELETE +FROM moPat.configuration_group +WHERE label_message_code = 'configurationGroup.label.ORBIS'; + +ALTER TABLE question + ADD COLUMN is_just_info TINYINT(1) NOT NULL DEFAULT 0; + +SELECT MAX(id) +INTO @largest_id +FROM moPat.configuration; + +-- Configuration may change with changing groups added. Please make sure this fits to your server config. +-- Adjust configuration_group_id to fit to your FHIR config group. Copy the values for all available FHIR configs on the server +-- You can find the ids with this query: +-- SELECT id FROM moPat.configuration_group WHERE configuration_group.label_message_code LIKE '%FHIR'; +-- On a server with no additional config groups, the id should be 11 +INSERT INTO moPat.configuration (`id`, `type`, `configuration_group_id`, `parent`, `position`, + `attribute`, `configuration_type`, `description_message_code`, + `class`, `label_message_code`, `test_method`, `update_method`, + `uuid`, `value`, `pattern`) +VALUES (@largest_id + 1, 'GENERAL', 1, NULL, 19, 'enableEncounterTemplateDownload', 'BOOLEAN', + 'configuration.label.enableEncounterTemplateDownload', 'GLOBAL', + 'configuration.label.enableEncounterTemplateDownload', NULL, NULL, + 'd8219938-021c-49b9-838e-1880b04e7e2e', false, NULL); diff --git a/pom.xml b/pom.xml index b9952fc9..57e4b9b3 100644 --- a/pom.xml +++ b/pom.xml @@ -169,6 +169,7 @@ + ca.uhn.hapi.fhir hapi-fhir-caching-caffeine @@ -178,16 +179,9 @@ com.github.ben-manes.caffeine caffeine - - com.google.errorprone - error_prone_annotations - - - org.checkerframework - checker-qual - + ca.uhn.hapi.fhir hapi-fhir-client diff --git a/src/main/java/de/imi/mopat/config/AppConfig.java b/src/main/java/de/imi/mopat/config/AppConfig.java index 781daa6a..bc788783 100644 --- a/src/main/java/de/imi/mopat/config/AppConfig.java +++ b/src/main/java/de/imi/mopat/config/AppConfig.java @@ -3,6 +3,10 @@ import de.imi.mopat.helper.controller.ClinicPatientDataRetrieverFactoryBean; import de.imi.mopat.helper.controller.MailSender; import de.imi.mopat.helper.controller.PatientDataRetriever; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; import org.apache.commons.lang.LocaleUtils; import org.springframework.cache.CacheManager; @@ -52,7 +56,8 @@ @ComponentScan(basePackages = {"de.imi.mopat.auth", "de.imi.mopat.config", "de.imi.mopat.controller", "de.imi.mopat.cron", "de.imi.mopat.dao", "de.imi.mopat.helper.model", "de.imi.mopat.helper.controller", "de.imi.mopat.io", "de.imi.mopat.io.impl", "de.imi.mopat.io.importer", - "de.imi.mopat.io.importer.fhir", "de.imi.mopat.io.importer.odm", "de.imi.mopat.model", "de.imi.mopat.validator"}) + "de.imi.mopat.io.importer.fhir", "de.imi.mopat.io.importer.odm", "de.imi.mopat.model", + "de.imi.mopat.validator","de.imi.mopat.service"}) @PropertySources({ @PropertySource("classpath:mopat.properties"), diff --git a/src/main/java/de/imi/mopat/controller/BundleController.java b/src/main/java/de/imi/mopat/controller/BundleController.java index ea31605c..df54549e 100644 --- a/src/main/java/de/imi/mopat/controller/BundleController.java +++ b/src/main/java/de/imi/mopat/controller/BundleController.java @@ -3,18 +3,13 @@ import de.imi.mopat.dao.AnswerDao; import de.imi.mopat.dao.BundleDao; import de.imi.mopat.dao.ConditionDao; -import de.imi.mopat.dao.ExportTemplateDao; import de.imi.mopat.dao.QuestionnaireDao; import de.imi.mopat.dao.ScoreDao; import de.imi.mopat.dao.user.AclClassDao; import de.imi.mopat.dao.user.AclObjectIdentityDao; -import de.imi.mopat.helper.controller.AuthService; -import de.imi.mopat.helper.controller.BundleService; +import de.imi.mopat.service.BundleService; import de.imi.mopat.helper.controller.LocaleHelper; -import de.imi.mopat.helper.controller.UserService; -import de.imi.mopat.helper.controller.ClinicService; -import de.imi.mopat.helper.model.BundleDTOMapper; -import de.imi.mopat.helper.model.QuestionnaireDTOMapper; +import de.imi.mopat.service.ClinicService; import de.imi.mopat.model.Answer; import de.imi.mopat.model.Bundle; import de.imi.mopat.model.BundleClinic; @@ -26,7 +21,6 @@ import de.imi.mopat.model.conditions.SelectAnswerCondition; import de.imi.mopat.model.conditions.SliderAnswerThresholdCondition; import de.imi.mopat.model.dto.BundleDTO; -import de.imi.mopat.model.dto.BundleQuestionnaireDTO; import de.imi.mopat.model.dto.QuestionnaireDTO; import de.imi.mopat.validator.BundleDTOValidator; diff --git a/src/main/java/de/imi/mopat/controller/ClinicController.java b/src/main/java/de/imi/mopat/controller/ClinicController.java index 251dc736..e64e5746 100644 --- a/src/main/java/de/imi/mopat/controller/ClinicController.java +++ b/src/main/java/de/imi/mopat/controller/ClinicController.java @@ -13,14 +13,14 @@ import de.imi.mopat.helper.model.BundleDTOMapper; import de.imi.mopat.helper.model.ClinicConfigurationDTOMapper; import de.imi.mopat.helper.model.ClinicDTOMapper; -import de.imi.mopat.helper.controller.UserService; +import de.imi.mopat.service.UserService; import de.imi.mopat.model.Bundle; import de.imi.mopat.model.BundleClinic; import de.imi.mopat.model.Clinic; -import de.imi.mopat.helper.controller.ClinicConfigurationMappingService; -import de.imi.mopat.helper.controller.ClinicConfigurationService; -import de.imi.mopat.helper.controller.ClinicService; -import de.imi.mopat.helper.controller.ConfigurationService; +import de.imi.mopat.service.ClinicConfigurationMappingService; +import de.imi.mopat.service.ClinicConfigurationService; +import de.imi.mopat.service.ClinicService; +import de.imi.mopat.service.ConfigurationService; import de.imi.mopat.model.*; import de.imi.mopat.model.dto.*; import de.imi.mopat.model.user.User; diff --git a/src/main/java/de/imi/mopat/controller/ConditionController.java b/src/main/java/de/imi/mopat/controller/ConditionController.java index 50a78c66..c4aebfd3 100644 --- a/src/main/java/de/imi/mopat/controller/ConditionController.java +++ b/src/main/java/de/imi/mopat/controller/ConditionController.java @@ -5,7 +5,7 @@ import de.imi.mopat.dao.ConditionDao; import de.imi.mopat.dao.QuestionDao; import de.imi.mopat.dao.QuestionnaireDao; -import de.imi.mopat.helper.controller.ConditionService; +import de.imi.mopat.service.ConditionService; import de.imi.mopat.helper.model.BundleDTOMapper; import de.imi.mopat.helper.model.ConditionDTOMapper; import de.imi.mopat.model.Answer; diff --git a/src/main/java/de/imi/mopat/controller/ConfigurationController.java b/src/main/java/de/imi/mopat/controller/ConfigurationController.java index 254044f0..8a1a4fdf 100644 --- a/src/main/java/de/imi/mopat/controller/ConfigurationController.java +++ b/src/main/java/de/imi/mopat/controller/ConfigurationController.java @@ -3,7 +3,7 @@ import com.mchange.v1.db.sql.UnsupportedTypeException; import de.imi.mopat.dao.ConfigurationDao; import de.imi.mopat.dao.ConfigurationGroupDao; -import de.imi.mopat.helper.controller.ConfigurationService; +import de.imi.mopat.service.ConfigurationService; import de.imi.mopat.helper.controller.MailSender; import de.imi.mopat.helper.controller.MultiPartFileUploadBean; import de.imi.mopat.helper.controller.StringUtilities; diff --git a/src/main/java/de/imi/mopat/controller/EncounterController.java b/src/main/java/de/imi/mopat/controller/EncounterController.java index b3440db8..f6c89d3f 100644 --- a/src/main/java/de/imi/mopat/controller/EncounterController.java +++ b/src/main/java/de/imi/mopat/controller/EncounterController.java @@ -9,10 +9,10 @@ import de.imi.mopat.dao.EncounterScheduledDao; import de.imi.mopat.dao.ExportTemplateDao; import de.imi.mopat.helper.controller.ApplicationMailer; -import de.imi.mopat.helper.controller.ClinicService; +import de.imi.mopat.helper.controller.FileUtils; import de.imi.mopat.helper.model.BundleDTOMapper; -import de.imi.mopat.helper.model.EncounterScheduledDTOMapper; import de.imi.mopat.helper.model.EncounterDTOMapper; +import de.imi.mopat.helper.model.EncounterScheduledDTOMapper; import de.imi.mopat.io.EncounterExporter; import de.imi.mopat.model.Bundle; import de.imi.mopat.model.BundleClinic; @@ -31,12 +31,17 @@ import de.imi.mopat.model.user.Authority; import de.imi.mopat.model.user.User; import de.imi.mopat.model.user.UserRole; +import de.imi.mopat.service.AuditService; +import de.imi.mopat.service.ClinicService; +import de.imi.mopat.service.EncounterScheduledService; +import de.imi.mopat.service.MailSendingStatus; +import de.imi.mopat.service.EncounterExportService; import de.imi.mopat.validator.EncounterScheduledDTOValidator; import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Arrays; -import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Date; @@ -49,6 +54,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; @@ -80,6 +89,8 @@ public class EncounterController { @Autowired(required = false) private EncounterExporter encounterExporter; @Autowired + private EncounterExportService encounterExportService; + @Autowired private EncounterScheduledDao encounterScheduledDao; @Autowired private EncounterScheduledDTOValidator encounterScheduledDTOValidator; @@ -99,6 +110,12 @@ public class EncounterController { private ClinicService clinicService; @Autowired private ClinicDao clinicDao; + @Autowired + private FileUtils fileUtils; + @Autowired + private EncounterScheduledService encounterSchedulingService; + @Autowired + private AuditService auditService; /** * Collects all emails to set for the encounterScheduledDTOs replyMails. @@ -223,6 +240,8 @@ public String showEncounter(@RequestParam(required = true, value = "id") final L "showEncounter(" + encounterId + ", model)", encounter.getCaseNumber(), patientAttributes, AuditEntryActionType.READ); model.addAttribute("encounter", encounter); + model.addAttribute("downloadEnabled", + configurationDao.isEncounterTemplateDownloadEnabled()); return "encounter/show"; } @@ -255,6 +274,75 @@ public String exportEncounterTemplate( return "redirect:/encounter/show?id=" + encounterId; } + /** + * Controls the HTTP GET requests for the URL /encounter/downloadexport. Used for the + * manual, on-demand download of an export template's content without persisting or transmitting + * it. Unlike {@link #exportEncounterTemplate}, this endpoint returns the export content + * directly as a file download instead of redirecting. + * + * @param encounterId The id from the specific encounter. + * @param templateId The id from the template to be downloaded. + * @return The assembled export content as a downloadable file. + */ + @GetMapping(value = "/encounter/downloadexport") + @PreAuthorize("hasRole('ROLE_ENCOUNTERMANAGER')") + public ResponseEntity downloadEncounterTemplate( + @RequestParam(required = true, value = "id") final Long encounterId, + @RequestParam(required = true, value = "templateid") final Long templateId) + throws Exception { + + if (!Boolean.TRUE.equals(configurationDao.isEncounterTemplateDownloadEnabled())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + Encounter encounter = encounterDao.getElementById(encounterId); + ExportTemplate exportTemplate = exportTemplateDao.getElementById(templateId); + + + if (!isFileExportEnabled(exportTemplate)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + String exportContent = encounterExportService.getExportContent(encounter, exportTemplate); + + Set patientAttributes = new HashSet<>(); + patientAttributes.add(AuditPatientAttribute.CASE_NUMBER); + patientAttributes.add(AuditPatientAttribute.TREATMENT_DATA); + auditEntryDao.writeAuditEntry(this.getClass().getSimpleName(), + "downloadEncounterTemplate(" + encounterId + ", " + templateId + ")", + encounter.getCaseNumber(), patientAttributes, AuditEntryActionType.READ); + + String fileExtension = exportTemplate.getExportTemplateType().getFileExtension(); + String filename = encounter.getCaseNumber() + "_" + exportTemplate.getOriginalFilename() + + "." + fileExtension; + + MediaType contentType = resolveContentType(fileExtension); + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(exportContent.getBytes(StandardCharsets.UTF_8)); + } + + private MediaType resolveContentType(String fileExtension) { + return switch (fileExtension.toLowerCase()) { + case "xml" -> new MediaType("application", "xml", StandardCharsets.UTF_8); + case "hl7" -> new MediaType("application", "hl7-v2", StandardCharsets.UTF_8); + case "json" -> new MediaType("application", "json", StandardCharsets.UTF_8); + default -> new MediaType("text", "plain", StandardCharsets.UTF_8); + }; + } + private boolean isFileExportEnabled(final ExportTemplate exportTemplate) { + if (exportTemplate == null || exportTemplate.getConfigurationGroup() == null) { + return false; + } + + return exportTemplate.getConfigurationGroup().getConfigurations().stream() + .anyMatch(configuration -> + "exportInDirectory".equals(configuration.getAttribute()) + && Boolean.parseBoolean(configuration.getValue())); + } + + /** * Controls the HTTP GET requests for the URL * /encounter/schedule. It is used to schedule encounter series. @@ -338,25 +426,9 @@ public String saveScheduledEncounter(@RequestParam final String action, final BindingResult result, final Model model, final RedirectAttributes redirectAttributes) { - if (action.equalsIgnoreCase("cancel")) { + if (isCancelAction(action)) { return "redirect:/encounter/list?series=true"; } - - switch (encounterScheduledDTO.getEncounterScheduledSerialType()) { - case UNIQUELY: - encounterScheduledDTO.setRepeatPeriod(null); - encounterScheduledDTO.setEndDate(null); - break; - case WEEKLY: - encounterScheduledDTO.setRepeatPeriod(7); - break; - case MONTHLY: - encounterScheduledDTO.setRepeatPeriod(30); - break; - default: - break; - } - encounterScheduledDTO.setReplyMails(getReplyMails()); encounterScheduledDTOValidator.validate(encounterScheduledDTO, result); @@ -373,121 +445,37 @@ public String saveScheduledEncounter(@RequestParam final String action, new ArrayList<>(Arrays.asList(EncounterScheduledSerialType.values()))); return "encounter/schedule"; } + MailSendingStatus status= encounterSchedulingService.save(encounterScheduledDTO, encounterScheduledExecutor); - EncounterScheduled encounterScheduled = null; - Bundle bundle = bundleDao.getElementById(encounterScheduledDTO.getBundleDTO().getId()); - Clinic clinic = clinicDao.getElementById(encounterScheduledDTO.getClinicDTO().getId()); - if (encounterScheduledDTO.getId() == null) { - encounterScheduled = new EncounterScheduled(encounterScheduledDTO.getCaseNumber(), - bundle, clinic, encounterScheduledDTO.getStartDate(), - encounterScheduledDTO.getEncounterScheduledSerialType(), - encounterScheduledDTO.getEndDate(), encounterScheduledDTO.getRepeatPeriod(), - encounterScheduledDTO.getEmail(), encounterScheduledDTO.getLocale().toString(), - encounterScheduledDTO.getPersonalText(), encounterScheduledDTO.getReplyMail()); - } else { - encounterScheduled = encounterScheduledDao.getElementById( - encounterScheduledDTO.getId()); - encounterScheduled.setCaseNumber(encounterScheduledDTO.getCaseNumber()); - encounterScheduled.setBundle(bundle); - encounterScheduled.setClinic(clinic); - encounterScheduled.setEmail(encounterScheduledDTO.getEmail()); - encounterScheduled.setEncounterScheduledSerialType( - encounterScheduledDTO.getEncounterScheduledSerialType()); - encounterScheduled.setStartDate(encounterScheduledDTO.getStartDate()); - encounterScheduled.setEndDate(encounterScheduledDTO.getEndDate()); - encounterScheduled.setRepeatPeriod(encounterScheduledDTO.getRepeatPeriod()); - encounterScheduled.setLocale(encounterScheduledDTO.getLocale().toString()); - encounterScheduled.setPersonalText(encounterScheduledDTO.getPersonalText()); - if (encounterScheduledDTO.getReplyMail().equalsIgnoreCase("empty")) { - encounterScheduled.setReplyMail(null); - } else { - encounterScheduled.setReplyMail(encounterScheduledDTO.getReplyMail()); - } - } - - Calendar now = Calendar.getInstance(); - now.setTime(new Date()); - - Calendar startDay = Calendar.getInstance(); - startDay.setTime(encounterScheduledDTO.getStartDate()); - - // If the scheduled encounter is scheduled for today, - // possibly send the notification mail immediately - - if (startDay.get(Calendar.DAY_OF_MONTH) == now.get(Calendar.DAY_OF_MONTH) && - startDay.get(Calendar.MONTH) == now.get(Calendar.MONTH) && - startDay.get(Calendar.YEAR) == now.get(Calendar.YEAR) - ) { - Calendar lastExecutionTime = Calendar.getInstance(); - - if (encounterScheduledExecutor.getLastExecutionTime() != null) { - lastExecutionTime.setTime(encounterScheduledExecutor.getLastExecutionTime()); - } + switch (status){ + case SUCCESS -> redirectAttributes.addFlashAttribute("success", + messageSource.getMessage("encounterScheduled.mail.success", new Object[]{}, + LocaleContextHolder.getLocale())); + case INVALID_ADDRESS -> redirectAttributes.addFlashAttribute( + "failure", + messageSource.getMessage( + "encounterScheduled.mail.invalidMail", + new Object[]{encounterScheduledDTO.getEmail()}, + LocaleContextHolder.getLocale()) + ); + case FAILURE -> redirectAttributes.addFlashAttribute( + "failure", + messageSource.getMessage( + "encounterScheduled.mail.fail", + null, + LocaleContextHolder.getLocale()) + ); - Calendar nextExecutionTime = Calendar.getInstance(); - nextExecutionTime.setTime(encounterScheduledExecutor.getNextExecutionTime()); - - // When the last execution time of the encounterScheduledExecutor - // is not known - // and the next run will be tomorrow or - // if the last execution time was today, - // we have to send the notification email immediately. - if ((encounterScheduledExecutor.getLastExecutionTime() == null - && now.get(Calendar.DAY_OF_MONTH) != nextExecutionTime.get(Calendar.DAY_OF_MONTH)) - || (encounterScheduledExecutor.getLastExecutionTime() != null - && now.get(Calendar.DAY_OF_MONTH) == lastExecutionTime.get( - Calendar.DAY_OF_MONTH))) { - // Get date today at midnight to set the encounter's time - now.set(Calendar.MILLISECOND, 0); - now.set(Calendar.SECOND, 0); - now.set(Calendar.MINUTE, 0); - now.set(Calendar.HOUR_OF_DAY, 0); - Date today = now.getTime(); - - Encounter encounter = new Encounter(); - encounter.setEncounterScheduled(encounterScheduled); - encounter.setBundle(bundle); - encounter.setClinic(encounterScheduled.getClinic()); - bundle.addEncounter(encounter); - encounter.setCaseNumber(encounterScheduled.getCaseNumber()); - encounter.setStartTime(new Timestamp(today.getTime())); - if (encounter.sendMail(applicationMailer, messageSource, - configurationDao.getBaseURL())) { - redirectAttributes.addFlashAttribute("success", - messageSource.getMessage("encounterScheduled.mail.success", new Object[]{}, - LocaleContextHolder.getLocale())); - } else { - String failMessage = messageSource.getMessage("encounterScheduled.mail.fail", - new Object[]{}, LocaleContextHolder.getLocale()); - if (encounter.getEncounterScheduled().getMailStatus() != null - && encounter.getEncounterScheduled().getMailStatus() - .equals(EncounterScheduledMailStatus.ADDRESS_REJECTED)) { - encounterScheduled.setMailStatus( - EncounterScheduledMailStatus.ADDRESS_REJECTED); - failMessage = messageSource.getMessage( - "encounterScheduled.mail.invalidMail", - new Object[]{encounterScheduled.getEmail()}, - LocaleContextHolder.getLocale()); - } - redirectAttributes.addFlashAttribute("failure", failMessage); - } - } } - Set patientAttributes = new HashSet<>(); - patientAttributes.add(AuditPatientAttribute.CASE_NUMBER); - patientAttributes.add(AuditPatientAttribute.EMAIL_ADDRESS); - patientAttributes.add(AuditPatientAttribute.FIRST_NAME); - patientAttributes.add(AuditPatientAttribute.LAST_NAME); - patientAttributes.add(AuditPatientAttribute.DATE_OF_BIRTH); - auditEntryDao.writeAuditEntry(this.getClass().getSimpleName(), - "saveScheduledEncounter(encounterScheduledDTO, result, model," + " redirectAttributes)", - encounterScheduled.getCaseNumber(), patientAttributes, AuditEntryActionType.WRITE); - encounterScheduledDao.merge(encounterScheduled); - bundleDao.merge(bundle); return "redirect:/encounter/list"; } + private boolean isCancelAction(String action) { + return "cancel".equalsIgnoreCase(action); + } + + /** * Controls the HTTP GET Request for the URL /encounter/sendEmail. Sends a remind mail * for a given {@link Encounter} object to the patient. @@ -773,28 +761,29 @@ public String editEmail( /** * Adds ClinicDTOS to the model + * * @param model */ private void addClinicInfoToModel(Model model, User user){ boolean isAdmin = false; - for(Authority authority: user.getAuthority()){ - if(authority.getAuthority().equals(UserRole.ROLE_ADMIN.getTextValue())){ - isAdmin=true; + for (Authority authority : user.getAuthority()) { + if (authority.getAuthority().equals(UserRole.ROLE_ADMIN.getTextValue())) { + isAdmin = true; break; } } - if(isAdmin){ + if (isAdmin) { model.addAttribute("clinicDTOs", clinicService.getAllClinicsWithoutBundle()); } else { List assignedClinics = clinicService.getAssignedClinics(user); - List clinicDTOs = clinicService.transformClinicsToDTOs(false, assignedClinics); + List clinicDTOs = clinicService.transformClinicsToDTOs(false, + assignedClinics); model.addAttribute("clinicDTOs", clinicDTOs); } } private User getCurrentUser() { - return (User) SecurityContextHolder.getContext().getAuthentication() - .getPrincipal(); + return (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } } diff --git a/src/main/java/de/imi/mopat/controller/InvitationController.java b/src/main/java/de/imi/mopat/controller/InvitationController.java index 4eaceb89..cd4de606 100644 --- a/src/main/java/de/imi/mopat/controller/InvitationController.java +++ b/src/main/java/de/imi/mopat/controller/InvitationController.java @@ -8,7 +8,7 @@ import de.imi.mopat.dao.user.InvitationDao; import de.imi.mopat.dao.user.UserDao; import de.imi.mopat.helper.controller.ApplicationMailer; -import de.imi.mopat.helper.controller.InvitationService; +import de.imi.mopat.service.InvitationService; import de.imi.mopat.helper.model.ClinicDTOMapper; import de.imi.mopat.helper.controller.LocaleHelper; import de.imi.mopat.model.Clinic; diff --git a/src/main/java/de/imi/mopat/controller/QuestionnaireController.java b/src/main/java/de/imi/mopat/controller/QuestionnaireController.java index fe56c78e..d615cbbb 100644 --- a/src/main/java/de/imi/mopat/controller/QuestionnaireController.java +++ b/src/main/java/de/imi/mopat/controller/QuestionnaireController.java @@ -10,11 +10,11 @@ import de.imi.mopat.dao.QuestionDao; import de.imi.mopat.dao.QuestionnaireDao; import de.imi.mopat.dao.ScoreDao; -import de.imi.mopat.helper.controller.AuthService; +import de.imi.mopat.service.AuthService; import de.imi.mopat.helper.controller.FhirVersionHelper; import de.imi.mopat.helper.controller.LocaleHelper; import de.imi.mopat.helper.controller.QuestionnaireService; -import de.imi.mopat.helper.controller.QuestionnaireVersionGroupService; +import de.imi.mopat.service.QuestionnaireVersionGroupService; import de.imi.mopat.helper.controller.StringUtilities; import de.imi.mopat.io.MetadataExporter; import de.imi.mopat.io.impl.MetadataExporterFactory; diff --git a/src/main/java/de/imi/mopat/controller/SurveyController.java b/src/main/java/de/imi/mopat/controller/SurveyController.java index 7ac94d20..80d88c2c 100644 --- a/src/main/java/de/imi/mopat/controller/SurveyController.java +++ b/src/main/java/de/imi/mopat/controller/SurveyController.java @@ -34,6 +34,10 @@ import de.imi.mopat.model.dto.ResponseDTO; import de.imi.mopat.model.score.Score; import de.imi.mopat.model.user.User; +import de.imi.mopat.service.AuthService; +import de.imi.mopat.service.ClinicConfigurationMappingService; +import de.imi.mopat.service.ClinicService; +import de.imi.mopat.service.SurveyService; import de.imi.mopat.validator.MoPatValidator; import java.sql.Timestamp; @@ -123,6 +127,8 @@ public class SurveyController { private ClinicDTOMapper clinicDTOMapper; @Autowired private Validator validator; + @Autowired + private SurveyService surveyService; // Initialize every needed configuration information as a final string private final String className = this.getClass().getName(); @@ -455,6 +461,7 @@ public String showBundles( // Add the map to the model model.addAttribute("bundleLanguageEncounterMap", bundleLanguageEncounterMap); + model.addAttribute("hideProfile", Boolean.FALSE); return "mobile/survey/bundles"; } diff --git a/src/main/java/de/imi/mopat/controller/UserController.java b/src/main/java/de/imi/mopat/controller/UserController.java index a424ace3..297dcddb 100644 --- a/src/main/java/de/imi/mopat/controller/UserController.java +++ b/src/main/java/de/imi/mopat/controller/UserController.java @@ -12,11 +12,11 @@ import de.imi.mopat.dao.user.PinAuthorizationDao; import de.imi.mopat.dao.user.UserDao; import de.imi.mopat.helper.controller.ApplicationMailer; -import de.imi.mopat.helper.controller.AuthService; +import de.imi.mopat.service.AuthService; import de.imi.mopat.helper.controller.Constants; import de.imi.mopat.model.Clinic; import de.imi.mopat.model.enumeration.PermissionType; -import de.imi.mopat.helper.controller.UserService; +import de.imi.mopat.service.UserService; import de.imi.mopat.model.user.AclEntry; import de.imi.mopat.model.user.Authority; import de.imi.mopat.model.user.ForgotPasswordToken; diff --git a/src/main/java/de/imi/mopat/cron/FrequentEncounterDeletor.java b/src/main/java/de/imi/mopat/cron/FrequentEncounterDeletor.java index fdfb1fe3..29e73a45 100644 --- a/src/main/java/de/imi/mopat/cron/FrequentEncounterDeletor.java +++ b/src/main/java/de/imi/mopat/cron/FrequentEncounterDeletor.java @@ -1,27 +1,26 @@ package de.imi.mopat.cron; +import de.imi.mopat.dao.AuditEntryDao; +import de.imi.mopat.dao.BundleDao; +import de.imi.mopat.dao.ConfigurationDao; +import de.imi.mopat.dao.EncounterDao; +import de.imi.mopat.dao.EncounterScheduledDao; +import de.imi.mopat.helper.controller.Constants; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.enumeration.AuditEntryActionType; +import de.imi.mopat.model.enumeration.AuditPatientAttribute; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; - import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import de.imi.mopat.dao.AuditEntryDao; -import de.imi.mopat.dao.BundleDao; -import de.imi.mopat.dao.ConfigurationDao; -import de.imi.mopat.dao.EncounterDao; -import de.imi.mopat.dao.EncounterScheduledDao; -import de.imi.mopat.helper.controller.Constants; -import de.imi.mopat.model.Bundle; -import de.imi.mopat.model.enumeration.AuditEntryActionType; -import de.imi.mopat.model.enumeration.AuditPatientAttribute; -import de.imi.mopat.model.Encounter; -import de.imi.mopat.model.EncounterScheduled; /** * This class checks on a regular basis (set in the mopat.properties) (see @@ -72,144 +71,145 @@ public class FrequentEncounterDeletor { */ @Scheduled(cron = "${de.imi.mopat.cron.FrequentEncounterDeletor" + ".checkTime}") public void deleteOldEncounters() { - //First delete the independent encounters - boolean deleteFinishedEncounters = true; - Long finishedEncounterTimeWindowInMillis = configurationDao.getFinishedEncounterTimeWindow(); - if (finishedEncounterTimeWindowInMillis == null) { - LOGGER.info("Could not find a value for the property {}; will take " - + "the default (30 days) instead", - Constants.FINISHED_ENCOUNTER_TIME_WINDOW_IN_MILLIS); - finishedEncounterTimeWindowInMillis = THIRTY_DAYS_IN_MILLISECONDS; - } else if (finishedEncounterTimeWindowInMillis == -1) { - deleteFinishedEncounters = false; - } + List oldEncounters = collectOldEncounters(); + List oldEncounterScheduleds = collectOldEncounterScheduleds(); + + Set deletedCaseNumbers = new HashSet<>(); + deleteEncounters(oldEncounters, deletedCaseNumbers); + deleteEncounterScheduleds(oldEncounterScheduleds, deletedCaseNumbers); + + writeAuditLog(deletedCaseNumbers); + } - boolean deleteIncompleteEncounters = true; - Long incompleteEncounterTimeWindowInMillis = configurationDao.getIncompleteEncounterTimeWindow(); - if (incompleteEncounterTimeWindowInMillis == null) { - LOGGER.info("Could not find a value for the property {}; will take " - + "the default (180 days) instead", - Constants.INCOMPLETE_ENCOUNTER_TIME_WINDOW_IN_MILLIS); - incompleteEncounterTimeWindowInMillis = ONEHUNDREDEIGHTY_DAYS_IN_MILLISECONDS; - } else if (incompleteEncounterTimeWindowInMillis == -1) { - deleteIncompleteEncounters = false; + /** + * Resolves a configured time-window value, falling back to a default if not set, + * or returning null if the window is explicitly disabled (-1). + */ + private Long resolveTimeWindow(Long configuredValue, long defaultValue, String propertyName) { + if (configuredValue == null) { + LOGGER.info("Could not find a value for the property {}; will take the default ({} ms) instead", + propertyName, defaultValue); + return defaultValue; } + if (configuredValue == -1) { + return null; // disabled + } + return configuredValue; + } + private Timestamp nowMinus(long millis) { + return new Timestamp(System.currentTimeMillis() - millis); + } + + private List collectOldEncounters() { List oldEncounters = new ArrayList<>(); - Timestamp nowMinusFinishedEncounterTimeWindow = new Timestamp( - System.currentTimeMillis() - finishedEncounterTimeWindowInMillis); - Timestamp nowMinusInclompleteEncounterTimeWindow = new Timestamp( - System.currentTimeMillis() - incompleteEncounterTimeWindowInMillis); + Long finishedWindow = resolveTimeWindow( + configurationDao.getFinishedEncounterTimeWindow(), + THIRTY_DAYS_IN_MILLISECONDS, + Constants.FINISHED_ENCOUNTER_TIME_WINDOW_IN_MILLIS); + + Long incompleteWindow = resolveTimeWindow( + configurationDao.getIncompleteEncounterTimeWindow(), + ONEHUNDREDEIGHTY_DAYS_IN_MILLISECONDS, + Constants.INCOMPLETE_ENCOUNTER_TIME_WINDOW_IN_MILLIS); + try { - if (deleteFinishedEncounters) { - oldEncounters.addAll(encounterDao.getFinishedEncounterOlderThan( - nowMinusFinishedEncounterTimeWindow)); + if (finishedWindow != null) { + oldEncounters.addAll(encounterDao.getFinishedEncounterOlderThan(nowMinus(finishedWindow))); } - if (deleteIncompleteEncounters) { - oldEncounters.addAll(encounterDao.getIncompleteEncountersOlderThan( - nowMinusInclompleteEncounterTimeWindow)); + if (incompleteWindow != null) { + oldEncounters.addAll(encounterDao.getIncompleteEncountersOlderThan(nowMinus(incompleteWindow))); } } catch (Exception e) { - LOGGER.error("Something went wrong while checking for old finished " - + "Encounters. Since this is important for not " - + "having a database of old finished encounters, " + "investigate this error ASAP", - e); + LOGGER.error("Something went wrong while checking for old finished Encounters. " + + "Since this is important for not having a database of old finished encounters, " + + "investigate this error ASAP", e); } + return oldEncounters; + } - //Now delete all encounters and encounters scheduled that are connected - boolean deleteFinishedEncounterScheduleds = true; - Long finishedEncounterScheduledTimeWindowInMillis = configurationDao.getFinishedEncounterScheduledTimeWindow(); - if (finishedEncounterScheduledTimeWindowInMillis == null) { - LOGGER.info("Could not find a value for the property {}; will take " - + "the default (90 days) instead", - Constants.FINISHED_ENCOUNTER_SCHEDULED_TIME_WINDOW_IN_MILLIS); - finishedEncounterScheduledTimeWindowInMillis = NINETY_DAYS_IN_MILLISECONDS; - } else if (finishedEncounterScheduledTimeWindowInMillis == -1) { - deleteFinishedEncounterScheduleds = false; - } + private List collectOldEncounterScheduleds() { + List oldEncounterScheduleds = new ArrayList<>(); - boolean deleteIncompleteEncounterScheduleds = true; - Long incompleteEncounterScheduledTimeWindowInMillis = configurationDao.getIncompleteEncounterScheduledTimeWindow(); - if (incompleteEncounterScheduledTimeWindowInMillis == null) { - LOGGER.info("Could not find a value for the property {}; will take " - + "the default (180 days) instead", - Constants.INCOMPLETE_ENCOUNTER_SCHEDULED_TIME_WINDOW_IN_MILLIS); - incompleteEncounterScheduledTimeWindowInMillis = ONEHUNDREDEIGHTY_DAYS_IN_MILLISECONDS; - } else if (incompleteEncounterScheduledTimeWindowInMillis == -1) { - deleteIncompleteEncounterScheduleds = false; - } + Long finishedWindow = resolveTimeWindow( + configurationDao.getFinishedEncounterScheduledTimeWindow(), + NINETY_DAYS_IN_MILLISECONDS, + Constants.FINISHED_ENCOUNTER_SCHEDULED_TIME_WINDOW_IN_MILLIS); - List oldEncounterScheduleds = new ArrayList<>(); + Long incompleteWindow = resolveTimeWindow( + configurationDao.getIncompleteEncounterScheduledTimeWindow(), + ONEHUNDREDEIGHTY_DAYS_IN_MILLISECONDS, + Constants.INCOMPLETE_ENCOUNTER_SCHEDULED_TIME_WINDOW_IN_MILLIS); - Timestamp nowMinusFinishedEncounterScheduledTimeWindow = new Timestamp( - System.currentTimeMillis() - finishedEncounterScheduledTimeWindowInMillis); - Timestamp nowMinusInclompleteEncounterScheduledTimeWindow = new Timestamp( - System.currentTimeMillis() - incompleteEncounterScheduledTimeWindowInMillis); try { - if (deleteFinishedEncounterScheduleds) { + if (finishedWindow != null) { oldEncounterScheduleds.addAll( - encounterScheduledDao.getFinishedEncounterScheduledOlderThan( - nowMinusFinishedEncounterScheduledTimeWindow)); + encounterScheduledDao.getFinishedEncounterScheduledOlderThan(nowMinus(finishedWindow))); } - if (deleteIncompleteEncounterScheduleds) { - for (EncounterScheduled encounterScheduled : encounterScheduledDao.getEncounterScheduledOlderThan( - nowMinusInclompleteEncounterScheduledTimeWindow)) { - //prevent to add encounterScheduled twice - if (!oldEncounterScheduleds.contains(encounterScheduled)) { - oldEncounterScheduleds.add(encounterScheduled); + if (incompleteWindow != null) { + for (EncounterScheduled es : encounterScheduledDao.getEncounterScheduledOlderThan(nowMinus(incompleteWindow))) { + if (!oldEncounterScheduleds.contains(es)) { + oldEncounterScheduleds.add(es); } } } } catch (Exception e) { - LOGGER.error("Something went wrong while checking for old finished " - + "EncounterScheduleds. Since this is important for" - + " not having a database of old finished " - + "encounters, investigate this error ASAP", e); + LOGGER.error("Something went wrong while checking for old finished EncounterScheduleds. " + + "Since this is important for not having a database of old finished encounters, " + + "investigate this error ASAP", e); } + return oldEncounterScheduleds; + } - Set patientAttributes = new HashSet<>( - Arrays.asList(AuditPatientAttribute.values())); - Set deletedCaseNumbers = new HashSet<>(); + private void deleteEncounters(List oldEncounters, Set deletedCaseNumbers) { for (Encounter encounter : oldEncounters) { // Do not remove encounters that belong to scheduled encounters - if (encounter.getEncounterScheduled() == null) { - try { - Bundle bundle = encounter.getBundle(); - bundle.removeEncounter(encounter); - bundleDao.merge(bundle); - encounterDao.remove(encounter); - deletedCaseNumbers.add(encounter.getCaseNumber()); - } catch (Exception e) { - LOGGER.error("Something went wrong while deleting an old " - + "finished Encounter. Since this is " - + "important for not having a database of " - + "old finished encounters, investigate " + "this error ASAP", e); - } + if (encounter.getEncounterScheduled() != null) { + continue; + } + try { + Bundle bundle = encounter.getBundle(); + bundle.removeEncounter(encounter); + bundleDao.merge(bundle); + encounterDao.remove(encounter); + deletedCaseNumbers.add(encounter.getCaseNumber()); + } catch (Exception e) { + LOGGER.error("Something went wrong while deleting an old finished Encounter. " + + "Since this is important for not having a database of old finished encounters, " + + "investigate this error ASAP", e); } } + } + private void deleteEncounterScheduleds(List oldEncounterScheduleds, Set deletedCaseNumbers) { for (EncounterScheduled encounterScheduled : oldEncounterScheduleds) { try { + for(Encounter nestedEncounter: encounterScheduled.getEncounters()) { + encounterDao.removeEncounterExportTemplatesForEncounter(nestedEncounter); + } + encounterScheduledDao.remove(encounterScheduled); deletedCaseNumbers.add(encounterScheduled.getCaseNumber()); } catch (Exception e) { - LOGGER.error("Something went wrong while deleting an old " - + "EncounterScheduled. Since this is important " - + "for not having a database of old finished " - + "encounters, investigate this error ASAP", e); + LOGGER.error("Something went wrong while deleting an old EncounterScheduled. " + + "Since this is important for not having a database of old finished encounters, " + + "investigate this error ASAP", e); } } + } + private void writeAuditLog(Set deletedCaseNumbers) { + Set patientAttributes = new HashSet<>(Arrays.asList(AuditPatientAttribute.values())); try { auditEntryDao.writeAuditEntries(this.getClass().getSimpleName(), "deleteOldEncounters()", deletedCaseNumbers, patientAttributes, AuditEntryActionType.DELETE); } catch (Exception e) { - LOGGER.error("Something went wrong while writing audit logs of " + "deleted" - + " old Encounters and old " + "EncounterScheduleds. " + "Since this is " - + "important for having a complete " + "audit log, " - + "investigate this error ASAP", e); + LOGGER.error("Something went wrong while writing audit logs of deleted old Encounters " + + "and old EncounterScheduleds. Since this is important for having a complete " + + "audit log, investigate this error ASAP", e); } } + } diff --git a/src/main/java/de/imi/mopat/dao/ConfigurationDao.java b/src/main/java/de/imi/mopat/dao/ConfigurationDao.java index 50d753f1..01d98d08 100644 --- a/src/main/java/de/imi/mopat/dao/ConfigurationDao.java +++ b/src/main/java/de/imi/mopat/dao/ConfigurationDao.java @@ -169,4 +169,6 @@ public Configuration getConfigurationByGroupName(final Long clinicId, final Stri * @return The imprint text to display. */ String getImprintText(); + + Boolean isEncounterTemplateDownloadEnabled(); } diff --git a/src/main/java/de/imi/mopat/dao/EncounterDao.java b/src/main/java/de/imi/mopat/dao/EncounterDao.java index 68055949..d5a21738 100644 --- a/src/main/java/de/imi/mopat/dao/EncounterDao.java +++ b/src/main/java/de/imi/mopat/dao/EncounterDao.java @@ -1,6 +1,7 @@ package de.imi.mopat.dao; import de.imi.mopat.model.Bundle; +import jakarta.transaction.Transactional; import java.sql.Timestamp; import java.util.List; import org.springframework.stereotype.Component; @@ -125,4 +126,12 @@ Long getEncounterCountByCaseNumberByBundleInInterval(Long bundleId, String caseN * @return If there exists a {@link Encounter encounters} for a clinic */ Boolean checkEncountersExistsByClinicId(Long clinicId); + + /** + * Deletes the associated {@link de.imi.mopat.model.EncounterExportTemplate} + * instances for the {@link Encounter}. + * + * @param encounter: The encounter to process + */ + void removeEncounterExportTemplatesForEncounter(Encounter encounter); } diff --git a/src/main/java/de/imi/mopat/dao/impl/ConfigurationDaoImpl.java b/src/main/java/de/imi/mopat/dao/impl/ConfigurationDaoImpl.java index cb6d542b..29961b78 100644 --- a/src/main/java/de/imi/mopat/dao/impl/ConfigurationDaoImpl.java +++ b/src/main/java/de/imi/mopat/dao/impl/ConfigurationDaoImpl.java @@ -235,4 +235,11 @@ public String getImprintText() { Constants.CLASS_GLOBAL); return configuration.getValue(); } + + @Override + public Boolean isEncounterTemplateDownloadEnabled(){ + Configuration configuration = getConfigurationByAttributeAndClass(Constants.ENABLE_ENCOUNTER_TEMPLATE_DOWNLOAD, + Constants.CLASS_GLOBAL); + return Boolean.valueOf(configuration.getValue()); + } } diff --git a/src/main/java/de/imi/mopat/dao/impl/EncounterDaoImpl.java b/src/main/java/de/imi/mopat/dao/impl/EncounterDaoImpl.java index 2e171eb3..498e42df 100644 --- a/src/main/java/de/imi/mopat/dao/impl/EncounterDaoImpl.java +++ b/src/main/java/de/imi/mopat/dao/impl/EncounterDaoImpl.java @@ -19,6 +19,7 @@ import java.util.Collection; import java.util.Date; import jakarta.persistence.PersistenceException; +import org.springframework.transaction.annotation.Transactional; /** * @@ -163,6 +164,19 @@ public Boolean checkEncountersExistsByClinicId(Long clinicId) { } + @Override + @Transactional("MoPat") + public void removeEncounterExportTemplatesForEncounter(Encounter encounter) { + moPatEntityManager.createQuery( + "delete from EncounterExportTemplate e where e.encounter.id = :encounterId" + ) + .setParameter("encounterId", encounter.getId()) + .executeUpdate(); + + moPatEntityManager.flush(); + moPatEntityManager.clear(); + } + @Override public Long getEncounterCountByBundleInInterval(final Long bundleId, final Date startDate, Date endDate) { diff --git a/src/main/java/de/imi/mopat/helper/controller/Constants.java b/src/main/java/de/imi/mopat/helper/controller/Constants.java index 768ed53e..28fbc77b 100644 --- a/src/main/java/de/imi/mopat/helper/controller/Constants.java +++ b/src/main/java/de/imi/mopat/helper/controller/Constants.java @@ -92,4 +92,6 @@ public abstract class Constants { // Configuration: The name of the attribute for the Imprint text message public static final String IMPRINT_TEXT = "imprintText"; + + public static final String ENABLE_ENCOUNTER_TEMPLATE_DOWNLOAD = "enableEncounterTemplateDownload"; } \ No newline at end of file diff --git a/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetriever.java b/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetriever.java index e42b4a4e..7d6f7897 100644 --- a/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetriever.java +++ b/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetriever.java @@ -29,6 +29,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; +import de.imi.mopat.service.ApplicationContextService; import org.slf4j.Logger; import org.springframework.security.core.context.SecurityContextHolder; diff --git a/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetrieverByPID.java b/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetrieverByPID.java index cd4746fe..ced5a31f 100644 --- a/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetrieverByPID.java +++ b/src/main/java/de/imi/mopat/helper/controller/HL7v22PatientInformationRetrieverByPID.java @@ -23,6 +23,7 @@ import de.imi.mopat.model.enumeration.AuditPatientAttribute; import de.imi.mopat.model.enumeration.Gender; import de.imi.mopat.model.user.User; +import de.imi.mopat.service.ApplicationContextService; import org.slf4j.Logger; import org.springframework.security.core.context.SecurityContextHolder; diff --git a/src/main/java/de/imi/mopat/helper/controller/QuestionnaireService.java b/src/main/java/de/imi/mopat/helper/controller/QuestionnaireService.java index c08e472a..2d7c110b 100644 --- a/src/main/java/de/imi/mopat/helper/controller/QuestionnaireService.java +++ b/src/main/java/de/imi/mopat/helper/controller/QuestionnaireService.java @@ -26,6 +26,7 @@ import de.imi.mopat.model.user.UserRole; import de.imi.mopat.validator.LogoValidator; import de.imi.mopat.validator.QuestionnaireDTOValidator; +import de.imi.mopat.service.*; import java.awt.image.BufferedImage; import java.io.File; diff --git a/src/main/java/de/imi/mopat/helper/model/ClinicConfigurationMappingDTOMapper.java b/src/main/java/de/imi/mopat/helper/model/ClinicConfigurationMappingDTOMapper.java index e1fc2828..44adb5e2 100644 --- a/src/main/java/de/imi/mopat/helper/model/ClinicConfigurationMappingDTOMapper.java +++ b/src/main/java/de/imi/mopat/helper/model/ClinicConfigurationMappingDTOMapper.java @@ -1,8 +1,8 @@ package de.imi.mopat.helper.model; import de.imi.mopat.dao.ConfigurationGroupDao; -import de.imi.mopat.helper.controller.ClinicConfigurationMappingService; -import de.imi.mopat.helper.controller.ConfigurationService; +import de.imi.mopat.service.ClinicConfigurationMappingService; +import de.imi.mopat.service.ConfigurationService; import de.imi.mopat.model.ClinicConfigurationMapping; import de.imi.mopat.model.Configuration; import de.imi.mopat.model.ConfigurationGroup; diff --git a/src/main/java/de/imi/mopat/helper/model/ClinicDTOMapper.java b/src/main/java/de/imi/mopat/helper/model/ClinicDTOMapper.java index 516decf9..f8201c93 100644 --- a/src/main/java/de/imi/mopat/helper/model/ClinicDTOMapper.java +++ b/src/main/java/de/imi/mopat/helper/model/ClinicDTOMapper.java @@ -1,6 +1,6 @@ package de.imi.mopat.helper.model; -import de.imi.mopat.helper.controller.ClinicConfigurationMappingService; +import de.imi.mopat.service.ClinicConfigurationMappingService; import de.imi.mopat.model.Clinic; import de.imi.mopat.model.ClinicConfiguration; import de.imi.mopat.model.ClinicConfigurationMapping; diff --git a/src/main/java/de/imi/mopat/helper/model/EncounterScheduledDTOMapper.java b/src/main/java/de/imi/mopat/helper/model/EncounterScheduledDTOMapper.java index 7a9f5d1a..b39df97c 100644 --- a/src/main/java/de/imi/mopat/helper/model/EncounterScheduledDTOMapper.java +++ b/src/main/java/de/imi/mopat/helper/model/EncounterScheduledDTOMapper.java @@ -1,6 +1,11 @@ package de.imi.mopat.helper.model; +import de.imi.mopat.dao.BundleDao; +import de.imi.mopat.dao.ClinicDao; +import de.imi.mopat.dao.EncounterScheduledDao; import de.imi.mopat.helper.controller.LocaleHelper; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.Clinic; import de.imi.mopat.model.Encounter; import de.imi.mopat.model.EncounterScheduled; import de.imi.mopat.model.dto.EncounterDTO; @@ -16,11 +21,23 @@ @Component public class EncounterScheduledDTOMapper implements Function { - @Autowired - private BundleDTOMapper bundleDTOMapper; - - @Autowired - private EncounterDTOMapper encounterDTOMapper; + private final BundleDTOMapper bundleDTOMapper; + private final EncounterDTOMapper encounterDTOMapper; + private final BundleDao bundleDao; // Neu hinzugefügt + private final ClinicDao clinicDao; // Neu hinzugefügt + private final EncounterScheduledDao encounterScheduledDao; // Neu hinzugefügt + + public EncounterScheduledDTOMapper(BundleDTOMapper bundleDTOMapper, + EncounterDTOMapper encounterDTOMapper, + BundleDao bundleDao, + ClinicDao clinicDao, + EncounterScheduledDao encounterScheduledDao) { + this.bundleDTOMapper = bundleDTOMapper; + this.encounterDTOMapper = encounterDTOMapper; + this.bundleDao = bundleDao; + this.clinicDao = clinicDao; + this.encounterScheduledDao = encounterScheduledDao; + } @Override public EncounterScheduledDTO apply(EncounterScheduled encounterScheduled) { @@ -60,5 +77,41 @@ public EncounterScheduledDTO apply(EncounterScheduled encounterScheduled) { return encounterScheduledDTO; } + public EncounterScheduled mapToEntity(EncounterScheduledDTO dto) { + Bundle bundle = bundleDao.getElementById(dto.getBundleDTO().getId()); + Clinic clinic = clinicDao.getElementById(dto.getClinicDTO().getId()); + + if (dto.getId() == null) { + return new EncounterScheduled( + dto.getCaseNumber(), bundle, clinic, dto.getStartDate(), + dto.getEncounterScheduledSerialType(), dto.getEndDate(), dto.getRepeatPeriod(), + dto.getEmail(), dto.getLocale().toString(), dto.getPersonalText(), + dto.getReplyMail()); + } + + EncounterScheduled entity = encounterScheduledDao.getElementById(dto.getId()); + updateEntity(entity, dto, bundle, clinic); + return entity; + } + + private void updateEntity(EncounterScheduled entity, EncounterScheduledDTO dto, Bundle bundle, Clinic clinic) { + entity.setCaseNumber(dto.getCaseNumber()); + entity.setBundle(bundle); + entity.setClinic(clinic); + entity.setStartDate(dto.getStartDate()); + entity.setEncounterScheduledSerialType(dto.getEncounterScheduledSerialType()); + entity.setEndDate(dto.getEndDate()); + entity.setRepeatPeriod(dto.getRepeatPeriod()); + entity.setEmail(dto.getEmail()); + entity.setLocale(dto.getLocale().toString()); + entity.setPersonalText(dto.getPersonalText()); + + if ("empty".equalsIgnoreCase(dto.getReplyMail())) { + entity.setReplyMail(null); + } else { + entity.setReplyMail(dto.getReplyMail()); + } + } + } diff --git a/src/main/java/de/imi/mopat/io/EncounterExporter.java b/src/main/java/de/imi/mopat/io/EncounterExporter.java index 8f272263..17f213d0 100644 --- a/src/main/java/de/imi/mopat/io/EncounterExporter.java +++ b/src/main/java/de/imi/mopat/io/EncounterExporter.java @@ -52,17 +52,17 @@ import org.springframework.stereotype.Component; /** - * This class provides the functionality to start an export. It is possible to export an complete - * {@link Encounter Encounter} with all assigned {@link ExportTemplate ExportTemplate}. In addition - * it is possible to export a list of {@link Questionnaire Questionnaire} object, a single + * This class provides the functionality to start an export. It is possible to export a complete + * {@link Encounter Encounter} with all assigned {@link ExportTemplate ExportTemplate}. In addition, + * it is possible to export a list of {@link Questionnaire Questionnaire} objects, a single * questionnaire object or a single export template which all need to belong to the given encounter. * Based on the export template a specialized exporter (implements * {@link EncounterExporterTemplate EncounterExporterTemplate}) will be executed. The values from - * the {@link Response Response} objects from the encounter, and the meta data from the encounter + * the {@link Response Response} objects from the encounter, and the metadata from the encounter * will be formatted based on the {@link ExportRule ExportRule} objects from the export templates. * Those formatted values will be forwarded to the specialized exporter which fills the template and * exports it. Every export (successful or failed) will be saved as an - * {@link EncounterExportTemplate EncounterExportTemplate} object to provide a export history. + * {@link EncounterExportTemplate EncounterExportTemplate} object to provide an export history. */ @Component public class EncounterExporter { @@ -90,7 +90,8 @@ public class EncounterExporter { * @return true if exporting the given {@link Questionnaire} * object worked, false otherwise. */ - public boolean export(final Encounter encounter, final Questionnaire questionnaire, boolean isTest) { + public boolean export(final Encounter encounter, final Questionnaire questionnaire, + boolean isTest) { assert encounter != null : "The Encounter was null"; assert questionnaire != null : "The Questionnaire was null"; Set exportTemplates = new HashSet<>(); @@ -102,10 +103,11 @@ public boolean export(final Encounter encounter, final Questionnaire questionnai boolean allSucceeded = true; boolean exportSucceeded; for (ExportTemplate exportTemplate : exportTemplates) { - if(isTest) + if (isTest) { exportSucceeded = exportTest(encounter, exportTemplate); - else + } else { exportSucceeded = export(encounter, exportTemplate); + } if (!exportSucceeded) { allSucceeded = false; @@ -186,7 +188,7 @@ public boolean exportTest(final Encounter encounter, final ExportTemplate export answerResponseMap = null; try { - exportEncounter(encounter, exportTemplate); + exportEncounter(encounter, exportTemplate); } catch (Exception ex) { LOGGER.error(MarkerFactory.getMarker("FATAL"), "fatal error while exporting test [exportTemplate={}, " + "Encounter={}]: {}", @@ -197,28 +199,40 @@ public boolean exportTest(final Encounter encounter, final ExportTemplate export } /** - * Exports the {@link ExportTemplate} object of a given {@link Encounter Encounter} object. + * Builds and returns the export content for given {@link Encounter} ans {@link ExportTemplate} + * without persisting or transmitting it and without recording an export history entry. * - * @param encounter An object of {@link Encounter}. Must not be - * null. - * @param exportTemplate The {@link ExportTemplate} object to export. Must not be - * null. - * @return {@link ExportStatus} for the given {@link ExportTemplate}. + * @param encounter the {@link Encounter} whose responses should be exported. Must not be + * {@code null}. + * @param exportTemplate the {@link ExportTemplate} to export. Must not be {@code null}. + * @return the assembled export content as a {@link String} + * @throws Exception if no exporter implementation exists for the export template's type, or if + * preparing or building the export content fails + */ + public String buildExportContent(Encounter encounter, ExportTemplate exportTemplate) + throws Exception { + return prepareExporter(encounter, exportTemplate).getExportContent(); + } + + /** + * @param encounter + * @param exportTemplate */ - private ExportStatus exportEncounter(final Encounter encounter, final ExportTemplate exportTemplate) throws Exception{ + private EncounterExporterTemplate prepareExporter(Encounter encounter, + ExportTemplate exportTemplate) throws Exception { ExportTemplateType exportTemplateType = exportTemplate.getExportTemplateType(); // Instantiate a new object based on the type of the export template // with the ConfigurationGroupDao and ConfigurationDao from the // context EncounterExporterTemplate exporter = exportTemplateType.createNewExporterInstance( configurationDao); - // If no implementation for the exporter exists throw an exception + if (exporter == null) { - LOGGER.error("No Implementation found for {}", - exportTemplate.getExportTemplateType()); + LOGGER.error("No Implementation found for {}", exportTemplate.getExportTemplateType()); throw new Exception( "No Implementation found for " + exportTemplate.getExportTemplateType()); } + // Initialize the exporter exporter.load(encounter, exportTemplate); @@ -227,9 +241,23 @@ private ExportStatus exportEncounter(final Encounter encounter, final ExportTemp String value = this.getFormattedValue(encounter, rule); exporter.write(rule.getExportField(), value); } + return exporter; + } + /** + * Exports the {@link ExportTemplate} object of a given {@link Encounter Encounter} object. + * + * @param encounter An object of {@link Encounter}. Must not be + * null. + * @param exportTemplate The {@link ExportTemplate} object to export. Must not be + * null. + * @return {@link ExportStatus} for the given {@link ExportTemplate}. + */ + private ExportStatus exportEncounter(final Encounter encounter, + final ExportTemplate exportTemplate) throws Exception { + ExportTemplateType exportTemplateType = exportTemplate.getExportTemplateType(); // Flush out the export template to the export folder - return exporter.flush(); + return prepareExporter(encounter, exportTemplate).flush(); } /** @@ -254,7 +282,7 @@ private String getFormattedValue(final Encounter encounter, final ExportRule exp String value = ""; // rule is of the type answer if (exportRule instanceof ExportRuleAnswer ruleAnswer) { - // there exists an response to the answer + // there exists a response to the answer if (answerResponseMap.containsKey(ruleAnswer.getAnswer())) { // get the response value based on the export rule value = this.getAnswerValue(ruleAnswer, diff --git a/src/main/java/de/imi/mopat/io/EncounterExporterTemplate.java b/src/main/java/de/imi/mopat/io/EncounterExporterTemplate.java index f584f918..455a7fce 100644 --- a/src/main/java/de/imi/mopat/io/EncounterExporterTemplate.java +++ b/src/main/java/de/imi/mopat/io/EncounterExporterTemplate.java @@ -41,4 +41,20 @@ public interface EncounterExporterTemplate { * @throws java.lang.Exception if flush to disk went wrong */ ExportStatus flush() throws Exception; + + /** + * Builds and returns the fully assembled export content for the currently loaded and filled + * export template, without triggering any of the side effects performed by {@link #flush()} + * (e.g. writing the export to disk, sending it to a communication server, or delivering it via + * HL7v2). This method is intended for on-demand, read-only access to the export data, such as + * a manual download triggered by the user. + + * Repeated calls to this method are safe and will not cause duplicate exports, since no data + * is persisted or transmitted as part of its execution. + * + * @return the assembled export content as a {@link String}, ready to be presented to the user + * (e.g. as a file download) + * @throws java.lang.Exception if the export content could not be built + */ + String getExportContent() throws Exception; } diff --git a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirDstu3.java b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirDstu3.java index 7843d9b1..9c17549d 100644 --- a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirDstu3.java +++ b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirDstu3.java @@ -36,7 +36,7 @@ public class EncounterExporterTemplateFhirDstu3 implements EncounterExporterTemplate { private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger( - EncounterExporterTemplateHL7v2.class); + EncounterExporterTemplateFhirDstu3.class); private static final SimpleDateFormat FILENAMEDATEFORMAT = new SimpleDateFormat( "dd.MM.yyyy_HH.mm.ss"); @@ -307,6 +307,11 @@ public ExportStatus flush() throws Exception { return exportStatus; } + @Override + public String getExportContent() throws Exception { + return FhirDstu3Helper.decodeResourceToString(questionnaireResponse, false); + } + /** * Handles the HL7 export process by generating and transmitting an HL7 message. * diff --git a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR4b.java b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR4b.java index 7d6e6488..148d1af5 100644 --- a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR4b.java +++ b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR4b.java @@ -298,6 +298,11 @@ public ExportStatus flush() throws Exception { return exportStatus; } +//TODO + @Override + public String getExportContent() throws Exception { + return FhirR4bHelper.decodeResourceToString(questionnaireResponse, false); + } /** * Handles the HL7 export process by generating and transmitting an HL7 message. diff --git a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR5.java b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR5.java index 080c7a81..ac717bc1 100644 --- a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR5.java +++ b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateFhirR5.java @@ -307,6 +307,11 @@ public ExportStatus flush() throws Exception { return exportStatus; } +//TODO + @Override + public String getExportContent() throws Exception { + return FhirR5Helper.decodeResourceToString(questionnaireResponse, false); + } /** * Handles the HL7 export process by generating and transmitting an HL7 message. diff --git a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateHL7v2.java b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateHL7v2.java index f9462fb6..86e774cf 100644 --- a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateHL7v2.java +++ b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateHL7v2.java @@ -52,7 +52,7 @@ public class EncounterExporterTemplateHL7v2 implements EncounterExporterTemplate private ExportTemplate exportTemplate; /** - * Constructor with given {@link ConfigurationDao} to get configuration informations within this + * Constructor with given {@link ConfigurationDao} to get configuration information within this * instance. * * @param configurationDao The {@link ConfigurationDao} from the context. @@ -121,10 +121,7 @@ public ExportStatus flush() throws Exception { String clientPKCSPath = null; String clientPKCSPassword = null; String serverCertificatePath = null; - String sendingFacility = null; - String receivingApplication = null; - String receivingFacility = null; - String obrFillerOrderNumber = null; + // Get export configurations for (Configuration configuration : exportTemplate.getConfigurationGroup() .getConfigurations()) { @@ -163,25 +160,31 @@ public ExportStatus flush() throws Exception { if (configuration.getAttribute().equals("clientPKCSPassword")) { clientPKCSPassword = configuration.getValue(); } - if (configuration.getAttribute().equals("sendingFacility")) { - sendingFacility = configuration.getValue(); - } - if (configuration.getAttribute().equals("receivingApplication")) { - receivingApplication = configuration.getValue(); - } - if (configuration.getAttribute().equals("receivingFacility")) { - receivingFacility = configuration.getValue(); - } - if (configuration.getAttribute().equals("OBRFillerOrderNumber")) { - obrFillerOrderNumber = configuration.getValue(); - } } - return doHandleExports( - isExportInDirectory, exportPathDirectory, isExportServer, hostname, + HL7MessageConfig hl7MessageConfig = readHL7MessageConfig(); + + return doHandleExports(isExportInDirectory, exportPathDirectory, isExportServer, hostname, port, useTLS, useClientAuth, clientPKCSPath, clientPKCSPassword, serverCertificatePath, - sendingFacility, receivingApplication, receivingFacility, obrFillerOrderNumber - ); + hl7MessageConfig.sendingFacility(), hl7MessageConfig.receivingApplication(), + hl7MessageConfig.receivingFacility(), hl7MessageConfig.obrFillerOrderNumber()); + } + + @Override + public String getExportContent() throws Exception { + HL7MessageConfig config = readHL7MessageConfig(); + if (config.sendingFacility() == null || config.receivingApplication() == null + || config.receivingFacility() == null || config.obrFillerOrderNumber() == null) { + // if any of the config values is missing: throw error + throw new Exception( + "Missing configuration for sendingFacility, receivingApplication, receivingFacility or OBRFillerOrderNumber. " + + "Could not build ExportContent"); + } + // else build message for export + ORU_R01 hl7Message = buildHL7Message(config.sendingFacility(), + config.receivingApplication(), config.receivingFacility(), + config.obrFillerOrderNumber()); + return hl7Message.encode(); } /** @@ -213,27 +216,18 @@ public ExportStatus flush() throws Exception { * @throws Exception If an error occurs during message construction, file export, or server * communication. */ - private ExportStatus doHandleExports( - Boolean isExportInDirectory, String exportPathDirectory, Boolean isExportServer, - String hostname, Integer port, Boolean useTLS, Boolean useClientAuth, - String clientPKCSPath, String clientPKCSPassword, String serverCertificatePath, - String sendingFacility, String receivingApplication, - String receivingFacility, String obrFillerOrderNumber - ) throws Exception { - HL7MessageHelper hl7MessageHelper = new HL7MessageHelper(); + private ExportStatus doHandleExports(Boolean isExportInDirectory, String exportPathDirectory, + Boolean isExportServer, String hostname, Integer port, Boolean useTLS, + Boolean useClientAuth, String clientPKCSPath, String clientPKCSPassword, + String serverCertificatePath, String sendingFacility, String receivingApplication, + String receivingFacility, String obrFillerOrderNumber) throws Exception { + + ORU_R01 hl7Message = buildHL7Message(sendingFacility, receivingApplication, + receivingFacility, obrFillerOrderNumber); //Properties have to be set, at least empty strings - if (sendingFacility != null && receivingApplication != null - && receivingFacility != null && obrFillerOrderNumber != null) { - - // Build Template specific message - String output = buildHL7MessageContent(); - ORU_R01 hl7Message = hl7MessageHelper.createMessageWithBlob( - exportTemplate, encounter, sendingFacility, receivingApplication, - receivingFacility, obrFillerOrderNumber, output - ); - hl7Message = hl7MessageHelper.overwriteMsh3NamespaceId(hl7Message, - getNode("Formname").getTextContent()); + if (sendingFacility != null && receivingApplication != null && receivingFacility != null + && obrFillerOrderNumber != null) { //Handle Server Export try { @@ -255,12 +249,68 @@ private ExportStatus doHandleExports( //Return Success if no exception was thrown return ExportStatus.SUCCESS; } else { - LOGGER.error("Missing configuration for sendingFacility, receivingApplication, " + - "receivingFacility or OBRFillerOrderNumber. Could not export message."); + LOGGER.error("Missing configuration for sendingFacility, receivingApplication, " + + "receivingFacility or OBRFillerOrderNumber. Could not export message."); return ExportStatus.FAILURE; } } + /** + * Builds HL7 message without sending or saving it. + * + * @param sendingFacility The identifier of the facility sending the message. + * @param receivingApplication Application identifier of the recipient. + * @param receivingFacility Identifier of the facility receiving the message. + * @param obrFillerOrderNumber Unique order number for the associated medical order. + * @return hl7Message + * @throws Exception If an error occurs during message construction, file export, or server + * communication. + */ + private ORU_R01 buildHL7Message(String sendingFacility, String receivingApplication, + String receivingFacility, String obrFillerOrderNumber) throws Exception { + // Build Template specific message + String output = buildHL7MessageContent(); + HL7MessageHelper hl7MessageHelper = new HL7MessageHelper(); + ORU_R01 hl7Message = hl7MessageHelper.createMessageWithBlob(exportTemplate, encounter, + sendingFacility, receivingApplication, receivingFacility, obrFillerOrderNumber, output); + hl7Message = hl7MessageHelper.overwriteMsh3NamespaceId(hl7Message, + getNode("Formname").getTextContent()); + return hl7Message; + } + + /** + * Reads configuration values, that are relevant for building an HL7 Message. + * + * @return a {@param HL7MessageConfig} holding facility related values, may be null + */ + + private HL7MessageConfig readHL7MessageConfig() { + String sendingFacility = null; + String receivingApplication = null; + String receivingFacility = null; + String obrFillerOrderNumber = null; + + for (Configuration configuration : exportTemplate.getConfigurationGroup() + .getConfigurations()) { + if (configuration.getAttribute().equals("sendingFacility")) { + sendingFacility = configuration.getValue(); + } + if (configuration.getAttribute().equals("receivingApplication")) { + receivingApplication = configuration.getValue(); + } + if (configuration.getAttribute().equals("receivingFacility")) { + receivingFacility = configuration.getValue(); + } + if (configuration.getAttribute().equals("OBRFillerOrderNumber")) { + obrFillerOrderNumber = configuration.getValue(); + } + } + + return new HL7MessageConfig(sendingFacility, receivingApplication, receivingFacility, + obrFillerOrderNumber); + + } + /** * Handles the export of an HL7 message to a server, optionally utilizing TLS and client * authentication for secure communication. The method performs the export only if the @@ -292,15 +342,12 @@ private ExportStatus doHandleExports( * @throws Exception If an error occurs during the export process, such as failure in message * transmission, keystore creation, or TLS setup. */ - private void doHandleServerExport( - Boolean isExportServer, String hostname, Integer port, Boolean useTLS, - Boolean useClientAuth, String clientPKCSPath, String clientPKCSPassword, - String serverCertificatePath, ORU_R01 hl7Message - ) throws Exception { + private void doHandleServerExport(Boolean isExportServer, String hostname, Integer port, + Boolean useTLS, Boolean useClientAuth, String clientPKCSPath, String clientPKCSPassword, + String serverCertificatePath, ORU_R01 hl7Message) throws Exception { HL7MessageHelper hl7MessageHelper = new HL7MessageHelper(); - if (Boolean.TRUE.equals(isExportServer) && - hostname != null && !hostname.isEmpty() && port != null - ) { + if (Boolean.TRUE.equals(isExportServer) && hostname != null && !hostname.isEmpty() + && port != null) { KeyStore keyStore = null; if (Boolean.TRUE.equals(useTLS)) { if (Boolean.TRUE.equals(useClientAuth)) { @@ -311,8 +358,8 @@ private void doHandleServerExport( } } - hl7MessageHelper.sendMessageViaComServer(hostname, port, hl7Message, useTLS, - keyStore, clientPKCSPassword); + hl7MessageHelper.sendMessageViaComServer(hostname, port, hl7Message, useTLS, keyStore, + clientPKCSPassword); } } @@ -330,11 +377,8 @@ private void doHandleServerExport( * @throws Exception If an error occurs during the export process, such as issues with encoding * the message or writing to the file system. */ - private void doHandleFilebasedExport( - Boolean isExportInDirectory, - String exportPathDirectory, - ORU_R01 hl7Message - ) throws Exception { + private void doHandleFilebasedExport(Boolean isExportInDirectory, String exportPathDirectory, + ORU_R01 hl7Message) throws Exception { if (Boolean.TRUE.equals(isExportInDirectory)) { // Make sure the path exists File path = new File(exportPathDirectory); @@ -476,4 +520,9 @@ private String createHL7FileName() { + UNDERSCORE + HL7XMLFileNameDateFormat.format(new Date()) + DOT + HL7_SUFFIX; return result; } + + private record HL7MessageConfig(String sendingFacility, String receivingApplication, + String receivingFacility, String obrFillerOrderNumber) { + + } } diff --git a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateODM.java b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateODM.java index e10373d2..8ea58fc1 100644 --- a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateODM.java +++ b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateODM.java @@ -24,6 +24,7 @@ import de.unimuenster.imi.org.cdisc.odm.v132.ODMcomplexTypeDefinitionSubjectData; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; @@ -31,6 +32,7 @@ import java.math.BigInteger; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; @@ -471,6 +473,13 @@ public ExportStatus flush() throws Exception { return exportStatus; } + @Override + public String getExportContent() throws Exception { + ByteArrayOutputStream outputstream = new ByteArrayOutputStream(); + odmProcessor.marshal(exportODM, outputstream); + return outputstream.toString(StandardCharsets.UTF_8); + } + /** * Exports the resultant ODM to a given path. * diff --git a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateREDCap.java b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateREDCap.java index 9ef89b29..b76547d2 100644 --- a/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateREDCap.java +++ b/src/main/java/de/imi/mopat/io/impl/EncounterExporterTemplateREDCap.java @@ -179,6 +179,11 @@ public ExportStatus flush() throws Exception { return exportStatus; } + @Override + public String getExportContent() throws Exception { + return mapper.writeValueAsString(List.of(exportJSON)); + } + /** * Exports the resultant REDCap JSON to a given path * @@ -202,6 +207,7 @@ public void exportToDirectory(final String exportPath) throws Exception { // Write to disk File exportFile = new File(subDirectory, this.createFileName()); + //TODO: check if, JSON or string should be written mapper.writeValue(exportFile, List.of(exportJSON)); mapper.writeValue(exportFile, "[" + mapper.writeValueAsString(exportJSON) + "]"); } diff --git a/src/main/java/de/imi/mopat/io/importer/MoPatQuestionnaireImporter.java b/src/main/java/de/imi/mopat/io/importer/MoPatQuestionnaireImporter.java index 62409894..09052957 100644 --- a/src/main/java/de/imi/mopat/io/importer/MoPatQuestionnaireImporter.java +++ b/src/main/java/de/imi/mopat/io/importer/MoPatQuestionnaireImporter.java @@ -5,7 +5,7 @@ import de.imi.mopat.dao.OperatorDao; import de.imi.mopat.dao.QuestionnaireDao; import de.imi.mopat.helper.controller.Constants; -import de.imi.mopat.helper.controller.QuestionnaireVersionGroupService; +import de.imi.mopat.service.QuestionnaireVersionGroupService; import de.imi.mopat.helper.controller.StringUtilities; import de.imi.mopat.model.Answer; import de.imi.mopat.model.ImageAnswer; diff --git a/src/main/java/de/imi/mopat/io/importer/fhir/FhirDstu3Helper.java b/src/main/java/de/imi/mopat/io/importer/fhir/FhirDstu3Helper.java index f4b17b04..68c046d3 100644 --- a/src/main/java/de/imi/mopat/io/importer/fhir/FhirDstu3Helper.java +++ b/src/main/java/de/imi/mopat/io/importer/fhir/FhirDstu3Helper.java @@ -196,11 +196,13 @@ public static boolean validateFileWithFhirInstanceValidator(final String fhirRes ValidationResult result = validator.validateWithResult(fhirResourceString); List messages = result.getMessages().stream().filter( - singleValidationMessage -> - (singleValidationMessage.getSeverity() == ResultSeverityEnum.ERROR - || singleValidationMessage.getSeverity() == ResultSeverityEnum.FATAL) - && !Objects.equals(singleValidationMessage.getMessageId(), - PROFILE_UNKNOWN_ID)).toList(); + m -> + (m.getSeverity() == ResultSeverityEnum.ERROR + || m.getSeverity() == ResultSeverityEnum.FATAL) + && !Objects.equals(m.getMessageId(), PROFILE_UNKNOWN_ID) + //que-7 seems to not work reliably right now. Remove it for the time being + && (m.getMessage() == null || !m.getMessage().contains("que-7")) + ).toList(); for (SingleValidationMessage message : messages) { addDefaultError(errors, message); diff --git a/src/main/java/de/imi/mopat/io/importer/fhir/FhirImporter.java b/src/main/java/de/imi/mopat/io/importer/fhir/FhirImporter.java index 33025938..cb3d857c 100644 --- a/src/main/java/de/imi/mopat/io/importer/fhir/FhirImporter.java +++ b/src/main/java/de/imi/mopat/io/importer/fhir/FhirImporter.java @@ -7,8 +7,7 @@ import de.imi.mopat.dao.QuestionnaireDao; import de.imi.mopat.helper.controller.Constants; import de.imi.mopat.helper.controller.FhirVersionHelper; -import de.imi.mopat.helper.controller.QuestionnaireVersionGroupService; -import de.imi.mopat.helper.controller.StringUtilities; +import de.imi.mopat.service.QuestionnaireVersionGroupService; import de.imi.mopat.io.importer.ImportFailedException; import de.imi.mopat.io.importer.ImportQuestionnaireResult; import de.imi.mopat.io.importer.ImportQuestionnaireValidation; diff --git a/src/main/java/de/imi/mopat/io/importer/odm/OdmQuestionnaireImporter.java b/src/main/java/de/imi/mopat/io/importer/odm/OdmQuestionnaireImporter.java index 018b56c5..4bafd3d7 100644 --- a/src/main/java/de/imi/mopat/io/importer/odm/OdmQuestionnaireImporter.java +++ b/src/main/java/de/imi/mopat/io/importer/odm/OdmQuestionnaireImporter.java @@ -4,7 +4,7 @@ import de.imi.mopat.dao.ConfigurationGroupDao; import de.imi.mopat.dao.ExportTemplateDao; import de.imi.mopat.dao.QuestionnaireDao; -import de.imi.mopat.helper.controller.QuestionnaireVersionGroupService; +import de.imi.mopat.service.QuestionnaireVersionGroupService; import de.imi.mopat.helper.controller.StringUtilities; import de.imi.mopat.io.importer.ImportQuestionnaireResult; import de.imi.mopat.model.ExportTemplate; diff --git a/src/main/java/de/imi/mopat/model/EncounterScheduled.java b/src/main/java/de/imi/mopat/model/EncounterScheduled.java index 5ecf58ea..63933ede 100644 --- a/src/main/java/de/imi/mopat/model/EncounterScheduled.java +++ b/src/main/java/de/imi/mopat/model/EncounterScheduled.java @@ -85,7 +85,7 @@ public class EncounterScheduled implements Serializable { @Enumerated(EnumType.STRING) @Column(name = "mail_status") private EncounterScheduledMailStatus mailStatus = EncounterScheduledMailStatus.ACTIVE; - @OneToMany(mappedBy = "EncounterScheduled", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "encounterScheduled", cascade = CascadeType.ALL, orphanRemoval = true) private Set encounters = new HashSet<>(); @ManyToOne(cascade = CascadeType.MERGE) @JoinColumn(name = "clinic_id", referencedColumnName = "id") diff --git a/src/main/java/de/imi/mopat/model/enumeration/ConfigurationType.java b/src/main/java/de/imi/mopat/model/enumeration/ConfigurationType.java index a5ac76bd..0ec00d5d 100644 --- a/src/main/java/de/imi/mopat/model/enumeration/ConfigurationType.java +++ b/src/main/java/de/imi/mopat/model/enumeration/ConfigurationType.java @@ -25,7 +25,8 @@ public enum ConfigurationType { PASSWORD("PASSWORD"), // Path to the File. Application must have read and // write rights FILE("FILE"), - RICH_TEXT("RICH_TEXT"); + RICH_TEXT("RICH_TEXT"), + ENABLE_API_TOKEN_ACCESS("ENABLE_API_TOKEN_ACCESS"); private final String textValue; diff --git a/src/main/java/de/imi/mopat/model/enumeration/ExportTemplateType.java b/src/main/java/de/imi/mopat/model/enumeration/ExportTemplateType.java index b946ca69..932bd093 100644 --- a/src/main/java/de/imi/mopat/model/enumeration/ExportTemplateType.java +++ b/src/main/java/de/imi/mopat/model/enumeration/ExportTemplateType.java @@ -93,6 +93,24 @@ public EncounterExporterTemplate createNewExporterInstance( return null; } + /** + * getFileExtension() provides dynamic file-name-ending based on the used export template. Used in Controller. + */ + public String getFileExtension() { + switch (this) { + case HL7v2: + return "hl7"; + case FHIR_DSTU3: + case FHIR_R4B: + case FHIR_R5, ODM: + return "xml"; + case REDCap: + return "json"; + default: + return "txt"; + } + } + /** * Creates a new {@link ExportTemplateImporter} instance. * diff --git a/src/main/java/de/imi/mopat/helper/controller/ApplicationContextService.java b/src/main/java/de/imi/mopat/service/ApplicationContextService.java similarity index 95% rename from src/main/java/de/imi/mopat/helper/controller/ApplicationContextService.java rename to src/main/java/de/imi/mopat/service/ApplicationContextService.java index dc598f1e..fd46072d 100644 --- a/src/main/java/de/imi/mopat/helper/controller/ApplicationContextService.java +++ b/src/main/java/de/imi/mopat/service/ApplicationContextService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/de/imi/mopat/service/AuditService.java b/src/main/java/de/imi/mopat/service/AuditService.java new file mode 100644 index 00000000..fe41ada6 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/AuditService.java @@ -0,0 +1,30 @@ +package de.imi.mopat.service; + +import de.imi.mopat.dao.AuditEntryDao; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.enumeration.AuditEntryActionType; +import de.imi.mopat.model.enumeration.AuditPatientAttribute; +import java.util.EnumSet; +import java.util.Set; +import org.springframework.stereotype.Service; + +@Service +public class AuditService { + + private final AuditEntryDao auditEntryDao; + + public AuditService(AuditEntryDao auditEntryDao) { + this.auditEntryDao = auditEntryDao; + } + + public void writeScheduledEncounterAudit(Class sourceClass, String methodName, + EncounterScheduled scheduled) { + + Set patientAttributes = EnumSet.of(AuditPatientAttribute.CASE_NUMBER, + AuditPatientAttribute.EMAIL_ADDRESS, AuditPatientAttribute.FIRST_NAME, + AuditPatientAttribute.LAST_NAME, AuditPatientAttribute.DATE_OF_BIRTH); + + auditEntryDao.writeAuditEntry(sourceClass.getSimpleName(), methodName, + scheduled.getCaseNumber(), patientAttributes, AuditEntryActionType.WRITE); + } +} \ No newline at end of file diff --git a/src/main/java/de/imi/mopat/helper/controller/AuthService.java b/src/main/java/de/imi/mopat/service/AuthService.java similarity index 98% rename from src/main/java/de/imi/mopat/helper/controller/AuthService.java rename to src/main/java/de/imi/mopat/service/AuthService.java index 830a302d..64162e7d 100644 --- a/src/main/java/de/imi/mopat/helper/controller/AuthService.java +++ b/src/main/java/de/imi/mopat/service/AuthService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.model.user.UserRole; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/de/imi/mopat/helper/controller/BundleService.java b/src/main/java/de/imi/mopat/service/BundleService.java similarity index 99% rename from src/main/java/de/imi/mopat/helper/controller/BundleService.java rename to src/main/java/de/imi/mopat/service/BundleService.java index f4ecd99c..4b9f7bf5 100644 --- a/src/main/java/de/imi/mopat/helper/controller/BundleService.java +++ b/src/main/java/de/imi/mopat/service/BundleService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.user.AclClassDao; import de.imi.mopat.dao.user.AclObjectIdentityDao; @@ -6,6 +6,7 @@ import de.imi.mopat.dao.BundleQuestionnaireDao; import de.imi.mopat.dao.ExportTemplateDao; import de.imi.mopat.dao.QuestionnaireDao; +import de.imi.mopat.helper.controller.QuestionnaireService; import de.imi.mopat.dao.ScoreDao; import de.imi.mopat.helper.model.BundleDTOMapper; import de.imi.mopat.helper.model.QuestionnaireDTOMapper; diff --git a/src/main/java/de/imi/mopat/helper/controller/ClinicConfigurationMappingService.java b/src/main/java/de/imi/mopat/service/ClinicConfigurationMappingService.java similarity index 99% rename from src/main/java/de/imi/mopat/helper/controller/ClinicConfigurationMappingService.java rename to src/main/java/de/imi/mopat/service/ClinicConfigurationMappingService.java index 98cfc807..49d16e59 100644 --- a/src/main/java/de/imi/mopat/helper/controller/ClinicConfigurationMappingService.java +++ b/src/main/java/de/imi/mopat/service/ClinicConfigurationMappingService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import com.fasterxml.jackson.annotation.JsonIgnore; import de.imi.mopat.dao.ClinicConfigurationDao; diff --git a/src/main/java/de/imi/mopat/helper/controller/ClinicConfigurationService.java b/src/main/java/de/imi/mopat/service/ClinicConfigurationService.java similarity index 98% rename from src/main/java/de/imi/mopat/helper/controller/ClinicConfigurationService.java rename to src/main/java/de/imi/mopat/service/ClinicConfigurationService.java index 27105d1e..2b607ee9 100644 --- a/src/main/java/de/imi/mopat/helper/controller/ClinicConfigurationService.java +++ b/src/main/java/de/imi/mopat/service/ClinicConfigurationService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.ConfigurationGroupDao; import de.imi.mopat.helper.model.ClinicConfigurationDTOMapper; diff --git a/src/main/java/de/imi/mopat/helper/controller/ClinicService.java b/src/main/java/de/imi/mopat/service/ClinicService.java similarity index 98% rename from src/main/java/de/imi/mopat/helper/controller/ClinicService.java rename to src/main/java/de/imi/mopat/service/ClinicService.java index 9716368e..281479a0 100644 --- a/src/main/java/de/imi/mopat/helper/controller/ClinicService.java +++ b/src/main/java/de/imi/mopat/service/ClinicService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.user.AclEntryDao; import de.imi.mopat.dao.ClinicDao; diff --git a/src/main/java/de/imi/mopat/helper/controller/ConditionService.java b/src/main/java/de/imi/mopat/service/ConditionService.java similarity index 99% rename from src/main/java/de/imi/mopat/helper/controller/ConditionService.java rename to src/main/java/de/imi/mopat/service/ConditionService.java index 4589d447..8b556fcd 100644 --- a/src/main/java/de/imi/mopat/helper/controller/ConditionService.java +++ b/src/main/java/de/imi/mopat/service/ConditionService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.AnswerDao; import de.imi.mopat.dao.BundleDao; diff --git a/src/main/java/de/imi/mopat/helper/controller/ConfigurationService.java b/src/main/java/de/imi/mopat/service/ConfigurationService.java similarity index 96% rename from src/main/java/de/imi/mopat/helper/controller/ConfigurationService.java rename to src/main/java/de/imi/mopat/service/ConfigurationService.java index 5365b02e..5579ed32 100644 --- a/src/main/java/de/imi/mopat/helper/controller/ConfigurationService.java +++ b/src/main/java/de/imi/mopat/service/ConfigurationService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.ConfigurationGroupDao; import de.imi.mopat.model.Configuration; diff --git a/src/main/java/de/imi/mopat/service/EncounterExportService.java b/src/main/java/de/imi/mopat/service/EncounterExportService.java new file mode 100644 index 00000000..a1a39b46 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/EncounterExportService.java @@ -0,0 +1,40 @@ +package de.imi.mopat.service; + +import de.imi.mopat.io.EncounterExporter; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.ExportTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class EncounterExportService { + + private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger( + EncounterExportService.class); + + @Autowired + private EncounterExporter encounterExporter; + + /** + * Application layer access point that retrieves export content without creating any side + * effects (no file is written, no message is sent, no export history entry is created). + * Encapsulates {@link EncounterExporter}. + * + * @param encounter the {@link Encounter} whose responses should be exported + * @param exportTemplate the {@link ExportTemplate} to export + * @return the assembled export content as a {@link String} + * @throws Exception if building the export content fails, e.g. due to missing configuration + */ + + public String getExportContent(final Encounter encounter, final ExportTemplate exportTemplate) + throws Exception { + try{ + return encounterExporter.buildExportContent(encounter, exportTemplate); + } + catch (Exception e){ + LOGGER.error("Could not build export content [exportTemplate={}, encounter={}] : {}", + exportTemplate.getId(), encounter.getId(), e.getMessage()); + throw e; + } + } +} diff --git a/src/main/java/de/imi/mopat/service/EncounterScheduledService.java b/src/main/java/de/imi/mopat/service/EncounterScheduledService.java new file mode 100644 index 00000000..9d5e1f13 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/EncounterScheduledService.java @@ -0,0 +1,67 @@ +package de.imi.mopat.service; + +import de.imi.mopat.cron.EncounterScheduledExecutor; +import de.imi.mopat.dao.BundleDao; +import de.imi.mopat.dao.ClinicDao; +import de.imi.mopat.dao.EncounterScheduledDao; +import de.imi.mopat.helper.model.EncounterScheduledDTOMapper; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.dto.EncounterScheduledDTO; +import de.imi.mopat.service.helper.SetRepeatConfiguration; +import de.imi.mopat.validator.EncounterScheduledDTOValidator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@Service +public class EncounterScheduledService { + + private final BundleDao bundleDao; + private final EncounterScheduledDao encounterScheduledDao; + private final EncounterScheduledDTOMapper mapper; + private final ImmediateMailService immediateMailService; + private final AuditService auditService; + private final SetRepeatConfiguration setRepeatConfiguration; + + public EncounterScheduledService(BundleDao bundleDao, + EncounterScheduledDao encounterScheduledDao, + EncounterScheduledDTOMapper mapper, ImmediateMailService immediateMailService, + AuditService auditService, SetRepeatConfiguration setRepeatConfiguration) { + this.bundleDao = bundleDao; + this.encounterScheduledDao = encounterScheduledDao; + this.mapper = mapper; + this.immediateMailService = immediateMailService; + this.auditService = auditService; + this.setRepeatConfiguration = setRepeatConfiguration; + } + + @Transactional + public MailSendingStatus save(EncounterScheduledDTO dto, EncounterScheduledExecutor executor) { + setRepeatConfiguration.apply(dto); + + EncounterScheduled scheduled = mapper.mapToEntity(dto); + + encounterScheduledDao.merge(scheduled); + Bundle bundle = scheduled.getBundle(); + + if (bundle != null) { + bundleDao.merge(bundle); + } + + auditService.writeScheduledEncounterAudit(EncounterScheduledService.class, + "save", scheduled); + + if (immediateMailService.shouldSendEmailImmediately(scheduled, executor)) { + return immediateMailService.createAndSendEncounter(scheduled); + } + return MailSendingStatus.SUCCESS; + } + + +} + + + + + diff --git a/src/main/java/de/imi/mopat/service/ImmediateMailService.java b/src/main/java/de/imi/mopat/service/ImmediateMailService.java new file mode 100644 index 00000000..d2338e56 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/ImmediateMailService.java @@ -0,0 +1,97 @@ +package de.imi.mopat.service; + +import de.imi.mopat.cron.EncounterScheduledExecutor; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.dto.EncounterScheduledDTO; +import de.imi.mopat.service.MailService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.Date; +@Service +public class ImmediateMailService { + private final MailService mailService; + + public ImmediateMailService(MailService mailService) { + this.mailService = mailService; + } + + public boolean shouldSendEmailImmediately(EncounterScheduled scheduled, EncounterScheduledExecutor executor){ + return (shouldSendMailToday(scheduled) && (shouldSendBecauseNeverExecuted(executor) + || shouldSendBecauseAlreadyExecutedToday(executor)) + ); + } + + private boolean shouldSendMailToday(EncounterScheduled scheduled){ + return (isSameDay(scheduled.getStartDate(), new Date())); + } + + private boolean shouldSendBecauseNeverExecuted(EncounterScheduledExecutor executor){ + if (executor.getLastExecutionTime() != null) return false; + return (!isSameDay(executor.getNextExecutionTime(), new Date())); + } + + private boolean shouldSendBecauseAlreadyExecutedToday(EncounterScheduledExecutor executor){ + if (executor.getLastExecutionTime() == null) return false; + return (isSameDay(executor.getLastExecutionTime(), new Date())); + }; + + private boolean isSameDay(Date dateOne, Date dateTwo) { + + Calendar cal1 = Calendar.getInstance(); + cal1.setTime(dateOne); + + Calendar cal2 = Calendar.getInstance(); + cal2.setTime(dateTwo); + + return cal1.get(Calendar.DAY_OF_MONTH) == cal2.get(Calendar.DAY_OF_MONTH) + && cal1.get(Calendar.MONTH) == cal2.get(Calendar.MONTH) + && cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR); + } + +public MailSendingStatus createAndSendEncounter( + EncounterScheduled scheduled +) { + + Encounter encounter = createEncounterForImmediateSending(scheduled); + + return mailService.sendEncounterMail(encounter); +} + + private Encounter createEncounterForImmediateSending( + EncounterScheduled scheduled + ) { + + Encounter encounter = new Encounter(); + + encounter.setEncounterScheduled(scheduled); + encounter.setBundle(scheduled.getBundle()); + encounter.setClinic(scheduled.getClinic()); + encounter.setCaseNumber(scheduled.getCaseNumber()); + + Bundle bundle = scheduled.getBundle(); + bundle.addEncounter(encounter); + + encounter.setStartTime( + new Timestamp(getTodayAtMidnight().getTime()) + ); + + return encounter; + } + + private Date getTodayAtMidnight() { + + Calendar calendar = Calendar.getInstance(); + + calendar.set(Calendar.MILLISECOND, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.HOUR_OF_DAY, 0); + + return calendar.getTime(); + } +} diff --git a/src/main/java/de/imi/mopat/helper/controller/InvitationService.java b/src/main/java/de/imi/mopat/service/InvitationService.java similarity index 93% rename from src/main/java/de/imi/mopat/helper/controller/InvitationService.java rename to src/main/java/de/imi/mopat/service/InvitationService.java index b0bcc576..fdcbb70a 100644 --- a/src/main/java/de/imi/mopat/helper/controller/InvitationService.java +++ b/src/main/java/de/imi/mopat/service/InvitationService.java @@ -1,9 +1,8 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.model.user.Invitation; import java.util.Comparator; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/de/imi/mopat/service/MailSendingStatus.java b/src/main/java/de/imi/mopat/service/MailSendingStatus.java new file mode 100644 index 00000000..ff3d90c8 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/MailSendingStatus.java @@ -0,0 +1,7 @@ +package de.imi.mopat.service; + +public enum MailSendingStatus { + SUCCESS, + INVALID_ADDRESS, + FAILURE +} diff --git a/src/main/java/de/imi/mopat/service/MailService.java b/src/main/java/de/imi/mopat/service/MailService.java new file mode 100644 index 00000000..98148d14 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/MailService.java @@ -0,0 +1,38 @@ +package de.imi.mopat.service; + +import de.imi.mopat.dao.ConfigurationDao; +import de.imi.mopat.helper.controller.ApplicationMailer; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.enumeration.EncounterScheduledMailStatus; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Service; + +@Service +public class MailService { + + private final ApplicationMailer applicationMailer; + private final MessageSource messageSource; + private final ConfigurationDao configurationDao; + + public MailService(ApplicationMailer applicationMailer, MessageSource messageSource, + ConfigurationService configurationService, ConfigurationDao configurationDao) { + this.applicationMailer = applicationMailer; + this.messageSource = messageSource; + this.configurationDao = configurationDao; + } + + public MailSendingStatus sendEncounterMail(Encounter encounter) { + boolean success = encounter.sendMail(applicationMailer, messageSource, + configurationDao.getBaseURL()); + if (success) { + return MailSendingStatus.SUCCESS; + } + if (EncounterScheduledMailStatus.ADDRESS_REJECTED.equals( + encounter.getEncounterScheduled().getMailStatus())) { + return MailSendingStatus.INVALID_ADDRESS; + } else { + return MailSendingStatus.FAILURE; + } + } + +} diff --git a/src/main/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupService.java b/src/main/java/de/imi/mopat/service/QuestionnaireVersionGroupService.java similarity index 99% rename from src/main/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupService.java rename to src/main/java/de/imi/mopat/service/QuestionnaireVersionGroupService.java index 97f09d2c..609e6212 100644 --- a/src/main/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupService.java +++ b/src/main/java/de/imi/mopat/service/QuestionnaireVersionGroupService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.QuestionnaireDao; import de.imi.mopat.dao.QuestionnaireVersionGroupDao; diff --git a/src/main/java/de/imi/mopat/service/SurveyService.java b/src/main/java/de/imi/mopat/service/SurveyService.java new file mode 100644 index 00000000..4e9914d0 --- /dev/null +++ b/src/main/java/de/imi/mopat/service/SurveyService.java @@ -0,0 +1,286 @@ +package de.imi.mopat.service; + +import de.imi.mopat.dao.BundleDao; +import de.imi.mopat.dao.EncounterDao; +import de.imi.mopat.helper.controller.LocaleHelper; +import de.imi.mopat.helper.model.BundleDTOMapper; +import de.imi.mopat.helper.model.EncounterDTOMapper; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.dto.BundleDTO; +import de.imi.mopat.model.dto.EncounterDTO; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class SurveyService { + + @Autowired + private BundleDao bundleDao; + + @Autowired + private BundleDTOMapper bundleDTOMapper; + + @Autowired + private EncounterDao encounterDao; + + @Autowired + private EncounterDTOMapper encounterDTOMapper; + + /** + * Builds a map of published, clinic-associated bundles to their incomplete encounters grouped + * by language for the case referenced by the given encounter. + * + *

Only bundles that are published and assigned to at least one clinic are included. + * Incomplete encounters are added only if their bundle is present in the resulting map.

+ * + * @param encounterDTO encounter containing the case number used to load incomplete encounters + * @return sorted map of bundles to language-specific lists of incomplete encounters + */ + public SortedMap>> getBundleLanguageEncounterMap( + EncounterDTO encounterDTO + ) { + SortedMap>> encountersByBundleAndLanguage = new TreeMap<>( + (o1, o2) -> o1.getName().compareToIgnoreCase(o2.getName()) + ); + + addIncompleteEncountersForCaseToMap(encounterDTO.getCaseNumber(), + encountersByBundleAndLanguage); + addPublishedAndAssignedBundlesToMap(encountersByBundleAndLanguage); + + return encountersByBundleAndLanguage; + } + + /** + * Returns whether the encounter identified by the given UUID is not available for further processing. + * + *

An encounter is treated as unavailable if the UUID is missing, no encounter exists for it, + * the encounter is already completed, or no scheduled encounter is assigned.

+ * + * @param uuid UUID of the encounter to validate + * @return {@code true} if the encounter is missing, completed, or otherwise unavailable + */ + public boolean isEncounterForUUIDCompletedOrUnavailable(String uuid) { + if (uuid == null || uuid.isEmpty()) { + return true; + } + + Encounter encounter = encounterDao.getElementByUUID(uuid); + return isCompletedOrUnavailable(encounter); + } + + /** + * Returns whether the given encounter is completed or cannot be used. + * + * @param encounter encounter to check + * @return {@code true} if the encounter is {@code null}, already ended, + * or has no scheduled encounter assigned + */ + public boolean isCompletedOrUnavailable(Encounter encounter) { + return encounter == null + || encounter.getEndTime() != null + || encounter.getEncounterScheduled() == null; + } + + /** + * Loads the encounter identified by the given UUID and maps it to an {@link EncounterDTO}. + * + * @param uuid UUID of the encounter to load + * @return mapped encounter DTO + */ + public EncounterDTO getEncounterDTOForUUID(String uuid) { + return encounterDTOMapper.apply(true, encounterDao.getElementByUUID(uuid)); + } + + /** + * Sets and persists the start time of the given encounter if it is accessed for the first time. + * + *

An encounter is considered to be accessed for the first time if it is not completed + * and no question has been seen yet.

+ * + * @param encounterDTO encounter to initialize + */ + public void startEncounterIfFirstAccess(EncounterDTO encounterDTO) { + if (!isFirstAccess(encounterDTO)) { + return; + } + + Timestamp startTime = new Timestamp(new Date().getTime()); + encounterDTO.setStartTime(startTime); + + Encounter encounter = encounterDao.getElementById(encounterDTO.getId()); + encounter.setStartTime(startTime); + encounterDao.merge(encounter); + } + + /** + * Returns whether the given encounter is being accessed for the first time. + * + * @param encounterDTO encounter to check + * @return {@code true} if the encounter is not completed and has no last seen question + */ + private boolean isFirstAccess(EncounterDTO encounterDTO) { + return encounterDTO.getEndTime() == null + && encounterDTO.getLastSeenQuestionId() == null; + } + + /** + * Returns whether the language selection step can be skipped for the given encounter. + * + *

The selection is skipped if the encounter is resumed or if the bundle provides only one + * available language.

+ * + * @param encounterDTO encounter to evaluate + * @return {@code true} if language selection is not needed + */ + public boolean shouldSkipLanguageSelection(EncounterDTO encounterDTO) { + return encounterDTO.getLastSeenQuestionId() != null + || hasOnlyOneAvailableLanguage(encounterDTO); + } + + /** + * Returns whether the bundle of the given encounter provides exactly one available language. + * + * @param encounterDTO encounter whose bundle is checked + * @return {@code true} if only one language is available + */ + private boolean hasOnlyOneAvailableLanguage(EncounterDTO encounterDTO) { + return encounterDTO.getBundleDTO().getAvailableLanguages().size() == 1; + } + + /** + * Returns the bundle language of the given encounter, initializing it if necessary. + * + *

If no bundle language is set yet, a matching survey locale is determined and assigned + * to the encounter DTO.

+ * + * @param encounterDTO encounter whose bundle language is resolved + * @return resolved bundle language + */ + public String resolveBundleLanguage(EncounterDTO encounterDTO) { + if (encounterDTO.getBundleLanguage() != null) { + return encounterDTO.getBundleLanguage(); + } + + String resolvedLanguage = determineBundleLanguage(encounterDTO); + encounterDTO.setBundleLanguage(resolvedLanguage); + return resolvedLanguage; + } + + /** + * Determines the most suitable bundle language for the given encounter. + * + *

The first available bundle language is matched against the survey locales. + * If no match is found, the current request locale is used as fallback.

+ * + * @param encounterDTO encounter whose bundle language should be determined + * @return resolved language code + */ + private String determineBundleLanguage(EncounterDTO encounterDTO) { + String configuredLanguage = encounterDTO.getBundleDTO().getAvailableLanguages().get(0); + + for (String locale : LocaleHelper.getLocalesUsedInSurvey()) { + if (matchesLanguage(locale, configuredLanguage)) { + return locale; + } + } + + return LocaleContextHolder.getLocale().toString(); + } + + /** + * Returns whether the given locale matches the specified language. + * + *

A match is assumed if the locale string contains the full language code + * or its two-character prefix.

+ * + * @param locale locale string to check + * @param language language code to match against + * @return {@code true} if the locale matches the language + */ + private boolean matchesLanguage(String locale, String language) { + return locale.contains(language) + || locale.contains(language.substring(0, 2)); + } + + /** + * Adds all published bundles with at least one assigned clinic to the given map. + * + *

Each matching bundle is inserted with an empty language-to-encounters map as its + * value.

+ * + * @param encountersByBundleAndLanguage target map to populate with eligible bundles + */ + private void addPublishedAndAssignedBundlesToMap( + SortedMap>> encountersByBundleAndLanguage + ) { + for (Bundle bundle : bundleDao.getAllElements()) { + if (bundle.getIsPublished() && !bundle.getBundleClinics().isEmpty()) { + encountersByBundleAndLanguage.put( + bundleDTOMapper.apply(false, bundle), + new HashMap<>()); + } + } + } + + /** + * Loads all incomplete encounters for the case referenced case id and adds them to the provided + * bundle/language map. + * + *

Each incomplete encounter is inserted only if its bundle is already present + * in the target map.

+ * + * @param caseNumber caseNumber to fetch encounters for + * @param encountersByBundleAndLanguage target map to enrich with incomplete encounters + */ + private void addIncompleteEncountersForCaseToMap( + String caseNumber, + SortedMap>> encountersByBundleAndLanguage + ) { + List incompleteEncounters = + encounterDao.getIncompleteEncounters(caseNumber); + + for (Encounter incompleteEncounter : incompleteEncounters) { + addIncompleteEncounterToMap(encountersByBundleAndLanguage, incompleteEncounter); + } + } + + /** + * Adds an incomplete encounter to the existing bundle/language map when the related bundle is + * already present in the map. + * + *

If the bundle is missing, the encounter is ignored. This typically means the bundle + * is not visible in the current context.

+ * + * @param encountersByBundleAndLanguage map of bundles to their language-specific encounters + * @param incompleteEncounter the incomplete encounter to insert + */ + private void addIncompleteEncounterToMap( + SortedMap>> encountersByBundleAndLanguage, + Encounter incompleteEncounter + ) { + BundleDTO bundle = bundleDTOMapper.apply(false, incompleteEncounter.getBundle()); + Map> encountersByLanguage = encountersByBundleAndLanguage.get( + bundle); + + if (encountersByLanguage == null) { + return; + } + + String language = incompleteEncounter.getBundleLanguage(); + EncounterDTO encounter = encounterDTOMapper.apply(true, incompleteEncounter); + + encountersByLanguage + .computeIfAbsent(language, key -> new ArrayList<>()) + .add(encounter); + } +} diff --git a/src/main/java/de/imi/mopat/helper/controller/UserService.java b/src/main/java/de/imi/mopat/service/UserService.java similarity index 99% rename from src/main/java/de/imi/mopat/helper/controller/UserService.java rename to src/main/java/de/imi/mopat/service/UserService.java index 00f413b3..b1c28091 100644 --- a/src/main/java/de/imi/mopat/helper/controller/UserService.java +++ b/src/main/java/de/imi/mopat/service/UserService.java @@ -1,4 +1,4 @@ -package de.imi.mopat.helper.controller; +package de.imi.mopat.service; import de.imi.mopat.dao.ClinicDao; import de.imi.mopat.dao.user.AclEntryDao; diff --git a/src/main/java/de/imi/mopat/service/helper/SetRepeatConfiguration.java b/src/main/java/de/imi/mopat/service/helper/SetRepeatConfiguration.java new file mode 100644 index 00000000..ab27ac0b --- /dev/null +++ b/src/main/java/de/imi/mopat/service/helper/SetRepeatConfiguration.java @@ -0,0 +1,32 @@ +package de.imi.mopat.service.helper; + +import de.imi.mopat.model.dto.EncounterScheduledDTO; +import org.springframework.stereotype.Component; + +@Component +public class SetRepeatConfiguration { + + // in case of REPEATEDLY, RepeatPeriod is already set in dto + public void apply(EncounterScheduledDTO dto) { + int WEEKLY_PERIOD_DAYS = 7; + int MONTHLY_PERIOD_DAYS = 30; + switch (dto.getEncounterScheduledSerialType()) { + case UNIQUELY -> { + dto.setRepeatPeriod(null); + dto.setEndDate(null); + break; + } + case WEEKLY -> { + dto.setRepeatPeriod(WEEKLY_PERIOD_DAYS); + break; + } + case MONTHLY -> { + dto.setRepeatPeriod(MONTHLY_PERIOD_DAYS); + break; + } + default -> { + break; + } + } + } +} \ No newline at end of file diff --git a/src/main/resources/message/messages.properties b/src/main/resources/message/messages.properties index ae8b09f8..a933cacf 100644 --- a/src/main/resources/message/messages.properties +++ b/src/main/resources/message/messages.properties @@ -302,6 +302,7 @@ configuration.label.applicationMailer.phoneFooter=Phone number included in the s configuration.label.baseUrl=Base URL of this application configuration.label.caseNumberType=Type of the case number for the input configuration.label.defaultLanguage=Default language of the application +configuration.label.enableEncounterTemplateDownload=Enablement of download option for encounter templates configuration.label.enableGlobalPinAuth=Allow the usage of a pin to enable users to quickly relogin. configuration.label.encounter.checkTimeActivated=Delete finished encounters after a given time configuration.label.encounter.finishedEncounterMailaddressTimeWindowInMillis=The time, after which email addresses of patients who completed encounters will be deleted (in ms) @@ -400,6 +401,7 @@ dateAnswer.validator.startLaterThanEnd=The earliest date is later than the lates editor.welcome=Welcome to Mobile Patient Survey (MoPat)!

This is the administration interface of MoPat.
From this interface on, you are able to
MoPat is developed by the Institute of Medical Informatics, M\u00fcnster, managed by Univ.-Prof. Dr. rer. nat. Dominik Heider.
You can contact us at {1} or {2}. encounter.button.encounterName=Name encounter.button.export=Export +encounter.button.download=Download encounter.error.caseNumberIsEmpty=The case number shouldn't be empty or only consist of space characters. encounter.export.auto=Automatically exported encounter.export.conflict=Conflict diff --git a/src/main/resources/message/messages_de_DE.properties b/src/main/resources/message/messages_de_DE.properties index 6274aaea..8737ea4c 100644 --- a/src/main/resources/message/messages_de_DE.properties +++ b/src/main/resources/message/messages_de_DE.properties @@ -1013,7 +1013,6 @@ survey.questionnaire.MinMaxAnswer=Geben Sie zwischen {min} und {max} Antworten. survey.questionnaire.button.answerQuestionsMultiple=Fragen beantworten survey.questionnaire.button.answerQuestionsSingle=Frage beantworten survey.questionnaire.button.closeApplication=Anwendung beenden -survey.questionnaire.button.completeQuestionnaireInBundle=Abschluss des Befragungsabschnittes survey.questionnaire.button.completenessCheck=Vollst\u00e4ndigkeitspr\u00fcfung survey.questionnaire.button.completenessCheckTitlePartMultiple=Der Fragebogen enth\u00e4lt {numberQuestions} nicht vollst\u00e4ndig beantwortete Fragen. Wollen Sie diese nachtr\u00e4glich beantworten? survey.questionnaire.button.completenessCheckTitlePartSingle=Der Fragebogen enth\u00e4lt eine nicht vollst\u00e4ndig beantwortete Frage. Wollen Sie diese nachtr\u00e4glich beantworten? @@ -1158,4 +1157,7 @@ value=Wert valueOf=Wert der Frage valueOfScore=Wert des Scores sliderAnswer.validator.localizedMinMaxText=Der eingegebene Text hat mehr als 255 Zeichen. -login.demo.infoText=Willkommen zu MoPat Demo!
Dies ist eine Testinstallation mit uneingeschr\u00e4nktem Zugriff zur Anwendung. Sie k\u00f6nnen hier alle Funktionen von MoPat ausprobieren. Ben\u00f6tigen Sie Hilfe, k\u00f6nnen Sie unser Handbuch zurate ziehen, in welchem sich ausf\u00fchrliche Informationen zu allen Funktionen finden lassen.
Achtung: Die Daten werden regelm\u00e4\u00dfig zur\u00fcckgesetzt. Von Ihnen angelegte Frageb\u00f6gen und Befragungen werden demnach gel\u00f6scht und es gibt keine M\u00f6glichkeit diese wiederherzustellen. Nutzen Sie dieses System ausschlie\u00dflich f\u00fcr Tests.
Ben\u00f6tigen Sie weitere Informationen, kontaktieren Sie uns: mopat@uni-muenster.de \ No newline at end of file +login.demo.infoText=Willkommen zu MoPat Demo!
Dies ist eine Testinstallation mit uneingeschr\u00e4nktem Zugriff zur Anwendung. Sie k\u00f6nnen hier alle Funktionen von MoPat ausprobieren. Ben\u00f6tigen Sie Hilfe, k\u00f6nnen Sie unser Handbuch zurate ziehen, in welchem sich ausf\u00fchrliche Informationen zu allen Funktionen finden lassen.
Achtung: Die Daten werden regelm\u00e4\u00dfig zur\u00fcckgesetzt. Von Ihnen angelegte Frageb\u00f6gen und Befragungen werden demnach gel\u00f6scht und es gibt keine M\u00f6glichkeit diese wiederherzustellen. Nutzen Sie dieses System ausschlie\u00dflich f\u00fcr Tests.
Ben\u00f6tigen Sie weitere Informationen, kontaktieren Sie uns: mopat@uni-muenster.de +survey.questionnaire.button.completeQuestionnaireInBundle=Abschluss des Befragungsabschnittes +configuration.label.enableEncounterTemplateDownload=Aktivierung der Download-Funktion f\u00FCr Encounter Templates +encounter.button.download=Herunterladen \ No newline at end of file diff --git a/src/main/resources/message/messages_en_GB.properties b/src/main/resources/message/messages_en_GB.properties index ae8b09f8..9a17432a 100644 --- a/src/main/resources/message/messages_en_GB.properties +++ b/src/main/resources/message/messages_en_GB.properties @@ -1155,4 +1155,5 @@ configuration.label.FHIRViaHL7v2Port=Port of the HL7 communication server for FH configuration.label.FHIRViaHL7v2SendingFacility= configuration.description.exportFHIRViaHL7v2=For FHIR, export can be set up using an HL7v2 communication server. In this case, the FHIR resource is embedded as a blob in the HL7 message. encounter.error.caseNumberInvalid=The case number was invalid. Please try again! -survey.questionnaire.button.completeQuestionnaireInBundle=Complete the survey section \ No newline at end of file +survey.questionnaire.button.completeQuestionnaireInBundle=Complete the survey section +configuration.label.enableEncounterTemplateDownload=Enablement of download option for encounter templates \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/encounter/show.html b/src/main/webapp/WEB-INF/encounter/show.html index d7ec185c..1aa7b385 100644 --- a/src/main/webapp/WEB-INF/encounter/show.html +++ b/src/main/webapp/WEB-INF/encounter/show.html @@ -69,6 +69,13 @@ th:text="${messages.get(#locale, 'encounter.button.export', 'Export')}" > + +
- + diff --git a/src/main/webapp/WEB-INF/mobile/survey/bundleScheduled.html b/src/main/webapp/WEB-INF/mobile/survey/bundleScheduled.html index 18c9f569..d98b535d 100644 --- a/src/main/webapp/WEB-INF/mobile/survey/bundleScheduled.html +++ b/src/main/webapp/WEB-INF/mobile/survey/bundleScheduled.html @@ -1,185 +1,164 @@ - - -
-
- - - -
- - - -
-
-
-
-
- - - -
-
-
-
-
-
- -
-
-
- - - - - - - - - - - - - - - -
-
-
-
-
-
- - -
-
-
-
-
- -
-
- -
+ + +
+ +
+ + + + +
+
+ +
+
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+
+ +
- + diff --git a/src/test/java/de/imi/mopat/helper/controller/AuthServiceTest.java b/src/test/java/de/imi/mopat/helper/controller/AuthServiceTest.java index 9d32b959..8ac6b66c 100644 --- a/src/test/java/de/imi/mopat/helper/controller/AuthServiceTest.java +++ b/src/test/java/de/imi/mopat/helper/controller/AuthServiceTest.java @@ -4,6 +4,7 @@ import de.imi.mopat.model.user.User; import de.imi.mopat.model.user.UserRole; import de.imi.mopat.model.user.UserTest; +import de.imi.mopat.service.AuthService; import de.imi.mopat.utils.Helper; import org.junit.Before; import org.junit.Test; diff --git a/src/test/java/de/imi/mopat/helper/controller/EncounterControllerDownloadExportTest.java b/src/test/java/de/imi/mopat/helper/controller/EncounterControllerDownloadExportTest.java new file mode 100644 index 00000000..0a1442e1 --- /dev/null +++ b/src/test/java/de/imi/mopat/helper/controller/EncounterControllerDownloadExportTest.java @@ -0,0 +1,264 @@ +package de.imi.mopat.helper.controller; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anySet; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.imi.mopat.controller.EncounterController; +import de.imi.mopat.dao.AuditEntryDao; +import de.imi.mopat.dao.ConfigurationDao; +import de.imi.mopat.dao.EncounterDao; +import de.imi.mopat.dao.ExportTemplateDao; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.Configuration; +import de.imi.mopat.model.ConfigurationGroup; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.ExportTemplate; +import de.imi.mopat.model.Questionnaire; +import de.imi.mopat.model.enumeration.AuditEntryActionType; +import de.imi.mopat.model.enumeration.AuditPatientAttribute; +import de.imi.mopat.model.enumeration.ExportTemplateType; +import de.imi.mopat.service.EncounterExportService; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import jdk.jfr.Enabled; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +public class EncounterControllerDownloadExportTest { + + private static final Long ENCOUNTER_ID = 11L; + private static final Long TEMPLATE_ID = 22L; + private static final String CASE_NUMBER = "CASE-001"; + private static final String EXPORT_CONTENT = "[{\"record_id\":\"CASE-001\"}]"; + + private EncounterController encounterController; + + private ConfigurationDao configurationDao; + private EncounterDao encounterDao; + private ExportTemplateDao exportTemplateDao; + private EncounterExportService encounterExportService; + private AuditEntryDao auditEntryDao; + + @Before + public void setUp() { + configurationDao = mock(ConfigurationDao.class); + encounterDao = mock(EncounterDao.class); + exportTemplateDao = mock(ExportTemplateDao.class); + encounterExportService = mock(EncounterExportService.class); + auditEntryDao = mock(AuditEntryDao.class); + + encounterController = new EncounterController(); + + ReflectionTestUtils.setField(encounterController, "configurationDao", configurationDao); + ReflectionTestUtils.setField(encounterController, "encounterDao", encounterDao); + ReflectionTestUtils.setField(encounterController, "exportTemplateDao", exportTemplateDao); + ReflectionTestUtils.setField(encounterController, "encounterExportService", + encounterExportService); + ReflectionTestUtils.setField(encounterController, "auditEntryDao", auditEntryDao); + } + /** + * Verifies that a download request returns the generated export content as a file response when + * frontend downloads are enabled and file export is enabled for the export template. + *

+ * The test checks the HTTP status, response body, content type, content-disposition header and that + * an audit entry is written. + * + * @throws Exception if generating or returning the export content fails + */ + @Test + public void downloadEncounterTemplate_whenDownloadEnabledAndFileExportEnabled_returnsFile() + throws Exception { + Encounter encounter = createEncounter(); + ExportTemplate exportTemplate = createExportTemplate(true); + + when(configurationDao.isEncounterTemplateDownloadEnabled()).thenReturn(true); + when(encounterDao.getElementById(ENCOUNTER_ID)).thenReturn(encounter); + when(exportTemplateDao.getElementById(TEMPLATE_ID)).thenReturn(exportTemplate); + when(encounterExportService.getExportContent(encounter, exportTemplate)) + .thenReturn(EXPORT_CONTENT); + + ResponseEntity response = encounterController.downloadEncounterTemplate( + ENCOUNTER_ID, + TEMPLATE_ID + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + + assertNotNull(response.getBody()); + assertArrayEquals( + EXPORT_CONTENT.getBytes(StandardCharsets.UTF_8), + response.getBody() + ); + + MediaType expectedContentType = new MediaType( + "application", + "json", + StandardCharsets.UTF_8 + ); + + assertEquals(expectedContentType, response.getHeaders().getContentType()); + + String contentDisposition = response.getHeaders() + .getFirst(HttpHeaders.CONTENT_DISPOSITION); + + assertNotNull(contentDisposition); + assertTrue(contentDisposition.contains("attachment")); + assertTrue(contentDisposition.contains("filename=\"CASE-001_redcap-template.json\"")); + + verify(encounterExportService).getExportContent(encounter, exportTemplate); + + verify(auditEntryDao).writeAuditEntry( + eq(EncounterController.class.getSimpleName()), + eq("downloadEncounterTemplate(" + ENCOUNTER_ID + ", " + TEMPLATE_ID + ")"), + eq(CASE_NUMBER), + anySet(), + eq(AuditEntryActionType.READ) + ); + } + + /** + * Verifies that the download endpoint rejects requests when frontend export downloads are globally + * disabled. + *

+ * The test ensures that no encounter or export template is loaded, no export content is generated + * and no audit entry is written. + * + * @throws Exception if the controller invocation fails + */ + @Test + public void downloadEncounterTemplate_whenDownloadConfigDisabled_returnsForbidden() + throws Exception { + when(configurationDao.isEncounterTemplateDownloadEnabled()).thenReturn(false); + + ResponseEntity response = encounterController.downloadEncounterTemplate( + ENCOUNTER_ID, + TEMPLATE_ID + ); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + + verify(encounterDao, never()).getElementById(any()); + verify(exportTemplateDao, never()).getElementById(any()); + verify(encounterExportService, never()).getExportContent(any(), any()); + verify(auditEntryDao, never()).writeAuditEntry( + any(), + any(), + any(), + any(), + any() + ); + } +//TODO: check, ob guard schon im Controller sein sollte, wenn ja, implementieren und tests behalten, sonst raus damit + /** + * Verifies that the download endpoint rejects requests when the selected export template does not + * have file export enabled. + *

+ * The test ensures that no export content is generated and no audit entry is written. + * + * @throws Exception if the controller invocation fails + */ + @Test + public void downloadEncounterTemplate_whenFileExportDisabled_returnsForbidden() + throws Exception { + Encounter encounter = createEncounter(); + ExportTemplate exportTemplate = createExportTemplate(false); + + when(configurationDao.isEncounterTemplateDownloadEnabled()).thenReturn(true); + when(encounterDao.getElementById(ENCOUNTER_ID)).thenReturn(encounter); + when(exportTemplateDao.getElementById(TEMPLATE_ID)).thenReturn(exportTemplate); + + ResponseEntity response = encounterController.downloadEncounterTemplate( + ENCOUNTER_ID, + TEMPLATE_ID + ); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + + verify(encounterExportService, never()).getExportContent(any(), any()); + verify(auditEntryDao, never()).writeAuditEntry( + any(), + any(), + any(), + any(), + any() + ); + } + + private Encounter createEncounter() { + Bundle bundle = new Bundle( + "Test Bundle", + "Test Bundle Description", + 1L, + true, + true, + false + ); + + Encounter encounter = new Encounter(bundle, CASE_NUMBER); + encounter.setBundleLanguage("de_DE"); + + return encounter; + } + + private ExportTemplate createExportTemplate(final boolean fileExportEnabled) { + Questionnaire questionnaire = new Questionnaire( + "Test Questionnaire", + "Test Questionnaire Description", + 1L, + true + ); + + ExportTemplate exportTemplate = new ExportTemplate(); + exportTemplate.setName("REDCap Test Template"); + exportTemplate.setExportTemplateType(ExportTemplateType.REDCap); + exportTemplate.setFilename("redcap-template.json"); + exportTemplate.setOriginalFilename("redcap-template"); + exportTemplate.setQuestionnaire(questionnaire); + exportTemplate.setConfigurationGroup(configurationGroup( + config("exportInDirectory", Boolean.toString(fileExportEnabled), 1), + config("exportPath", "/tmp/mopat-test-export", 2), + config("exportViaRest", "false", 3) + )); + + return exportTemplate; + } + + private ConfigurationGroup configurationGroup(final Configuration... configurations) { + ConfigurationGroup configurationGroup = new ConfigurationGroup(); + configurationGroup.setName("Test Configuration Group"); + configurationGroup.setLabelMessageCode("test.configuration.group"); + configurationGroup.setConfigurations(new ArrayList<>()); + + for (Configuration configuration : configurations) { + configurationGroup.getConfigurations().add(configuration); + } + + return configurationGroup; + } + + private Configuration config( + final String attribute, + final String value, + final Integer position + ) { + Configuration configuration = mock(Configuration.class); + when(configuration.getAttribute()).thenReturn(attribute); + when(configuration.getValue()).thenReturn(value); + when(configuration.getPosition()).thenReturn(position); + return configuration; + } +} \ No newline at end of file diff --git a/src/test/java/de/imi/mopat/helper/controller/QuestionServiceTest.java b/src/test/java/de/imi/mopat/helper/controller/QuestionServiceTest.java index d6de6814..dc73ed18 100644 --- a/src/test/java/de/imi/mopat/helper/controller/QuestionServiceTest.java +++ b/src/test/java/de/imi/mopat/helper/controller/QuestionServiceTest.java @@ -14,6 +14,7 @@ import de.imi.mopat.model.conditions.Condition; import de.imi.mopat.model.conditions.ConditionTest; import java.util.Random; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/src/test/java/de/imi/mopat/helper/controller/QuestionnaireServiceTest.java b/src/test/java/de/imi/mopat/helper/controller/QuestionnaireServiceTest.java index 99b80d03..2dbd93f7 100644 --- a/src/test/java/de/imi/mopat/helper/controller/QuestionnaireServiceTest.java +++ b/src/test/java/de/imi/mopat/helper/controller/QuestionnaireServiceTest.java @@ -15,6 +15,7 @@ import de.imi.mopat.utils.MultipartFileUtils; import de.imi.mopat.validator.LogoValidator; import de.imi.mopat.validator.QuestionnaireDTOValidator; +import de.imi.mopat.service.*; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -38,6 +39,7 @@ import org.springframework.validation.BindingResult; import org.springframework.web.multipart.MultipartFile; + import java.io.IOException; import java.util.Collections; import java.util.HashSet; diff --git a/src/test/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupServiceTest.java b/src/test/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupServiceTest.java index 74cd766f..b8af4068 100644 --- a/src/test/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupServiceTest.java +++ b/src/test/java/de/imi/mopat/helper/controller/QuestionnaireVersionGroupServiceTest.java @@ -13,6 +13,7 @@ import de.imi.mopat.model.QuestionnaireTest; import de.imi.mopat.model.dto.QuestionnaireDTO; import de.imi.mopat.model.dto.QuestionnaireVersionGroupDTO; +import de.imi.mopat.service.QuestionnaireVersionGroupService; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; diff --git a/src/test/java/de/imi/mopat/io/FhirImporterTest.java b/src/test/java/de/imi/mopat/io/FhirImporterTest.java index 2143cb46..0aad3c6a 100644 --- a/src/test/java/de/imi/mopat/io/FhirImporterTest.java +++ b/src/test/java/de/imi/mopat/io/FhirImporterTest.java @@ -18,7 +18,7 @@ import de.imi.mopat.dao.QuestionDao; import de.imi.mopat.dao.QuestionnaireDao; import de.imi.mopat.dao.ScoreDao; -import de.imi.mopat.helper.controller.QuestionnaireVersionGroupService; +import de.imi.mopat.service.QuestionnaireVersionGroupService; import de.imi.mopat.io.impl.MetadataExporterFhirDstu3; import de.imi.mopat.io.impl.MetadataExporterFhirR4b; import de.imi.mopat.io.impl.MetadataExporterFhirR5; @@ -374,53 +374,39 @@ public void testImportFhirQuestionnaireDstu3_WithErrors() throws IOException { result.getImportResult()); // - assertTrue(checkIfErrorIsPresent(result, "error", 2, "Questionnaire", - "Regel que-2: 'The link ids for groups and questions must be unique within the questionnaire' fehlgeschlagen")); + assertTrue(checkIfErrorIsPresent(result, "error", 2, "Questionnaire")); // - assertTrue(checkIfErrorIsPresent(result, "error", 9, "/f:Questionnaire", - "Undefiniertes Element 'titel'")); + assertTrue(checkIfErrorIsPresent(result, "error", 9, "/f:Questionnaire")); // - assertTrue(checkIfErrorIsPresent(result, "error", 27, "/f:Questionnaire/f:description", - "Element muss einen Inhalt haben")); - assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description", - "Primitive Typen müssen einen Wert, oder child Extensions haben")); - assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description", - "Constraint failed: ele-1: 'All FHIR elements must have a @value or children' (defined in Element)")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "/f:Questionnaire/f:description")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description")); // - assertTrue(checkIfErrorIsPresent(result, "error", 24, "Questionnaire.date", - "Kein gültiges Datum/Uhrzeit (30-06-2025)")); + assertTrue(checkIfErrorIsPresent(result, "error", 24, "Questionnaire.date")); // - assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]", - "Regel que-1: 'Group items must have nested items, display items cannot have nested items' fehlgeschlagen (type: ; item: ) (log: (type: ; item: ))")); - assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]", - "Questionnaire.item.type: mindestens erforderlich = 1, aber nur gefunden 0")); + assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]")); + assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]")); // - assertTrue(checkIfErrorIsPresent(result, "error", 49, "Questionnaire.item[1].repeats", - "Boolesche Werte müssen 'wahr' oder 'falsch' sein.")); + assertTrue(checkIfErrorIsPresent(result, "error", 49, "Questionnaire.item[1].repeats")); // - assertTrue(checkIfErrorIsPresent(result, "error", 70, "Questionnaire.item[3]", - "Regel que-1: 'Group items must have nested items, display items cannot have nested items' fehlgeschlagen")); + assertTrue(checkIfErrorIsPresent(result, "error", 70, "Questionnaire.item[3]")); // - assertTrue(checkIfErrorIsPresent(result, "error", 4, "Questionnaire.language", - "Der bereitgestellte Code ist nicht im maximum value set 'All Languages' (http://hl7.org/fhir/ValueSet/all-languages|3.0.2), und ein Code aus diesem ValueSet ist erforderlich) (Code = Unknown code 'urn:ietf:bcp:47#english' for in-memory expansion of ValueSet 'http://hl7.org/fhir/ValueSet/all-languages'#{2}, Fehler = {3}))")); + assertTrue(checkIfErrorIsPresent(result, "error", 4, "Questionnaire.language")); // - assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status", - "Unknown code 'http://hl7.org/fhir/publication-status#drafted'")); - assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status", - "Der angegebene Wert ('drafted') ist nicht im ValueSet 'PublicationStatus' (http://hl7.org/fhir/ValueSet/publication-status|3.0.2), und ein Code aus diesem Valueset ist erforderlich) (error message = Unknown code 'http://hl7.org/fhir/publication-status#drafted' for in-memory expansion of ValueSet 'http://hl7.org/fhir/ValueSet/publication-status')")); + assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status")); + assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status")); // assertTrue(checkIfErrorIsPresent(result, "error", 35, - "Questionnaire.item[0].option[0].value.ofType(Coding)", - "Coding.system muss eine absolute Referenz sein, nicht eine lokale Referenz")); + "Questionnaire.item[0].option[0].value.ofType(Coding)")); } catch (ImportFailedException ex) { @@ -445,45 +431,33 @@ public void testImportFhirQuestionnaireR4b_WithErrors() throws IOException { result.getImportResult()); // - assertTrue(checkIfErrorIsPresent(result, "error", 2, "Questionnaire", - "Constraint failed: que-2: 'The link ids for groups and questions must be unique within the questionnaire' (defined in http://hl7.org/fhir/StructureDefinition/Questionnaire)")); + assertTrue(checkIfErrorIsPresent(result, "error", 2, "Questionnaire")); // - assertTrue(checkIfErrorIsPresent(result, "error", 9, "/f:Questionnaire", - "Undefined element 'titel' at /f:Questionnaire")); + assertTrue(checkIfErrorIsPresent(result, "error", 9, "/f:Questionnaire")); // - assertTrue(checkIfErrorIsPresent(result, "error", 27, "/f:Questionnaire/f:description", - "Element must have some content")); - assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description", - "Primitive types must have a value or must have child extensions")); - assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description", - "Constraint failed: ele-1: 'All FHIR elements must have a @value or children' (defined in http://hl7.org/fhir/StructureDefinition/Element)")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "/f:Questionnaire/f:description")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description")); // - assertTrue(checkIfErrorIsPresent(result, "error", 24, "Questionnaire.date", - "Not a valid date/time format: '30-06-2025'")); + assertTrue(checkIfErrorIsPresent(result, "error", 24, "Questionnaire.date")); // - assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]", - "Constraint failed: que-6: 'Required and repeat aren't permitted for display items' (defined in http://hl7.org/fhir/StructureDefinition/Questionnaire)")); - assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]", - "Questionnaire.item.type: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Questionnaire|4.3.0)")); + assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]")); + assertTrue(checkIfErrorIsPresent(result, "error", 43, "Questionnaire.item[1]")); // - assertTrue(checkIfErrorIsPresent(result, "error", 49, "Questionnaire.item[1].repeats", - "Boolean values must be 'true' or 'false'")); + assertTrue(checkIfErrorIsPresent(result, "error", 49, "Questionnaire.item[1].repeats")); // - assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status", - "Unknown code 'http://hl7.org/fhir/publication-status#drafted'")); - assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status", - "The value provided ('drafted') was not found in the value set 'PublicationStatus' (http://hl7.org/fhir/ValueSet/publication-status|4.3.0), and a code is required from this value set (error message = Unknown code 'http://hl7.org/fhir/publication-status#drafted' for in-memory expansion of ValueSet 'http://hl7.org/fhir/ValueSet/publication-status')")); + assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status")); + assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status")); // assertTrue(checkIfErrorIsPresent(result, "error", 35, - "Questionnaire.item[0].answerOption[0].value.ofType(Coding)", - "Coding.system must be an absolute reference, not a local reference")); + "Questionnaire.item[0].answerOption[0].value.ofType(Coding)")); } catch (ImportFailedException ex) { @@ -508,49 +482,36 @@ public void testImportFhirQuestionnaireR5_WithErrors() throws IOException { result.getImportResult()); // - assertTrue(checkIfErrorIsPresent(result, "error", 2, "Questionnaire", - "Constraint failed: que-2: 'The link ids for groups and questions must be unique within the questionnaire' (defined in http://hl7.org/fhir/StructureDefinition/Questionnaire)")); + assertTrue(checkIfErrorIsPresent(result, "error", 2, "Questionnaire")); // - assertTrue(checkIfErrorIsPresent(result, "error", 9, "/f:Questionnaire", - "Undefined element 'titel' at /f:Questionnaire")); + assertTrue(checkIfErrorIsPresent(result, "error", 9, "/f:Questionnaire")); // - assertTrue(checkIfErrorIsPresent(result, "error", 27, "/f:Questionnaire/f:description", - "Element must have some content")); - assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description", - "Primitive types must have a value or must have child extensions")); - assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description", - "Constraint failed: ele-1: 'All FHIR elements must have a @value or children' (defined in http://hl7.org/fhir/StructureDefinition/Element)")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "/f:Questionnaire/f:description")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description")); + assertTrue(checkIfErrorIsPresent(result, "error", 27, "Questionnaire.description")); // - assertTrue(checkIfErrorIsPresent(result, "error", 24, "Questionnaire.date", - "Not a valid date/time format: '30-06-2025'")); + assertTrue(checkIfErrorIsPresent(result, "error", 24, "Questionnaire.date")); // - assertTrue(checkIfErrorIsPresent(result, "error", 44, "Questionnaire.item[1]", - "Constraint failed: que-6: 'Required and repeat aren't permitted for display items' (defined in http://hl7.org/fhir/StructureDefinition/Questionnaire)")); - assertTrue(checkIfErrorIsPresent(result, "error", 44, "Questionnaire.item[1]", - "Questionnaire.item.type: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Questionnaire|5.0.0)")); + assertTrue(checkIfErrorIsPresent(result, "error", 44, "Questionnaire.item[1]")); + assertTrue(checkIfErrorIsPresent(result, "error", 44, "Questionnaire.item[1]")); // - assertTrue(checkIfErrorIsPresent(result, "error", 50, "Questionnaire.item[1].repeats", - "Boolean values must be 'true' or 'false'")); + assertTrue(checkIfErrorIsPresent(result, "error", 50, "Questionnaire.item[1].repeats")); // - assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status", - "Unknown code 'http://hl7.org/fhir/publication-status#drafted'")); - assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status", - "The value provided ('drafted') was not found in the value set 'PublicationStatus' (http://hl7.org/fhir/ValueSet/publication-status|5.0.0), and a code is required from this value set (error message = Unknown code 'http://hl7.org/fhir/publication-status#drafted' for in-memory expansion of ValueSet 'http://hl7.org/fhir/ValueSet/publication-status')")); + assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status")); + assertTrue(checkIfErrorIsPresent(result, "error", 21, "Questionnaire.status")); // - assertTrue(checkIfErrorIsPresent(result, "error", 34, "Questionnaire.item[0].type", - "The value provided ('choice') was not found in the value set 'Questionnaire Item Type' (http://hl7.org/fhir/ValueSet/item-type|5.0.0), and a code is required from this value set (error message = Unknown code 'http://hl7.org/fhir/item-type#choice' for in-memory expansion of ValueSet 'http://hl7.org/fhir/ValueSet/item-type')")); + assertTrue(checkIfErrorIsPresent(result, "error", 34, "Questionnaire.item[0].type")); // assertTrue(checkIfErrorIsPresent(result, "error", 36, - "Questionnaire.item[0].answerOption[0].value.ofType(Coding)", - "Coding.system must be an absolute reference, not a local reference")); + "Questionnaire.item[0].answerOption[0].value.ofType(Coding)")); } catch (ImportFailedException ex) { @@ -576,6 +537,23 @@ private boolean checkIfErrorIsPresent(ImportQuestionnaireValidation result, Stri return match; } + private boolean checkIfErrorIsPresent(ImportQuestionnaireValidation result, String type, + int lineNumber, String fhirPath) { + boolean match = false; + + for (ImportQuestionnaireError error : filterValidationByIndex(result, lineNumber)) { + Object[] arguments = error.getErrorArguments(); + try { + if (arguments[0].equals(type) && lineNumber == ((int) arguments[1]) + && arguments[2].equals(fhirPath)) { + match = true; + } + } catch (Exception ignored) { + } + } + return match; + } + private List filterValidationByIndex( ImportQuestionnaireValidation result, int index) { return result.getValidationErrors().stream().filter( diff --git a/src/test/java/de/imi/mopat/model/BundleTest.java b/src/test/java/de/imi/mopat/model/BundleTest.java index bd3148b4..7c3c4513 100644 --- a/src/test/java/de/imi/mopat/model/BundleTest.java +++ b/src/test/java/de/imi/mopat/model/BundleTest.java @@ -11,7 +11,7 @@ import de.imi.mopat.config.ApplicationSecurityConfig; import de.imi.mopat.config.MvcWebApplicationInitializer; import de.imi.mopat.config.PersistenceConfig; -import de.imi.mopat.helper.controller.BundleService; +import de.imi.mopat.service.BundleService; import de.imi.mopat.helper.model.BundleDTOMapper; import de.imi.mopat.model.dto.BundleDTO; import de.imi.mopat.utils.Helper; diff --git a/src/test/java/de/imi/mopat/service/AuditServiceTest.java b/src/test/java/de/imi/mopat/service/AuditServiceTest.java new file mode 100644 index 00000000..397d54fa --- /dev/null +++ b/src/test/java/de/imi/mopat/service/AuditServiceTest.java @@ -0,0 +1,93 @@ +package de.imi.mopat.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.imi.mopat.dao.AuditEntryDao; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.enumeration.AuditEntryActionType; +import de.imi.mopat.model.enumeration.AuditPatientAttribute; +import java.util.EnumSet; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class AuditServiceTest { + + @Mock + private AuditEntryDao auditEntryDao; + + @InjectMocks + private AuditService auditService; + + @Mock + private EncounterScheduled encounterScheduled; + + private static final Set EXPECTED_ATTRIBUTES = EnumSet.of( + AuditPatientAttribute.CASE_NUMBER, + AuditPatientAttribute.EMAIL_ADDRESS, + AuditPatientAttribute.FIRST_NAME, + AuditPatientAttribute.LAST_NAME, + AuditPatientAttribute.DATE_OF_BIRTH + ); + + /** + * Test of {@link AuditService#writeScheduledEncounterAudit(Class, String, EncounterScheduled)} + */ + + @Test + public void testWriteScheduledEncounterAuditWritesCorrectAuditEntry() { + String caseNumber = "TEST-CASE-123"; + when(encounterScheduled.getCaseNumber()).thenReturn(caseNumber); + + auditService.writeScheduledEncounterAudit( + EncounterScheduledService.class, "save", encounterScheduled); + + verify(auditEntryDao).writeAuditEntry( + EncounterScheduledService.class.getSimpleName(), + "save", + caseNumber, + EXPECTED_ATTRIBUTES, + AuditEntryActionType.WRITE + ); + } + + @Test + public void testWriteScheduledEncounterAuditUsesSimpleNameOfSourceClass() { + String caseNumber = "CASE-456"; + when(encounterScheduled.getCaseNumber()).thenReturn(caseNumber); + + auditService.writeScheduledEncounterAudit( + AuditService.class, "testMethod", encounterScheduled); + + verify(auditEntryDao).writeAuditEntry( + "AuditService", + "testMethod", + caseNumber, + EXPECTED_ATTRIBUTES, + AuditEntryActionType.WRITE + ); + } + + @Test + public void testWriteScheduledEncounterAuditAlwaysUsesActionTypeWrite() { + when(encounterScheduled.getCaseNumber()).thenReturn("ANY"); + + auditService.writeScheduledEncounterAudit( + AuditService.class, "anyMethod", encounterScheduled); + + verify(auditEntryDao).writeAuditEntry( + any(), + any(), + any(), + any(), + eq(AuditEntryActionType.WRITE) + ); + } +} \ No newline at end of file diff --git a/src/test/java/de/imi/mopat/service/EncounterExportServiceDownloadTest.java b/src/test/java/de/imi/mopat/service/EncounterExportServiceDownloadTest.java new file mode 100644 index 00000000..56cf1168 --- /dev/null +++ b/src/test/java/de/imi/mopat/service/EncounterExportServiceDownloadTest.java @@ -0,0 +1,241 @@ +package de.imi.mopat.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.imi.mopat.dao.ConfigurationDao; +import de.imi.mopat.dao.EncounterDao; +import de.imi.mopat.helper.controller.Constants; +import de.imi.mopat.io.EncounterExporter; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.BundleQuestionnaire; +import de.imi.mopat.model.Configuration; +import de.imi.mopat.model.ConfigurationGroup; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.ExportTemplate; +import de.imi.mopat.model.Questionnaire; +import de.imi.mopat.model.enumeration.ExportTemplateType; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.stream.Stream; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.springframework.context.MessageSource; +import org.springframework.test.util.ReflectionTestUtils; + + +/** + * Tests the download-content generation for encounter export templates. + *

+ * These tests verify that export content can be generated on demand for frontend downloads without + * executing the regular export flow. In particular, they ensure that no export file is written, no + * external transmission is triggered and no export history entry is created. + */ +public class EncounterExportServiceDownloadTest { + + private static final String CASE_NUMBER = "CASE-RED-001"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private ConfigurationDao configurationDao; + private EncounterDao encounterDao; + private MessageSource messageSource; + + private EncounterExportService encounterExportService; + + @Before + public void setUp() { + configurationDao = mock(ConfigurationDao.class); + encounterDao = mock(EncounterDao.class); + messageSource = mock(MessageSource.class); + + EncounterExporter encounterExporter = new EncounterExporter(); + ReflectionTestUtils.setField(encounterExporter, "configurationDao", configurationDao); + ReflectionTestUtils.setField(encounterExporter, "encounterDao", encounterDao); + ReflectionTestUtils.setField(encounterExporter, "messageSource", messageSource); + + encounterExportService = new EncounterExportService(); + ReflectionTestUtils.setField(encounterExportService, "encounterExporter", encounterExporter); + } + + @Test + public void getExportContent_redcap_returnsValidJsonWithoutSideEffects() throws Exception { + File objectStorageRoot = temporaryFolder.newFolder("object-storage"); + + when(configurationDao.getObjectStoragePath()) + .thenReturn(objectStorageRoot.getAbsolutePath()); + + File templateDirectory = new File( + objectStorageRoot.getAbsolutePath() + Constants.EXPORT_TEMPLATE_SUB_DIRECTORY); + assertTrue(templateDirectory.mkdirs()); + + String templateFilename = "redcap-test-template.json"; + + String redcapTemplateJson = """ + [ + { + "record_id": "", + "redcap_repeat_instrument": "", + "redcap_repeat_instance": "", + "redcap_event_name": "", + "redcap_data_access_group": "", + "test_field": "", + "test_form_complete": "" + } + ] + """; + + Files.writeString( + new File(templateDirectory, templateFilename).toPath(), + redcapTemplateJson, + StandardCharsets.UTF_8 + ); + + ConfigurationGroup configurationGroup = configurationGroup( + config("exportInDirectory", "true", 1), + config("exportPath", new File(objectStorageRoot, "exports").getAbsolutePath(), 2), + config("exportViaRest", "false", 3) + ); + + TestFixture fixture = createFixture( + ExportTemplateType.REDCap, + templateFilename, + "redcap-test-template", + configurationGroup + ); + + Encounter encounter = fixture.encounter(); + ExportTemplate exportTemplate = fixture.exportTemplate(); + + int historyEntriesBefore = encounter.getEncounterExportTemplates().size(); + long fileCountBefore = countFiles(objectStorageRoot.toPath()); + + String exportContent = encounterExportService.getExportContent(encounter, exportTemplate); + + assertNotNull(exportContent); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode root = objectMapper.readTree(exportContent); + + assertTrue("REDCap export content should be a JSON array", root.isArray()); + assertEquals("REDCap JSON array should contain exactly one record", 1, root.size()); + + JsonNode firstRecord = root.get(0); + + assertEquals(CASE_NUMBER, firstRecord.get("record_id").asText()); + assertEquals("1", firstRecord.get("test_form_complete").asText()); + + assertEquals( + "Download content generation must not create export history entries", + historyEntriesBefore, + encounter.getEncounterExportTemplates().size() + ); + + assertEquals( + "Download content generation must not create additional files", + fileCountBefore, + countFiles(objectStorageRoot.toPath()) + ); + + verify(encounterDao, never()).merge(any(Encounter.class)); + } + + private TestFixture createFixture( + final ExportTemplateType exportTemplateType, + final String filename, + final String originalFilename, + final ConfigurationGroup configurationGroup + ) { + Questionnaire questionnaire = new Questionnaire( + "Test Questionnaire", + "Test Questionnaire Description", + 1L, + true + ); + + Bundle bundle = new Bundle( + "Test Bundle", + "Test Bundle Description", + 1L, + true, + true, + false + ); + + BundleQuestionnaire bundleQuestionnaire = new BundleQuestionnaire( + bundle, + questionnaire, + 1, + true, + false + ); + + Encounter encounter = new Encounter(bundle, CASE_NUMBER); + encounter.setBundleLanguage("de_DE"); + + ExportTemplate exportTemplate = new ExportTemplate(); + exportTemplate.setName("Test Export Template"); + exportTemplate.setExportTemplateType(exportTemplateType); + exportTemplate.setFilename(filename); + exportTemplate.setOriginalFilename(originalFilename); + exportTemplate.setConfigurationGroup(configurationGroup); + exportTemplate.setQuestionnaire(questionnaire); + + bundleQuestionnaire.addExportTemplate(exportTemplate); + + return new TestFixture(encounter, exportTemplate); + } + + private ConfigurationGroup configurationGroup(final Configuration... configurations) { + ConfigurationGroup configurationGroup = new ConfigurationGroup(); + configurationGroup.setName("Test Configuration Group"); + configurationGroup.setLabelMessageCode("test.configuration.group"); + configurationGroup.setConfigurations(new ArrayList<>()); + + for (Configuration configuration : configurations) { + configurationGroup.getConfigurations().add(configuration); + } + + return configurationGroup; + } + + private Configuration config( + final String attribute, + final String value, + final Integer position + ) { + Configuration configuration = mock(Configuration.class); + when(configuration.getAttribute()).thenReturn(attribute); + when(configuration.getValue()).thenReturn(value); + when(configuration.getPosition()).thenReturn(position); + return configuration; + } + + private long countFiles(final Path root) throws Exception { + try (Stream stream = Files.walk(root)) { + return stream + .filter(Files::isRegularFile) + .count(); + } + } + + private record TestFixture( + Encounter encounter, + ExportTemplate exportTemplate + ) { + } +} \ No newline at end of file diff --git a/src/test/java/de/imi/mopat/service/EncounterScheduledServiceTest.java b/src/test/java/de/imi/mopat/service/EncounterScheduledServiceTest.java new file mode 100644 index 00000000..0053bb38 --- /dev/null +++ b/src/test/java/de/imi/mopat/service/EncounterScheduledServiceTest.java @@ -0,0 +1,166 @@ +package de.imi.mopat.service; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.imi.mopat.cron.EncounterScheduledExecutor; +import de.imi.mopat.dao.BundleDao; +import de.imi.mopat.dao.EncounterScheduledDao; +import de.imi.mopat.helper.model.EncounterScheduledDTOMapper; +import de.imi.mopat.model.Bundle; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.dto.EncounterScheduledDTO; +import de.imi.mopat.service.helper.SetRepeatConfiguration; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class EncounterScheduledServiceTest { + + @Mock + private BundleDao bundleDao; + + @Mock + private EncounterScheduledDao encounterScheduledDao; + + @Mock + private EncounterScheduledDTOMapper mapper; + + @Mock + private ImmediateMailService immediateMailService; + + @Mock + private AuditService auditService; + + @Mock + private SetRepeatConfiguration setRepeatConfiguration; + + @InjectMocks + private EncounterScheduledService encounterScheduledService; + + @Mock + private EncounterScheduledDTO dto; + + @Mock + private EncounterScheduled scheduled; + + @Mock + private Bundle bundle; + + @Mock + private EncounterScheduledExecutor executor; + + @Before + public void setUp() { + when(mapper.mapToEntity(dto)).thenReturn(scheduled); + when(scheduled.getBundle()).thenReturn(bundle); + } + + /** + * Test of {@link EncounterScheduledService#save(EncounterScheduledDTO, EncounterScheduledExecutor)} )}, + */ + + @Test + public void testSaveWhenShouldSendImmediatelyAndMailSucceeds() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(true); + when(immediateMailService.createAndSendEncounter(scheduled)).thenReturn(MailSendingStatus.SUCCESS); + + MailSendingStatus result = encounterScheduledService.save(dto, executor); + + assertEquals("should return SUCCESS", MailSendingStatus.SUCCESS, result); + } + + @Test + public void testSaveWhenShouldSendImmediatelyAndAddressInvalid() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(true); + when(immediateMailService.createAndSendEncounter(scheduled)).thenReturn(MailSendingStatus.INVALID_ADDRESS); + + MailSendingStatus result = encounterScheduledService.save(dto, executor); + + assertEquals("Should return INVALID_ADDRESS", MailSendingStatus.INVALID_ADDRESS, result); + } + + @Test + public void testSaveWhenShouldSendImmediatelyAndMailFails() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(true); + when(immediateMailService.createAndSendEncounter(scheduled)).thenReturn(MailSendingStatus.FAILURE); + + MailSendingStatus result = encounterScheduledService.save(dto, executor); + + assertEquals("Should return FAILURE", MailSendingStatus.FAILURE, result); + } + + @Test + public void testSaveWhenShouldNotSendImmediately() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + MailSendingStatus result = encounterScheduledService.save(dto, executor); + + assertEquals("Should return SUCCESS when not sending immediately ", + MailSendingStatus.SUCCESS, result); + verify(immediateMailService, never()).createAndSendEncounter(any()); + } + + @Test + public void testSaveAlwaysAppliesRepeatConfiguration() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + encounterScheduledService.save(dto, executor); + + verify(setRepeatConfiguration).apply(dto); + } + + @Test + public void testSaveAlwaysMergesScheduledEncounter() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + encounterScheduledService.save(dto, executor); + + verify(encounterScheduledDao).merge(scheduled); + } + + @Test + public void testSaveWhenBundleIsNotNullMergesBundle() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + encounterScheduledService.save(dto, executor); + + verify(bundleDao).merge(bundle); + } + + @Test + public void testSaveWhenBundleIsNullDoesNotMergeBundle() { + when(scheduled.getBundle()).thenReturn(null); + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + encounterScheduledService.save(dto, executor); + + verify(bundleDao, never()).merge(any()); + } + + @Test + public void testSaveAlwaysWritesAuditEntry() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + encounterScheduledService.save(dto, executor); + + verify(auditService).writeScheduledEncounterAudit( + EncounterScheduledService.class, "save", scheduled); + } + + @Test + public void testSaveWhenShouldNotSendImmediatelyNeverCallsCreateAndSend() { + when(immediateMailService.shouldSendEmailImmediately(scheduled, executor)).thenReturn(false); + + encounterScheduledService.save(dto, executor); + + verify(immediateMailService, never()).createAndSendEncounter(any()); + } +} \ No newline at end of file diff --git a/src/test/java/de/imi/mopat/service/ImmediateMailServiceTest.java b/src/test/java/de/imi/mopat/service/ImmediateMailServiceTest.java new file mode 100644 index 00000000..8ebaf571 --- /dev/null +++ b/src/test/java/de/imi/mopat/service/ImmediateMailServiceTest.java @@ -0,0 +1,145 @@ +package de.imi.mopat.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.imi.mopat.cron.EncounterScheduledExecutor; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.EncounterScheduledTest; +import java.util.Calendar; +import java.util.Date; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ImmediateMailServiceTest { + + @Mock + private MailService mailService; + + @InjectMocks + private ImmediateMailService immediateMailService; + + @Mock + private EncounterScheduled scheduled; + + @Mock + private EncounterScheduledExecutor executor; + + private Date today; + private Date yesterday; + private Date tomorrow; + + @Before + public void setUp() { + today = new Date(); + + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_MONTH, -1); + yesterday = cal.getTime(); + + cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_MONTH, 1); + tomorrow = cal.getTime(); + } + /** + * Test of {@link ImmediateMailService#shouldSendEmailImmediately(EncounterScheduled, EncounterScheduledExecutor)}, when StartState is tomorrow + */ + @Test + public void testShouldSendEmailImmediatelyWhenStartDateIsTomorrow() { + when(scheduled.getStartDate()).thenReturn(tomorrow); + + assertFalse("Email does not send immediately, when start date not today", + immediateMailService.shouldSendEmailImmediately(scheduled, executor)); + } + + /** + * Test of {@link ImmediateMailService#shouldSendEmailImmediately(EncounterScheduled, EncounterScheduledExecutor)}, + * when StartDate is today and executor never executed + */ + @Test + public void testShouldSendEmailImmediatelyWhenStartDateTodayNeverExecuted() { + when(scheduled.getStartDate()).thenReturn(today); + when(executor.getLastExecutionTime()).thenReturn(null); + when(executor.getNextExecutionTime()).thenReturn(tomorrow); + + assertTrue("Email sends immediately, when it start date today and executor never executed", + immediateMailService.shouldSendEmailImmediately(scheduled, executor)); + } + /** + * Test of {@link ImmediateMailService#shouldSendEmailImmediately(EncounterScheduled, EncounterScheduledExecutor)}, + * when StartDate is today but next execution time is today + */ + @Test + public void testShouldSendEmailImmediatelyWhenStartDateTodayNeverExecutedNextExecIsToday() { + when(scheduled.getStartDate()).thenReturn(today); + when(executor.getLastExecutionTime()).thenReturn(null); + when(executor.getNextExecutionTime()).thenReturn(today); + + assertFalse("Email does not send immediately, when start date is today, executor never executed but next execution time is today", + immediateMailService.shouldSendEmailImmediately(scheduled, executor)); + } + + /** + * Test of {@link ImmediateMailService#shouldSendEmailImmediately(EncounterScheduled, EncounterScheduledExecutor)}, + * when StartDate is today but already executed + */ + @Test + public void testShouldSendEmailImmediatelyWhenStartDateTodayAndAlreadyExecutedToday() { + when(scheduled.getStartDate()).thenReturn(today); + when(executor.getLastExecutionTime()).thenReturn(today); + + assertTrue("Email sends immediately, when start date is today and last execution time was today", + immediateMailService.shouldSendEmailImmediately(scheduled, executor)); + } + + @Test + public void testShouldSendEmailImmediatelyWhenStartDateTodayAndLastExecutionWasYesterday() { + when(scheduled.getStartDate()).thenReturn(today); + when(executor.getLastExecutionTime()).thenReturn(yesterday); + + assertFalse("Email does not send immediately, when start date is today and last execution time was yesterday", + immediateMailService.shouldSendEmailImmediately(scheduled, executor)); + } + /** + * Test of {@link ImmediateMailService#createAndSendEncounter(EncounterScheduled)}, + */ + @Test + public void testCreateAndSendEncounterDelegatesToMailService() { + EncounterScheduled realScheduled = EncounterScheduledTest.getNewValidEncounterScheduled(); + when(mailService.sendEncounterMail(any())).thenReturn(MailSendingStatus.SUCCESS); + + MailSendingStatus result = immediateMailService.createAndSendEncounter(realScheduled); + + assertEquals("returns SUCCESS", MailSendingStatus.SUCCESS, result); + verify(mailService).sendEncounterMail(any()); + } + + @Test + public void testCreateAndSendEncounterWhenMailFails() { + EncounterScheduled realScheduled = EncounterScheduledTest.getNewValidEncounterScheduled(); + when(mailService.sendEncounterMail(any())).thenReturn(MailSendingStatus.FAILURE); + + MailSendingStatus result = immediateMailService.createAndSendEncounter(realScheduled); + + assertEquals("returns FAILURE", MailSendingStatus.FAILURE, result); + } + + @Test + public void testCreateAndSendEncounterWhenAddressInvalid() { + EncounterScheduled realScheduled = EncounterScheduledTest.getNewValidEncounterScheduled(); + when(mailService.sendEncounterMail(any())).thenReturn(MailSendingStatus.INVALID_ADDRESS); + + MailSendingStatus result = immediateMailService.createAndSendEncounter(realScheduled); + + assertEquals("returns INVALID_ADDRESS", MailSendingStatus.INVALID_ADDRESS, result); + } +} \ No newline at end of file diff --git a/src/test/java/de/imi/mopat/service/MailServiceTest.java b/src/test/java/de/imi/mopat/service/MailServiceTest.java new file mode 100644 index 00000000..11665c16 --- /dev/null +++ b/src/test/java/de/imi/mopat/service/MailServiceTest.java @@ -0,0 +1,95 @@ +package de.imi.mopat.service; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.when; + +import de.imi.mopat.dao.ConfigurationDao; +import de.imi.mopat.helper.controller.ApplicationMailer; +import de.imi.mopat.model.Encounter; +import de.imi.mopat.model.EncounterScheduled; +import de.imi.mopat.model.enumeration.EncounterScheduledMailStatus; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.context.MessageSource; + +@RunWith(MockitoJUnitRunner.class) +public class MailServiceTest { + + private static final String BASE_URL = "http://localhost/"; + + @Mock + private ApplicationMailer applicationMailer; + + @Mock + private MessageSource messageSource; + + @Mock + private ConfigurationDao configurationDao; + + @Mock + private ConfigurationService configurationService; + + @InjectMocks + private MailService mailService; + + @Mock + private Encounter encounter; + + @Mock + private EncounterScheduled encounterScheduled; + + @Before + public void setUp() { + when(configurationDao.getBaseURL()).thenReturn(BASE_URL); + when(encounter.getEncounterScheduled()).thenReturn(encounterScheduled); + } + + /** + * Test of {@link MailService#sendEncounterMail(Encounter)} + */ + + @Test + public void testSendEncounterMailWhenMailSentSuccessfully() { + when(encounter.sendMail(applicationMailer, messageSource, BASE_URL)).thenReturn(true); + + MailSendingStatus result = mailService.sendEncounterMail(encounter); + + assertEquals("should return SUCCESS", MailSendingStatus.SUCCESS, result); + } + + @Test + public void testSendEncounterMailWhenMailFailsAndAddressIsRejected() { + when(encounter.sendMail(applicationMailer, messageSource, BASE_URL)).thenReturn(false); + when(encounterScheduled.getMailStatus()) + .thenReturn(EncounterScheduledMailStatus.ADDRESS_REJECTED); + + MailSendingStatus result = mailService.sendEncounterMail(encounter); + + assertEquals("should return INVALID_ADDRESS", MailSendingStatus.INVALID_ADDRESS, result); + } + + @Test + public void testSendEncounterMailWhenMailFailsAndStatusIsActive() { + when(encounter.sendMail(applicationMailer, messageSource, BASE_URL)).thenReturn(false); + when(encounterScheduled.getMailStatus()).thenReturn(EncounterScheduledMailStatus.ACTIVE); + + MailSendingStatus result = mailService.sendEncounterMail(encounter); + + assertEquals("should return FAILURE", MailSendingStatus.FAILURE, result); + } + + @Test + public void testSendEncounterMailWhenMailFailsAndStatusIsDeactivatedPatient() { + when(encounter.sendMail(applicationMailer, messageSource, BASE_URL)).thenReturn(false); + when(encounterScheduled.getMailStatus()) + .thenReturn(EncounterScheduledMailStatus.DEACTIVATED_PATIENT); + + MailSendingStatus result = mailService.sendEncounterMail(encounter); + + assertEquals("should return FAILURE", MailSendingStatus.FAILURE, result); + } +} \ No newline at end of file