_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);
+ }
+}