diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftRequest.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftRequest.java new file mode 100644 index 0000000000..1563f27f6c --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftRequest.java @@ -0,0 +1,42 @@ +package org.bahmni.module.bahmnicore.contract; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class FormDraftRequest { + + @JsonProperty + private String patientUuid; + + @JsonProperty + private String providerUuid; + + @JsonProperty + private String formData; + + public FormDraftRequest() { + } + + public String getPatientUuid() { + return patientUuid; + } + + public void setPatientUuid(String patientUuid) { + this.patientUuid = patientUuid; + } + + public String getProviderUuid() { + return providerUuid; + } + + public void setProviderUuid(String providerUuid) { + this.providerUuid = providerUuid; + } + + public String getFormData() { + return formData; + } + + public void setFormData(String formData) { + this.formData = formData; + } +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftResponse.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftResponse.java new file mode 100644 index 0000000000..9d80ea8edd --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftResponse.java @@ -0,0 +1,76 @@ +package org.bahmni.module.bahmnicore.contract; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Date; + +public class FormDraftResponse { + + @JsonProperty + private String uuid; + + @JsonProperty + private String formData; + + @JsonProperty + private Boolean markedAsSaved; + + @JsonProperty + private Long timestamp; + + public FormDraftResponse() { + } + + public FormDraftResponse(String uuid) { + this.uuid = uuid; + } + + public FormDraftResponse(String uuid, String formData) { + this.uuid = uuid; + this.formData = formData; + } + + public FormDraftResponse(String uuid, String formData, Boolean markedAsSaved) { + this.uuid = uuid; + this.formData = formData; + this.markedAsSaved = markedAsSaved; + } + + public FormDraftResponse(String uuid, String formData, Boolean markedAsSaved, Long timestamp) { + this.uuid = uuid; + this.formData = formData; + this.markedAsSaved = markedAsSaved; + this.timestamp = timestamp; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getFormData() { + return formData; + } + + public void setFormData(String formData) { + this.formData = formData; + } + + public Boolean getMarkedAsSaved() { + return markedAsSaved; + } + + public void setMarkedAsSaved(Boolean markedAsSaved) { + this.markedAsSaved = markedAsSaved; + } + + public Long getTimestamp() { + return timestamp; + } + + public void setTimestamp(Long timestamp) { + this.timestamp = timestamp; + } +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftSummaryResponse.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftSummaryResponse.java new file mode 100644 index 0000000000..feb2ad274c --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/contract/FormDraftSummaryResponse.java @@ -0,0 +1,77 @@ +package org.bahmni.module.bahmnicore.contract; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class FormDraftSummaryResponse { + + @JsonProperty + private String draftUuid; + + @JsonProperty + private String patientUuid; + + @JsonProperty + private String patientName; + + @JsonProperty + private String patientIdentifier; + + @JsonProperty + private String formName; + + @JsonProperty + private Long timestamp; + + public FormDraftSummaryResponse() { + } + + public String getDraftUuid() { + return draftUuid; + } + + public void setDraftUuid(String draftUuid) { + this.draftUuid = draftUuid; + } + + public String getPatientUuid() { + return patientUuid; + } + + public void setPatientUuid(String patientUuid) { + this.patientUuid = patientUuid; + } + + public String getPatientName() { + return patientName; + } + + public void setPatientName(String patientName) { + this.patientName = patientName; + } + + public String getPatientIdentifier() { + return patientIdentifier; + } + + public void setPatientIdentifier(String patientIdentifier) { + this.patientIdentifier = patientIdentifier; + } + + public String getFormName() { + return formName; + } + + public void setFormName(String formName) { + this.formName = formName; + } + + public Long getTimestamp() { + return timestamp; + } + + public void setTimestamp(Long timestamp) { + this.timestamp = timestamp; + } +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/dao/FormDraftDAO.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/dao/FormDraftDAO.java new file mode 100644 index 0000000000..a969099448 --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/dao/FormDraftDAO.java @@ -0,0 +1,58 @@ +package org.bahmni.module.bahmnicore.dao; + +import java.util.List; + +import org.bahmni.module.bahmnicore.model.FormDraft; + +public interface FormDraftDAO { + + /** + * Save a new form draft. + * Each draft gets a unique UUID and is never updated once voided (new drafts are created instead). + * + * @param draft the FormDraft object to save or update + * @return the saved FormDraft object + */ + FormDraft saveOrUpdate(FormDraft draft); + + /** + * Retrieve the latest non-voided form draft for a patient and user. + * + * @param patientId the OpenMRS patient ID + * @param userId the OpenMRS user ID (provider) + * @return the latest non-voided FormDraft object, or null if not found + */ + FormDraft getLatestByPatientAndUser(Integer patientId, Integer userId); + + /** + * Soft delete (void) the latest non-voided form draft for a patient and user. + * Sets voided = true and dateVoided = now, voidedBy = currentUser, voidReason = "Draft deleted" + * + * @param patientId the OpenMRS patient ID + * @param userId the OpenMRS user ID (provider) + */ + void deleteLatestDraft(Integer patientId, Integer userId); + + /** + * Soft delete (void) all non-voided form drafts. + * Sets voided = true and dateVoided = now, voidedBy = currentUser, voidReason = "Draft deleted by scheduler" + */ + void deleteAllDrafts(); + + /** + * Retrieve all non-voided, unsaved drafts for a user, ordered newest first. + * Drafts where markedAsSaved is true are excluded. + * + * @param userId the OpenMRS user ID (provider) + * @return list of FormDraft objects, ordered by COALESCE(dateChanged, dateCreated) DESC + */ + List getAllByUserOrderedByDateDesc(Integer userId); + + /** + * Permanently delete (hard delete) all form drafts older than the specified number of days. + * + * @param retentionDays the number of days to retain drafts + * @return the number of draft records deleted + */ + Integer deleteDraftsOlderThanDays(Integer retentionDays); +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/dao/impl/FormDraftDaoImpl.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/dao/impl/FormDraftDaoImpl.java new file mode 100644 index 0000000000..0a39d042cf --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/dao/impl/FormDraftDaoImpl.java @@ -0,0 +1,122 @@ +package org.bahmni.module.bahmnicore.dao.impl; + +import org.bahmni.module.bahmnicore.dao.FormDraftDAO; +import org.bahmni.module.bahmnicore.model.FormDraft; +import org.hibernate.SessionFactory; +import org.hibernate.query.Query; +import org.openmrs.api.context.Context; +import org.openmrs.api.db.DAOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +public class FormDraftDaoImpl implements FormDraftDAO { + + private static final Logger log = LoggerFactory.getLogger(FormDraftDaoImpl.class); + + private SessionFactory sessionFactory; + + public void setSessionFactory(SessionFactory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public FormDraft saveOrUpdate(FormDraft draft) throws DAOException { + try { + sessionFactory.getCurrentSession().saveOrUpdate(draft); + return draft; + } catch (Exception e) { + log.error("Error saving or updating form draft", e); + throw new DAOException("Failed to save or update form draft", e); + } + } + + @Override + public FormDraft getLatestByPatientAndUser(Integer patientId, Integer userId) throws DAOException { + try { + Query query = sessionFactory.getCurrentSession() + .createQuery("FROM FormDraft WHERE patient.patientId = :patientId AND user.userId = :userId " + + "AND voided = false ORDER BY dateCreated DESC", FormDraft.class); + query.setParameter("patientId", patientId); + query.setParameter("userId", userId); + query.setMaxResults(1); + return query.uniqueResult(); + } catch (Exception e) { + log.error("Error retrieving latest form draft for patient: " + patientId + ", user: " + userId, e); + throw new DAOException("Failed to retrieve form draft", e); + } + } + + @Override + public void deleteLatestDraft(Integer patientId, Integer userId) throws DAOException { + try { + FormDraft draft = getLatestByPatientAndUser(patientId, userId); + if (draft != null) { + draft.setVoided(true); + draft.setDateVoided(new Date()); + draft.setVoidedBy(Context.getAuthenticatedUser()); + draft.setVoidReason("Draft deleted"); + sessionFactory.getCurrentSession().saveOrUpdate(draft); + } + } catch (DAOException e) { + throw e; + } catch (Exception e) { + log.error("Error deleting latest form draft for patient: " + patientId + ", user: " + userId, e); + throw new DAOException("Failed to delete form draft", e); + } + } + + @Override + public void deleteAllDrafts() throws DAOException { + try { + sessionFactory.getCurrentSession() + .createQuery("UPDATE FormDraft SET voided = true, dateVoided = :now, " + + "voidedBy = :user, voidReason = :reason WHERE voided = false") + .setParameter("now", new Date()) + .setParameter("user", Context.getAuthenticatedUser()) + .setParameter("reason", "Draft deleted by scheduler") + .executeUpdate(); + } catch (Exception e) { + log.error("Error deleting all form drafts", e); + throw new DAOException("Failed to delete all form drafts", e); + } + } + + @Override + public List getAllByUserOrderedByDateDesc(Integer userId) throws DAOException { + try { + Query query = sessionFactory.getCurrentSession() + .createQuery("FROM FormDraft WHERE user.userId = :userId " + + "AND voided = false " + + "AND (markedAsSaved IS NULL OR markedAsSaved = false) " + + "ORDER BY COALESCE(dateChanged, dateCreated) DESC", FormDraft.class); + query.setParameter("userId", userId); + return query.getResultList(); + } catch (Exception e) { + log.error("Error retrieving all form drafts for user: " + userId, e); + throw new DAOException("Failed to retrieve form drafts for user", e); + } + } + + @Override + public Integer deleteDraftsOlderThanDays(Integer retentionDays) throws DAOException { + try { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DAY_OF_MONTH, -retentionDays); + Date cutoffDate = calendar.getTime(); + + Integer deletedCount = sessionFactory.getCurrentSession() + .createQuery("DELETE FROM FormDraft WHERE dateCreated < :cutoffDate") + .setParameter("cutoffDate", cutoffDate) + .executeUpdate(); + log.info("Deleted {} form drafts older than {} days", deletedCount, retentionDays); + return deletedCount; + } catch (Exception e) { + log.error("Error deleting form drafts older than {} days", retentionDays, e); + throw new DAOException("Failed to delete form drafts", e); + } + } +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/model/FormDraft.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/model/FormDraft.java new file mode 100644 index 0000000000..fe74e4f5e7 --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/model/FormDraft.java @@ -0,0 +1,73 @@ +package org.bahmni.module.bahmnicore.model; + +import org.openmrs.BaseChangeableOpenmrsData; +import org.openmrs.Patient; +import org.openmrs.User; + +public class FormDraft extends BaseChangeableOpenmrsData { + + private Integer id; + + private String uuid; + + private Patient patient; + + private User user; + + private String formDataPath; // Path to JSON file on filesystem + + private Boolean markedAsSaved; // Track if draft has been submitted/saved + + public FormDraft() { + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public Patient getPatient() { + return patient; + } + + public void setPatient(Patient patient) { + this.patient = patient; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getFormDataPath() { + return formDataPath; + } + + public void setFormDataPath(String formDataPath) { + this.formDataPath = formDataPath; + } + + public Boolean getMarkedAsSaved() { + return markedAsSaved; + } + + public void setMarkedAsSaved(Boolean markedAsSaved) { + this.markedAsSaved = markedAsSaved; + } +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/service/FormDraftService.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/service/FormDraftService.java new file mode 100644 index 0000000000..8ef333691e --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/service/FormDraftService.java @@ -0,0 +1,77 @@ +package org.bahmni.module.bahmnicore.service; + +import org.bahmni.module.bahmnicore.contract.FormDraftRequest; +import org.bahmni.module.bahmnicore.contract.FormDraftSummaryResponse; +import org.bahmni.module.bahmnicore.model.FormDraft; + +import java.util.List; + +public interface FormDraftService { + + /** + * Create a new form draft. Each draft gets a unique UUID. + * + * @param request FormDraftRequest containing patient, provider, and form data + * @return the created FormDraft object with generated UUID + */ + FormDraft saveDraft(FormDraftRequest request); + + /** + * Retrieve the latest non-voided form draft for a patient and provider. + * + * @param patientUuid the UUID of the patient + * @param providerUuid the UUID of the provider + * @return the latest FormDraft object if found, null otherwise + */ + FormDraft getDraft(String patientUuid, String providerUuid); + + /** + * Soft delete (void) the latest non-voided form draft for a patient and provider. + * Sets voided=true and updates audit fields (dateVoided, voidedBy, voidReason). + * + * @param patientUuid the UUID of the patient + * @param providerUuid the UUID of the provider + */ + void discardDraft(String patientUuid, String providerUuid); + + /** + * Retrieve form data from file using UTF-8 charset. + * + * @param formDataPath the file path to read from + * @return the form data as a string, or null if file doesn't exist + */ + String getFormData(String formDataPath); + + /** + * Mark the latest form draft as saved for a patient and provider. + * Sets markedAsSaved=true so subsequent saves will create a new draft. + * + * @param patientUuid the UUID of the patient + * @param providerUuid the UUID of the provider + */ + void markDraftAsSaved(String patientUuid, String providerUuid); + + /** + * Soft delete (void) all non-voided form drafts regardless of markedAsSaved value. + * Intended to be called by a scheduled task at midnight. + */ + void discardAllDrafts(); + + /** + * Retrieve a summary list of all unsaved drafts for a given provider. + * Reads formData to extract formUuid/formName where available. + * Drafts with missing patient name or identifier are skipped with a warning log. + * + * @param providerUuid the UUID of the provider + * @return list of FormDraftSummaryResponse, ordered newest first; empty list if provider not found + */ + List getDraftsByProvider(String providerUuid); + + /** + * Delete all form drafts older than the configured retention period, regardless of voided status. + * The retention period is read from global property 'bahmni.formDraft.voidedRetentionDays'. + * The property is initialized to 15 days by the Liquibase changeset during module deployment. + * Intended to be called by a scheduled task at midnight. + */ + void deleteDraftsOlderThanRetentionPeriod(); +} diff --git a/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/service/impl/FormDraftServiceImpl.java b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/service/impl/FormDraftServiceImpl.java new file mode 100644 index 0000000000..0eb5141a8e --- /dev/null +++ b/bahmnicore-api/src/main/java/org/bahmni/module/bahmnicore/service/impl/FormDraftServiceImpl.java @@ -0,0 +1,471 @@ +package org.bahmni.module.bahmnicore.service.impl; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.bahmni.module.bahmnicore.contract.FormDraftRequest; +import org.bahmni.module.bahmnicore.contract.FormDraftSummaryResponse; +import org.bahmni.module.bahmnicore.dao.FormDraftDAO; +import org.bahmni.module.bahmnicore.model.FormDraft; +import org.bahmni.module.bahmnicore.service.FormDraftService; +import org.openmrs.Patient; +import org.openmrs.Provider; +import org.openmrs.User; +import org.openmrs.api.APIException; +import org.openmrs.api.PatientService; +import org.openmrs.api.ProviderService; +import org.openmrs.api.UserService; +import org.openmrs.api.context.Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +public class FormDraftServiceImpl implements FormDraftService { + + private static final Logger log = LoggerFactory.getLogger(FormDraftServiceImpl.class); + private static final String FORM_DRAFTS_SUBDIRECTORY = "form_draft"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String VOIDED_RETENTION_DAYS_PROPERTY = "bahmni.formDraft.voidedRetentionDays"; + + private FormDraftDAO formDraftDAO; + private PatientService patientService; + private UserService userService; + private ProviderService providerService; + private User authenticatedUser; + + private String formDraftsBasePath; + + public FormDraftServiceImpl() { + String appDataDir = System.getProperty("OPENMRS_APPLICATION_DATA_DIRECTORY"); + if (appDataDir == null || appDataDir.isEmpty()) { + throw new IllegalStateException("OPENMRS_APPLICATION_DATA_DIRECTORY system property not set"); + } + this.formDraftsBasePath = appDataDir + FORM_DRAFTS_SUBDIRECTORY; + } + + @Autowired + public void setFormDraftDAO(FormDraftDAO formDraftDAO) { + this.formDraftDAO = formDraftDAO; + } + + @Autowired(required = false) + public void setPatientService(PatientService patientService) { + this.patientService = patientService; + } + + @Autowired(required = false) + public void setUserService(UserService userService) { + this.userService = userService; + } + + @Autowired(required = false) + public void setProviderService(ProviderService providerService) { + this.providerService = providerService; + } + + protected void setFormDraftsBasePath(String basePath) { + this.formDraftsBasePath = basePath; + } + + protected void setAuthenticatedUser(User user) { + this.authenticatedUser = user; + } + + private User getAuthenticatedUser() { + return authenticatedUser != null ? authenticatedUser : Context.getAuthenticatedUser(); + } + + /** + * Resolves a User from a Provider UUID via Provider → Person → User lookup. + */ + private User resolveUser(String providerUuid) { + ProviderService ps = this.providerService != null ? this.providerService : Context.getProviderService(); + Provider provider = ps.getProviderByUuid(providerUuid); + if (provider != null && provider.getPerson() != null) { + UserService us = userService != null ? userService : Context.getUserService(); + Collection users = us.getUsersByPerson(provider.getPerson(), false); + if (users != null && !users.isEmpty()) { + return users.iterator().next(); + } + } + return null; + } + + @Override + public FormDraft saveDraft(FormDraftRequest request) { + try { + validateRequest(request); + + PatientService ps = patientService != null ? patientService : Context.getPatientService(); + Patient patient = ps.getPatientByUuid(request.getPatientUuid()); + if (patient == null) { + throw new APIException("Patient not found with UUID: " + request.getPatientUuid()); + } + + User user = resolveUser(request.getProviderUuid()); + if (user == null) { + throw new APIException("User/Provider not found with UUID: " + request.getProviderUuid()); + } + + FormDraft draft = formDraftDAO.getLatestByPatientAndUser(patient.getPatientId(), user.getUserId()); + boolean isNewDraft = (draft == null); + boolean contentChanged = true; + + if (draft != null && draft.getMarkedAsSaved() != null && draft.getMarkedAsSaved()) { + isNewDraft = true; + draft = null; + } + + if (draft == null) { + draft = new FormDraft(); + draft.setUuid(UUID.randomUUID().toString()); + draft.setDateCreated(new Date()); + draft.setCreator(getAuthenticatedUser()); + draft.setMarkedAsSaved(false); + } else { + contentChanged = hasFormDataChanged(draft.getFormDataPath(), request.getFormData()); + if (contentChanged) { + draft.setDateChanged(new Date()); + draft.setChangedBy(getAuthenticatedUser()); + } + } + + draft.setPatient(patient); + draft.setUser(user); + + String filePath = generateFilePath(draft.getUuid()); + if (isNewDraft || contentChanged) { + writeFormDataToFile(filePath, request.getFormData()); + } + draft.setFormDataPath(filePath); + + return formDraftDAO.saveOrUpdate(draft); + + } catch (IllegalArgumentException e) { + throw e; + } catch (APIException e) { + throw e; + } catch (IOException e) { + log.error("Error writing form draft file", e); + throw new RuntimeException("Failed to save form draft file: " + e.getMessage(), e); + } catch (Exception e) { + log.error("Error saving form draft", e); + throw new RuntimeException("Failed to save form draft: " + e.getMessage(), e); + } + } + + /** + * Check if the form data content has changed by comparing with existing file. + * Returns true if content differs or file doesn't exist. + */ + private boolean hasFormDataChanged(String filePath, String newFormData) { + if (filePath == null || newFormData == null) { + return true; + } + + try { + File file = new File(filePath); + if (!file.exists()) { + return true; + } + + String existingContent = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + return !existingContent.equals(newFormData); + } catch (IOException e) { + log.warn("Error reading existing form data file, assuming content changed", e); + return true; + } + } + + /** + * Validate that required fields are present and not empty. + */ + private void validateRequest(FormDraftRequest request) { + if (request.getPatientUuid() == null || request.getPatientUuid().isEmpty()) { + throw new IllegalArgumentException("Patient UUID is required"); + } + if (request.getProviderUuid() == null || request.getProviderUuid().isEmpty()) { + throw new IllegalArgumentException("Provider UUID is required"); + } + if (request.getFormData() == null || request.getFormData().isEmpty()) { + throw new IllegalArgumentException("Form data is required"); + } + } + + /** + * Generate file path for form draft data using UUID. + * Format: {OPENMRS_APPLICATION_DATA_DIRECTORY}/form_draft/{draftUuid}.json + */ + private String generateFilePath(String draftUuid) { + return String.format("%s%s%s.json", + formDraftsBasePath, + File.separator, + draftUuid); + } + + /** + * Write form data JSON to file atomically. + * Uses temp file + rename to ensure consistency. + */ + private void writeFormDataToFile(String filePath, String formData) throws IOException { + File targetFile = new File(filePath); + File parentDir = targetFile.getParentFile(); + + if (!parentDir.exists()) { + if (!parentDir.mkdirs()) { + throw new IOException("Failed to create directory: " + parentDir.getAbsolutePath()); + } + } + + String tempPath = filePath + ".tmp"; + File tempFile = new File(tempPath); + + try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(tempFile), StandardCharsets.UTF_8)) { + writer.write(formData); + writer.flush(); + } catch (IOException e) { + tempFile.delete(); + throw e; + } + + if (!tempFile.renameTo(targetFile)) { + tempFile.delete(); + throw new IOException("Failed to finalize form data file: " + filePath); + } + } + + /** + * Retrieve the latest non-voided form draft for a patient and provider. + */ + @Override + public FormDraft getDraft(String patientUuid, String providerUuid) { + try { + if (patientUuid == null || patientUuid.isEmpty()) { + throw new IllegalArgumentException("Patient UUID is required"); + } + if (providerUuid == null || providerUuid.isEmpty()) { + throw new IllegalArgumentException("Provider UUID is required"); + } + + PatientService ps = patientService != null ? patientService : Context.getPatientService(); + Patient patient = ps.getPatientByUuid(patientUuid); + if (patient == null) { + return null; + } + + User user = resolveUser(providerUuid); + if (user == null) { + return null; + } + + return formDraftDAO.getLatestByPatientAndUser(patient.getPatientId(), user.getUserId()); + + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + log.error("Error retrieving form draft", e); + throw new RuntimeException("Failed to retrieve form draft: " + e.getMessage(), e); + } + } + + @Override + public void discardDraft(String patientUuid, String providerUuid) { + try { + if (patientUuid == null || patientUuid.isEmpty()) { + throw new IllegalArgumentException("Patient UUID is required"); + } + if (providerUuid == null || providerUuid.isEmpty()) { + throw new IllegalArgumentException("Provider UUID is required"); + } + + PatientService ps = patientService != null ? patientService : Context.getPatientService(); + Patient patient = ps.getPatientByUuid(patientUuid); + if (patient == null) { + throw new APIException("Patient not found with UUID: " + patientUuid); + } + + User user = resolveUser(providerUuid); + if (user == null) { + throw new APIException("User/Provider not found with UUID: " + providerUuid); + } + + formDraftDAO.deleteLatestDraft(patient.getPatientId(), user.getUserId()); + + } catch (IllegalArgumentException e) { + throw e; + } catch (APIException e) { + throw e; + } catch (Exception e) { + log.error("Error discarding form draft", e); + throw new RuntimeException("Failed to discard form draft: " + e.getMessage(), e); + } + } + + @Override + public void discardAllDrafts() { + formDraftDAO.deleteAllDrafts(); + } + + @Override + public String getFormData(String formDataPath) { + if (formDataPath == null) { + return null; + } + + try { + File file = new File(formDataPath); + if (!file.exists()) { + return null; + } + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Error reading form data file: " + formDataPath, e); + return null; + } + } + + @Override + public List getDraftsByProvider(String providerUuid) { + if (providerUuid == null || providerUuid.trim().isEmpty()) { + throw new IllegalArgumentException("Provider UUID is required"); + } + + User user = resolveUser(providerUuid); + if (user == null) { + log.warn("getDraftsByProvider: no user found for providerUuid={}", providerUuid); + return new ArrayList<>(); + } + + List drafts = formDraftDAO.getAllByUserOrderedByDateDesc(user.getUserId()); + List results = new ArrayList<>(); + for (FormDraft draft : drafts) { + FormDraftSummaryResponse summary = buildSummary(draft); + if (summary != null) { + results.add(summary); + } + } + return results; + } + + private FormDraftSummaryResponse buildSummary(FormDraft draft) { + Patient patient = draft.getPatient(); + if (patient == null) { + log.warn("buildSummary: draft {} has null patient — skipping", draft.getUuid()); + return null; + } + + String patientName = patient.getPersonName() != null + ? patient.getPersonName().getFullName() + : ""; + String patientIdentifier = patient.getPatientIdentifier() != null + ? patient.getPatientIdentifier().getIdentifier() + : null; + long timestamp = draft.getDateChanged() != null + ? draft.getDateChanged().getTime() + : draft.getDateCreated().getTime(); + + String formName = extractFormName(draft.getFormDataPath()); + + FormDraftSummaryResponse response = new FormDraftSummaryResponse(); + response.setDraftUuid(draft.getUuid()); + response.setPatientUuid(patient.getUuid()); + response.setPatientName(patientName); + response.setPatientIdentifier(patientIdentifier); + response.setFormName(formName); + response.setTimestamp(timestamp); + return response; + } + + private String extractFormName(String formDataPath) { + String formData = getFormData(formDataPath); + if (formData == null || formData.trim().isEmpty()) { + return null; + } + try { + JsonNode root = OBJECT_MAPPER.readTree(formData); + if (!root.isArray()) { + log.warn("extractFormName: expected observations array but got object at path={}", formDataPath); + return null; + } + for (JsonNode obs : root) { + String formFieldPath = obs.path("formFieldPath").asText(null); + if (formFieldPath != null && !formFieldPath.isEmpty()) { + return formFieldPath.split("\\.")[0]; + } + } + return null; + } catch (Exception e) { + log.warn("extractFormName: failed to parse form data at path={}", formDataPath, e); + return null; + } + } + + @Override + public void markDraftAsSaved(String patientUuid, String providerUuid) { + try { + if (patientUuid == null || patientUuid.isEmpty()) { + throw new IllegalArgumentException("Patient UUID is required"); + } + if (providerUuid == null || providerUuid.isEmpty()) { + throw new IllegalArgumentException("Provider UUID is required"); + } + + PatientService ps = patientService != null ? patientService : Context.getPatientService(); + Patient patient = ps.getPatientByUuid(patientUuid); + if (patient == null) { + throw new APIException("Patient not found with UUID: " + patientUuid); + } + + User user = resolveUser(providerUuid); + if (user == null) { + throw new APIException("User/Provider not found with UUID: " + providerUuid); + } + + FormDraft draft = formDraftDAO.getLatestByPatientAndUser(patient.getPatientId(), user.getUserId()); + if (draft != null) { + draft.setMarkedAsSaved(true); + draft.setDateChanged(new Date()); + draft.setChangedBy(getAuthenticatedUser()); + formDraftDAO.saveOrUpdate(draft); + } + + } catch (IllegalArgumentException e) { + throw e; + } catch (APIException e) { + throw e; + } catch (Exception e) { + log.error("Error marking form draft as saved", e); + throw new RuntimeException("Failed to mark form draft as saved: " + e.getMessage(), e); + } + } + + @Override + public void deleteDraftsOlderThanRetentionPeriod() { + try { + String retentionDaysStr = Context.getAdministrationService() + .getGlobalProperty(VOIDED_RETENTION_DAYS_PROPERTY); + if (retentionDaysStr == null) { + throw new IllegalStateException("Global property '" + VOIDED_RETENTION_DAYS_PROPERTY + "' is not set"); + } + Integer retentionDays = Integer.parseInt(retentionDaysStr); + Integer deletedCount = formDraftDAO.deleteDraftsOlderThanDays(retentionDays); + log.info("Deleted {} form drafts older than {} days", deletedCount, retentionDays); + } catch (Exception e) { + log.error("Error deleting form drafts by retention period", e); + throw new RuntimeException("Failed to delete form drafts: " + e.getMessage(), e); + } + } +} diff --git a/bahmnicore-api/src/main/resources/FormDraft.hbm.xml b/bahmnicore-api/src/main/resources/FormDraft.hbm.xml new file mode 100644 index 0000000000..61442061d9 --- /dev/null +++ b/bahmnicore-api/src/main/resources/FormDraft.hbm.xml @@ -0,0 +1,42 @@ + + + + + + + + + draft_id_seq + + + + + + + + + + + + + + + + + + diff --git a/bahmnicore-api/src/main/resources/moduleApplicationContext.xml b/bahmnicore-api/src/main/resources/moduleApplicationContext.xml index 65f1a81634..d557cf3919 100644 --- a/bahmnicore-api/src/main/resources/moduleApplicationContext.xml +++ b/bahmnicore-api/src/main/resources/moduleApplicationContext.xml @@ -310,4 +310,40 @@ + + + + + org.bahmni.module.bahmnicore.service.FormDraftService + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bahmnicore-api/src/test/java/org/bahmni/module/bahmnicore/service/impl/FormDraftServiceImplTest.java b/bahmnicore-api/src/test/java/org/bahmni/module/bahmnicore/service/impl/FormDraftServiceImplTest.java new file mode 100644 index 0000000000..0dde16cb89 --- /dev/null +++ b/bahmnicore-api/src/test/java/org/bahmni/module/bahmnicore/service/impl/FormDraftServiceImplTest.java @@ -0,0 +1,570 @@ +package org.bahmni.module.bahmnicore.service.impl; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.bahmni.module.bahmnicore.contract.FormDraftRequest; +import org.bahmni.module.bahmnicore.contract.FormDraftSummaryResponse; +import org.bahmni.module.bahmnicore.dao.FormDraftDAO; +import org.bahmni.module.bahmnicore.model.FormDraft; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.openmrs.Patient; +import org.openmrs.PatientIdentifier; +import org.openmrs.Person; +import org.openmrs.PersonName; +import org.openmrs.Provider; +import org.openmrs.User; +import org.openmrs.api.APIException; +import org.openmrs.api.PatientService; +import org.openmrs.api.ProviderService; +import org.openmrs.api.UserService; +import org.openmrs.api.context.Context; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +public class FormDraftServiceImplTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + private FormDraftDAO formDraftDAO; + + @Mock + private PatientService patientService; + + @Mock + private UserService userService; + + @Mock + private ProviderService providerService; + + private FormDraftServiceImpl formDraftService; + private Person person; + + private static final String PATIENT_UUID = "patient-uuid-123"; + private static final int PATIENT_ID = 1; + private static final String PROVIDER_UUID = "provider-uuid-456"; + private static final int PROVIDER_ID = 2; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + // Set OPENMRS_APPLICATION_DATA_DIRECTORY for test environment + System.setProperty("OPENMRS_APPLICATION_DATA_DIRECTORY", temporaryFolder.getRoot().getAbsolutePath()); + + formDraftService = new FormDraftServiceImpl(); + formDraftService.setFormDraftDAO(formDraftDAO); + formDraftService.setPatientService(patientService); + formDraftService.setUserService(userService); + formDraftService.setProviderService(providerService); + + // Set authenticated user for testing + User mockUser = new User(); + mockUser.setUuid("user-uuid"); + formDraftService.setAuthenticatedUser(mockUser); + + person = new Person(); + } + + @After + public void tearDown() { + // Clean up system property + System.clearProperty("OPENMRS_APPLICATION_DATA_DIRECTORY"); + } + + @Test + public void saveDraft_shouldCreateNewDraftWhenNoneExists() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, "{\"form\":\"data\"}"); + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(null); + when(formDraftDAO.saveOrUpdate(any(FormDraft.class))).thenAnswer(inv -> inv.getArguments()[0]); + + FormDraft result = formDraftService.saveDraft(request); + + assertNotNull(result); + assertNotNull(result.getUuid()); + assertEquals(patient, result.getPatient()); + assertEquals(user, result.getUser()); + assertNotNull(result.getDateCreated()); + assertNull(result.getDateChanged()); + + verify(formDraftDAO).saveOrUpdate(any(FormDraft.class)); + } + + @Test + public void saveDraft_shouldUpdateExistingDraftForSamePatientProvider() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, "{\"updated\":\"data\"}"); + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + FormDraft existingDraft = new FormDraft(); + existingDraft.setUuid("existing-uuid"); + existingDraft.setPatient(patient); + existingDraft.setUser(user); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(existingDraft); + when(formDraftDAO.saveOrUpdate(any(FormDraft.class))).thenAnswer(inv -> inv.getArguments()[0]); + + FormDraft result = formDraftService.saveDraft(request); + + assertEquals("existing-uuid", result.getUuid()); + assertNotNull(result.getDateChanged()); + verify(formDraftDAO).saveOrUpdate(existingDraft); + } + + @Test(expected = IllegalArgumentException.class) + public void saveDraft_shouldThrowWhenPatientUuidIsNull() { + FormDraftRequest request = buildRequest(null, PROVIDER_UUID, "{\"form\":\"data\"}"); + formDraftService.saveDraft(request); + } + + @Test(expected = IllegalArgumentException.class) + public void saveDraft_shouldThrowWhenProviderUuidIsEmpty() { + FormDraftRequest request = buildRequest(PATIENT_UUID, "", "{\"form\":\"data\"}"); + formDraftService.saveDraft(request); + } + + @Test(expected = IllegalArgumentException.class) + public void saveDraft_shouldThrowWhenFormDataIsNull() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, null); + formDraftService.saveDraft(request); + } + + @Test + public void saveDraft_shouldPersistFormDataPath() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, "{\"form\":\"data\"}"); + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FormDraft.class); + when(formDraftDAO.saveOrUpdate(captor.capture())).thenAnswer(inv -> inv.getArguments()[0]); + + formDraftService.saveDraft(request); + + FormDraft saved = captor.getValue(); + assertNotNull(saved.getFormDataPath()); + assertTrue(saved.getFormDataPath().endsWith(".json")); + assertTrue(saved.getFormDataPath().contains(saved.getUuid())); + } + + @Test + public void getDraft_shouldReturnDraftForValidPatientAndProvider() { + FormDraft existingDraft = new FormDraft(); + existingDraft.setUuid("draft-uuid"); + existingDraft.setFormDataPath("/path/to/draft.json"); + + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(existingDraft); + + FormDraft result = formDraftService.getDraft(PATIENT_UUID, PROVIDER_UUID); + + assertNotNull(result); + assertEquals("draft-uuid", result.getUuid()); + } + + @Test + public void getDraft_shouldReturnNullWhenNoDraftExists() { + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(null); + + FormDraft result = formDraftService.getDraft(PATIENT_UUID, PROVIDER_UUID); + + assertNull(result); + } + + @Test(expected = IllegalArgumentException.class) + public void getDraft_shouldThrowWhenPatientUuidIsNull() { + formDraftService.getDraft(null, PROVIDER_UUID); + } + + @Test(expected = IllegalArgumentException.class) + public void getDraft_shouldThrowWhenProviderUuidIsEmpty() { + formDraftService.getDraft(PATIENT_UUID, ""); + } + + @Test + public void discardAllDrafts_shouldCallDaoDeleteAllDrafts() { + formDraftService.discardAllDrafts(); + verify(formDraftDAO).deleteAllDrafts(); + } + + @Test + public void discardDraft_shouldCallDaoDeleteLatestDraft() { + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + + formDraftService.discardDraft(PATIENT_UUID, PROVIDER_UUID); + + verify(formDraftDAO).deleteLatestDraft(PATIENT_ID, PROVIDER_ID); + } + + @Test(expected = IllegalArgumentException.class) + public void discardDraft_shouldThrowWhenPatientUuidIsNull() { + formDraftService.discardDraft(null, PROVIDER_UUID); + } + + @Test(expected = IllegalArgumentException.class) + public void discardDraft_shouldThrowWhenProviderUuidIsEmpty() { + formDraftService.discardDraft(PATIENT_UUID, ""); + } + + @Test + public void markDraftAsSaved_shouldUpdateDraftMarkedAsSavedFlag() { + FormDraft existingDraft = new FormDraft(); + existingDraft.setUuid("draft-uuid"); + existingDraft.setMarkedAsSaved(false); + + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(existingDraft); + when(formDraftDAO.saveOrUpdate(any(FormDraft.class))).thenAnswer(inv -> inv.getArguments()[0]); + + formDraftService.markDraftAsSaved(PATIENT_UUID, PROVIDER_UUID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FormDraft.class); + verify(formDraftDAO).saveOrUpdate(captor.capture()); + + FormDraft updatedDraft = captor.getValue(); + assertTrue(updatedDraft.getMarkedAsSaved()); + assertNotNull(updatedDraft.getDateChanged()); + } + + @Test + public void markDraftAsSaved_shouldDoNothingWhenNoDraftExists() { + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(null); + + formDraftService.markDraftAsSaved(PATIENT_UUID, PROVIDER_UUID); + + verify(formDraftDAO, org.mockito.Mockito.never()).saveOrUpdate(any(FormDraft.class)); + } + + @Test(expected = IllegalArgumentException.class) + public void markDraftAsSaved_shouldThrowWhenPatientUuidIsNull() { + formDraftService.markDraftAsSaved(null, PROVIDER_UUID); + } + + @Test(expected = IllegalArgumentException.class) + public void markDraftAsSaved_shouldThrowWhenPatientUuidIsEmpty() { + formDraftService.markDraftAsSaved("", PROVIDER_UUID); + } + + @Test(expected = IllegalArgumentException.class) + public void markDraftAsSaved_shouldThrowWhenProviderUuidIsNull() { + formDraftService.markDraftAsSaved(PATIENT_UUID, null); + } + + @Test(expected = IllegalArgumentException.class) + public void markDraftAsSaved_shouldThrowWhenProviderUuidIsEmpty() { + formDraftService.markDraftAsSaved(PATIENT_UUID, ""); + } + + @Test(expected = APIException.class) + public void markDraftAsSaved_shouldThrowWhenPatientNotFound() { + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(null); + + formDraftService.markDraftAsSaved(PATIENT_UUID, PROVIDER_UUID); + } + + @Test(expected = APIException.class) + public void markDraftAsSaved_shouldThrowWhenProviderNotFound() { + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + when(providerService.getProviderByUuid(PROVIDER_UUID)).thenReturn(null); + + formDraftService.markDraftAsSaved(PATIENT_UUID, PROVIDER_UUID); + } + + @Test + public void saveDraft_shouldResolveUserViaProvider() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, "{\"form\":\"data\"}"); + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser("user-uuid-999", PROVIDER_ID); + + mockProviderResolution(user); + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(null); + when(formDraftDAO.saveOrUpdate(any(FormDraft.class))).thenAnswer(inv -> inv.getArguments()[0]); + + FormDraft result = formDraftService.saveDraft(request); + + assertNotNull(result); + assertEquals(user, result.getUser()); + } + + @Test + public void saveDraft_shouldCreateNewDraftWhenExistingDraftIsMarkedAsSaved() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, "{\"updated\":\"data\"}"); + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + FormDraft markedDraft = new FormDraft(); + markedDraft.setUuid("marked-draft-uuid"); + markedDraft.setPatient(patient); + markedDraft.setUser(user); + markedDraft.setMarkedAsSaved(true); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(markedDraft); + when(formDraftDAO.saveOrUpdate(any(FormDraft.class))).thenAnswer(inv -> inv.getArguments()[0]); + + FormDraft result = formDraftService.saveDraft(request); + + // Should create a new draft instead of updating the marked one + assertNotNull(result.getUuid()); + assertNotEquals("marked-draft-uuid", result.getUuid()); + assertFalse(result.getMarkedAsSaved()); + verify(formDraftDAO).saveOrUpdate(any(FormDraft.class)); + } + + @Test + public void saveDraft_shouldInitializeMarkedAsSavedAsFalseForNewDraft() { + FormDraftRequest request = buildRequest(PATIENT_UUID, PROVIDER_UUID, "{\"form\":\"data\"}"); + Patient patient = buildPatient(PATIENT_UUID, PATIENT_ID); + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + + when(patientService.getPatientByUuid(PATIENT_UUID)).thenReturn(patient); + mockProviderResolution(user); + when(formDraftDAO.getLatestByPatientAndUser(PATIENT_ID, PROVIDER_ID)).thenReturn(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FormDraft.class); + when(formDraftDAO.saveOrUpdate(captor.capture())).thenAnswer(inv -> inv.getArguments()[0]); + + formDraftService.saveDraft(request); + + FormDraft saved = captor.getValue(); + assertFalse(saved.getMarkedAsSaved()); + } + + + @Test + public void getDraftsByProvider_returnsDraftsNewestFirst() throws Exception { + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + mockProviderResolution(user); + + Patient patient = buildPatientWithDetails(PATIENT_UUID, PATIENT_ID, "John Doe", "ET001"); + + // formData is a serialized observations array; formName is derived from formFieldPath prefix + File formDataFile = temporaryFolder.newFile("draft-form-identity.json"); + writeFile(formDataFile, "[{\"formFieldPath\":\"Vitals.1/1-0\",\"concept\":{\"name\":\"Weight\"},\"value\":70}]"); + + FormDraft draftOlder = new FormDraft(); + draftOlder.setUuid("draft-uuid-older"); + draftOlder.setPatient(patient); + draftOlder.setFormDataPath(formDataFile.getAbsolutePath()); + draftOlder.setDateCreated(new java.util.Date(1000L)); + + FormDraft draftNewer = new FormDraft(); + draftNewer.setUuid("draft-uuid-newer"); + draftNewer.setPatient(patient); + draftNewer.setFormDataPath(formDataFile.getAbsolutePath()); + draftNewer.setDateCreated(new java.util.Date(2000L)); + draftNewer.setDateChanged(new java.util.Date(3000L)); + + when(formDraftDAO.getAllByUserOrderedByDateDesc(PROVIDER_ID)).thenReturn(Arrays.asList(draftNewer, draftOlder)); + + List results = formDraftService.getDraftsByProvider(PROVIDER_UUID); + + assertEquals(2, results.size()); + assertEquals("draft-uuid-newer", results.get(0).getDraftUuid()); + assertEquals(3000L, (long) results.get(0).getTimestamp()); + assertEquals("Vitals", results.get(0).getFormName()); + assertEquals("draft-uuid-older", results.get(1).getDraftUuid()); + assertEquals(1000L, (long) results.get(1).getTimestamp()); + } + + @Test + public void getDraftsByProvider_returnsEmptyList_whenNoDrafts() { + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + mockProviderResolution(user); + when(formDraftDAO.getAllByUserOrderedByDateDesc(PROVIDER_ID)).thenReturn(Collections.emptyList()); + + List results = formDraftService.getDraftsByProvider(PROVIDER_UUID); + + assertNotNull(results); + assertTrue(results.isEmpty()); + } + + @Test + public void getDraftsByProvider_returnsEmptyList_whenProviderNotFound() { + when(providerService.getProviderByUuid(PROVIDER_UUID)).thenReturn(null); + + List results = formDraftService.getDraftsByProvider(PROVIDER_UUID); + + assertNotNull(results); + assertTrue(results.isEmpty()); + } + + @Test(expected = IllegalArgumentException.class) + public void getDraftsByProvider_throwsWhenProviderUuidIsNull() { + formDraftService.getDraftsByProvider(null); + } + + @Test(expected = IllegalArgumentException.class) + public void getDraftsByProvider_throwsWhenProviderUuidIsBlank() { + formDraftService.getDraftsByProvider(" "); + } + + @Test + public void getDraftsByProvider_skipsEntry_whenPatientIsNull() { + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + mockProviderResolution(user); + + FormDraft draftWithNullPatient = new FormDraft(); + draftWithNullPatient.setUuid("draft-no-patient"); + draftWithNullPatient.setPatient(null); + draftWithNullPatient.setDateCreated(new java.util.Date()); + + when(formDraftDAO.getAllByUserOrderedByDateDesc(PROVIDER_ID)).thenReturn(Collections.singletonList(draftWithNullPatient)); + + List results = formDraftService.getDraftsByProvider(PROVIDER_UUID); + + assertTrue(results.isEmpty()); + } + + @Test + public void getDraftsByProvider_setsNullFormFields_whenFormDataIsMalformedJson() throws Exception { + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + mockProviderResolution(user); + + Patient patient = buildPatientWithDetails(PATIENT_UUID, PATIENT_ID, "Jane Smith", "ET002"); + + File malformedFile = temporaryFolder.newFile("malformed-draft.json"); + writeFile(malformedFile, "NOT_VALID_JSON{{{{"); + + FormDraft draft = new FormDraft(); + draft.setUuid("draft-malformed"); + draft.setPatient(patient); + draft.setFormDataPath(malformedFile.getAbsolutePath()); + draft.setDateCreated(new java.util.Date()); + + when(formDraftDAO.getAllByUserOrderedByDateDesc(PROVIDER_ID)).thenReturn(Collections.singletonList(draft)); + + List results = formDraftService.getDraftsByProvider(PROVIDER_UUID); + + assertEquals(1, results.size()); + assertNull(results.get(0).getFormName()); + } + + @Test + public void getDraftsByProvider_setsNullFormFields_whenFormDataFileAbsent() { + User user = buildUser(PROVIDER_UUID, PROVIDER_ID); + mockProviderResolution(user); + + Patient patient = buildPatientWithDetails(PATIENT_UUID, PATIENT_ID, "Bob Jones", "ET003"); + + FormDraft draft = new FormDraft(); + draft.setUuid("draft-no-file"); + draft.setPatient(patient); + draft.setFormDataPath("/nonexistent/path/draft.json"); + draft.setDateCreated(new java.util.Date()); + + when(formDraftDAO.getAllByUserOrderedByDateDesc(PROVIDER_ID)).thenReturn(Collections.singletonList(draft)); + + List results = formDraftService.getDraftsByProvider(PROVIDER_UUID); + + assertEquals(1, results.size()); + assertNull(results.get(0).getFormName()); + } + + // --- Helpers --- + + private void mockProviderResolution(User user) { + Provider provider = new Provider(); + provider.setPerson(person); + when(providerService.getProviderByUuid(PROVIDER_UUID)).thenReturn(provider); + when(userService.getUsersByPerson(person, false)).thenReturn(Collections.singletonList(user)); + } + + private FormDraftRequest buildRequest(String patientUuid, String providerUuid, String formData) { + FormDraftRequest request = new FormDraftRequest(); + request.setPatientUuid(patientUuid); + request.setProviderUuid(providerUuid); + request.setFormData(formData); + return request; + } + + private Patient buildPatient(String uuid, int patientId) { + Patient patient = new Patient(); + patient.setUuid(uuid); + patient.setPatientId(patientId); + return patient; + } + + private User buildUser(String uuid, int userId) { + User user = new User(); + user.setUuid(uuid); + user.setUserId(userId); + return user; + } + + private Patient buildPatientWithDetails(String uuid, int patientId, String fullName, String identifier) { + Patient patient = buildPatient(uuid, patientId); + PersonName personName = new PersonName(); + personName.setGivenName(fullName.split(" ")[0]); + personName.setFamilyName(fullName.contains(" ") ? fullName.split(" ")[1] : ""); + patient.addName(personName); + PatientIdentifier patientIdentifier = new PatientIdentifier(); + patientIdentifier.setIdentifier(identifier); + patient.addIdentifier(patientIdentifier); + return patient; + } + + private void writeFile(File file, String content) throws Exception { + try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) { + writer.write(content); + } + } +} diff --git a/bahmnicore-api/src/test/resources/TestingApplicationContext.xml b/bahmnicore-api/src/test/resources/TestingApplicationContext.xml index b7b812da9a..88ef8821d0 100644 --- a/bahmnicore-api/src/test/resources/TestingApplicationContext.xml +++ b/bahmnicore-api/src/test/resources/TestingApplicationContext.xml @@ -27,6 +27,7 @@ AddressHierarchyEntry.hbm.xml AddressHierarchyLevel.hbm.xml + FormDraft.hbm.xml diff --git a/bahmnicore-omod/src/main/java/org/bahmni/module/bahmnicore/task/DiscardAllFormDraftsTask.java b/bahmnicore-omod/src/main/java/org/bahmni/module/bahmnicore/task/DiscardAllFormDraftsTask.java new file mode 100644 index 0000000000..d8b5bb27b0 --- /dev/null +++ b/bahmnicore-omod/src/main/java/org/bahmni/module/bahmnicore/task/DiscardAllFormDraftsTask.java @@ -0,0 +1,32 @@ +package org.bahmni.module.bahmnicore.task; + +import org.bahmni.module.bahmnicore.service.FormDraftService; +import org.openmrs.api.context.Context; +import org.openmrs.scheduler.tasks.AbstractTask; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DiscardAllFormDraftsTask extends AbstractTask { + + private static final Logger log = LoggerFactory.getLogger(DiscardAllFormDraftsTask.class); + + @Override + public void execute() { + try { + log.info("DiscardAllFormDraftsTask: starting midnight task"); + + FormDraftService formDraftService = Context.getService(FormDraftService.class); + + + log.debug("DiscardAllFormDraftsTask: discarding all non-voided drafts"); + formDraftService.discardAllDrafts(); + + log.debug("DiscardAllFormDraftsTask: deleting drafts older than retention period"); + formDraftService.deleteDraftsOlderThanRetentionPeriod(); + + log.info("DiscardAllFormDraftsTask: completed successfully"); + } catch (Exception e) { + log.error("DiscardAllFormDraftsTask: failed during execution", e); + } + } +} diff --git a/bahmnicore-omod/src/main/java/org/bahmni/module/bahmnicore/web/v1_0/controller/FormDraftController.java b/bahmnicore-omod/src/main/java/org/bahmni/module/bahmnicore/web/v1_0/controller/FormDraftController.java new file mode 100644 index 0000000000..1417c61e1d --- /dev/null +++ b/bahmnicore-omod/src/main/java/org/bahmni/module/bahmnicore/web/v1_0/controller/FormDraftController.java @@ -0,0 +1,179 @@ +package org.bahmni.module.bahmnicore.web.v1_0.controller; + +import org.bahmni.module.bahmnicore.contract.FormDraftRequest; +import org.bahmni.module.bahmnicore.contract.FormDraftResponse; +import org.bahmni.module.bahmnicore.contract.FormDraftSummaryResponse; +import org.bahmni.module.bahmnicore.model.FormDraft; +import org.bahmni.module.bahmnicore.service.FormDraftService; +import org.bahmni.module.bahmnicore.util.WebUtils; +import org.openmrs.api.context.Context; +import org.openmrs.module.webservices.rest.web.RestConstants; +import org.openmrs.module.webservices.rest.web.v1_0.controller.BaseRestController; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.List; + +@Controller +@RequestMapping(value = "/rest/" + RestConstants.VERSION_1 + "/bahmnicore/formdraft") +public class FormDraftController extends BaseRestController { + + private static final Logger log = LoggerFactory.getLogger(FormDraftController.class); + + @Autowired + private FormDraftService formDraftService; + + /** + * List all unsaved drafts for a given provider. + * GET /rest/v1/bahmnicore/formdraft/list?providerUuid=xxx + */ + @RequestMapping(value = "/list", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity getDraftsByProvider( + @RequestParam(value = "providerUuid", required = true) String providerUuid) { + try { + List drafts = formDraftService.getDraftsByProvider(providerUuid); + return new ResponseEntity<>(drafts, HttpStatus.OK); + } catch (IllegalArgumentException e) { + log.warn("Invalid request for draft list", e); + return new ResponseEntity<>(WebUtils.wrapErrorResponse(null, e.getMessage()), HttpStatus.BAD_REQUEST); + } catch (Exception e) { + log.error("Error retrieving draft list for provider: " + providerUuid, e); + return new ResponseEntity<>(WebUtils.wrapErrorResponse(null, e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Auto-save a form draft. Upserts by patient and provider UUID. + * POST /rest/v1/bahmnicore/formdraft + * + * @param request FormDraftRequest with patientUuid, providerUuid, and formData + * @return FormDraftResponse with uuid, formData, markedAsSaved flag, and timestamp + */ + @RequestMapping(method = RequestMethod.POST) + @ResponseBody + public ResponseEntity saveDraft(@RequestBody FormDraftRequest request) { + try { + FormDraft draft = formDraftService.saveDraft(request); + String formData = formDraftService.getFormData(draft.getFormDataPath()); + Long timestamp = draft.getDateChanged() != null ? draft.getDateChanged().getTime() : draft.getDateCreated().getTime(); + FormDraftResponse response = new FormDraftResponse(draft.getUuid(), formData, draft.getMarkedAsSaved(), timestamp); + return new ResponseEntity<>(response, HttpStatus.OK); + } catch (IllegalArgumentException e) { + log.warn("Invalid form draft request", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } catch (Exception e) { + log.error("Error saving form draft", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } + } + + /** + * Retrieve a form draft by patient and provider UUIDs. + * GET /rest/v1/bahmnicore/formdraft?patientUuid=xxx&providerUuid=yyy + * + * @param patientUuid the UUID of the patient + * @param providerUuid the UUID of the provider + * @return FormDraftResponse with uuid, formData, and timestamp + */ + @RequestMapping(method = RequestMethod.GET) + @ResponseBody + public ResponseEntity getDraft( + @RequestParam(value = "patientUuid", required = true) String patientUuid, + @RequestParam(value = "providerUuid", required = true) String providerUuid) { + try { + FormDraft draft = formDraftService.getDraft(patientUuid, providerUuid); + if (draft == null) { + return new ResponseEntity<>(new FormDraftResponse(), HttpStatus.OK); + } + + String formData = formDraftService.getFormData(draft.getFormDataPath()); + Long timestamp = draft.getDateChanged() != null ? draft.getDateChanged().getTime() : draft.getDateCreated().getTime(); + FormDraftResponse response = new FormDraftResponse(draft.getUuid(), formData, draft.getMarkedAsSaved(), timestamp); + return new ResponseEntity<>(response, HttpStatus.OK); + } catch (IllegalArgumentException e) { + log.warn("Invalid form draft request", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } catch (Exception e) { + log.error("Error retrieving form draft", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } + } + + /** + * Mark a form draft as saved (finalized). + * PATCH /rest/v1/bahmnicore/formdraft?patientUuid=xxx&providerUuid=yyy + * + * @param patientUuid the UUID of the patient + * @param providerUuid the UUID of the provider + * @return 200 OK on success + */ + @RequestMapping(method = RequestMethod.PATCH) + @ResponseBody + public ResponseEntity markDraftAsSaved( + @RequestParam(value = "patientUuid", required = true) String patientUuid, + @RequestParam(value = "providerUuid", required = true) String providerUuid) { + try { + formDraftService.markDraftAsSaved(patientUuid, providerUuid); + log.info("Draft marked as saved for patient: " + patientUuid + " and provider: " + providerUuid); + return new ResponseEntity<>(HttpStatus.OK); + } catch (IllegalArgumentException e) { + log.warn("Invalid form draft request", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } catch (Exception e) { + log.error("Error marking draft as saved", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } + } + + /** + * Discard (void) a form draft by patient and provider UUIDs. + * DELETE /rest/v1/bahmnicore/formdraft?patientUuid=xxx&providerUuid=yyy + * + * @param patientUuid the UUID of the patient + * @param providerUuid the UUID of the provider + * @return 204 No Content on success + */ + @RequestMapping(method = RequestMethod.DELETE) + @ResponseBody + public ResponseEntity discardDraft( + @RequestParam(value = "patientUuid", required = true) String patientUuid, + @RequestParam(value = "providerUuid", required = true) String providerUuid) { + try { + formDraftService.discardDraft(patientUuid, providerUuid); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (IllegalArgumentException e) { + log.warn("Invalid form draft request", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } catch (Exception e) { + log.error("Error discarding form draft", e); + return new ResponseEntity<>( + WebUtils.wrapErrorResponse(null, e.getMessage()), + HttpStatus.BAD_REQUEST); + } + } + +} diff --git a/bahmnicore-omod/src/main/resources/config.xml b/bahmnicore-omod/src/main/resources/config.xml index d3915522b2..e3848a269f 100644 --- a/bahmnicore-omod/src/main/resources/config.xml +++ b/bahmnicore-omod/src/main/resources/config.xml @@ -146,6 +146,7 @@ EntityMappingType.hbm.xml Notes.hbm.xml NoteType.hbm.xml + FormDraft.hbm.xml diff --git a/bahmnicore-omod/src/main/resources/liquibase.xml b/bahmnicore-omod/src/main/resources/liquibase.xml index 5a6e15aa1a..f076f78bad 100644 --- a/bahmnicore-omod/src/main/resources/liquibase.xml +++ b/bahmnicore-omod/src/main/resources/liquibase.xml @@ -4713,4 +4713,131 @@ + + + + + + + Create form_draft table for auto-save functionality in observation forms + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM scheduler_task_config WHERE schedulable_class = 'org.bahmni.module.bahmnicore.task.DiscardAllFormDraftsTask' + + + Register scheduled task to discard all form drafts at midnight daily + + INSERT INTO scheduler_task_config(name, schedulable_class, start_time, start_time_pattern, repeat_interval, start_on_startup, started, created_by, date_created, uuid) + VALUES ('Discard All Form Drafts Task', 'org.bahmni.module.bahmnicore.task.DiscardAllFormDraftsTask', DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL 1 DAY), '%Y-%m-%d 00:00:00'), 'MM/dd/yyyy HH:mm:ss', 86400, 1, 1, 1, CURDATE(), UUID()); + + + + + + + SELECT COUNT(*) FROM privilege WHERE privilege = 'Delete Form Draft' + + + Remove Delete Form Draft privilege as discard no longer requires a privilege check + + privilege = 'Delete Form Draft' + + + privilege = 'Delete Form Draft' + + + + + + + SELECT COUNT(*) FROM global_property WHERE property = 'bahmni.formDraft.voidedRetentionDays' + + + Add global property for form draft retention period (days to retain before permanent deletion) + + insert into global_property (`property`, `property_value`, `description`, `uuid`) + values ('bahmni.formDraft.voidedRetentionDays', '15', 'Number of days to retain form drafts before permanent deletion. Default is 15 days.', uuid()); + + + + + + + SELECT COUNT(*) FROM global_property WHERE property = 'bahmni.formDraft.discardOnSave' + + + Add global property to control whether saving a consultation discards the draft instead of marking it as saved + + insert into global_property (`property`, `property_value`, `description`, `uuid`) + values ('bahmni.formDraft.discardOnSave', 'true', 'When true, saving a consultation discards the auto-saved draft (DELETE) instead of marking it as saved (PATCH). Default is false.', uuid()); + + + diff --git a/bahmnicore-omod/src/test/java/org/bahmni/module/bahmnicore/web/v1_0/controller/FormDraftControllerTest.java b/bahmnicore-omod/src/test/java/org/bahmni/module/bahmnicore/web/v1_0/controller/FormDraftControllerTest.java new file mode 100644 index 0000000000..654d35c3f2 --- /dev/null +++ b/bahmnicore-omod/src/test/java/org/bahmni/module/bahmnicore/web/v1_0/controller/FormDraftControllerTest.java @@ -0,0 +1,174 @@ +package org.bahmni.module.bahmnicore.web.v1_0.controller; + +import org.bahmni.module.bahmnicore.contract.FormDraftRequest; +import org.bahmni.module.bahmnicore.contract.FormDraftResponse; +import org.bahmni.module.bahmnicore.contract.FormDraftSummaryResponse; +import org.bahmni.module.bahmnicore.model.FormDraft; +import org.bahmni.module.bahmnicore.service.FormDraftService; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FormDraftControllerTest { + + private FormDraftController controller; + private FormDraftService formDraftService; + + private static final String PATIENT_UUID = "patient-uuid-123"; + private static final String PROVIDER_UUID = "provider-uuid-456"; + private static final String DRAFT_UUID = "draft-uuid"; + private static final String FORM_DATA_PATH = "/path/to/draft.json"; + + @Before + public void setUp() throws Exception { + formDraftService = mock(FormDraftService.class); + controller = new FormDraftController(); + // Use reflection to inject the mock service since there's no public setter + java.lang.reflect.Field field = controller.getClass().getDeclaredField("formDraftService"); + field.setAccessible(true); + field.set(controller, formDraftService); + } + + @Test + public void getDraft_shouldReturnEmptyResponseWhenNoDraftExists() { + when(formDraftService.getDraft(PATIENT_UUID, PROVIDER_UUID)).thenReturn(null); + + ResponseEntity response = controller.getDraft(PATIENT_UUID, PROVIDER_UUID); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody() instanceof FormDraftResponse); + } + + @Test + public void getDraft_shouldReturnBadRequestWhenServiceThrowsException() { + doThrow(new IllegalArgumentException("Invalid UUID")).when(formDraftService).getDraft(PATIENT_UUID, PROVIDER_UUID); + + ResponseEntity response = controller.getDraft(PATIENT_UUID, PROVIDER_UUID); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void saveDraft_shouldReturnBadRequestWhenValidationFails() { + FormDraftRequest request = buildFormDraftRequest(null, PROVIDER_UUID, "{\"form\":\"data\"}"); + doThrow(new IllegalArgumentException("Patient UUID is required")).when(formDraftService).saveDraft(any(FormDraftRequest.class)); + + ResponseEntity response = controller.saveDraft(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void saveDraft_shouldReturnBadRequestWhenServiceThrowsException() { + FormDraftRequest request = buildFormDraftRequest(PATIENT_UUID, PROVIDER_UUID, "{\"form\":\"data\"}"); + doThrow(new RuntimeException("Unexpected error")).when(formDraftService).saveDraft(any(FormDraftRequest.class)); + + ResponseEntity response = controller.saveDraft(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void markDraftAsSaved_shouldReturnBadRequestWhenPatientUuidIsNull() { + doThrow(new IllegalArgumentException("Patient UUID is required")).when(formDraftService) + .markDraftAsSaved(null, PROVIDER_UUID); + + ResponseEntity response = controller.markDraftAsSaved(null, PROVIDER_UUID); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void markDraftAsSaved_shouldReturnBadRequestWhenProviderUuidIsEmpty() { + doThrow(new IllegalArgumentException("Provider UUID is required")).when(formDraftService) + .markDraftAsSaved(PATIENT_UUID, ""); + + ResponseEntity response = controller.markDraftAsSaved(PATIENT_UUID, ""); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + public void markDraftAsSaved_shouldReturnBadRequestWhenServiceThrows() { + doThrow(new RuntimeException("Service error")).when(formDraftService) + .markDraftAsSaved(PATIENT_UUID, PROVIDER_UUID); + + ResponseEntity response = controller.markDraftAsSaved(PATIENT_UUID, PROVIDER_UUID); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + + @Test + public void getDraftsByProvider_returns200WithList() { + FormDraftSummaryResponse summary = new FormDraftSummaryResponse(); + summary.setDraftUuid("draft-uuid-1"); + summary.setPatientUuid("patient-uuid-1"); + summary.setPatientName("John Doe"); + summary.setPatientIdentifier("ET001"); + summary.setTimestamp(1000L); + when(formDraftService.getDraftsByProvider(PROVIDER_UUID)).thenReturn(Collections.singletonList(summary)); + + ResponseEntity response = controller.getDraftsByProvider(PROVIDER_UUID); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List body = (List) response.getBody(); + assertNotNull(body); + assertEquals(1, body.size()); + } + + @Test + public void getDraftsByProvider_returns200WithEmptyList() { + when(formDraftService.getDraftsByProvider(PROVIDER_UUID)).thenReturn(Collections.emptyList()); + + ResponseEntity response = controller.getDraftsByProvider(PROVIDER_UUID); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List body = (List) response.getBody(); + assertNotNull(body); + assertTrue(body.isEmpty()); + } + + @Test + public void getDraftsByProvider_returns400_whenProviderUuidIsInvalid() { + doThrow(new IllegalArgumentException("Provider UUID is required")).when(formDraftService) + .getDraftsByProvider(" "); + + ResponseEntity response = controller.getDraftsByProvider(" "); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + // --- Helpers --- + + private FormDraftRequest buildFormDraftRequest(String patientUuid, String providerUuid, String formData) { + FormDraftRequest request = new FormDraftRequest(); + request.setPatientUuid(patientUuid); + request.setProviderUuid(providerUuid); + request.setFormData(formData); + return request; + } + + private FormDraft buildFormDraft(String uuid, String formDataPath) { + FormDraft draft = new FormDraft(); + draft.setUuid(uuid); + draft.setFormDataPath(formDataPath); + draft.setDateCreated(new Date()); + return draft; + } +}