options) {
+ this.options = options;
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldGroup.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldGroup.java
new file mode 100644
index 00000000000..5dc6cf6db97
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldGroup.java
@@ -0,0 +1,117 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Defines UI groups for customizable fields, scoped to a specific {@link CustomizableFieldContext}.
+ *
+ * Each group has a stable string {@link #key} that is used as:
+ *
+ * - the value stored in the database;
+ * - the Vaadin layout location ID when embedding a {@code CustomizableFieldsGroup} component.
+ *
+ *
+ * To add a new group, append an enum value with the owning context and a unique, stable key.
+ * The key must not be changed after data has been stored against it.
+ */
+public enum CustomizableFieldGroup {
+
+ // ---- CASE groups --------------------------------------------------------
+ CASE_DATA_GENERAL(CustomizableFieldContext.CASE, "caseDataGeneral"),
+ CASE_DATA_CLASSIFICATION(CustomizableFieldContext.CASE, "caseDataClassification"),
+ CASE_DATA_INVESTIGATION(CustomizableFieldContext.CASE, "caseDataInvestigation"),
+ CASE_DATA_IDENTIFIERS(CustomizableFieldContext.CASE, "caseDataIdentifiers"),
+ CASE_DATA_DISEASE(CustomizableFieldContext.CASE, "caseDataDisease"),
+ CASE_DATA_REINFECTION(CustomizableFieldContext.CASE, "caseDataReinfection"),
+ CASE_DATA_OUTCOME(CustomizableFieldContext.CASE, "caseDataOutcome"),
+ CASE_DATA_SEQUELAE(CustomizableFieldContext.CASE, "caseDataSequelae"),
+ CASE_DATA_JURISDICTION(CustomizableFieldContext.CASE, "caseDataJurisdiction"),
+ CASE_DATA_PLACE_OF_STAY(CustomizableFieldContext.CASE, "caseDataPlaceOfStay"),
+ CASE_DATA_QUARANTINE(CustomizableFieldContext.CASE, "caseDataQuarantine"),
+ CASE_DATA_REPORT_GEO(CustomizableFieldContext.CASE, "caseDataReportGeo"),
+ CASE_DATA_HEALTH_CONDITIONS(CustomizableFieldContext.CASE, "caseDataHealthConditions"),
+ CASE_DATA_DIAGNOSTIC(CustomizableFieldContext.CASE, "caseDataDiagnostic"),
+ CASE_DATA_MEDICAL_INFORMATION(CustomizableFieldContext.CASE, "caseDataMedicalInformation"),
+ CASE_DATA_VACCINATION(CustomizableFieldContext.CASE, "caseDataVaccination"),
+ CASE_DATA_CLINICIAN_NOTIFICATION(CustomizableFieldContext.CASE, "caseDataClinicianNotification"),
+ CASE_DATA_CONTACT_TRACING(CustomizableFieldContext.CASE, "caseDataContactTracing"),
+
+ // ---- EPIDATA groups -----------------------------------------------------
+ EPIDATA_EXPOSURE_INVESTIGATION(CustomizableFieldContext.EPIDATA, "exposureInvestigation"),
+ EPIDATA_ACTIVITY_AS_CASE(CustomizableFieldContext.EPIDATA, "activityAsCase"),
+ EPIDATA_CONTACT_WITH_SOURCE_CASE(CustomizableFieldContext.EPIDATA, "contactWithSourceCase"),
+
+ // ---- EXPOSURE groups ----------------------------------------------------
+ EXPOSURE_DETAILS(CustomizableFieldContext.EXPOSURE, "exposureDetails"),
+ EXPOSURES_GENERAL(CustomizableFieldContext.EXPOSURE, "exposuresGeneral"),
+ LOCATION_GENERAL(CustomizableFieldContext.EXPOSURE, "locationGeneral");
+
+ private final CustomizableFieldContext context;
+ /**
+ * Stable key stored in the database and used as the Vaadin layout location ID.
+ */
+ private final String key;
+
+ CustomizableFieldGroup(CustomizableFieldContext context, String key) {
+ this.context = context;
+ this.key = key;
+ }
+
+ /**
+ * The {@link CustomizableFieldContext} this group belongs to.
+ */
+ public CustomizableFieldContext getContext() {
+ return context;
+ }
+
+ /**
+ * Stable string key used as the database-stored value and as the Vaadin layout location ID.
+ */
+ public String getKey() {
+ return key;
+ }
+
+ /**
+ * Returns all groups that belong to the given context.
+ */
+ public static List getGroupsForContext(CustomizableFieldContext context) {
+ List result = new ArrayList<>();
+ for (CustomizableFieldGroup group : values()) {
+ if (group.context == context) {
+ result.add(group);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Looks up a group by its stable {@link #key}, returning {@code null} if not found.
+ */
+ public static CustomizableFieldGroup fromKey(String key) {
+ if (key == null) {
+ return null;
+ }
+ for (CustomizableFieldGroup group : values()) {
+ if (group.key.equals(key)) {
+ return group;
+ }
+ }
+ return null;
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataCriteria.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataCriteria.java
new file mode 100644
index 00000000000..09f4e8fc9a7
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataCriteria.java
@@ -0,0 +1,68 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import de.symeda.sormas.api.utils.IgnoreForUrl;
+import de.symeda.sormas.api.utils.criteria.BaseCriteria;
+
+/**
+ * Criteria for filtering customizable field metadata in admin views.
+ */
+public class CustomizableFieldMetadataCriteria extends BaseCriteria {
+
+ private static final long serialVersionUID = 1L;
+
+ private String freeTextFilter;
+ private CustomizableFieldContext contextClass;
+ private CustomizableFieldType fieldType;
+ private Boolean active;
+
+ public String getFreeTextFilter() {
+ return freeTextFilter;
+ }
+
+ public CustomizableFieldMetadataCriteria freeTextFilter(String freeTextFilter) {
+ this.freeTextFilter = freeTextFilter;
+ return this;
+ }
+
+ @IgnoreForUrl
+ public CustomizableFieldContext getContextClass() {
+ return contextClass;
+ }
+
+ public void setContextClass(CustomizableFieldContext contextClass) {
+ this.contextClass = contextClass;
+ }
+
+ @IgnoreForUrl
+ public CustomizableFieldType getFieldType() {
+ return fieldType;
+ }
+
+ public void setFieldType(CustomizableFieldType fieldType) {
+ this.fieldType = fieldType;
+ }
+
+ @IgnoreForUrl
+ public Boolean getActive() {
+ return active;
+ }
+
+ public void setActive(Boolean active) {
+ this.active = active;
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataDto.java
new file mode 100644
index 00000000000..3015345ff55
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataDto.java
@@ -0,0 +1,201 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.util.Map;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+
+import de.symeda.sormas.api.EntityDto;
+import de.symeda.sormas.api.i18n.Validations;
+import de.symeda.sormas.api.utils.FieldConstraints;
+
+/**
+ * DTO for customizable field metadata.
+ * Contains configuration for a custom field that can be added to entities.
+ */
+@SuppressWarnings({
+ "java:S1845", // suppress sonar field name clash warning
+ "java:S2160" // suppress missing equals handled in EnityDto
+})
+public class CustomizableFieldMetadataDto extends EntityDto {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String I18N_PREFIX = "CustomizableFieldMetadata";
+
+ public static final String NAME = "name";
+ public static final String DESCRIPTION = "description";
+ public static final String FIELD_TYPE = "fieldType";
+ public static final String CONTEXT_CLASS = "contextClass";
+ public static final String UI_GROUP = "uiGroup";
+ public static final String UI_LINE_POSITION = "uiLinePosition";
+ public static final String UI_LINE_WEIGHT = "uiLineWeight";
+ public static final String ACTIVE = "active";
+ public static final String MANDATORY = "mandatory";
+ public static final String READ_ONLY = "readOnly";
+ public static final String DEFAULT_VALUE = "defaultValue";
+ public static final String VISIBILITY_RESTRICTIONS = "visibilityRestrictions";
+ public static final String CUSTOM_PROPERTIES = "customProperties";
+ public static final String TRANSLATIONS = "translations";
+
+ @NotBlank(message = Validations.required)
+ @Size(max = FieldConstraints.CHARACTER_LIMIT_SMALL, message = Validations.textTooLong)
+ private String name;
+
+ @Size(max = FieldConstraints.CHARACTER_LIMIT_DEFAULT, message = Validations.textTooLong)
+ private String description;
+
+ @NotNull(message = Validations.required)
+ private CustomizableFieldType fieldType;
+
+ @NotNull(message = Validations.required)
+ private CustomizableFieldContext contextClass;
+
+ private CustomizableFieldGroup uiGroup;
+
+ private Integer uiLinePosition;
+ private Float uiLineWeight;
+
+ private boolean active = true;
+ private boolean mandatory = false;
+ private boolean readOnly = false;
+
+ @Size(max = FieldConstraints.CHARACTER_LIMIT_DEFAULT)
+ private String defaultValue;
+
+ private CustomizableFieldVisibilityRestrictions visibilityRestrictions;
+ private CustomizableFieldCustomProperties customProperties;
+ private Map> translations;
+
+ public CustomizableFieldMetadataDto() {
+ // Required for deserialization frameworks like Jackson and REST clients
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public CustomizableFieldType getFieldType() {
+ return fieldType;
+ }
+
+ public void setFieldType(CustomizableFieldType fieldType) {
+ this.fieldType = fieldType;
+ }
+
+ public CustomizableFieldContext getContextClass() {
+ return contextClass;
+ }
+
+ public void setContextClass(CustomizableFieldContext contextClass) {
+ this.contextClass = contextClass;
+ }
+
+ public CustomizableFieldGroup getUiGroup() {
+ return uiGroup;
+ }
+
+ public void setUiGroup(CustomizableFieldGroup uiGroup) {
+ this.uiGroup = uiGroup;
+ }
+
+ public Integer getUiLinePosition() {
+ return uiLinePosition;
+ }
+
+ public void setUiLinePosition(Integer uiLinePosition) {
+ this.uiLinePosition = uiLinePosition;
+ }
+
+ public Float getUiLineWeight() {
+ return uiLineWeight;
+ }
+
+ public void setUiLineWeight(Float uiLineWeight) {
+ this.uiLineWeight = uiLineWeight;
+ }
+
+ public boolean isActive() {
+ return active;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ }
+
+ public boolean isMandatory() {
+ return mandatory;
+ }
+
+ public void setMandatory(boolean mandatory) {
+ this.mandatory = mandatory;
+ }
+
+ public boolean isReadOnly() {
+ return readOnly;
+ }
+
+ public void setReadOnly(boolean readOnly) {
+ this.readOnly = readOnly;
+ }
+
+ public String getDefaultValue() {
+ return defaultValue;
+ }
+
+ public void setDefaultValue(String defaultValue) {
+ this.defaultValue = defaultValue;
+ }
+
+ public CustomizableFieldVisibilityRestrictions getVisibilityRestrictions() {
+ return visibilityRestrictions;
+ }
+
+ public void setVisibilityRestrictions(CustomizableFieldVisibilityRestrictions visibilityRestrictions) {
+ this.visibilityRestrictions = visibilityRestrictions;
+ }
+
+ public CustomizableFieldCustomProperties getCustomProperties() {
+ return customProperties;
+ }
+
+ public void setCustomProperties(CustomizableFieldCustomProperties customProperties) {
+ this.customProperties = customProperties;
+ }
+
+ public Map> getTranslations() {
+ return translations;
+ }
+
+ public void setTranslations(Map> translations) {
+ this.translations = translations;
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataFacade.java
new file mode 100644
index 00000000000..375243f85ae
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataFacade.java
@@ -0,0 +1,66 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.util.List;
+
+import javax.ejb.Remote;
+
+import de.symeda.sormas.api.CoreFacade;
+
+/**
+ * Facade interface for managing customizable field metadata.
+ */
+@Remote
+public interface CustomizableFieldMetadataFacade
+ extends
+ CoreFacade {
+
+ /**
+ * Get all active custom fields for a specific context class
+ */
+ List getActiveFieldsForContext(CustomizableFieldContext contextClass);
+
+ /**
+ * Get custom fields grouped in a specific UI group
+ */
+ List getFieldsForUIGroup(CustomizableFieldGroup uiGroup);
+
+ /**
+ * Get custom fields ordered by UI line position
+ */
+ List getFieldsOrderedByUIPosition(CustomizableFieldGroup uiGroup);
+
+ /**
+ * Find field by name within a specific context
+ */
+ CustomizableFieldMetadataDto getByNameAndContext(String name, CustomizableFieldContext contextClass);
+
+ /**
+ * Clone an existing field with a new name
+ */
+ CustomizableFieldMetadataDto cloneField(String sourceUuid, String newName);
+
+ /**
+ * Set the active state of a field
+ */
+ void setFieldActive(String uuid, boolean active);
+
+ /**
+ * Get all field metadata
+ */
+ List getAll();
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataReferenceDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataReferenceDto.java
new file mode 100644
index 00000000000..18bef8f5520
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldMetadataReferenceDto.java
@@ -0,0 +1,27 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import de.symeda.sormas.api.ReferenceDto;
+
+public class CustomizableFieldMetadataReferenceDto extends ReferenceDto {
+
+ private static final long serialVersionUID = 1L;
+
+ public CustomizableFieldMetadataReferenceDto(String uuid) {
+ super(uuid);
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldType.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldType.java
new file mode 100644
index 00000000000..d07c346cd1a
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldType.java
@@ -0,0 +1,41 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import de.symeda.sormas.api.i18n.I18nProperties;
+
+/**
+ * Defines the types of customizable fields that can be created.
+ */
+public enum CustomizableFieldType {
+
+ TEXT,
+ TEXTAREA,
+ NUMBER,
+ DECIMAL,
+ DATE,
+ DATE_TIME,
+ COMBOBOX,
+ CHECKBOX,
+ YES_NO_UNKNOWN,
+ CHECKBOX_LIST,
+ RADIO_BUTTON_LIST;
+
+ @Override
+ public String toString() {
+ return I18nProperties.getEnumCaption(this);
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueCriteria.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueCriteria.java
new file mode 100644
index 00000000000..29c9de85b6b
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueCriteria.java
@@ -0,0 +1,23 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import de.symeda.sormas.api.utils.criteria.BaseCriteria;
+
+public class CustomizableFieldValueCriteria extends BaseCriteria {
+
+ private static final long serialVersionUID = 1L;
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueDto.java
new file mode 100644
index 00000000000..f2d2d33f6b5
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueDto.java
@@ -0,0 +1,290 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.symeda.sormas.api.EntityDto;
+import de.symeda.sormas.api.i18n.Validations;
+import de.symeda.sormas.api.utils.YesNoUnknown;
+
+/**
+ * DTO for customizable field values.
+ * Stores the actual value for a custom field for a specific entity instance.
+ */
+public class CustomizableFieldValueDto extends EntityDto {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Shared mapper for {@link #getValueAsStringSet()} / {@link #setValueAsStringSet(Set)}. */
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ public static final String I18N_PREFIX = "CustomizableFieldValue";
+
+ public static final String CUSTOMIZABLE_FIELD_METADATA_UUID = "customizableFieldMetadataUuid";
+ public static final String ENTITY_UUID = "entityUuid";
+ public static final String CONTEXT_CLASS = "contextClass";
+ public static final String VALUE = "value";
+
+ @NotBlank(message = Validations.required)
+ private String customizableFieldMetadataUuid;
+
+ @NotBlank(message = Validations.required)
+ private String entityUuid;
+
+ @NotNull(message = Validations.required)
+ private CustomizableFieldContext contextClass;
+
+ private String value;
+
+ public CustomizableFieldValueDto() {
+ }
+
+ public String getCustomizableFieldMetadataUuid() {
+ return customizableFieldMetadataUuid;
+ }
+
+ public void setCustomizableFieldMetadataUuid(String customizableFieldMetadataUuid) {
+ this.customizableFieldMetadataUuid = customizableFieldMetadataUuid;
+ }
+
+ public String getEntityUuid() {
+ return entityUuid;
+ }
+
+ public void setEntityUuid(String entityUuid) {
+ this.entityUuid = entityUuid;
+ }
+
+ public CustomizableFieldContext getContextClass() {
+ return contextClass;
+ }
+
+ public void setContextClass(CustomizableFieldContext contextClass) {
+ this.contextClass = contextClass;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+
+ // -------------------------------------------------------------------------
+ // Typed accessors – parse from the raw ISO string stored in {@link #value}.
+ // Setters serialise back to the canonical ISO representation.
+ // All getters return {@code null} when the value is absent or unparseable.
+ // -------------------------------------------------------------------------
+
+ /**
+ * Parses {@link #value} as an ISO date ({@code yyyy-MM-dd}).
+ *
+ * @return the date, or {@code null} if the value is absent or not a valid ISO date
+ */
+ public LocalDate getValueAsDate() {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return LocalDate.parse(value);
+ } catch (DateTimeParseException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Stores a {@link LocalDate} as an ISO date string ({@code yyyy-MM-dd}).
+ * Passing {@code null} clears the value.
+ */
+ public void setValueAsDate(LocalDate date) {
+ this.value = date != null ? date.toString() : null;
+ }
+
+ /**
+ * Parses {@link #value} as an ISO date-time ({@code yyyy-MM-ddTHH:mm} or longer).
+ *
+ * @return the date-time, or {@code null} if the value is absent or not a valid ISO date-time
+ */
+ public LocalDateTime getValueAsDateTime() {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return LocalDateTime.parse(value);
+ } catch (DateTimeParseException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Stores a {@link LocalDateTime} as an ISO date-time string, truncated to minutes
+ * ({@code yyyy-MM-ddTHH:mm}).
+ * Passing {@code null} clears the value.
+ */
+ public void setValueAsDateTime(LocalDateTime dateTime) {
+ this.value = dateTime != null ? dateTime.withSecond(0).withNano(0).toString() : null;
+ }
+
+ /**
+ * Parses {@link #value} as a whole number.
+ *
+ * @return the integer value, or {@code null} if the value is absent or not a valid integer
+ */
+ public Integer getValueAsInteger() {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Stores an {@link Integer} as a plain decimal string.
+ * Passing {@code null} clears the value.
+ */
+ public void setValueAsInteger(Integer number) {
+ this.value = number != null ? number.toString() : null;
+ }
+
+ /**
+ * Parses {@link #value} as a decimal number.
+ *
+ * @return the decimal value, or {@code null} if the value is absent or not a valid number
+ */
+ public BigDecimal getValueAsDecimal() {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return new BigDecimal(value.trim().replace(',', '.'));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Stores a {@link BigDecimal} as a plain decimal string.
+ * Passing {@code null} clears the value.
+ */
+ public void setValueAsDecimal(BigDecimal decimal) {
+ this.value = decimal != null ? decimal.toPlainString() : null;
+ }
+
+ /**
+ * Parses {@link #value} as a boolean ({@code "true"} / {@code "false"}, case-insensitive).
+ *
+ * @return {@link Boolean#TRUE} if the stored value equals {@code "true"} (ignoring case),
+ * {@link Boolean#FALSE} if it equals {@code "false"} (ignoring case),
+ * or {@code null} if the value is absent or does not match either token
+ */
+ public Boolean getValueAsBoolean() {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ if (Boolean.TRUE.toString().equalsIgnoreCase(value.trim())) {
+ return Boolean.TRUE;
+ }
+ if (Boolean.FALSE.toString().equalsIgnoreCase(value.trim())) {
+ return Boolean.FALSE;
+ }
+ return null;
+ }
+
+ /**
+ * Stores a {@link Boolean} as {@code "true"} or {@code "false"}.
+ * Passing {@code null} clears the value.
+ */
+ public void setValueAsBoolean(Boolean bool) {
+ this.value = bool != null ? bool.toString() : null;
+ }
+
+ /**
+ * Parses {@link #value} as a {@link YesNoUnknown} enum constant (case-insensitive name).
+ *
+ * @return the matching constant, or {@code null} if the value is absent or not a valid name
+ */
+ public YesNoUnknown getValueAsYesNoUnknown() {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return YesNoUnknown.valueOf(value.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Stores a {@link YesNoUnknown} constant as its {@link Enum#name()} ({@code "YES"}, {@code "NO"} or {@code "UNKNOWN"}).
+ * Passing {@code null} clears the value.
+ */
+ public void setValueAsYesNoUnknown(YesNoUnknown yesNoUnknown) {
+ this.value = yesNoUnknown != null ? yesNoUnknown.name() : null;
+ }
+
+ /**
+ * Parses {@link #value} as a JSON array of strings, as used by
+ * {@link de.symeda.sormas.api.customizablefield.CustomizableFieldType#CHECKBOX_LIST}.
+ *
+ * @return a mutable {@link LinkedHashSet} of the stored strings (preserving insertion order),
+ * or an empty set if the value is absent or cannot be parsed
+ */
+ public Set getValueAsStringSet() {
+ if (value == null || value.isEmpty()) {
+ return new LinkedHashSet<>();
+ }
+ try {
+ return MAPPER.readValue(value, new TypeReference>() {
+ });
+ } catch (IOException e) {
+ return new LinkedHashSet<>();
+ }
+ }
+
+ /**
+ * Serialises a set of strings to a JSON array and stores it in {@link #value}.
+ * Passing {@code null} or an empty set clears the value.
+ */
+ public void setValueAsStringSet(Set set) {
+ if (set == null || set.isEmpty()) {
+ this.value = null;
+ return;
+ }
+ try {
+ this.value = MAPPER.writeValueAsString(set);
+ } catch (JsonProcessingException e) {
+ this.value = null;
+ }
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueFacade.java
new file mode 100644
index 00000000000..3b411c1c0b0
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueFacade.java
@@ -0,0 +1,62 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.util.List;
+import java.util.Map;
+
+import javax.ejb.Remote;
+
+import de.symeda.sormas.api.CoreFacade;
+
+/**
+ * Facade interface for managing customizable field values.
+ */
+@Remote
+public interface CustomizableFieldValueFacade
+ extends CoreFacade {
+
+ /**
+ * Load all custom field values for a specific entity.
+ * Returns a map of field metadata DTO -> field value DTO.
+ */
+ Map getValuesForEntity(String entityUuid, CustomizableFieldContext contextClass);
+
+ /**
+ * Save all custom field values for an entity in a single context.
+ * fieldValues: map of field metadata DTO -> value DTO (the raw string is read from {@link CustomizableFieldValueDto#getValue()})
+ */
+ void saveEntityCustomFields(
+ String entityUuid,
+ CustomizableFieldContext contextClass,
+ Map fieldValues);
+
+ /**
+ * Save all custom field values for an entity across multiple contexts in one call.
+ * fieldsByContext: map of context -> (field metadata DTO -> value DTO)
+ */
+ void saveEntityCustomFields(String entityUuid, Map> fieldsByContext);
+
+ /**
+ * Delete all custom field values for an entity
+ */
+ void deleteValuesForEntity(String entityUuid, CustomizableFieldContext contextClass);
+
+ /**
+ * Get all field values
+ */
+ List getAll();
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueReferenceDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueReferenceDto.java
new file mode 100644
index 00000000000..740a11a4e36
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldValueReferenceDto.java
@@ -0,0 +1,27 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import de.symeda.sormas.api.ReferenceDto;
+
+public class CustomizableFieldValueReferenceDto extends ReferenceDto {
+
+ private static final long serialVersionUID = 1L;
+
+ public CustomizableFieldValueReferenceDto(String uuid) {
+ super(uuid);
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityContext.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityContext.java
new file mode 100644
index 00000000000..95a175a966c
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityContext.java
@@ -0,0 +1,82 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.io.Serializable;
+import java.util.Objects;
+import java.util.Optional;
+
+import de.symeda.sormas.api.Disease;
+
+/**
+ * Runtime context against which {@link CustomizableFieldVisibilityRestrictions} are evaluated.
+ *
+ * Where {@link CustomizableFieldVisibilityRestrictions} represents the stored what should
+ * restrict this field configuration, this class carries the current runtime values
+ * (e.g. the disease of the case being viewed) that are tested against those restrictions.
+ *
+ * New criteria can be added as additional fields without changing the evaluation contract;
+ * {@link CustomizableFieldVisibilityRestrictions#matches(CustomizableFieldVisibilityContext)}
+ * handles all criteria in one place.
+ *
+ * Builder-style fluent setters allow convenient one-liner construction:
+ *
+ *
+ *
+ * CustomizableFieldVisibilityContext ctx = new CustomizableFieldVisibilityContext().withDisease(caze.getDisease());
+ *
+ */
+public class CustomizableFieldVisibilityContext implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private Disease disease;
+
+ public CustomizableFieldVisibilityContext() {
+ // no-arg constructor required by deserialization frameworks
+ }
+
+ public Optional getDisease() {
+ return Optional.ofNullable(disease);
+ }
+
+ public void setDisease(Disease disease) {
+ this.disease = disease;
+ }
+
+ public CustomizableFieldVisibilityContext withDisease(Disease disease) {
+ this.disease = disease;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CustomizableFieldVisibilityContext that = (CustomizableFieldVisibilityContext) o;
+ return Objects.equals(disease, that.disease);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(disease);
+ }
+
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityRestrictions.java b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityRestrictions.java
new file mode 100644
index 00000000000..2c14c489989
--- /dev/null
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/customizablefield/CustomizableFieldVisibilityRestrictions.java
@@ -0,0 +1,76 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.api.customizablefield;
+
+import java.io.Serializable;
+import java.util.List;
+
+import org.apache.commons.collections4.CollectionUtils;
+
+import de.symeda.sormas.api.Disease;
+
+/**
+ * Typed representation of the {@code visibilityRestrictions} JSON column on
+ * {@link CustomizableFieldMetadataDto}.
+ *
+ * Controls when a customizable field is visible to the user:
+ *
+ * - diseases – when set, the field is only visible for cases/entities
+ * associated with one of the listed {@link Disease} values. When {@code null}
+ * or empty, the field is visible regardless of disease.
+ *
+ * Additional visibility criteria can be added here without touching the
+ * database schema (the whole object is stored as a single {@code jsonb} column).
+ */
+public class CustomizableFieldVisibilityRestrictions implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * The diseases for which this field should be visible.
+ * When {@code null} or empty, the field is visible for all diseases.
+ */
+ private List diseases;
+
+ public CustomizableFieldVisibilityRestrictions() {
+ // Required for JSON deserialization.
+ }
+
+ public List getDiseases() {
+ return diseases;
+ }
+
+ public void setDiseases(List diseases) {
+ this.diseases = diseases;
+ }
+
+ /**
+ * Returns {@code true} when the given runtime context satisfies all restrictions defined
+ * on this object. A restriction criterion is considered satisfied when either:
+ *
+ * - the restriction list for that criterion is {@code null} or empty (= no restriction), or
+ * - the context's value for that criterion is contained in the restriction list.
+ *
+ *
+ * @param context
+ * the runtime context to evaluate; {@code null} is treated as an empty context
+ * (all criteria with non-empty restriction lists will fail)
+ * @return {@code true} if the field should be visible given this context
+ */
+ public boolean matches(CustomizableFieldVisibilityContext context) {
+ return CollectionUtils.isEmpty(diseases) || (context != null && context.getDisease().filter(diseases::contains).isPresent());
+ }
+}
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java
index 1efeef0a8a1..9b238b2e334 100644
--- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java
@@ -53,6 +53,7 @@ public interface Captions {
String actionCancel = "actionCancel";
String actionClear = "actionClear";
String actionClearAll = "actionClearAll";
+ String actionClone = "actionClone";
String actionClose = "actionClose";
String actionCompare = "actionCompare";
String actionConfirm = "actionConfirm";
@@ -1214,6 +1215,46 @@ public interface Captions {
String customizableEnumValueDiseaseCount = "customizableEnumValueDiseaseCount";
String customizableEnumValueInactiveValues = "customizableEnumValueInactiveValues";
String customizableEnumValueNoProperties = "customizableEnumValueNoProperties";
+ String CustomizableFieldContext_CASE = "CustomizableFieldContext.CASE";
+ String CustomizableFieldContext_EPIDATA = "CustomizableFieldContext.EPIDATA";
+ String CustomizableFieldGroup_CASE_DATA_CLASSIFICATION = "CustomizableFieldGroup.CASE_DATA_CLASSIFICATION";
+ String CustomizableFieldGroup_CASE_DATA_CLINICIAN_NOTIFICATION = "CustomizableFieldGroup.CASE_DATA_CLINICIAN_NOTIFICATION";
+ String CustomizableFieldGroup_CASE_DATA_CONTACT_TRACING = "CustomizableFieldGroup.CASE_DATA_CONTACT_TRACING";
+ String CustomizableFieldGroup_CASE_DATA_DIAGNOSTIC = "CustomizableFieldGroup.CASE_DATA_DIAGNOSTIC";
+ String CustomizableFieldGroup_CASE_DATA_DISEASE = "CustomizableFieldGroup.CASE_DATA_DISEASE";
+ String CustomizableFieldGroup_CASE_DATA_GENERAL = "CustomizableFieldGroup.CASE_DATA_GENERAL";
+ String CustomizableFieldGroup_CASE_DATA_HEALTH_CONDITIONS = "CustomizableFieldGroup.CASE_DATA_HEALTH_CONDITIONS";
+ String CustomizableFieldGroup_CASE_DATA_IDENTIFIERS = "CustomizableFieldGroup.CASE_DATA_IDENTIFIERS";
+ String CustomizableFieldGroup_CASE_DATA_INVESTIGATION = "CustomizableFieldGroup.CASE_DATA_INVESTIGATION";
+ String CustomizableFieldGroup_CASE_DATA_JURISDICTION = "CustomizableFieldGroup.CASE_DATA_JURISDICTION";
+ String CustomizableFieldGroup_CASE_DATA_MEDICAL_INFORMATION = "CustomizableFieldGroup.CASE_DATA_MEDICAL_INFORMATION";
+ String CustomizableFieldGroup_CASE_DATA_OUTCOME = "CustomizableFieldGroup.CASE_DATA_OUTCOME";
+ String CustomizableFieldGroup_CASE_DATA_PLACE_OF_STAY = "CustomizableFieldGroup.CASE_DATA_PLACE_OF_STAY";
+ String CustomizableFieldGroup_CASE_DATA_QUARANTINE = "CustomizableFieldGroup.CASE_DATA_QUARANTINE";
+ String CustomizableFieldGroup_CASE_DATA_REINFECTION = "CustomizableFieldGroup.CASE_DATA_REINFECTION";
+ String CustomizableFieldGroup_CASE_DATA_REPORT_GEO = "CustomizableFieldGroup.CASE_DATA_REPORT_GEO";
+ String CustomizableFieldGroup_CASE_DATA_SEQUELAE = "CustomizableFieldGroup.CASE_DATA_SEQUELAE";
+ String CustomizableFieldGroup_CASE_DATA_VACCINATION = "CustomizableFieldGroup.CASE_DATA_VACCINATION";
+ String CustomizableFieldGroup_EPIDATA_ACTIVITY_AS_CASE = "CustomizableFieldGroup.EPIDATA_ACTIVITY_AS_CASE";
+ String CustomizableFieldGroup_EPIDATA_CONTACT_WITH_SOURCE_CASE = "CustomizableFieldGroup.EPIDATA_CONTACT_WITH_SOURCE_CASE";
+ String CustomizableFieldGroup_EPIDATA_EXPOSURE_INVESTIGATION = "CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION";
+ String CustomizableFieldMetadata_active = "CustomizableFieldMetadata.active";
+ String CustomizableFieldMetadata_contextClass = "CustomizableFieldMetadata.contextClass";
+ String CustomizableFieldMetadata_defaultValue = "CustomizableFieldMetadata.defaultValue";
+ String CustomizableFieldMetadata_description = "CustomizableFieldMetadata.description";
+ String CustomizableFieldMetadata_fieldType = "CustomizableFieldMetadata.fieldType";
+ String CustomizableFieldMetadata_mandatory = "CustomizableFieldMetadata.mandatory";
+ String CustomizableFieldMetadata_name = "CustomizableFieldMetadata.name";
+ String CustomizableFieldMetadata_readOnly = "CustomizableFieldMetadata.readOnly";
+ String CustomizableFieldMetadata_uiGroup = "CustomizableFieldMetadata.uiGroup";
+ String CustomizableFieldMetadata_uiLinePosition = "CustomizableFieldMetadata.uiLinePosition";
+ String CustomizableFieldMetadata_uiLineWeight = "CustomizableFieldMetadata.uiLineWeight";
+ String CustomizableFieldMetadata_visibilityRestrictions = "CustomizableFieldMetadata.visibilityRestrictions";
+ String CustomizableFieldMetadata_visibilityRestrictionsDiseases = "CustomizableFieldMetadata.visibilityRestrictionsDiseases";
+ String customizableFieldsActiveOnly = "customizableFieldsActiveOnly";
+ String customizableFieldsAllActive = "customizableFieldsAllActive";
+ String customizableFieldsInactiveOnly = "customizableFieldsInactiveOnly";
+ String customizableFieldsNewLabel = "customizableFieldsNewLabel";
String dashboardAggregatedNumber = "dashboardAggregatedNumber";
String dashboardAlive = "dashboardAlive";
String dashboardApplyCustomFilter = "dashboardApplyCustomFilter";
@@ -1841,6 +1882,7 @@ public interface Captions {
String Exposure_animalVaccinated = "Exposure.animalVaccinated";
String Exposure_bodyOfWater = "Exposure.bodyOfWater";
String Exposure_childcareFacilityDetails = "Exposure.childcareFacilityDetails";
+ String Exposure_conditionOfAnimal = "Exposure.conditionOfAnimal";
String Exposure_connectionNumber = "Exposure.connectionNumber";
String Exposure_contactFactorDetails = "Exposure.contactFactorDetails";
String Exposure_contactFactors = "Exposure.contactFactors";
@@ -3490,6 +3532,8 @@ public interface Captions {
String View_configuration_countries_short = "View.configuration.countries.short";
String View_configuration_customizableEnums = "View.configuration.customizableEnums";
String View_configuration_customizableEnums_short = "View.configuration.customizableEnums.short";
+ String View_configuration_customizableFields = "View.configuration.customizableFields";
+ String View_configuration_customizableFields_short = "View.configuration.customizableFields.short";
String View_configuration_devMode = "View.configuration.devMode";
String View_configuration_devMode_short = "View.configuration.devMode.short";
String View_configuration_diseaseconfiguration = "View.configuration.diseaseconfiguration";
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java
index 2d7df0d4e69..ebc166eab95 100644
--- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java
@@ -506,6 +506,7 @@ public interface Strings {
String headingClinicalMeasurements = "headingClinicalMeasurements";
String headingClinicalPresentation = "headingClinicalPresentation";
String headingClinicalVisitsDeleted = "headingClinicalVisitsDeleted";
+ String headingCloneCustomizableField = "headingCloneCustomizableField";
String headingClusterType = "headingClusterType";
String headingComparisonCase = "headingComparisonCase";
String headingCompleteness = "headingCompleteness";
@@ -514,6 +515,7 @@ public interface Strings {
String headingConfirmBulkGrantSpecialAccess = "headingConfirmBulkGrantSpecialAccess";
String headingConfirmChoice = "headingConfirmChoice";
String headingConfirmDearchiving = "headingConfirmDearchiving";
+ String headingConfirmDeleteCustomizableField = "headingConfirmDeleteCustomizableField";
String headingConfirmDeletion = "headingConfirmDeletion";
String headingConfirmDisabling = "headingConfirmDisabling";
String headingConfirmEnabling = "headingConfirmEnabling";
@@ -546,6 +548,7 @@ public interface Strings {
String headingCorrectSample = "headingCorrectSample";
String headingCreateAdditionalTest = "headingCreateAdditionalTest";
String headingCreateCampaignDataForm = "headingCreateCampaignDataForm";
+ String headingCreateCustomizableField = "headingCreateCustomizableField";
String headingCreateEntry = "headingCreateEntry";
String headingCreateNewAction = "headingCreateNewAction";
String headingCreateNewAggregateReport = "headingCreateNewAggregateReport";
@@ -579,6 +582,11 @@ public interface Strings {
String headingCreateSurveillanceReport = "headingCreateSurveillanceReport";
String headingCurrentHospitalization = "headingCurrentHospitalization";
String headingCustomizableEnumConfigurationInfo = "headingCustomizableEnumConfigurationInfo";
+ String headingCustomizableFieldBasics = "headingCustomizableFieldBasics";
+ String headingCustomizableFieldBehavior = "headingCustomizableFieldBehavior";
+ String headingCustomizableFieldPlacement = "headingCustomizableFieldPlacement";
+ String headingCustomizableFieldTranslations = "headingCustomizableFieldTranslations";
+ String headingCustomizableFieldVisibility = "headingCustomizableFieldVisibility";
String headingDatabaseExportFailed = "headingDatabaseExportFailed";
String headingDataImport = "headingDataImport";
String headingDearchiveAdverseEvent = "headingDearchiveAdverseEvent";
@@ -620,6 +628,7 @@ public interface Strings {
String headingEditContacts = "headingEditContacts";
String headingEditContinent = "headingEditContinent";
String headingEditCountry = "headingEditCountry";
+ String headingEditCustomizableField = "headingEditCustomizableField";
String headingEditEventParticipant = "headingEditEventParticipant";
String headingEditEvents = "headingEditEvents";
String headingEditLineListing = "headingEditLineListing";
@@ -1086,6 +1095,8 @@ public interface Strings {
String infoNoAefiInvestigations = "infoNoAefiInvestigations";
String infoNoCasesFoundStatistics = "infoNoCasesFoundStatistics";
String infoNoCustomizableEnumTranslations = "infoNoCustomizableEnumTranslations";
+ String infoNoCustomizableFieldOptions = "infoNoCustomizableFieldOptions";
+ String infoNoCustomizableFieldTranslations = "infoNoCustomizableFieldTranslations";
String infoNoDiseaseConfigurationAgeGroups = "infoNoDiseaseConfigurationAgeGroups";
String infoNoDiseaseSelected = "infoNoDiseaseSelected";
String infoNoEnvironmentSamples = "infoNoEnvironmentSamples";
@@ -1175,6 +1186,7 @@ public interface Strings {
String infoVaccinationDoseCount = "infoVaccinationDoseCount";
String infoWeeklyReportsView = "infoWeeklyReportsView";
String labelActualLongSeed = "labelActualLongSeed";
+ String labelCustomizableFieldOptions = "labelCustomizableFieldOptions";
String labelNoVaccinationDate = "labelNoVaccinationDate";
String labelNoVaccineName = "labelNoVaccineName";
String labelNumberOfAreas = "labelNumberOfAreas";
@@ -1383,6 +1395,10 @@ public interface Strings {
String messageCountVisitsNotSetToLostAccessDeniedReason = "messageCountVisitsNotSetToLostAccessDeniedReason";
String messageCreateCollectionTask = "messageCreateCollectionTask";
String messageCustomizableEnumValueSaved = "messageCustomizableEnumValueSaved";
+ String messageCustomizableFieldCloned = "messageCustomizableFieldCloned";
+ String messageCustomizableFieldCreated = "messageCustomizableFieldCreated";
+ String messageCustomizableFieldDeleted = "messageCustomizableFieldDeleted";
+ String messageCustomizableFieldSaved = "messageCustomizableFieldSaved";
String messageDatabaseExportFailed = "messageDatabaseExportFailed";
String messageDeleteImmunizationVaccinations = "messageDeleteImmunizationVaccinations";
String messageDeleteReasonNotFilled = "messageDeleteReasonNotFilled";
@@ -1797,6 +1813,7 @@ public interface Strings {
String promptCasesEpiWeekTo = "promptCasesEpiWeekTo";
String promptCaseSex = "promptCaseSex";
String promptCasesSearchField = "promptCasesSearchField";
+ String promptConfirmDeleteCustomizableField = "promptConfirmDeleteCustomizableField";
String promptContactDateFrom = "promptContactDateFrom";
String promptContactDateTo = "promptContactDateTo";
String promptContactDateType = "promptContactDateType";
@@ -1807,6 +1824,8 @@ public interface Strings {
String promptCustomizableEnumSearchField = "promptCustomizableEnumSearchField";
String promptCustomizableEnumTranslationCaption = "promptCustomizableEnumTranslationCaption";
String promptCustomizableEnumTranslationLanguage = "promptCustomizableEnumTranslationLanguage";
+ String promptCustomizableFieldOption = "promptCustomizableFieldOption";
+ String promptCustomizableFieldSearchField = "promptCustomizableFieldSearchField";
String promptDateTo = "promptDateTo";
String promptDisease = "promptDisease";
String promptDiseaseConfigurationAgeFrom = "promptDiseaseConfigurationAgeFrom";
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java b/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java
index 69dcc8310c8..bd934480758 100644
--- a/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/importexport/DatabaseTable.java
@@ -105,6 +105,8 @@ public enum DatabaseTable {
FACILITIES(DatabaseTableType.INFRASTRUCTURE, "facilities", null),
POINTS_OF_ENTRY(DatabaseTableType.INFRASTRUCTURE, "points_of_entry", null),
CUSTOMIZABLE_ENUM_VALUES(DatabaseTableType.CONFIGURATION, "customizable_enum_values", null),
+ CUSTOMIZABLE_FIELD_METADATA(DatabaseTableType.CONFIGURATION, "customizable_field_metadata", null),
+ CUSTOMIZABLE_FIELD_VALUE(DatabaseTableType.CONFIGURATION, CUSTOMIZABLE_FIELD_METADATA, "customizable_field_value"),
CAMPAIGNS(DatabaseTableType.SORMAS, "campaigns", dependingOnFeature(FeatureType.CAMPAIGNS)),
CAMPAIGN_CAMPAIGNFORMMETA(DatabaseTableType.SORMAS, CAMPAIGNS, "campaign_campaignformmeta"),
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java b/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java
index dfe51b3d861..2951456bcb2 100644
--- a/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java
@@ -484,6 +484,7 @@ public Set getDefaultUserRights() {
SORMAS_UI,
DEV_MODE,
CUSTOMIZABLE_ENUM_MANAGEMENT,
+ CUSTOMIZABLE_FIELD_MANAGEMENT,
SYSTEM_CONFIGURATION));
break;
case ADMIN_SUPERVISOR:
diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java b/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java
index d86c6fc3ebd..e2ab2daf826 100644
--- a/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java
+++ b/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java
@@ -321,6 +321,7 @@ public enum UserRight {
EXTERNAL_EMAIL_SEND(UserRightGroup.EXTERNAL_EMAILS),
EXTERNAL_EMAIL_ATTACH_DOCUMENTS(UserRightGroup.EXTERNAL_EMAILS, UserRight._EXTERNAL_EMAIL_SEND),
CUSTOMIZABLE_ENUM_MANAGEMENT(UserRightGroup.CONFIGURATION),
+ CUSTOMIZABLE_FIELD_MANAGEMENT(UserRightGroup.CONFIGURATION),
SYSTEM_CONFIGURATION(UserRightGroup.CONFIGURATION),
DISEASE_MANAGEMENT(UserRightGroup.CONFIGURATION),
EPIPULSE_EXPORT_VIEW(UserRightGroup.EPIPULSE),
@@ -572,6 +573,7 @@ public enum UserRight {
public static final String _EXTERNAL_EMAIL_SEND = "EXTERNAL_EMAIL_SEND";
public static final String _EXTERNAL_EMAIL_ATTACH_DOCUMENTS = "EXTERNAL_EMAIL_ATTACH_DOCUMENTS";
public static final String _CUSTOMIZABLE_ENUM_MANAGEMENT = "CUSTOMIZABLE_ENUM_MANAGEMENT";
+ public static final String _CUSTOMIZABLE_FIELD_MANAGEMENT = "CUSTOMIZABLE_FIELD_MANAGEMENT";
public static final String _SYSTEM_CONFIGURATION = "SYSTEM_CONFIGURATION";
public static final String _DISEASE_MANAGEMENT = "DISEASE_MANAGEMENT";
diff --git a/sormas-api/src/main/resources/captions.properties b/sormas-api/src/main/resources/captions.properties
index f9ed2b2e7a3..0477e6e5986 100644
--- a/sormas-api/src/main/resources/captions.properties
+++ b/sormas-api/src/main/resources/captions.properties
@@ -144,6 +144,7 @@ actionImportAllSubcontinents=Import default subcontinents
actionLogout=Logout
actionNewEntry=New entry
actionOkay=Okay
+actionClone=Clone
actionConfirmFilters=Confirm filters
actionResetFilters=Reset filters
actionApplyFilters=Apply filters
@@ -3334,6 +3335,8 @@ View.configuration.emailTemplates=Email Templates
View.configuration.emailTemplates.short=Email Templates
View.configuration.customizableEnums=Customizable Enum Configuration
View.configuration.customizableEnums.short=Customizable Enums
+View.configuration.customizableFields=Customizable Fields
+View.configuration.customizableFields.short=Customizable Fields
View.configuration.diseaseconfiguration=Disease Configuration
View.configuration.diseaseconfiguration.short=Disease Configuration
View.contacts=Contact Directory
@@ -3709,6 +3712,52 @@ SystemConfigurationValue.pattern = Pattern
SystemConfigurationValue.value = Value
SystemConfigurationValue.General = General
+# CustomizableFieldMetadata
+CustomizableFieldMetadata.name = Internal Name
+CustomizableFieldMetadata.description = Description
+CustomizableFieldMetadata.fieldType = Field Type
+CustomizableFieldMetadata.contextClass = Context / Entity
+CustomizableFieldMetadata.uiGroup = UI Group
+CustomizableFieldMetadata.uiLinePosition = Line Position
+CustomizableFieldMetadata.uiLineWeight = Line Weight
+CustomizableFieldMetadata.active = Active
+CustomizableFieldMetadata.mandatory = Mandatory
+CustomizableFieldMetadata.readOnly = Read Only
+CustomizableFieldMetadata.defaultValue = Default Value
+CustomizableFieldMetadata.visibilityRestrictions = Visibility Restrictions
+CustomizableFieldMetadata.visibilityRestrictionsDiseases = Visible for Diseases
+customizableFieldsAllActive = All
+customizableFieldsActiveOnly = Active only
+customizableFieldsInactiveOnly = Inactive only
+customizableFieldsNewLabel = New name for clone
+
+# CustomizableFieldGroup
+CustomizableFieldGroup.CASE_DATA_GENERAL = Case Data General
+CustomizableFieldGroup.CASE_DATA_CLASSIFICATION = Case Data Classification
+CustomizableFieldGroup.CASE_DATA_INVESTIGATION = Case Data Investigation
+CustomizableFieldGroup.CASE_DATA_IDENTIFIERS = Case Data Identifiers
+CustomizableFieldGroup.CASE_DATA_DISEASE = Case Data Disease
+CustomizableFieldGroup.CASE_DATA_REINFECTION = Case Data Reinfection
+CustomizableFieldGroup.CASE_DATA_OUTCOME = Case Data Outcome
+CustomizableFieldGroup.CASE_DATA_SEQUELAE = Case Data Sequelae
+CustomizableFieldGroup.CASE_DATA_JURISDICTION = Case Data Jurisdiction
+CustomizableFieldGroup.CASE_DATA_PLACE_OF_STAY = Case Data Place Of Stay
+CustomizableFieldGroup.CASE_DATA_QUARANTINE = Case Data Quarantine
+CustomizableFieldGroup.CASE_DATA_REPORT_GEO = Case Data Report Geo
+CustomizableFieldGroup.CASE_DATA_HEALTH_CONDITIONS = Case Data Health Conditions
+CustomizableFieldGroup.CASE_DATA_DIAGNOSTIC = Case Data Diagnostic
+CustomizableFieldGroup.CASE_DATA_MEDICAL_INFORMATION = Case Data Medical Information
+CustomizableFieldGroup.CASE_DATA_VACCINATION = Case Data Vaccination
+CustomizableFieldGroup.CASE_DATA_CLINICIAN_NOTIFICATION = Case Data Clinician Notification
+CustomizableFieldGroup.CASE_DATA_CONTACT_TRACING = Case Data Contact Tracing
+CustomizableFieldGroup.EPIDATA_EXPOSURE_INVESTIGATION = Exposure Investigation
+CustomizableFieldGroup.EPIDATA_ACTIVITY_AS_CASE = Activity as Case
+CustomizableFieldGroup.EPIDATA_CONTACT_WITH_SOURCE_CASE = Contact with Source Case
+
+# CustomizableFieldContext
+CustomizableFieldContext.CASE = Case
+CustomizableFieldContext.EPIDATA = Epidemiological Data
+
# Notifier
Notifier.notification = Notification
Notification.dateOfNotification = Date of notification
diff --git a/sormas-api/src/main/resources/enum.properties b/sormas-api/src/main/resources/enum.properties
index cbc929e9ca0..c52494fdb13 100644
--- a/sormas-api/src/main/resources/enum.properties
+++ b/sormas-api/src/main/resources/enum.properties
@@ -402,6 +402,8 @@ DatabaseTable.CAMPAIGN_CAMPAIGNFORMMETA = Campaigns → Campaign Form Meta
DatabaseTable.CAMPAIGN_FORM_META = Campaign form meta
DatabaseTable.CAMPAIGN_FORM_DATA = Campaign form data
DatabaseTable.CAMPAIGN_DIAGRAM_DEFINITIONS = Campaign diagram definitions
+DatabaseTable.CUSTOMIZABLE_FIELD_METADATA = Customizable field metadata
+DatabaseTable.CUSTOMIZABLE_FIELD_VALUE = Customizable field value
DatabaseTable.POPULATION_DATA = Population data
DatabaseTable.SURVEILLANCE_REPORTS = Surveillance reports
DatabaseTable.AGGREGATE_REPORTS = Aggregate reports
@@ -1899,6 +1901,7 @@ UserRight.EXTERNAL_VISITS = External visits
UserRight.SORMAS_UI = Access Sormas UI
UserRight.DEV_MODE = Access developer options
UserRight.CUSTOMIZABLE_ENUM_MANAGEMENT = Manage customizable enums
+UserRight.CUSTOMIZABLE_FIELD_MANAGEMENT = Manage customizable fields
UserRight.DISEASE_MANAGEMENT = Disease management
UserRight.DOCUMENT_VIEW = View existing documents
UserRight.DOCUMENT_UPLOAD = Upload documents
diff --git a/sormas-api/src/main/resources/strings.properties b/sormas-api/src/main/resources/strings.properties
index 811a9abee24..3d394e17124 100644
--- a/sormas-api/src/main/resources/strings.properties
+++ b/sormas-api/src/main/resources/strings.properties
@@ -511,6 +511,15 @@ headingContactsNotLinked = None of the contacts were linked
headingCasesNotLinked = None of the cases were linked
headingContactsNotRestored = = None of the contacts were restored
headingCreateAdditionalTest = Create new additional test results
+headingCreateCustomizableField = Create Customizable Field
+headingEditCustomizableField = Edit Customizable Field
+headingCloneCustomizableField = Clone Customizable Field
+headingConfirmDeleteCustomizableField = Confirm Delete
+headingCustomizableFieldBasics = Basics
+headingCustomizableFieldPlacement = Placement
+headingCustomizableFieldBehavior = Behavior
+headingCustomizableFieldVisibility = Visibility
+headingCustomizableFieldTranslations = Translations
headingCreateEntry = Create entry
headingCreateNewAction = Create new action
headingCreateNewAggregateReport = Create a new aggregated report
@@ -1163,6 +1172,8 @@ infoNoEnvironmentSamples = No samples have been created for this environment
infoRecoveryNaturalImmunity = Natural immunity from recovering from the disease
infoRestrictDiseasesDescription=Mark all diseases that the user is supposed to have access to
infoNoCustomizableEnumTranslations = Click on the + button below to add translations to this customizable enum value.
+infoNoCustomizableFieldTranslations = Click on the + button below to add translations for this field.
+infoNoCustomizableFieldOptions = Click on the + button below to add options for this field.
infoCustomizableEnumConfigurationInfo = Customizable enums are value sets that can be customized in order to react to the individual needs of your country or a specific epidemiological situation. The table on this screen contains all customizable enum values in the database. Each value is associated with a data type, e.g. disease variants or occupation types. Some of these data types have default values that are automatically added to the database when SORMAS is set up or new data types are added to the system.
You can add new enum values or edit existing ones, add translations for languages supported by SORMAS, select the diseases that the value should be visible for (by default, customizable enum values are visible for all diseases), and configure additional properties.
Properties are used to further control the behaviour of customizable enum values. E.g. the "has details" property that is supported by most enum values toggles whether selecting this enum value would bring up an additional text field that users can add more information to.
infoNoImmunizationAdverseEvents = No adverse events have been created for this immunization
infoAefiSelectPrimarySuspectVaccine = The list below contains all vaccinations of the immunization. Please select the suspect vaccination related to this adverse event.
@@ -1539,6 +1550,11 @@ messageContactCaseRemoved = The source case has been removed from this contact
messageContactCaseChanged = The source case of the contact has been changed
messageSampleOpened = Opened sample found for search string
messageSystemConfigurationValueSaved = System configuration value saved
+messageCustomizableFieldSaved = Customizable field saved
+messageCustomizableFieldCreated = Customizable field created
+messageCustomizableFieldCloned = Customizable field cloned
+messageCustomizableFieldDeleted = Customizable field deleted
+promptCustomizableFieldSearchField = Search by name, description or group
messageSystemFollowUpCanceled = [System] Follow-up automatically canceled because contact was converted to a case
messageSystemFollowUpCanceledByDropping = [System] Follow-up automatically canceled because contact was dropped
messageSetContactRegionAndDistrict = Please choose a responsible region and responsible district and save the contact before removing the source case.
@@ -1707,6 +1723,7 @@ labelNumberOfTemplates = No. of Templates
labelNoVaccinationDate = No vaccination date
labelNoVaccineName = No vaccine name
labelNumberOfDiseaseConfigurations = No. of diseases
+labelCustomizableFieldOptions = Field options
# Numbers
numberEight = eight
@@ -1840,6 +1857,7 @@ promptDiseaseConfigurationStartAgeType = Start age unit
promptDiseaseConfigurationEndAge = End age
promptDiseaseConfigurationEndAgeType = End age unit
promptCaseSex=Case sex
+promptConfirmDeleteCustomizableField = Are you sure you want to delete this customizable field? All field values linked to it will also be deleted.
#DiseaseNetworkDiagram
DiseaseNetworkDiagram.Classification.HEALTHY = Healthy
@@ -1886,6 +1904,7 @@ promptEnvironmentSampleLonTo= ... to
promptCustomizableEnumTranslationLanguage = Language
promptCustomizableEnumTranslationCaption = Translated caption
promptCustomizableEnumSearchField = Search by value or caption...
+promptCustomizableFieldOption = Option value
promptSurveyFreeTextSearch = Name
promptSurveyTokenFreeTextSearch = Token
diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java
index d40289a68d4..66fe1227262 100644
--- a/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java
+++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/caze/CaseFacadeEjb.java
@@ -137,6 +137,7 @@
import de.symeda.sormas.api.contact.ContactDto;
import de.symeda.sormas.api.contact.ContactReferenceDto;
import de.symeda.sormas.api.customizableenum.CustomizableEnumType;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldContext;
import de.symeda.sormas.api.disease.DiseaseVariant;
import de.symeda.sormas.api.document.DocumentRelatedEntityType;
import de.symeda.sormas.api.epidata.EpiDataDto;
@@ -254,6 +255,7 @@
import de.symeda.sormas.backend.contact.ContactService;
import de.symeda.sormas.backend.contact.VisitSummaryExportDetails;
import de.symeda.sormas.backend.customizableenum.CustomizableEnumFacadeEjb.CustomizableEnumFacadeEjbLocal;
+import de.symeda.sormas.backend.customizablefield.CustomizableFieldValueService;
import de.symeda.sormas.backend.disease.DiseaseConfigurationFacadeEjb.DiseaseConfigurationFacadeEjbLocal;
import de.symeda.sormas.backend.document.Document;
import de.symeda.sormas.backend.document.DocumentRelatedEntityService;
@@ -491,6 +493,8 @@ public class CaseFacadeEjb extends AbstractCoreFacadeEjb tokens = surveyService.findBy(new SurveyTokenCriteria().caseAssignedTo(otherCase.toReference()));
tokens.forEach(s -> {
diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java
index b16b793b29b..6cf38ade92a 100644
--- a/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java
+++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/contact/ContactFacadeEjb.java
@@ -659,6 +659,7 @@ private void deleteContact(Contact contact, DeletionDetails deletionDetails) {
}
service.delete(contact, deletionDetails);
+ epiDataFacade.softDeleteCustomizableFieldValues(contact.getEpiData(), deletionDetails);
if (contact.getCaze() != null) {
caseFacade.onCaseChanged(caseFacade.toDto(contact.getCaze()), contact.getCaze());
}
diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadata.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadata.java
new file mode 100644
index 00000000000..70c49a28b8d
--- /dev/null
+++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadata.java
@@ -0,0 +1,210 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.backend.customizablefield;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+
+import org.hibernate.annotations.Type;
+import org.hibernate.annotations.TypeDef;
+
+import com.vladmihalcea.hibernate.type.json.JsonBinaryType;
+
+import de.symeda.sormas.api.customizablefield.CustomizableFieldContext;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldType;
+import de.symeda.sormas.backend.common.CoreAdo;
+
+/**
+ * Entity class for customizable field metadata.
+ * Stores the configuration for custom fields that can be added to entities.
+ */
+@Entity(name = "customizablefieldmetadata")
+@TypeDef(name = "jsonb", typeClass = JsonBinaryType.class)
+@SuppressWarnings({
+ "java:S1845", // suppress sonar field name clash warning
+ "java:S2160" // suppress missing equals handled in AbstractDomainObject
+})
+public class CustomizableFieldMetadata extends CoreAdo {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String TABLE_NAME = "customizablefieldmetadata";
+
+ public static final String NAME = "name";
+ public static final String DESCRIPTION = "description";
+ public static final String FIELD_TYPE = "fieldType";
+ public static final String CONTEXT_CLASS = "contextClass";
+ public static final String UI_GROUP = "uiGroup";
+ public static final String UI_LINE_POSITION = "uiLinePosition";
+ public static final String UI_LINE_WEIGHT = "uiLineWeight";
+ public static final String ACTIVE = "active";
+ public static final String MANDATORY = "mandatory";
+ public static final String READ_ONLY = "readOnly";
+ public static final String DEFAULT_VALUE = "defaultValue";
+
+ private String name;
+ private String description;
+ private CustomizableFieldType fieldType;
+ private CustomizableFieldContext contextClass;
+ private CustomizableFieldGroup uiGroup;
+ private Integer uiLinePosition;
+ private Float uiLineWeight;
+ private boolean active = true;
+ private boolean mandatory = false;
+ private boolean readOnly = false;
+ private String defaultValue;
+
+ // JSON-serialized complex properties
+ private String visibilityRestrictions;
+ private String customProperties;
+ private String translations;
+
+ @Column(length = 512, nullable = false)
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ @Column(columnDefinition = "text")
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ public CustomizableFieldType getFieldType() {
+ return fieldType;
+ }
+
+ public void setFieldType(CustomizableFieldType fieldType) {
+ this.fieldType = fieldType;
+ }
+
+ @Column(length = 256, nullable = false)
+ @Enumerated(EnumType.STRING)
+ public CustomizableFieldContext getContextClass() {
+ return contextClass;
+ }
+
+ public void setContextClass(CustomizableFieldContext contextClass) {
+ this.contextClass = contextClass;
+ }
+
+ @Column(length = 256)
+ @Enumerated(EnumType.STRING)
+ public CustomizableFieldGroup getUiGroup() {
+ return uiGroup;
+ }
+
+ public void setUiGroup(CustomizableFieldGroup uiGroup) {
+ this.uiGroup = uiGroup;
+ }
+
+ @Column
+ public Integer getUiLinePosition() {
+ return uiLinePosition;
+ }
+
+ public void setUiLinePosition(Integer uiLinePosition) {
+ this.uiLinePosition = uiLinePosition;
+ }
+
+ @Column
+ public Float getUiLineWeight() {
+ return uiLineWeight;
+ }
+
+ public void setUiLineWeight(Float uiLineWeight) {
+ this.uiLineWeight = uiLineWeight;
+ }
+
+ @Column(nullable = false)
+ public boolean isActive() {
+ return active;
+ }
+
+ public void setActive(boolean active) {
+ this.active = active;
+ }
+
+ @Column
+ public boolean isMandatory() {
+ return mandatory;
+ }
+
+ public void setMandatory(boolean mandatory) {
+ this.mandatory = mandatory;
+ }
+
+ @Column
+ public boolean isReadOnly() {
+ return readOnly;
+ }
+
+ public void setReadOnly(boolean readOnly) {
+ this.readOnly = readOnly;
+ }
+
+ @Column(columnDefinition = "text")
+ public String getDefaultValue() {
+ return defaultValue;
+ }
+
+ public void setDefaultValue(String defaultValue) {
+ this.defaultValue = defaultValue;
+ }
+
+ // JSON-serialized properties
+ @Column(columnDefinition = "jsonb")
+ @Type(type = "jsonb")
+ public String getVisibilityRestrictions() {
+ return visibilityRestrictions;
+ }
+
+ public void setVisibilityRestrictions(String visibilityRestrictions) {
+ this.visibilityRestrictions = visibilityRestrictions;
+ }
+
+ @Column(columnDefinition = "jsonb")
+ @Type(type = "jsonb")
+ public String getCustomProperties() {
+ return customProperties;
+ }
+
+ public void setCustomProperties(String customProperties) {
+ this.customProperties = customProperties;
+ }
+
+ @Column(columnDefinition = "jsonb")
+ @Type(type = "jsonb")
+ public String getTranslations() {
+ return translations;
+ }
+
+ public void setTranslations(String translations) {
+ this.translations = translations;
+ }
+}
diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataFacadeEjb.java
new file mode 100644
index 00000000000..04b5d98af3e
--- /dev/null
+++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/customizablefield/CustomizableFieldMetadataFacadeEjb.java
@@ -0,0 +1,337 @@
+/*
+ * SORMAS® - Surveillance Outbreak Response Management & Analysis System
+ * Copyright © 2016-2026 SORMAS Foundation gGmbH
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package de.symeda.sormas.backend.customizablefield;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.ejb.EJB;
+import javax.ejb.LocalBean;
+import javax.ejb.Stateless;
+import javax.inject.Inject;
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+
+import org.apache.commons.lang3.NotImplementedException;
+import org.apache.commons.lang3.StringUtils;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.symeda.sormas.api.common.DeletableEntityType;
+import de.symeda.sormas.api.common.DeletionDetails;
+import de.symeda.sormas.api.common.progress.ProcessedEntity;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldContext;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldCustomProperties;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataCriteria;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataReferenceDto;
+import de.symeda.sormas.api.customizablefield.CustomizableFieldVisibilityRestrictions;
+import de.symeda.sormas.api.utils.SortProperty;
+import de.symeda.sormas.api.utils.ValidationRuntimeException;
+import de.symeda.sormas.backend.common.AbstractCoreFacadeEjb;
+import de.symeda.sormas.backend.util.DtoHelper;
+import de.symeda.sormas.backend.util.Pseudonymizer;
+
+/**
+ * Facade EJB implementation for customizable field metadata.
+ */
+@Stateless(name = "CustomizableFieldMetadataFacade")
+public class CustomizableFieldMetadataFacadeEjb
+ extends
+ AbstractCoreFacadeEjb
+ implements CustomizableFieldMetadataFacade {
+
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ @EJB
+ private CustomizableFieldValueService customizableFieldValueService;
+
+ public CustomizableFieldMetadataFacadeEjb() {
+ }
+
+ @Inject
+ public CustomizableFieldMetadataFacadeEjb(CustomizableFieldMetadataService service) {
+ super(CustomizableFieldMetadata.class, CustomizableFieldMetadataDto.class, service);
+ }
+
+ @Override
+ public List getActiveFieldsForContext(CustomizableFieldContext contextClass) {
+ return service.getActiveFieldsForContext(contextClass).stream().map(this::toDto).collect(Collectors.toList());
+ }
+
+ @Override
+ public List getFieldsForUIGroup(CustomizableFieldGroup uiGroup) {
+ return service.getFieldsForUIGroup(uiGroup).stream().map(this::toDto).collect(Collectors.toList());
+ }
+
+ @Override
+ public List getFieldsOrderedByUIPosition(CustomizableFieldGroup uiGroup) {
+ return getFieldsForUIGroup(uiGroup); // Already ordered in service
+ }
+
+ @Override
+ public CustomizableFieldMetadataDto getByNameAndContext(String name, CustomizableFieldContext contextClass) {
+ CustomizableFieldMetadata entity = service.getByNameAndContext(name, contextClass);
+ return entity != null ? toDto(entity) : null;
+ }
+
+ @Override
+ public CustomizableFieldMetadataDto cloneField(String sourceUuid, String newName) {
+ CustomizableFieldMetadata cloned = service.cloneField(sourceUuid, newName);
+ return toDto(cloned);
+ }
+
+ @Override
+ public void setFieldActive(String uuid, boolean active) {
+ CustomizableFieldMetadata field = service.getByUuid(uuid);
+ if (field != null) {
+ field.setActive(active);
+ service.ensurePersisted(field);
+ }
+ }
+
+ @Override
+ public List getAll() {
+ return service.getAll().stream().map(this::toDto).collect(Collectors.toList());
+ }
+
+ @Override
+ public List getIndexList(
+ CustomizableFieldMetadataCriteria criteria,
+ Integer first,
+ Integer max,
+ List sortProperties) {
+
+ return service.getIndexList(criteria, first, max, sortProperties).stream().map(this::toDto).collect(Collectors.toList());
+ }
+
+ @Override
+ public long count(CustomizableFieldMetadataCriteria criteria) {
+ return service.count(criteria);
+ }
+
+ @Override
+ public CustomizableFieldMetadataDto save(@Valid @NotNull CustomizableFieldMetadataDto dto) {
+ CustomizableFieldMetadata entity = service.getByUuid(dto.getUuid());
+ entity = fillOrBuildEntity(dto, entity, true);
+ service.ensurePersisted(entity);
+ return toDto(entity);
+ }
+
+ @Override
+ public void delete(String uuid, DeletionDetails deletionDetails) {
+ CustomizableFieldMetadata entity = service.getByUuid(uuid);
+ if (entity != null) {
+ customizableFieldValueService.softDeleteValuesForMetadata(uuid, deletionDetails);
+ service.delete(entity, deletionDetails);
+ }
+ }
+
+ @Override
+ public List delete(List uuids, DeletionDetails deletionDetails) {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public void restore(String uuid) {
+ super.restore(uuid);
+ }
+
+ @Override
+ public List restore(List uuids) {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public List getArchivedUuidsSince(Date since) {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public void validate(@Valid CustomizableFieldMetadataDto dto) throws ValidationRuntimeException {
+ // no validation required for customizable field metadata
+ }
+
+ @Override
+ protected DeletableEntityType getDeletableEntityType() {
+ return DeletableEntityType.CUSTOMIZABLE_FIELD_METADATA;
+ }
+
+ @Override
+ protected CustomizableFieldMetadataReferenceDto toRefDto(CustomizableFieldMetadata entity) {
+ return new CustomizableFieldMetadataReferenceDto(entity.getUuid());
+ }
+
+ @Override
+ protected void pseudonymizeDto(
+ CustomizableFieldMetadata source,
+ CustomizableFieldMetadataDto dto,
+ Pseudonymizer pseudonymizer,
+ boolean inJurisdiction) {
+ // no sensitive fields to pseudonymize
+ }
+
+ @Override
+ protected void restorePseudonymizedDto(
+ CustomizableFieldMetadataDto dto,
+ CustomizableFieldMetadataDto existingDto,
+ CustomizableFieldMetadata entity,
+ Pseudonymizer pseudonymizer) {
+ // no sensitive fields to restore
+ }
+
+ @Override
+ protected CustomizableFieldMetadata fillOrBuildEntity(
+ @NotNull CustomizableFieldMetadataDto source,
+ CustomizableFieldMetadata target,
+ boolean checkChangeDate) {
+ if (source == null) {
+ return null;
+ }
+
+ target = DtoHelper.fillOrBuildEntity(source, target, CustomizableFieldMetadata::new, checkChangeDate);
+
+ target.setName(source.getName());
+ target.setDescription(source.getDescription());
+ target.setFieldType(source.getFieldType());
+ target.setContextClass(source.getContextClass());
+ target.setUiGroup(source.getUiGroup());
+ target.setUiLinePosition(source.getUiLinePosition());
+ target.setUiLineWeight(source.getUiLineWeight());
+ target.setActive(source.isActive());
+ target.setMandatory(source.isMandatory());
+ target.setReadOnly(source.isReadOnly());
+ target.setDefaultValue(source.getDefaultValue());
+
+ // Serialize complex properties to JSON
+ if (source.getVisibilityRestrictions() != null) {
+ try {
+ target.setVisibilityRestrictions(mapper.writeValueAsString(source.getVisibilityRestrictions()));
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize visibilityRestrictions", e);
+ }
+ } else {
+ target.setVisibilityRestrictions(null);
+ }
+
+ if (source.getCustomProperties() != null) {
+ try {
+ target.setCustomProperties(mapper.writeValueAsString(source.getCustomProperties()));
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize customProperties", e);
+ }
+ } else {
+ target.setCustomProperties(null);
+ }
+
+ if (source.getTranslations() != null) {
+ try {
+ target.setTranslations(mapper.writeValueAsString(source.getTranslations()));
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize translations", e);
+ }
+ } else {
+ target.setTranslations(null);
+ }
+
+ return target;
+ }
+
+ @Override
+ protected CustomizableFieldMetadataDto toDto(CustomizableFieldMetadata source) {
+ if (source == null) {
+ return null;
+ }
+
+ CustomizableFieldMetadataDto target = new CustomizableFieldMetadataDto();
+ DtoHelper.fillDto(target, source);
+
+ target.setName(source.getName());
+ target.setDescription(source.getDescription());
+ target.setFieldType(source.getFieldType());
+ target.setContextClass(source.getContextClass());
+ target.setUiGroup(source.getUiGroup());
+ target.setUiLinePosition(source.getUiLinePosition());
+ target.setUiLineWeight(source.getUiLineWeight());
+ target.setActive(source.isActive());
+ target.setMandatory(source.isMandatory());
+ target.setReadOnly(source.isReadOnly());
+ target.setDefaultValue(source.getDefaultValue());
+
+ // Deserialize JSON to objects
+ target.setVisibilityRestrictions(parseVisibilityRestrictions(source.getVisibilityRestrictions()));
+ target.setCustomProperties(parseCustomProperties(source.getCustomProperties()));
+ target.setTranslations(parseTranslations(source.getTranslations()));
+
+ return target;
+ }
+
+ private static CustomizableFieldVisibilityRestrictions parseVisibilityRestrictions(String json) {
+ if (StringUtils.isBlank(json)) {
+ return null;
+ }
+ try {
+ return mapper.readValue(json, CustomizableFieldVisibilityRestrictions.class);
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to parse visibilityRestrictions JSON", e);
+ }
+ }
+
+ private static CustomizableFieldCustomProperties parseCustomProperties(String json) {
+ if (StringUtils.isBlank(json)) {
+ return null;
+ }
+ try {
+ return mapper.readValue(json, CustomizableFieldCustomProperties.class);
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to parse customProperties JSON", e);
+ }
+ }
+
+ private static Map> parseTranslations(String json) {
+ if (StringUtils.isBlank(json)) {
+ // we could return an empty map but it might be interpreted as no translation and postgres might create a '{}' jsonb entry
+ return Collections.emptyMap();
+ }
+ try {
+ return mapper.readValue(json, new TypeReference