diff --git a/pom.xml b/pom.xml index 1d3e88724..5fc0bc731 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common.flw flw-api - 3.8.4 + 3.12.0 war FLW-API diff --git a/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java new file mode 100644 index 000000000..1ff0158d7 --- /dev/null +++ b/src/main/java/com/iemr/flw/domain/iemr/DynamicFormMigrationLog.java @@ -0,0 +1,53 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.domain.iemr; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.sql.Timestamp; + +/** + * One row per applied {@code com.iemr.flw.seeder.migration.FormStructureMigration}, keyed by its + * stable migrationId — lets {@code DynamicFormMigrationRunner} skip a migration it has already run. + */ +@Entity +@Table(name = "t_dynamic_form_migration_log", schema = "db_iemr") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DynamicFormMigrationLog { + + @Id + @Column(name = "migrationId", length = 50) + private String migrationId; + + @CreationTimestamp + @Column(name = "appliedAt", nullable = false, updatable = false) + private Timestamp appliedAt; +} diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java index 28f50859b..b645c9742 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormSection.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormSection.java @@ -90,6 +90,11 @@ public class FormSection { @Column(name = "isEditable", nullable = false) private Boolean isEditable = false; + /** Soft-delete flag: false means removed from its version. Never hard-deleted — historical + * section responses reference it. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java index 5902aa218..62a5f646b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/FormVersion.java @@ -71,6 +71,11 @@ public class FormVersion { @Column(name = "isLatest", nullable = false) private Boolean isLatest = true; + /** Soft-delete flag: false means the version is retired. Never hard-deleted — historical + * form responses reference their version. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "createdAt", nullable = false, updatable = false) private Timestamp createdAt; diff --git a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java index 50af2b382..fbb12ce74 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java +++ b/src/main/java/com/iemr/flw/domain/iemr/OptionCondition.java @@ -74,6 +74,10 @@ public class OptionCondition { @ToString.Exclude private FormSection targetSection; + /** Soft-delete flag: false means removed from its option. Never hard-deleted. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java index 124ea6faf..645fa0d1b 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionOption.java @@ -76,6 +76,11 @@ public class QuestionOption { @Column(name = "displayOrder", nullable = false) private Integer displayOrder; + /** Soft-delete flag: false means removed from its question. Never hard-deleted — historical + * QuestionResponse rows reference optionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java index b7d41dc16..ebfe9ac33 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java +++ b/src/main/java/com/iemr/flw/domain/iemr/QuestionValidation.java @@ -72,6 +72,10 @@ public class QuestionValidation { @Column(name = "errorMessage", nullable = false, length = 500) private String errorMessage; + /** Soft-delete flag: false means removed from its question. Never hard-deleted. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java index 1aa324cb5..a50418d0e 100644 --- a/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java +++ b/src/main/java/com/iemr/flw/domain/iemr/SectionQuestion.java @@ -98,6 +98,11 @@ public class SectionQuestion { @Column(name = "containsPii", nullable = false) private Boolean containsPii = false; + /** Soft-delete flag: false means unlinked from its section. Never hard-deleted — historical + * QuestionResponse rows reference questionId as a plain Long outside JPA's cascade graph. */ + @Column(name = "isActive", nullable = false) + private Boolean isActive = true; + @Column(name = "created_by", length = 100) private String createdBy; diff --git a/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java b/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java index c9ecb63c4..9f401d390 100644 --- a/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java +++ b/src/main/java/com/iemr/flw/dto/iemr/DynamicFormDTO.java @@ -21,6 +21,7 @@ */ package com.iemr.flw.dto.iemr; +import com.fasterxml.jackson.annotation.JsonInclude; import com.iemr.flw.masterEnum.FormType; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -62,4 +63,9 @@ public class DynamicFormDTO { @Valid private List sections = new ArrayList<>(); + + /** Every version's full definition, oldest first — populated only by getAllForms (the top-level + * fields above stay the latest version); omitted from the JSON elsewhere and ignored on write. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private List versions; } diff --git a/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java b/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java index 551e93a6d..9f7abac5b 100644 --- a/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java +++ b/src/main/java/com/iemr/flw/mapper/DynamicFormMapper.java @@ -48,6 +48,7 @@ public interface DynamicFormMapper { @Mapping(target = "sections", ignore = true) @Mapping(target = "versionNumber", ignore = true) + @Mapping(target = "versions", ignore = true) DynamicFormDTO toDto(DynamicForm entity); FormVersionDTO toDto(FormVersion entity); diff --git a/src/main/java/com/iemr/flw/masterEnum/ValidationType.java b/src/main/java/com/iemr/flw/masterEnum/ValidationType.java index 931e24699..ed7891588 100644 --- a/src/main/java/com/iemr/flw/masterEnum/ValidationType.java +++ b/src/main/java/com/iemr/flw/masterEnum/ValidationType.java @@ -38,5 +38,8 @@ public enum ValidationType { REGEX, /** Field becomes mandatory when another field equals a specific value. * validationParam format: "QUESTION_UUID=OPTION_VALUE" e.g. "Q-GENDER=FEMALE". */ - MANDATORY_IF + MANDATORY_IF, + /** Display unit for a numeric answer. validationParam = unit label e.g. "sq. m.". + * Informational only — not a rule the answer is checked against. */ + UNIT } \ No newline at end of file diff --git a/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java new file mode 100644 index 000000000..9f993076b --- /dev/null +++ b/src/main/java/com/iemr/flw/repo/iemr/DynamicFormMigrationLogRepo.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.repo.iemr; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository for applied form-structure migration records. + * Id type is the migrationId String itself — existsById/save need no custom methods. + */ +@Repository +public interface DynamicFormMigrationLogRepo extends JpaRepository { +} diff --git a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java index 9f135959b..66192716c 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/FormSectionRepo.java @@ -24,12 +24,14 @@ import com.iemr.flw.domain.iemr.FormSection; import com.iemr.flw.masterEnum.SectionPhase; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for form sections. @@ -40,6 +42,20 @@ public interface FormSectionRepo extends JpaRepository { List findByFormVersion_VersionIdOrderByDisplayOrderAsc(Long versionId); + /** Read-path variant excluding removed (isActive=false) sections. */ + List findByFormVersion_VersionIdAndIsActiveTrueOrderByDisplayOrderAsc(Long versionId); + + Optional findByFormVersion_VersionIdAndSectionUuid(Long versionId, String sectionUuid); + + Optional findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(Long versionId); + + /** Shifts displayOrder by delta for every section in [from, to] within a version — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE FormSection s SET s.displayOrder = s.displayOrder + :delta " + + "WHERE s.formVersion.versionId = :versionId AND s.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("versionId") Long versionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + @Query("SELECT fs.formVersion.versionId, COUNT(fs) FROM FormSection fs " + "WHERE fs.formVersion.versionId IN :versionIds " + "AND fs.sectionPhase = :sectionPhase " + diff --git a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java index 633418894..7ec5cad52 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/OptionConditionRepo.java @@ -29,6 +29,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for option conditions. @@ -38,6 +39,13 @@ public interface OptionConditionRepo extends JpaRepository findByQuestionOption_OptionId(Long optionId); + /** No surrogate natural key on conditions — matched by (actionType, target) within an option. */ + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + Long optionId, String actionType, String targetQuestionUuid); + + Optional findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + Long optionId, String actionType, String targetSectionUuid); + @Query("SELECT oc.targetQuestion.questionId FROM OptionCondition oc " + "WHERE oc.questionOption.sectionQuestion.formSection.formVersion.versionId = :versionId " + "AND oc.targetQuestion IS NOT NULL") @@ -47,9 +55,11 @@ public interface OptionConditionRepo extends JpaRepository findByOptionIds(@Param("optionIds") Collection optionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java index f75120551..0d8e87969 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionOptionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.QuestionOption; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question options. @@ -38,11 +40,24 @@ public interface QuestionOptionRepo extends JpaRepository List findBySectionQuestion_QuestionIdOrderByDisplayOrderAsc(Long questionId); + Optional findBySectionQuestion_QuestionIdAndOptionValue(Long questionId, String optionValue); + + Optional findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(Long questionId); + + /** Shifts displayOrder by delta for every option in [from, to] within a question — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE QuestionOption o SET o.displayOrder = o.displayOrder + :delta " + + "WHERE o.sectionQuestion.questionId = :questionId AND o.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("questionId") Long questionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all options for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) options — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT o FROM QuestionOption o JOIN FETCH o.sectionQuestion " - + "WHERE o.sectionQuestion.questionId IN :questionIds ORDER BY o.displayOrder ASC") + + "WHERE o.sectionQuestion.questionId IN :questionIds AND o.isActive = true ORDER BY o.displayOrder ASC") List findByQuestionIdsOrderByDisplayOrderAsc(@Param("questionIds") Collection questionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java index d1cf41876..17e1ea616 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/QuestionValidationRepo.java @@ -22,6 +22,7 @@ package com.iemr.flw.repo.iemr; import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.masterEnum.ValidationType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -29,6 +30,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for question validation rules. @@ -38,11 +40,17 @@ public interface QuestionValidationRepo extends JpaRepository findBySectionQuestion_QuestionId(Long questionId); + /** No surrogate natural key on validations — matched by the (type, param) content tuple within a question. */ + Optional findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + Long questionId, ValidationType validationType, String validationParam); + /** * Loads all validations for a set of questions in one query. * JOIN FETCH ensures sectionQuestion is hydrated so callers can group by questionId without extra queries. + * Excludes removed (isActive=false) validations — used only by read paths; reconciliation matching + * uses the natural-key finder above, which must see inactive rows too. */ @Query("SELECT v FROM QuestionValidation v JOIN FETCH v.sectionQuestion " - + "WHERE v.sectionQuestion.questionId IN :questionIds") + + "WHERE v.sectionQuestion.questionId IN :questionIds AND v.isActive = true") List findByQuestionIds(@Param("questionIds") Collection questionIds); } diff --git a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java index 6a117e968..520993666 100644 --- a/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java +++ b/src/main/java/com/iemr/flw/repo/iemr/SectionQuestionRepo.java @@ -23,12 +23,14 @@ import com.iemr.flw.domain.iemr.SectionQuestion; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; /** * Repository for section questions. @@ -38,11 +40,27 @@ public interface SectionQuestionRepo extends JpaRepository findByFormSection_SectionIdOrderByDisplayOrderAsc(Long sectionId); + Optional findByFormSection_SectionIdAndQuestionUuid(Long sectionId, String questionUuid); + + /** Version-scoped lookup — a condition's target question may live in a different section of the same version. */ + Optional findByFormSection_FormVersion_VersionIdAndQuestionUuid(Long versionId, String questionUuid); + + Optional findTopByFormSection_SectionIdOrderByDisplayOrderDesc(Long sectionId); + + /** Shifts displayOrder by delta for every question in [from, to] within a section — used to make room for/close a gap around an inserted or moved sibling. */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE SectionQuestion q SET q.displayOrder = q.displayOrder + :delta " + + "WHERE q.formSection.sectionId = :sectionId AND q.displayOrder BETWEEN :from AND :to") + void shiftDisplayOrder(@Param("sectionId") Long sectionId, @Param("from") int from, + @Param("to") int to, @Param("delta") int delta); + /** * Loads all questions for a set of sections in one query. * JOIN FETCH ensures formSection is hydrated so callers can group by sectionId without extra queries. + * Excludes unlinked (isActive=false) questions — used only by read paths; reconciliation matching + * uses the natural-key finders above, which must see inactive rows too. */ @Query("SELECT q FROM SectionQuestion q JOIN FETCH q.formSection " - + "WHERE q.formSection.sectionId IN :sectionIds ORDER BY q.displayOrder ASC") + + "WHERE q.formSection.sectionId IN :sectionIds AND q.isActive = true ORDER BY q.displayOrder ASC") List findBySectionIdsOrderByDisplayOrderAsc(@Param("sectionIds") Collection sectionIds); } diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java new file mode 100644 index 000000000..ae4de9c6e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationApplier.java @@ -0,0 +1,49 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.DynamicFormMigrationLog; +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Applies one {@link FormStructureMigration} and records it as applied, as a single atomic unit. + * Kept as its own bean (rather than a method on {@link DynamicFormMigrationRunner}) so the + * {@code @Transactional} proxy is actually honored — a runner calling this method on itself would + * bypass Spring AOP's self-invocation limitation. + */ +@Service +@RequiredArgsConstructor +public class DynamicFormMigrationApplier { + + private final DynamicFormReconciliationService reconciliationService; + private final DynamicFormMigrationLogRepo migrationLogRepo; + + @Transactional + public void apply(FormStructureMigration migration) { + migration.apply(reconciliationService); + migrationLogRepo.save(new DynamicFormMigrationLog(migration.migrationId(), null)); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java new file mode 100644 index 000000000..2071b61ed --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/DynamicFormMigrationRunner.java @@ -0,0 +1,73 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.repo.iemr.DynamicFormMigrationLogRepo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +/** + * Applies every {@link FormStructureMigration} bean, in {@link FormStructureMigration#migrationId()} + * order, skipping ones already recorded in {@code t_dynamic_form_migration_log}. + * + * Runs as an {@link ApplicationRunner} rather than another {@code @PostConstruct} specifically so it + * always runs after the 6 form seeders: Spring Boot invokes ApplicationRunners only once the whole + * application context has finished refreshing, which is strictly after every bean's + * {@code @PostConstruct} (the seeders included) has already completed — no {@code @Order} + * coordination needed between the two mechanisms. + * + * A migration that throws propagates out of this loop and aborts startup — a broken migration + * should fail loudly, not be silently skipped, and later migrations shouldn't apply out of order + * behind a failed one. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DynamicFormMigrationRunner implements ApplicationRunner { + + private final List migrations; + private final DynamicFormMigrationLogRepo migrationLogRepo; + private final DynamicFormMigrationApplier applier; + + @Override + public void run(ApplicationArguments args) { + migrations.stream() + .sorted(Comparator.comparing(FormStructureMigration::migrationId)) + .forEach(this::applyIfNeeded); + } + + private void applyIfNeeded(FormStructureMigration migration) { + String id = migration.migrationId(); + if (migrationLogRepo.existsById(id)) { + log.info("Form structure migration '{}' already applied — skipping.", id); + return; + } + applier.apply(migration); + log.info("Form structure migration '{}' applied.", id); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java new file mode 100644 index 000000000..969bb04f3 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/FormStructureMigration.java @@ -0,0 +1,78 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.service.DynamicFormReconciliationService; + +/** + * One incremental, one-off structural change to a form that has already been created by one of the + * {@code com.iemr.flw.seeder} classes — e.g. "add this new question to that existing section", or + * "add this new option to that existing question". {@code DynamicFormMigrationRunner} discovers + * every Spring bean implementing this interface, sorts them by {@link #migrationId()}, and applies + * each exactly once, recording it in {@code t_dynamic_form_migration_log} so it is skipped on + * every later restart. A migration should never be edited after it has shipped — write a new one + * instead — since the log only records "has this id run", not what it actually did. + * + * Naming convention: {@code V_ShortDescription}, e.g. {@code V001_AddSideEffectsQuestion}, + * zero-padded so lexicographic sort matches numeric order. Give each class a javadoc stating the + * business reason for the change — that, plus the class itself, is the permanent changelog. + * + * Example: + *
{@code
+ * @Component
+ * public class V001_AddSideEffectsQuestionToTbCounselling implements FormStructureMigration {
+ *
+ *     @Override
+ *     public String migrationId() {
+ *         return "V001";
+ *     }
+ *
+ *     @Override
+ *     public void apply(DynamicFormReconciliationService svc) {
+ *         FormVersion version = svc.latestVersion("TB_COUNSELLING")
+ *                 .orElseThrow(() -> new IllegalStateException(
+ *                         "TB_COUNSELLING not found — must run after TbCounsellingFormSeeder"));
+ *         FormSection section = svc.section(version, "TB_SEC_B")
+ *                 .orElseThrow(() -> new IllegalStateException("Section TB_SEC_B not found"));
+ *
+ *         SectionQuestionDTO question = new SectionQuestionDTO();
+ *         question.setQuestionUuid("TB_B_Q7");
+ *         question.setQuestionText("Side effects reported?");
+ *         question.setQuestionType(QuestionType.RADIO);
+ *         question.setOptions(List.of(yesOption, noOption));
+ *         svc.ensureQuestion(section, question);
+ *     }
+ * }
+ * }
+ */ +public interface FormStructureMigration { + + /** Stable id, e.g. "V001" — the migration-log primary key and the sort key for apply order. Never change it once shipped. */ + String migrationId(); + + /** + * Applies this migration's change(s) via the reconciliation service's ensureX methods. + * Should remain safe to re-run (ensureX no-ops on rows that already exist) even though the + * runner normally calls this only once per migrationId, per the tracking log. + */ + void apply(DynamicFormReconciliationService reconciliationService); +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java b/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java new file mode 100644 index 000000000..78c7cd645 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V001_1_CreateContactTracingVersion2.java @@ -0,0 +1,108 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.service.DynamicFormDefinitionService; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Clones the seeded version 1 of both contact tracing forms — Community Contact Tracing and + * Occupational Contact Tracing — into a new version 2, so the later structural changes to them are + * applied to version 2 only and version 1 stays exactly as the seeders created it: + * COMMUNITY_CONTACT_TRACING (V002, V003, V006, V007, V008) and OCCUPATION_CONTACT_TRACING (V002, V004, + * V005). Responses keep the versionId they were submitted against; new responses and the app use the + * latest version. + * + * Id "V001_1" sorts between V001 and V002, so this runs before the first migration that touches + * either form — every migration resolves a form via {@code latestVersion}, which is version 2 from + * here on. + * + * Uses {@link DynamicFormDefinitionService#updateForm}, which copies every section, question, option, + * validation and condition into a new version and marks the current one isLatest=false. All ids are + * cleared from the version-1 DTO first: the mapper copies them onto the new entities, and saving an + * entity with an existing id would move that version-1 row into version 2 instead of copying it. + * Conditions are re-wired by targetQuestionUuid/targetSectionUuid, which are kept. + */ +@Component +@RequiredArgsConstructor +public class V001_1_CreateContactTracingVersion2 implements FormStructureMigration { + + private static final List FORM_UUIDS = List.of( + "COMMUNITY_CONTACT_TRACING", + "OCCUPATION_CONTACT_TRACING"); + + private final DynamicFormDefinitionService definitionService; + + @Override + public String migrationId() { + return "V001_1"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FORM_UUIDS.forEach(formUuid -> createVersion2(svc, formUuid)); + } + + private void createVersion2(DynamicFormReconciliationService svc, String formUuid) { + FormVersion latest = svc.latestVersion(formUuid) + .orElseThrow(() -> new IllegalStateException(formUuid + " not found — must run after its seeder")); + if (latest.getVersionNumber() > 1) { + return; + } + + Long formId = latest.getDynamicForm().getFormId(); + DynamicFormDTO version1 = definitionService.getFormDefinition(formId); + clearIds(version1); + definitionService.updateForm(formId, version1); + } + + private void clearIds(DynamicFormDTO form) { + for (FormSectionDTO section : form.getSections()) { + section.setSectionId(null); + for (SectionQuestionDTO question : section.getQuestions()) { + question.setQuestionId(null); + for (QuestionValidationDTO validation : question.getValidations()) { + validation.setValidationId(null); + } + for (QuestionOptionDTO option : question.getOptions()) { + option.setOptionId(null); + for (OptionConditionDTO condition : option.getConditions()) { + condition.setConditionId(null); + condition.setTargetQuestionId(null); + condition.setTargetSectionId(null); + } + } + } + } + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java new file mode 100644 index 000000000..6fc90ff6e --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V001_ReplaceTfuAdrOptions.java @@ -0,0 +1,104 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Replaces the TPT Follow Up form's "Adverse Drug Reactions" (TFU_ADR) option list with the + * clinically revised set of symptoms, per the updated ADR checklist supplied for this form. + * + * 7 of the 24 existing options already match the new list exactly (same wording) and are left + * untouched by {@code ensureOption}'s natural-key match: NAUSEA, VOMITING, ABDOMINAL_PAIN, + * DARK_COLOUR_URINE, ITCHING, TINGLING_BURNING_NUMBNESS_HANDS_FEET, FLU_LIKE_SYNDROME. The other + * 17 old options are no longer part of the revised list and are soft-removed (never hard-deleted — + * historical responses may reference them); the 13 new options are added. + */ +@Component +public class V001_ReplaceTfuAdrOptions implements FormStructureMigration { + + private static final List REMOVED_OPTION_VALUES = List.of( + "UNKNOWN", + "FLU_LIKE_SYNDROME_WITH_DIZZINESS_HEADACHE", + "YELLOWISH_DISCOLORATION_SKIN_EYES", + "DISCOLORATION_BODY_FLUIDS", + "PALE_STOOL", + "SKIN_RASH", + "SLEEPINESS_LETHARGY", + "PERSISTENT_UNFORMED_WATERY_STOOLS", + "MENTAL_CHANGES_SIGNS_BLEEDING", + "CONVULSIONS", + "ANAEMIA", + "ARTHRALGIA", + "DECREASED_APPETITE", + "HYPOTENSION_SYNCOPE", + "CONJUCTIVITIES", + "SHOCK", + "NONE_REPORTED"); + + @Override + public String migrationId() { + return "V001"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("TPT_FOLLOW_UP") + .orElseThrow(() -> new IllegalStateException( + "TPT_FOLLOW_UP not found — must run after TptFollowUpFormSeeder")); + FormSection section = svc.section(version, "TFU_SEC_2") + .orElseThrow(() -> new IllegalStateException("Section TFU_SEC_2 not found")); + SectionQuestion question = svc.question(section, "TFU_ADR") + .orElseThrow(() -> new IllegalStateException("Question TFU_ADR not found")); + + REMOVED_OPTION_VALUES.forEach(value -> svc.option(question, value) + .ifPresent(opt -> svc.removeOption(opt.getOptionId()))); + + addOption(svc, question, "Yellowness of Skin", "YELLOWNESS_OF_SKIN"); + addOption(svc, question, "Loose motions >4 times in a day", "LOOSE_MOTIONS_GT4_PER_DAY"); + addOption(svc, question, "Rashes", "RASHES"); + addOption(svc, question, "Pain in Joints", "PAIN_IN_JOINTS"); + addOption(svc, question, "Impaired vision: Pain, Blurring of vision, Disturbance in colour vision", "IMPAIRED_VISION"); + addOption(svc, question, "Swelling of face or legs, Less or no urine", "SWELLING_FACE_LEGS_LESS_NO_URINE"); + addOption(svc, question, "Seeing abnormal things, changes of thoughts, suicidal thoughts", "ABNORMAL_THOUGHTS_SUICIDAL"); + addOption(svc, question, "Tiredness, lethargy, headache, giddiness, pale look, palpitations", "TIREDNESS_LETHARGY_GIDDINESS_PALPITATIONS"); + addOption(svc, question, "Ringing in ears, Loss of hearing, dizziness and loss of balance leading to recurrent fall", "TINNITUS_HEARING_LOSS_DIZZINESS_FALL"); + addOption(svc, question, "Slowness of activities, Swelling of face or neck, dis-appropriate weight gain", "SLOWNESS_FACIAL_NECK_SWELLING_WEIGHT_GAIN"); + addOption(svc, question, "Pain and swelling in muscles and Tendons, difficulty in movement", "MUSCLE_TENDON_PAIN_SWELLING"); + addOption(svc, question, "Convulsion", "CONVULSION"); + addOption(svc, question, "Orange and red color of urine, sweat, sputum, saliva or tears", "ORANGE_RED_DISCOLORATION_BODY_FLUIDS"); + } + + private void addOption(DynamicFormReconciliationService svc, SectionQuestion question, String label, String value) { + QuestionOptionDTO dto = new QuestionOptionDTO(); + dto.setOptionLabel(label); + dto.setOptionValue(value); + svc.ensureOption(question, dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java b/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java new file mode 100644 index 000000000..836c1b1e3 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V002_RenameNoOfContactsQuestion.java @@ -0,0 +1,83 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Renames the "No. of contacts" question to "Total No. of contacts" (and its Hindi translation) + * in both the Community Contact Tracing (CCT_NO_OF_CONTACTS) and Occupational Contact Tracing + * (OCT_NO_OF_CONTACTS) forms, per the revised field-label wording supplied for these forms. + * + * Only the label text changes — type, mandatoriness and position are read off the existing row + * and passed back unchanged, since {@code updateQuestion} overwrites all of its scalar fields + * unconditionally. + */ +@Component +public class V002_RenameNoOfContactsQuestion implements FormStructureMigration { + + private static final String NEW_TEXT = "Total No. of contacts"; + private static final String NEW_TEXT_HINDI = "कुल संपर्कों की संख्या"; + + @Override + public String migrationId() { + return "V002"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + renameQuestion(svc, "COMMUNITY_CONTACT_TRACING", "CCT_SEC_1", "CCT_NO_OF_CONTACTS"); + renameQuestion(svc, "OCCUPATION_CONTACT_TRACING", "OCT_SEC_1", "OCT_NO_OF_CONTACTS"); + } + + private void renameQuestion(DynamicFormReconciliationService svc, String formUuid, String sectionUuid, + String questionUuid) { + FormVersion version = svc.latestVersion(formUuid) + .orElseThrow(() -> new IllegalStateException( + formUuid + " not found — must run after its seeder")); + FormSection section = svc.section(version, sectionUuid) + .orElseThrow(() -> new IllegalStateException("Section " + sectionUuid + " not found")); + SectionQuestion question = svc.question(section, questionUuid) + .orElseThrow(() -> new IllegalStateException("Question " + questionUuid + " not found")); + + if (NEW_TEXT.equals(question.getQuestionText()) && NEW_TEXT_HINDI.equals(question.getQuestionTextHindi())) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(NEW_TEXT); + dto.setQuestionTextHindi(NEW_TEXT_HINDI); + dto.setQuestionType(question.getQuestionType()); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java b/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java new file mode 100644 index 000000000..ba69d37b4 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V003_ChangeExposureSettingToDropdownMulti.java @@ -0,0 +1,76 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Changes the Community Contact Tracing form's "Place of exposure" question (CCT_EXPOSURE_SETTING) + * from CHECKBOX_MULTI to DROPDOWN_MULTI, so the 12 place options render as a multi-select dropdown + * instead of a long checkbox list. + * + * Both types submit {@code optionValues} and store one response row per selected option, so + * existing responses remain valid. Options and the OTHER_PLACE → CCT_EXPOSURE_SETTING_OTHER + * conditional rule are untouched. All other scalar fields are read off the existing row and passed + * back unchanged, since {@code updateQuestion} overwrites all of them unconditionally. + */ +@Component +public class V003_ChangeExposureSettingToDropdownMulti implements FormStructureMigration { + + @Override + public String migrationId() { + return "V003"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion question = svc.question(section, "CCT_EXPOSURE_SETTING") + .orElseThrow(() -> new IllegalStateException("Question CCT_EXPOSURE_SETTING not found")); + + if (question.getQuestionType() == QuestionType.DROPDOWN_MULTI) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(question.getQuestionText()); + dto.setQuestionTextHindi(question.getQuestionTextHindi()); + dto.setQuestionType(QuestionType.DROPDOWN_MULTI); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java b/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java new file mode 100644 index 000000000..24db1f454 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V004_ChangeOccupationToDropdown.java @@ -0,0 +1,76 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Changes the Occupational Contact Tracing form's "Occupation" question (OCT_OCCUPATION) from RADIO + * to DROPDOWN, so the 11 occupation options render as a single-select dropdown instead of a long + * radio list. + * + * Both types submit a single {@code optionValue} and store one response row, so existing responses + * remain valid. Options and their conditional rules (address questions, OTHER_OCCUPATION → + * OCT_OCCUPATION_OTHER) are untouched. All other scalar fields are read off the existing row and + * passed back unchanged, since {@code updateQuestion} overwrites all of them unconditionally. + */ +@Component +public class V004_ChangeOccupationToDropdown implements FormStructureMigration { + + @Override + public String migrationId() { + return "V004"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("OCCUPATION_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "OCCUPATION_CONTACT_TRACING not found — must run after OccupationalContactTracingFormSeeder")); + FormSection section = svc.section(version, "OCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section OCT_SEC_1 not found")); + SectionQuestion question = svc.question(section, "OCT_OCCUPATION") + .orElseThrow(() -> new IllegalStateException("Question OCT_OCCUPATION not found")); + + if (question.getQuestionType() == QuestionType.DROPDOWN) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(question.getQuestionText()); + dto.setQuestionTextHindi(question.getQuestionTextHindi()); + dto.setQuestionType(QuestionType.DROPDOWN); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java b/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java new file mode 100644 index 000000000..11a6a3d0c --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V005_AddAreaOfSharedSpaceQuestion.java @@ -0,0 +1,115 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Adds an "Approximate Area of Shared Space" question to the Occupational Contact Tracing form, + * directly after "Type of Space" (OCT_TYPE_OF_SPACE), per the revised criteria for this form: + * enabled once a Type of Space is selected, whole square metres only, accepted range 50–5000. + * + * Mapped to Type of Space as a condition: hidden by default, and each of the 3 OCT_TYPE_OF_SPACE + * options reveals it (SHOW_QUESTION) and makes it mandatory (MANDATORY_IF) — conditions attach to + * options, so "any Type of Space selected" is one condition per option, all targeting this single + * question. Conventions used, since questions carry no dedicated fields for these: + * the helper text is stored in {@code defaultValue}, and the unit as a {@link ValidationType#UNIT} + * validation whose {@code validationParam} is the unit label. + * + * Inserted at display order 7 — {@code ensureQuestion} shifts "Daily duration of contact" to 8 and + * "Remarks" to 9. + */ +@Component +public class V005_AddAreaOfSharedSpaceQuestion implements FormStructureMigration { + + private static final String QUESTION_UUID = "OCT_AREA_OF_SHARED_SPACE"; + + private static final List TYPE_OF_SPACE_VALUES = List.of( + "OPEN_SPACE", + "CLOSED_SPACE_VENTILATED", + "CLOSED_SPACE_NOT_VENTILATED"); + + @Override + public String migrationId() { + return "V005"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("OCCUPATION_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "OCCUPATION_CONTACT_TRACING not found — must run after OccupationalContactTracingFormSeeder")); + FormSection section = svc.section(version, "OCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section OCT_SEC_1 not found")); + SectionQuestion typeOfSpace = svc.question(section, "OCT_TYPE_OF_SPACE") + .orElseThrow(() -> new IllegalStateException("Question OCT_TYPE_OF_SPACE not found")); + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionUuid(QUESTION_UUID); + dto.setQuestionText("Approximate Area of Shared Space"); + dto.setQuestionTextHindi("साझा स्थान का अनुमानित क्षेत्रफल"); + dto.setQuestionType(QuestionType.NUMBER_PICKER); + dto.setDefaultValue("Give estimated measurement of area"); + dto.setIsMandatory(true); + dto.setDisplayOrder(7); + dto.setVisibleByDefault(false); + dto.setOptions(List.of()); + dto.setValidations(List.of()); + SectionQuestion area = svc.ensureQuestion(section, dto); + + svc.ensureValidation(area, validation(ValidationType.REGEX, + "^([5-9][0-9]|[1-9][0-9]{2}|[1-4][0-9]{3}|5000)$", "Enter a value between 50 and 5000.")); + svc.ensureValidation(area, validation(ValidationType.UNIT, "sq. m.", "Value is in sq. m.")); + + for (String value : TYPE_OF_SPACE_VALUES) { + svc.ensureValidation(area, validation(ValidationType.MANDATORY_IF, + "OCT_TYPE_OF_SPACE=" + value, "This field is mandatory")); + + QuestionOption option = svc.option(typeOfSpace, value) + .orElseThrow(() -> new IllegalStateException("Option OCT_TYPE_OF_SPACE=" + value + " not found")); + OptionConditionDTO show = new OptionConditionDTO(); + show.setActionType("SHOW_QUESTION"); + show.setTargetQuestionUuid(QUESTION_UUID); + svc.ensureCondition(option, show, version); + } + } + + private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) { + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(type); + v.setValidationParam(param); + v.setErrorMessage(errorMessage); + return v; + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java b/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java new file mode 100644 index 000000000..822ea0867 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V006_PerRelationshipTypeOfSpace.java @@ -0,0 +1,172 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Replaces the Community Contact Tracing form's single, standalone "Type of Space" question + * (CCT_TYPE_OF_SPACE) with one per "Relationship with community contacts" (CCT_RELATIONSHIP) + * option, each followed by its own "Approximate Area of Shared Space", so the exposure setting is + * captured per relationship type: + * + *
+ * CCT_RELATIONSHIP=<R> ─ SHOW_QUESTION ─► CCT_TYPE_OF_SPACE_<R>
+ *                                          └─ each option ─ SHOW_QUESTION ─► CCT_AREA_OF_SHARED_SPACE_<R>
+ * 
+ * + * The old CCT_TYPE_OF_SPACE is soft-removed (never hard-deleted — historical responses may + * reference it). The 14 new questions (a Type of Space / Area pair per relationship) are inserted + * into its slot, all hidden by default and mandatory only when revealed (MANDATORY_IF). Area + * questions follow V006's conventions: helper text in {@code defaultValue}, unit as a + * {@link ValidationType#UNIT} validation, whole square metres 50–5000. + */ +@Component +public class V006_PerRelationshipTypeOfSpace implements FormStructureMigration { + + private static final List RELATIONSHIP_VALUES = List.of( + "NEIGHBOUR", + "FRIEND", + "FELLOW_WORSHIPPER", + "FELLOW_COMMUTER", + "COMMUNITY_GROUP_MEMBER", + "FELLOW_PATIENT", + "OTHER"); + + /** {label, labelHindi, value} — same options as the retired CCT_TYPE_OF_SPACE. */ + private static final List TYPE_OF_SPACE_OPTIONS = List.of( + new String[] {"Open space", "खुला स्थान", "OPEN_SPACE"}, + new String[] {"Closed space with ventilation", "वेंटिलेशन के साथ बंद स्थान", "CLOSED_SPACE_VENTILATED"}, + new String[] {"Closed space with no ventilation", "बिना वेंटिलेशन के बंद स्थान", "CLOSED_SPACE_NOT_VENTILATED"}); + + private static final String AREA_REGEX = "^([5-9][0-9]|[1-9][0-9]{2}|[1-4][0-9]{3}|5000)$"; + private static final String MANDATORY_ERROR = "This field is mandatory"; + + @Override + public String migrationId() { + return "V006"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion relationship = svc.question(section, "CCT_RELATIONSHIP") + .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found")); + SectionQuestion oldTypeOfSpace = svc.question(section, "CCT_TYPE_OF_SPACE") + .orElseThrow(() -> new IllegalStateException("Question CCT_TYPE_OF_SPACE not found")); + + int base = oldTypeOfSpace.getDisplayOrder(); + if (Boolean.TRUE.equals(oldTypeOfSpace.getIsActive())) { + svc.unlinkQuestion(oldTypeOfSpace.getQuestionId()); + } + + for (int i = 0; i < RELATIONSHIP_VALUES.size(); i++) { + String r = RELATIONSHIP_VALUES.get(i); + String typeOfSpaceUuid = "CCT_TYPE_OF_SPACE_" + r; + String areaUuid = "CCT_AREA_OF_SHARED_SPACE_" + r; + + // Type of Space for this relationship — revealed by CCT_RELATIONSHIP= + SectionQuestion typeOfSpace = svc.ensureQuestion(section, question(typeOfSpaceUuid, + "Type of Space", "स्थान का प्रकार", QuestionType.RADIO, null, base + 1 + 2 * i)); + svc.ensureValidation(typeOfSpace, validation(ValidationType.MANDATORY_IF, + "CCT_RELATIONSHIP=" + r, MANDATORY_ERROR)); + QuestionOption relationshipOption = svc.option(relationship, r) + .orElseThrow(() -> new IllegalStateException("Option CCT_RELATIONSHIP=" + r + " not found")); + svc.ensureCondition(relationshipOption, showQuestion(typeOfSpaceUuid), version); + + // Approximate Area of Shared Space for this relationship — revealed by any of its Type of Space options + SectionQuestion area = svc.ensureQuestion(section, question(areaUuid, + "Approximate Area of Shared Space", "साझा स्थान का अनुमानित क्षेत्रफल", QuestionType.NUMBER_PICKER, + "Give estimated measurement of area", base + 2 + 2 * i)); + svc.ensureValidation(area, validation(ValidationType.REGEX, AREA_REGEX, "Enter a value between 50 and 5000.")); + svc.ensureValidation(area, validation(ValidationType.UNIT, "sq. m.", "Value is in sq. m.")); + + for (int j = 0; j < TYPE_OF_SPACE_OPTIONS.size(); j++) { + String[] o = TYPE_OF_SPACE_OPTIONS.get(j); + QuestionOption spaceOption = svc.ensureOption(typeOfSpace, option(o[0], o[1], o[2], j + 1)); + svc.ensureValidation(area, validation(ValidationType.MANDATORY_IF, + typeOfSpaceUuid + "=" + o[2], MANDATORY_ERROR)); + svc.ensureCondition(spaceOption, showQuestion(areaUuid), version); + } + } + + } + + private SectionQuestionDTO question(String uuid, String text, String textHindi, QuestionType type, + String defaultValue, int order) { + SectionQuestionDTO q = new SectionQuestionDTO(); + q.setQuestionUuid(uuid); + q.setQuestionText(text); + q.setQuestionTextHindi(textHindi); + q.setQuestionType(type); + q.setDefaultValue(defaultValue); + q.setIsMandatory(true); + q.setDisplayOrder(order); + q.setVisibleByDefault(false); + q.setOptions(List.of()); + q.setValidations(List.of()); + return q; + } + + private QuestionOptionDTO option(String label, String labelHindi, String value, int order) { + QuestionOptionDTO o = new QuestionOptionDTO(); + o.setOptionLabel(label); + o.setOptionLabelHindi(labelHindi); + o.setOptionValue(value); + o.setOptionValueHindi(labelHindi); + o.setDisplayOrder(order); + o.setConditions(List.of()); + return o; + } + + private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) { + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(type); + v.setValidationParam(param); + v.setErrorMessage(errorMessage); + return v; + } + + private OptionConditionDTO showQuestion(String targetQuestionUuid) { + OptionConditionDTO c = new OptionConditionDTO(); + c.setActionType("SHOW_QUESTION"); + c.setTargetQuestionUuid(targetQuestionUuid); + return c; + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java b/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java new file mode 100644 index 000000000..3486eea07 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V007_AddRelationshipHoursQuestions.java @@ -0,0 +1,129 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.masterEnum.ValidationType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Adds a mandatory "Time spent with contact in hours" number picker to the Community Contact + * Tracing form for each "Relationship with community contacts" (CCT_RELATIONSHIP) option, so the + * exposure duration is captured per relationship type alongside its existing contact count. + * + * One question per option (CCT_RELATIONSHIP_HOURS_<OPTION_VALUE>) rather than a shared one, + * since CCT_RELATIONSHIP is multi-select and a shared field couldn't disambiguate which duration + * belongs to which relationship. Each is hidden by default, revealed by a SHOW_QUESTION condition + * on its option, and mandatory only when that option is selected (MANDATORY_IF). + * + * Each question is inserted directly after its relationship's "Approximate Area of Shared Space" + * (CCT_AREA_OF_SHARED_SPACE_<OPTION_VALUE>), so every relationship's revealed questions read + * Type of Space, Area, Time spent; {@code ensureQuestion} shifts everything after it down by one. + * Must run after V006_PerRelationshipTypeOfSpace, which creates those Area questions — and since it + * runs later, each option's SHOW_QUESTION conditions are also created in that same order. + */ +@Component +public class V007_AddRelationshipHoursQuestions implements FormStructureMigration { + + private static final List RELATIONSHIP_VALUES = List.of( + "NEIGHBOUR", + "FRIEND", + "FELLOW_WORSHIPPER", + "FELLOW_COMMUTER", + "COMMUNITY_GROUP_MEMBER", + "FELLOW_PATIENT", + "OTHER"); + + private static final String QUESTION_TEXT = "Time spent with contact in hours"; + private static final String QUESTION_TEXT_HINDI = "संपर्क के साथ बिताया गया समय (घंटों में)"; + private static final String HOURS_REGEX = "^([0-9]|1[0-9]|2[0-4])$"; + private static final String HOURS_ERROR = "Enter whole hours between 0 and 24"; + + @Override + public String migrationId() { + return "V007"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion relationship = svc.question(section, "CCT_RELATIONSHIP") + .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found")); + + for (String value : RELATIONSHIP_VALUES) { + String questionUuid = "CCT_RELATIONSHIP_HOURS_" + value; + String areaUuid = "CCT_AREA_OF_SHARED_SPACE_" + value; + + SectionQuestion area = svc.question(section, areaUuid) + .orElseThrow(() -> new IllegalStateException( + "Question " + areaUuid + " not found — must run after V006_PerRelationshipTypeOfSpace")); + SectionQuestion hours = svc.ensureQuestion(section, + hoursQuestion(questionUuid, area.getDisplayOrder() + 1)); + svc.ensureValidation(hours, validation(ValidationType.REGEX, HOURS_REGEX, HOURS_ERROR)); + svc.ensureValidation(hours, validation(ValidationType.MANDATORY_IF, + "CCT_RELATIONSHIP=" + value, "This field is mandatory")); + + QuestionOption option = svc.option(relationship, value) + .orElseThrow(() -> new IllegalStateException("Option CCT_RELATIONSHIP=" + value + " not found")); + OptionConditionDTO show = new OptionConditionDTO(); + show.setActionType("SHOW_QUESTION"); + show.setTargetQuestionUuid(questionUuid); + svc.ensureCondition(option, show, version); + } + } + + private SectionQuestionDTO hoursQuestion(String uuid, int order) { + SectionQuestionDTO q = new SectionQuestionDTO(); + q.setQuestionUuid(uuid); + q.setQuestionText(QUESTION_TEXT); + q.setQuestionTextHindi(QUESTION_TEXT_HINDI); + q.setQuestionType(QuestionType.NUMBER_PICKER); + q.setIsMandatory(true); + q.setDisplayOrder(order); + q.setVisibleByDefault(false); + q.setOptions(List.of()); + q.setValidations(List.of()); + return q; + } + + private QuestionValidationDTO validation(ValidationType type, String param, String errorMessage) { + QuestionValidationDTO v = new QuestionValidationDTO(); + v.setValidationType(type); + v.setValidationParam(param); + v.setErrorMessage(errorMessage); + return v; + } +} diff --git a/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java b/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java new file mode 100644 index 000000000..7db08a382 --- /dev/null +++ b/src/main/java/com/iemr/flw/seeder/migration/V008_ChangeRelationshipToDropdownMulti.java @@ -0,0 +1,77 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.seeder.migration; + +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.masterEnum.QuestionType; +import com.iemr.flw.service.DynamicFormReconciliationService; +import org.springframework.stereotype.Component; + +/** + * Changes the Community Contact Tracing form's "Relationship with community contacts" question + * (CCT_RELATIONSHIP) from CHECKBOX_MULTI to DROPDOWN_MULTI, so the 7 relationship options render as + * a multi-select dropdown instead of a checkbox list. + * + * Both types submit {@code optionValues} and store one response row per selected option, so + * existing responses remain valid. Options and their conditions (per-relationship count, Type of + * Space and hours questions, and "Other" text) are untouched. All other scalar fields are read off + * the existing row and passed back unchanged, since {@code updateQuestion} overwrites all of them + * unconditionally. + */ +@Component +public class V008_ChangeRelationshipToDropdownMulti implements FormStructureMigration { + + @Override + public String migrationId() { + return "V008"; + } + + @Override + public void apply(DynamicFormReconciliationService svc) { + FormVersion version = svc.latestVersion("COMMUNITY_CONTACT_TRACING") + .orElseThrow(() -> new IllegalStateException( + "COMMUNITY_CONTACT_TRACING not found — must run after CommunityContactTracingFormSeeder")); + FormSection section = svc.section(version, "CCT_SEC_1") + .orElseThrow(() -> new IllegalStateException("Section CCT_SEC_1 not found")); + SectionQuestion question = svc.question(section, "CCT_RELATIONSHIP") + .orElseThrow(() -> new IllegalStateException("Question CCT_RELATIONSHIP not found")); + + if (question.getQuestionType() == QuestionType.DROPDOWN_MULTI) { + return; + } + + SectionQuestionDTO dto = new SectionQuestionDTO(); + dto.setQuestionText(question.getQuestionText()); + dto.setQuestionTextHindi(question.getQuestionTextHindi()); + dto.setQuestionType(QuestionType.DROPDOWN_MULTI); + dto.setIsMandatory(question.getIsMandatory()); + dto.setMaxLength(question.getMaxLength()); + dto.setDefaultValue(question.getDefaultValue()); + dto.setContainsPii(question.getContainsPii()); + dto.setDisplayOrder(question.getDisplayOrder()); + + svc.updateQuestion(question.getQuestionId(), dto); + } +} diff --git a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java index c7aae24df..ed2d92d56 100644 --- a/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java +++ b/src/main/java/com/iemr/flw/service/DynamicFormDefinitionService.java @@ -47,7 +47,7 @@ public interface DynamicFormDefinitionService { /** Returns a specific version's full definition tree (no cache). */ DynamicFormDTO getFormDefinitionByVersion(Long formId, Integer versionNumber); - /** Returns metadata for all active forms (without full tree). */ + /** Returns every active form's latest full definition, with each version's full definition in {@code versions}. */ List getAllForms(); /** Sets the form's isActive flag to true. */ diff --git a/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java new file mode 100644 index 000000000..f558f0078 --- /dev/null +++ b/src/main/java/com/iemr/flw/service/DynamicFormReconciliationService.java @@ -0,0 +1,101 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; + +import java.util.Optional; + +/** + * Idempotent, in-place reconciliation for dynamic form structure — inserts whatever is missing + * from the current latest {@link FormVersion} and leaves existing rows untouched. Never creates a + * new version (unlike {@link DynamicFormDefinitionService#updateForm}, which always clones one). + * + * Used both by the startup seeders (walking their full hardcoded DTO tree every run) and by the + * granular structure endpoints (a single section/question/option at a time). + * + * Each {@code ensureX} method operates on exactly one level — callers walk the tree themselves and + * call the next level down, since only the caller knows whether it's safe yet to wire cross-sibling + * references (see {@link #ensureCondition}). + */ +public interface DynamicFormReconciliationService { + + /** Finds the form by formUuid, or creates it. If found inactive, reactivates it (touches no other field). */ + DynamicForm ensureForm(DynamicFormDTO dto); + + /** Finds the form's isLatest=true version, or creates version 1 if none exists yet. */ + FormVersion ensureLatestVersion(DynamicForm form); + + /** Finds a section by sectionUuid within the version, or creates it. */ + FormSection ensureSection(FormVersion version, FormSectionDTO dto); + + /** Finds a question by questionUuid within the section, or creates it (a section's question is "linked" the moment it's created — no separate link step exists). */ + SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto); + + /** Finds an option by optionValue within the question, or creates it. */ + QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto); + + /** Finds a validation by (validationType, validationParam) within the question, or creates it — validations carry no natural key of their own. */ + QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto); + + /** + * Finds a condition by (actionType, target) within the option, or creates it, resolving + * targetQuestionUuid/targetSectionUuid against the given version. Call only once every sibling + * section/question this condition might target already exists as a row. + */ + OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version); + + /** Updates a question's scalar fields only (options/validations are managed by their own endpoints). A displayOrder change moves the question among its siblings, shifting them accordingly. */ + SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto); + + /** Soft-deletes (isActive=false) a question — never hard-deleted; historical responses may reference it. */ + void unlinkQuestion(Long questionId); + + /** Soft-deletes (isActive=false) an option — never hard-deleted; historical responses may reference it. */ + void removeOption(Long optionId); + + // ── Read-only lookups, for FormStructureMigration classes to locate what they're changing ── + + /** Finds the given form's current latest version, if the form exists at all. */ + Optional latestVersion(String formUuid); + + /** Finds a section by sectionUuid within the given version. */ + Optional section(FormVersion version, String sectionUuid); + + /** Finds a question by questionUuid within the given section. */ + Optional question(FormSection section, String questionUuid); + + /** Finds an option by optionValue within the given question. */ + Optional option(SectionQuestion question, String optionValue); +} diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java index c40b65d7b..e61bc5c0a 100644 --- a/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormDefinitionServiceImpl.java @@ -150,7 +150,9 @@ public List getAllForms() { .stream() .map(form -> { try { - return loadLatestFromDb(form.getFormId()); + DynamicFormDTO latest = loadLatestFromDb(form.getFormId()); + latest.setVersions(loadAllVersionsFromDb(form.getFormId())); + return latest; } catch (RuntimeException e) { log.warn("Skipping form {} — no active version: {}", form.getFormId(), e.getMessage()); return null; @@ -266,6 +268,14 @@ private QuestionValidation buildValidationEntity(QuestionValidationDTO dto, // ── LOAD / READ HELPERS ─────────────────────────────────────────────────── + /** Full definition of every active version of the form, oldest first. */ + private List loadAllVersionsFromDb(Long formId) { + return versionRepo.findByDynamicForm_FormIdOrderByVersionNumberAsc(formId).stream() + .filter(version -> Boolean.TRUE.equals(version.getIsActive())) + .map(version -> buildFormDto(version.getDynamicForm(), version)) + .collect(Collectors.toList()); + } + private DynamicFormDTO loadLatestFromDb(Long formId) { FormVersion version = versionRepo.findByDynamicForm_FormIdAndIsLatest(formId, true) .orElseThrow(() -> new RuntimeException("No active version for formId: " + formId)); @@ -280,7 +290,7 @@ private DynamicFormDTO loadLatestFromDb(Long formId) { private DynamicFormDTO buildFormDto(DynamicForm form, FormVersion version) { // Query 1: sections List sections = sectionRepo - .findByFormVersion_VersionIdOrderByDisplayOrderAsc(version.getVersionId()); + .findByFormVersion_VersionIdAndIsActiveTrueOrderByDisplayOrderAsc(version.getVersionId()); if (sections.isEmpty()) { DynamicFormDTO dto = mapper.toDto(form); dto.setVersionNumber(version.getVersionNumber()); diff --git a/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java new file mode 100644 index 000000000..d5de312bd --- /dev/null +++ b/src/main/java/com/iemr/flw/service/impl/DynamicFormReconciliationServiceImpl.java @@ -0,0 +1,324 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.flw.service.impl; + +import com.iemr.flw.domain.iemr.DynamicForm; +import com.iemr.flw.domain.iemr.FormSection; +import com.iemr.flw.domain.iemr.FormVersion; +import com.iemr.flw.domain.iemr.OptionCondition; +import com.iemr.flw.domain.iemr.QuestionOption; +import com.iemr.flw.domain.iemr.QuestionValidation; +import com.iemr.flw.domain.iemr.SectionQuestion; +import com.iemr.flw.dto.iemr.DynamicFormDTO; +import com.iemr.flw.dto.iemr.FormSectionDTO; +import com.iemr.flw.dto.iemr.OptionConditionDTO; +import com.iemr.flw.dto.iemr.QuestionOptionDTO; +import com.iemr.flw.dto.iemr.QuestionValidationDTO; +import com.iemr.flw.dto.iemr.SectionQuestionDTO; +import com.iemr.flw.mapper.DynamicFormMapper; +import com.iemr.flw.repo.iemr.DynamicFormRepo; +import com.iemr.flw.repo.iemr.FormSectionRepo; +import com.iemr.flw.repo.iemr.FormVersionRepo; +import com.iemr.flw.repo.iemr.OptionConditionRepo; +import com.iemr.flw.repo.iemr.QuestionOptionRepo; +import com.iemr.flw.repo.iemr.QuestionValidationRepo; +import com.iemr.flw.repo.iemr.SectionQuestionRepo; +import com.iemr.flw.service.DynamicFormReconciliationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.IntConsumer; +import java.util.function.Supplier; + +/** + * In-place reconciliation for dynamic form structure — see {@link DynamicFormReconciliationService}. + * Standalone: does not depend on {@link DynamicFormDefinitionServiceImpl}'s private helpers, since + * that service's write path (full version clone) is a deliberately different mechanism from this one. + * + * @author Piramal Swasthya + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class DynamicFormReconciliationServiceImpl implements DynamicFormReconciliationService { + + private final DynamicFormRepo formRepo; + private final FormVersionRepo versionRepo; + private final FormSectionRepo sectionRepo; + private final SectionQuestionRepo questionRepo; + private final QuestionOptionRepo optionRepo; + private final QuestionValidationRepo validationRepo; + private final OptionConditionRepo conditionRepo; + private final DynamicFormMapper mapper; + + // ── Form / Version ──────────────────────────────────────────────────────── + + @Override + @Transactional + public DynamicForm ensureForm(DynamicFormDTO dto) { + Optional existing = formRepo.findByFormUuid(dto.getFormUuid()); + if (existing.isPresent()) { + DynamicForm form = existing.get(); + if (Boolean.FALSE.equals(form.getIsActive())) { + form.setIsActive(true); + form = formRepo.save(form); + log.info("Form '{}' was inactive — reactivated.", dto.getFormUuid()); + } + return form; + } + DynamicForm form = mapper.toEntity(dto); + form.setFormId(null); + return formRepo.save(form); + } + + @Override + @Transactional + public FormVersion ensureLatestVersion(DynamicForm form) { + return versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true) + .orElseGet(() -> { + FormVersion version = new FormVersion(); + version.setDynamicForm(form); + version.setVersionNumber(1); + version.setIsLatest(true); + return versionRepo.save(version); + }); + } + + // ── Section / Question / Option / Validation / Condition ───────────────── + + @Override + @Transactional + public FormSection ensureSection(FormVersion version, FormSectionDTO dto) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getSectionUuid()) + .orElseGet(() -> createSection(version, dto)); + } + + @Override + @Transactional + public SectionQuestion ensureQuestion(FormSection section, SectionQuestionDTO dto) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), dto.getQuestionUuid()) + .orElseGet(() -> createQuestion(section, dto)); + } + + @Override + @Transactional + public QuestionOption ensureOption(SectionQuestion question, QuestionOptionDTO dto) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), dto.getOptionValue()) + .orElseGet(() -> createOption(question, dto)); + } + + @Override + @Transactional + public QuestionValidation ensureValidation(SectionQuestion question, QuestionValidationDTO dto) { + return validationRepo.findBySectionQuestion_QuestionIdAndValidationTypeAndValidationParam( + question.getQuestionId(), dto.getValidationType(), dto.getValidationParam()) + .orElseGet(() -> createValidation(question, dto)); + } + + @Override + @Transactional + public OptionCondition ensureCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + Optional existing; + if (dto.getTargetQuestionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetQuestion_QuestionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetQuestionUuid()); + } else if (dto.getTargetSectionUuid() != null) { + existing = conditionRepo.findByQuestionOption_OptionIdAndActionTypeAndTargetSection_SectionUuid( + option.getOptionId(), dto.getActionType(), dto.getTargetSectionUuid()); + } else { + existing = Optional.empty(); + } + return existing.orElseGet(() -> createCondition(option, dto, version)); + } + + // ── Update / Remove ──────────────────────────────────────────────────────── + + @Override + @Transactional + public SectionQuestion updateQuestion(Long questionId, SectionQuestionDTO dto) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setQuestionText(dto.getQuestionText()); + question.setQuestionTextHindi(dto.getQuestionTextHindi()); + question.setQuestionType(dto.getQuestionType()); + question.setIsMandatory(dto.getIsMandatory()); + question.setMaxLength(dto.getMaxLength()); + question.setDefaultValue(dto.getDefaultValue()); + question.setContainsPii(dto.getContainsPii()); + if (dto.getDisplayOrder() != null && !dto.getDisplayOrder().equals(question.getDisplayOrder())) { + moveQuestion(question, dto.getDisplayOrder()); + } + return questionRepo.save(question); + } + + @Override + @Transactional + public void unlinkQuestion(Long questionId) { + SectionQuestion question = questionRepo.findById(questionId) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + questionId)); + question.setIsActive(false); + questionRepo.save(question); + } + + @Override + @Transactional + public void removeOption(Long optionId) { + QuestionOption option = optionRepo.findById(optionId) + .orElseThrow(() -> new NoSuchElementException("Option not found: " + optionId)); + option.setIsActive(false); + optionRepo.save(option); + } + + // ── Read-only lookups ────────────────────────────────────────────────────── + + @Override + public Optional latestVersion(String formUuid) { + return formRepo.findByFormUuid(formUuid) + .flatMap(form -> versionRepo.findByDynamicForm_FormIdAndIsLatest(form.getFormId(), true)); + } + + @Override + public Optional section(FormVersion version, String sectionUuid) { + return sectionRepo.findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), sectionUuid); + } + + @Override + public Optional question(FormSection section, String questionUuid) { + return questionRepo.findByFormSection_SectionIdAndQuestionUuid(section.getSectionId(), questionUuid); + } + + @Override + public Optional option(SectionQuestion question, String optionValue) { + return optionRepo.findBySectionQuestion_QuestionIdAndOptionValue(question.getQuestionId(), optionValue); + } + + // ── Create helpers ───────────────────────────────────────────────────────── + + private FormSection createSection(FormVersion version, FormSectionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> sectionRepo.findTopByFormVersion_VersionIdOrderByDisplayOrderDesc(version.getVersionId()) + .map(FormSection::getDisplayOrder), + from -> sectionRepo.shiftDisplayOrder(version.getVersionId(), from, Integer.MAX_VALUE, 1)); + FormSection section = mapper.toEntity(dto); + section.setSectionId(null); + section.setFormVersion(version); + section.setDisplayOrder(order); + section.setQuestions(new ArrayList<>()); + return sectionRepo.save(section); + } + + private SectionQuestion createQuestion(FormSection section, SectionQuestionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> questionRepo.findTopByFormSection_SectionIdOrderByDisplayOrderDesc(section.getSectionId()) + .map(SectionQuestion::getDisplayOrder), + from -> questionRepo.shiftDisplayOrder(section.getSectionId(), from, Integer.MAX_VALUE, 1)); + SectionQuestion question = mapper.toEntity(dto); + question.setQuestionId(null); + question.setFormSection(section); + question.setDisplayOrder(order); + question.setOptions(new ArrayList<>()); + question.setValidations(new ArrayList<>()); + return questionRepo.save(question); + } + + private QuestionOption createOption(SectionQuestion question, QuestionOptionDTO dto) { + int order = resolveOrder(dto.getDisplayOrder(), + () -> optionRepo.findTopBySectionQuestion_QuestionIdOrderByDisplayOrderDesc(question.getQuestionId()) + .map(QuestionOption::getDisplayOrder), + from -> optionRepo.shiftDisplayOrder(question.getQuestionId(), from, Integer.MAX_VALUE, 1)); + QuestionOption option = mapper.toEntity(dto); + option.setOptionId(null); + option.setSectionQuestion(question); + option.setDisplayOrder(order); + option.setConditions(new ArrayList<>()); + return optionRepo.save(option); + } + + private QuestionValidation createValidation(SectionQuestion question, QuestionValidationDTO dto) { + QuestionValidation validation = mapper.toEntity(dto); + validation.setValidationId(null); + validation.setSectionQuestion(question); + return validationRepo.save(validation); + } + + private OptionCondition createCondition(QuestionOption option, OptionConditionDTO dto, FormVersion version) { + OptionCondition condition = mapper.toEntity(dto); + condition.setConditionId(null); + condition.setQuestionOption(option); + + if (dto.getTargetQuestionUuid() != null) { + condition.setTargetQuestion(questionRepo + .findByFormSection_FormVersion_VersionIdAndQuestionUuid(version.getVersionId(), dto.getTargetQuestionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetQuestionUuid not found in this form version: " + dto.getTargetQuestionUuid()))); + } else if (dto.getTargetQuestionId() != null) { + condition.setTargetQuestion(questionRepo.findById(dto.getTargetQuestionId()) + .orElseThrow(() -> new NoSuchElementException("Question not found: " + dto.getTargetQuestionId()))); + } + + if (dto.getTargetSectionUuid() != null) { + condition.setTargetSection(sectionRepo + .findByFormVersion_VersionIdAndSectionUuid(version.getVersionId(), dto.getTargetSectionUuid()) + .orElseThrow(() -> new NoSuchElementException( + "targetSectionUuid not found in this form version: " + dto.getTargetSectionUuid()))); + } else if (dto.getTargetSectionId() != null) { + condition.setTargetSection(sectionRepo.findById(dto.getTargetSectionId()) + .orElseThrow(() -> new NoSuchElementException("Section not found: " + dto.getTargetSectionId()))); + } + + return conditionRepo.save(condition); + } + + /** + * Omitted displayOrder → append after the current max sibling (no shift needed). + * Explicit displayOrder → push every sibling at or after that position up by one, then use it as-is. + */ + private int resolveOrder(Integer requested, Supplier> currentMaxSupplier, + IntConsumer shiftFromInclusive) { + if (requested == null) { + return currentMaxSupplier.get().orElse(0) + 1; + } + shiftFromInclusive.accept(requested); + return requested; + } + + /** + * Moves a question already at oldPos to newPos within its section, shifting the siblings in + * between to keep displayOrder contiguous — same three-branch logic a full reorder needs + * regardless of direction. + */ + private void moveQuestion(SectionQuestion question, int newPos) { + int oldPos = question.getDisplayOrder(); + Long sectionId = question.getFormSection().getSectionId(); + if (newPos < oldPos) { + questionRepo.shiftDisplayOrder(sectionId, newPos, oldPos - 1, 1); + } else { + questionRepo.shiftDisplayOrder(sectionId, oldPos + 1, newPos, -1); + } + question.setDisplayOrder(newPos); + } +}